POST /api/v1/maps/{id}/undo повертає полотно до попереднього знімка.
Відкат оформлюється як нова ревізія, а не відмотування лічильника:
інакше клієнт зі старим номером тихо перезаписав би відкочене.
Ідентифікатори вузлів зберігаються, тож ребра прив'язуються назад самі.
В UI: малювання зв'язку від краю вузла, видалення по Delete, кнопка
відкату. Намальоване рукою ребро не прив'язується до topo.links —
лінія на полотні це подання, а не факт про мережу.
Виправлено флак у тестах: TestSchedulerRunsTaskAndFillsCredentials
перевіряв канал статусів знімком, хоча SUCCEEDED надсилається вже після
запису в sink. Під навантаженням падав раз на п'ять; тепер 0 з 8.
Перевірено наживо: намальовано зв'язок -> відкат -> видалено вузол ->
відкат повернув той самий id, ребро й живий стан лінка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
6.5 KiB
Go
186 lines
6.5 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
func (f *fixture) undo(t *testing.T) (int, store.UndoResult) {
|
||
t.Helper()
|
||
code, body := f.send(t, http.MethodPost, "/api/v1/maps/"+f.mapID+"/undo", f.token, "")
|
||
var res store.UndoResult
|
||
if code == http.StatusOK {
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
t.Fatalf("розбір: %v", err)
|
||
}
|
||
}
|
||
return code, res
|
||
}
|
||
|
||
// Найпростіший сценарій undo: посунув вузол, передумав.
|
||
func TestUndoRestoresNodePosition(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
// Перший патч створює точку, до якої буде куди відкочуватись:
|
||
// у щойно створеної мапи знімків ще немає.
|
||
before := f.mapState(t)
|
||
nodeID := before.Nodes[0].ID
|
||
origX, origY := before.Nodes[0].X, before.Nodes[0].Y
|
||
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"revision": before.Revision,
|
||
"nodes": map[string]any{"upsert": []map[string]any{{"id": nodeID, "x": origX, "y": origY}}},
|
||
"comment": "початковий знімок",
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("підготовка: код %d", code)
|
||
}
|
||
|
||
mid := f.mapState(t)
|
||
raw, _ = json.Marshal(map[string]any{
|
||
"revision": mid.Revision,
|
||
"nodes": map[string]any{"upsert": []map[string]any{{"id": nodeID, "x": 4242, "y": 2424}}},
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("правка: код %d", code)
|
||
}
|
||
|
||
code, res := f.undo(t)
|
||
if code != http.StatusOK {
|
||
t.Fatalf("undo: код %d", code)
|
||
}
|
||
// Відкат — це нова ревізія, а не відмотування лічильника: інакше
|
||
// клієнти зі старим номером тихо перезаписали б відкочене.
|
||
if res.Revision <= mid.Revision+1 {
|
||
t.Fatalf("відкат не створив нову ревізію: %+v", res)
|
||
}
|
||
|
||
after := f.mapState(t)
|
||
var n *store.MapNode
|
||
for i := range after.Nodes {
|
||
if after.Nodes[i].ID == nodeID {
|
||
n = &after.Nodes[i]
|
||
}
|
||
}
|
||
if n == nil {
|
||
t.Fatal("вузол зник після відкату")
|
||
}
|
||
if n.X != origX || n.Y != origY {
|
||
t.Fatalf("координати не відновились: %v,%v (було %v,%v)", n.X, n.Y, origX, origY)
|
||
}
|
||
}
|
||
|
||
// Головний сценарій: випадково видалив вузол — undo має повернути і його,
|
||
// і ребра, що на нього спирались. Саме тут відкат легко зламати.
|
||
func TestUndoRestoresDeletedNodeWithEdges(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
before := f.mapState(t)
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"revision": before.Revision,
|
||
"nodes": map[string]any{"upsert": []map[string]any{{"id": before.Nodes[0].ID, "x": before.Nodes[0].X}}},
|
||
"comment": "початковий знімок",
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("підготовка: код %d", code)
|
||
}
|
||
|
||
mid := f.mapState(t)
|
||
victim := mid.Edges[0].SourceNodeID
|
||
raw, _ = json.Marshal(map[string]any{
|
||
"revision": mid.Revision,
|
||
"nodes": map[string]any{"remove": []string{victim}},
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("видалення: код %d", code)
|
||
}
|
||
|
||
deleted := f.mapState(t)
|
||
if len(deleted.Nodes) != len(mid.Nodes)-1 || len(deleted.Edges) != 0 {
|
||
t.Fatalf("видалення не спрацювало: %d вузлів, %d ребер",
|
||
len(deleted.Nodes), len(deleted.Edges))
|
||
}
|
||
|
||
if code, _ := f.undo(t); code != http.StatusOK {
|
||
t.Fatalf("undo: код %d", code)
|
||
}
|
||
|
||
after := f.mapState(t)
|
||
if len(after.Nodes) != len(mid.Nodes) {
|
||
t.Fatalf("вузли не відновились: %d, було %d", len(after.Nodes), len(mid.Nodes))
|
||
}
|
||
if len(after.Edges) != len(mid.Edges) {
|
||
t.Fatalf("ребра не відновились: %d, було %d", len(after.Edges), len(mid.Edges))
|
||
}
|
||
|
||
// Ідентифікатори мають зберегтись: інакше зламались би посилання
|
||
// в UI і в будь-яких зовнішніх закладках.
|
||
found := false
|
||
for _, n := range after.Nodes {
|
||
if n.ID == victim {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("вузол відновлено з новим id")
|
||
}
|
||
// І ребро має знову вказувати на нього.
|
||
if after.Edges[0].SourceNodeID != victim && after.Edges[0].TargetNodeID != victim {
|
||
t.Fatalf("ребро не прив'язалось назад: %+v", after.Edges[0])
|
||
}
|
||
// Живий стан підмішується на читанні, тож має бути на місці й після відкату.
|
||
if after.Edges[0].LinkStatus == "" {
|
||
t.Fatal("після відкату ребро втратило зв'язок із topo.links")
|
||
}
|
||
}
|
||
|
||
// У щойно створеної мапи історії немає — відкочуватись нема куди.
|
||
func TestUndoWithoutHistory(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
code, body := f.send(t, http.MethodPost, "/api/v1/maps", f.token,
|
||
`{"name":"Порожня","slug":"empty"}`)
|
||
if code != http.StatusCreated {
|
||
t.Fatalf("створення: код %d", code)
|
||
}
|
||
var m store.MapSummary
|
||
_ = json.Unmarshal(body, &m)
|
||
|
||
code, body = f.send(t, http.MethodPost, "/api/v1/maps/"+m.ID+"/undo", f.token, "")
|
||
if code != http.StatusConflict {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
if !contains(string(body), "nothing_to_undo") {
|
||
t.Fatalf("немає зрозумілого коду: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestUndoRequiresWriteScope(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
roToken := "np_ro_undo_" + f.mapID
|
||
if _, err := f.pool.Exec(f.ctx, `
|
||
INSERT INTO core.api_tokens (tenant_id, name, prefix, token_hash, scopes)
|
||
VALUES ($1, 'ro-undo', 'np_ro_', $2, ARRAY['maps:read'])
|
||
`, f.tenantID, sha256sum(roToken)); err != nil {
|
||
t.Fatalf("seed: %v", err)
|
||
}
|
||
|
||
if code, _ := f.send(t, http.MethodPost, "/api/v1/maps/"+f.mapID+"/undo", roToken, ""); code != http.StatusForbidden {
|
||
t.Fatalf("токен без maps:write відкотив мапу: код %d", code)
|
||
}
|
||
}
|
||
|
||
func contains(s, sub string) bool {
|
||
return len(s) >= len(sub) && (func() bool {
|
||
for i := 0; i+len(sub) <= len(s); i++ {
|
||
if s[i:i+len(sub)] == sub {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})()
|
||
}
|