PATCH /api/v1/maps/{id} з оптимістичним блокуванням за revision, плюс
створення, видалення й автопобудова з виявленої топології.
Усі скалярні поля патча — вказівники: перетягування шле лише x/y, і якби
відсутні поля означали порожні, кожен рух миші стирав би стиль, розмір і
прив'язку до пристрою.
Ребро може посилатися на вузол, створений тим же патчем, за client_id.
Той, хто спізнився з ревізією, отримує 409, а не тихо затирає чужу правку.
Знімок пишеться тією ж транзакцією, що й зміна, — інакше в історії лишався
б крок, якого в мапі немає.
Автопобудова ідемпотентна: повторний запуск не дублює вузлів і не скидає
ручну розкладку.
Тестами знайдено: revision <= $2 - $3 з двома нетипізованими параметрами
дає "operator is not unique: unknown - unknown" — потрібні явні касти.
Перевірено: 23 інтеграційні тести API (-race), плюс живий прогін проти
даних, зібраних агентом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
477 lines
15 KiB
Go
477 lines
15 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/coder/websocket"
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
func (f *fixture) send(t *testing.T, method, path, token, body string) (int, []byte) {
|
||
t.Helper()
|
||
req, err := http.NewRequest(method, f.srv.URL+path, bytes.NewBufferString(body))
|
||
if err != nil {
|
||
t.Fatalf("запит: %v", err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
resp, err := f.srv.Client().Do(req)
|
||
if err != nil {
|
||
t.Fatalf("%s %s: %v", method, path, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
buf := new(bytes.Buffer)
|
||
_, _ = buf.ReadFrom(resp.Body)
|
||
return resp.StatusCode, buf.Bytes()
|
||
}
|
||
|
||
func (f *fixture) mapState(t *testing.T) store.MapState {
|
||
t.Helper()
|
||
code, body := f.get(t, "/api/v1/maps/"+f.mapID, f.token)
|
||
if code != http.StatusOK {
|
||
t.Fatalf("стан мапи: код %d", code)
|
||
}
|
||
var st store.MapState
|
||
if err := json.Unmarshal(body, &st); err != nil {
|
||
t.Fatalf("розбір: %v", err)
|
||
}
|
||
return st
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
// Перетягування вузла — найчастіша операція редактора. Клієнт шле лише
|
||
// x/y, і решта полів має лишитись недоторканою.
|
||
func TestPatchMoveNodeKeepsOtherFields(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
before := f.mapState(t)
|
||
var node store.MapNode
|
||
for _, n := range before.Nodes {
|
||
if n.DeviceID == f.deviceID {
|
||
node = n
|
||
}
|
||
}
|
||
if node.ID == "" {
|
||
t.Fatal("вузол не знайдено")
|
||
}
|
||
|
||
patch := map[string]any{
|
||
"revision": before.Revision,
|
||
"nodes": map[string]any{
|
||
"upsert": []map[string]any{{"id": node.ID, "x": 777, "y": 888}},
|
||
},
|
||
}
|
||
raw, _ := json.Marshal(patch)
|
||
|
||
code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw))
|
||
if code != http.StatusOK {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
|
||
var res store.PatchResult
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
t.Fatalf("розбір: %v", err)
|
||
}
|
||
if res.Revision != before.Revision+1 {
|
||
t.Fatalf("ревізія = %d, очікували %d", res.Revision, before.Revision+1)
|
||
}
|
||
|
||
after := f.mapState(t)
|
||
var moved store.MapNode
|
||
for _, n := range after.Nodes {
|
||
if n.ID == node.ID {
|
||
moved = n
|
||
}
|
||
}
|
||
if moved.X != 777 || moved.Y != 888 {
|
||
t.Fatalf("координати не збереглись: %v,%v", moved.X, moved.Y)
|
||
}
|
||
// Ось заради чого поля в патчі — вказівники.
|
||
if moved.Label != node.Label {
|
||
t.Fatalf("підпис затерто: %q → %q", node.Label, moved.Label)
|
||
}
|
||
if moved.DeviceID != node.DeviceID {
|
||
t.Fatal("прив'язку до пристрою затерто рухом миші")
|
||
}
|
||
if moved.Status != node.Status {
|
||
t.Fatalf("статус загубився: %q → %q", node.Status, moved.Status)
|
||
}
|
||
}
|
||
|
||
// Дві людини редагують одну мапу. Той, хто спізнився, має отримати
|
||
// відмову, а не тихо затерти чужу правку.
|
||
func TestPatchRevisionConflict(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
before := f.mapState(t)
|
||
patch := func(rev int64, x float64) (int, []byte) {
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"revision": rev,
|
||
"nodes": map[string]any{
|
||
"upsert": []map[string]any{{"id": before.Nodes[0].ID, "x": x}},
|
||
},
|
||
})
|
||
return f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw))
|
||
}
|
||
|
||
if code, body := patch(before.Revision, 10); code != http.StatusOK {
|
||
t.Fatalf("перший патч: код %d: %s", code, body)
|
||
}
|
||
|
||
// Друга вкладка все ще тримає стару ревізію.
|
||
code, body := patch(before.Revision, 20)
|
||
if code != http.StatusConflict {
|
||
t.Fatalf("застаріла ревізія прийнята з кодом %d: %s", code, body)
|
||
}
|
||
|
||
after := f.mapState(t)
|
||
if after.Nodes[0].X != 10 {
|
||
t.Fatalf("перша правка втрачена: x = %v", after.Nodes[0].X)
|
||
}
|
||
}
|
||
|
||
// Без revision патч застосовується без перевірки — так робить лише
|
||
// серверний код; для UI це означало б мовчазне затирання.
|
||
func TestPatchWithoutRevisionSkipsCheck(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"nodes": map[string]any{
|
||
"upsert": []map[string]any{{"id": f.mapState(t).Nodes[0].ID, "x": 42}},
|
||
},
|
||
})
|
||
if code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
}
|
||
|
||
// Створення вузла й ребра одним патчем: ребро посилається на вузол за
|
||
// клієнтським ідентифікатором, якого в базі ще немає.
|
||
func TestPatchCreatesNodeAndEdgeTogether(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
before := f.mapState(t)
|
||
existing := before.Nodes[0].ID
|
||
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"revision": before.Revision,
|
||
"nodes": map[string]any{
|
||
"upsert": []map[string]any{{
|
||
"client_id": "tmp-1", "kind": "cloud", "label": "Інтернет",
|
||
"x": 900, "y": 100, "style": map[string]any{"icon": "cloud"},
|
||
}},
|
||
},
|
||
"edges": map[string]any{
|
||
"upsert": []map[string]any{{
|
||
"client_id": "tmp-edge", "source_node_id": existing,
|
||
"target_node_id": "tmp-1", "label": "uplink", "style": "bezier",
|
||
}},
|
||
},
|
||
"comment": "додав хмару",
|
||
})
|
||
|
||
code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw))
|
||
if code != http.StatusOK {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
|
||
var res store.PatchResult
|
||
if err := json.Unmarshal(body, &res); err != nil {
|
||
t.Fatalf("розбір: %v", err)
|
||
}
|
||
newNode := res.NodeIDs["tmp-1"]
|
||
if newNode == "" {
|
||
t.Fatalf("сервер не повернув id нового вузла: %+v", res)
|
||
}
|
||
if res.EdgeIDs["tmp-edge"] == "" {
|
||
t.Fatalf("сервер не повернув id нового ребра: %+v", res)
|
||
}
|
||
|
||
after := f.mapState(t)
|
||
if len(after.Nodes) != len(before.Nodes)+1 {
|
||
t.Fatalf("вузлів: %d", len(after.Nodes))
|
||
}
|
||
|
||
var linked bool
|
||
for _, e := range after.Edges {
|
||
if e.TargetNodeID == newNode && e.Label == "uplink" {
|
||
linked = true
|
||
if e.Style != "bezier" {
|
||
t.Fatalf("стиль ребра: %q", e.Style)
|
||
}
|
||
}
|
||
}
|
||
if !linked {
|
||
t.Fatal("ребро не прив'язалось до щойно створеного вузла")
|
||
}
|
||
}
|
||
|
||
// Видалення вузла має забрати й ребра, що на нього спирались, —
|
||
// інакше полотно лишиться з ребрами в нікуди.
|
||
func TestPatchDeleteNodeRemovesEdges(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
before := f.mapState(t)
|
||
if len(before.Edges) == 0 {
|
||
t.Fatal("нема ребра для перевірки")
|
||
}
|
||
victim := before.Edges[0].SourceNodeID
|
||
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"revision": before.Revision,
|
||
"nodes": map[string]any{"remove": []string{victim}},
|
||
})
|
||
if code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
|
||
after := f.mapState(t)
|
||
for _, n := range after.Nodes {
|
||
if n.ID == victim {
|
||
t.Fatal("вузол не видалено")
|
||
}
|
||
}
|
||
if len(after.Edges) != 0 {
|
||
t.Fatalf("лишились ребра в нікуди: %d", len(after.Edges))
|
||
}
|
||
}
|
||
|
||
// Кожна правка лишає знімок — це основа undo в редакторі.
|
||
func TestPatchStoresRevisionSnapshot(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": 555}},
|
||
},
|
||
"comment": "перетягнув ядро",
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("код %d", code)
|
||
}
|
||
|
||
var (
|
||
comment string
|
||
nodes int
|
||
revision int64
|
||
)
|
||
if err := f.pool.QueryRow(f.ctx, `
|
||
SELECT COALESCE(comment,''), jsonb_array_length(snapshot->'nodes'), revision
|
||
FROM topo.map_revisions
|
||
WHERE map_id = $1 ORDER BY revision DESC LIMIT 1
|
||
`, f.mapID).Scan(&comment, &nodes, &revision); err != nil {
|
||
t.Fatalf("знімок не збережено: %v", err)
|
||
}
|
||
if comment != "перетягнув ядро" {
|
||
t.Fatalf("коментар: %q", comment)
|
||
}
|
||
if nodes != len(before.Nodes) {
|
||
t.Fatalf("у знімку %d вузлів, на мапі %d", nodes, len(before.Nodes))
|
||
}
|
||
if revision != before.Revision+1 {
|
||
t.Fatalf("ревізія знімка: %d", revision)
|
||
}
|
||
}
|
||
|
||
// Чужий вузол не має редагуватись через свою мапу.
|
||
func TestPatchRejectsForeignNode(t *testing.T) {
|
||
f := setup(t)
|
||
other := setup(t)
|
||
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"nodes": map[string]any{
|
||
"upsert": []map[string]any{{"id": other.mapState(t).Nodes[0].ID, "x": 1}},
|
||
},
|
||
})
|
||
code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw))
|
||
if code != http.StatusBadRequest {
|
||
t.Fatalf("чужий вузол прийнято з кодом %d: %s", code, body)
|
||
}
|
||
}
|
||
|
||
// Невідоме поле — майже завжди друкарська помилка в клієнті. Мовчки
|
||
// проковтнути її означає, що правка "збереглась", але не застосувалась.
|
||
func TestPatchRejectsUnknownField(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token,
|
||
`{"nodez":{"upsert":[]}}`)
|
||
if code != http.StatusBadRequest {
|
||
t.Fatalf("невідоме поле прийнято з кодом %d", code)
|
||
}
|
||
}
|
||
|
||
func TestPatchRequiresWriteScope(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
// Токен лише на читання.
|
||
roToken := "np_ro_" + f.mapID
|
||
sum := sha256sum(roToken)
|
||
if _, err := f.pool.Exec(f.ctx, `
|
||
INSERT INTO core.api_tokens (tenant_id, name, prefix, token_hash, scopes)
|
||
VALUES ($1, 'ro', 'np_ro_', $2, ARRAY['maps:read'])
|
||
`, f.tenantID, sum); err != nil {
|
||
t.Fatalf("seed: %v", err)
|
||
}
|
||
|
||
code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, roToken, `{}`)
|
||
if code != http.StatusForbidden {
|
||
t.Fatalf("токен без maps:write прийнято з кодом %d", code)
|
||
}
|
||
// А читати він має право.
|
||
if code, _ := f.get(t, "/api/v1/maps", roToken); code != http.StatusOK {
|
||
t.Fatalf("читання відхилено з кодом %d", code)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Створення, видалення, автопобудова
|
||
// ---------------------------------------------------------------------
|
||
|
||
func TestCreateAndDeleteMap(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
code, body := f.send(t, http.MethodPost, "/api/v1/maps", f.token,
|
||
`{"name":"Друга","slug":"druha","kind":"floor_plan"}`)
|
||
if code != http.StatusCreated {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
var m store.MapSummary
|
||
if err := json.Unmarshal(body, &m); err != nil {
|
||
t.Fatalf("розбір: %v", err)
|
||
}
|
||
if m.Kind != "floor_plan" || m.Revision != 1 {
|
||
t.Fatalf("мапа: %+v", m)
|
||
}
|
||
|
||
if code, _ := f.send(t, http.MethodDelete, "/api/v1/maps/"+m.ID, f.token, ""); code != http.StatusNoContent {
|
||
t.Fatalf("видалення: код %d", code)
|
||
}
|
||
if code, _ := f.get(t, "/api/v1/maps/"+m.ID, f.token); code != http.StatusNotFound {
|
||
t.Fatalf("видалена мапа доступна: код %d", code)
|
||
}
|
||
|
||
// М'яке видалення: топологія лишається на місці.
|
||
var links int
|
||
if err := f.pool.QueryRow(f.ctx,
|
||
`SELECT count(*) FROM topo.links WHERE tenant_id = $1`, f.tenantID).Scan(&links); err != nil {
|
||
t.Fatalf("підрахунок: %v", err)
|
||
}
|
||
if links == 0 {
|
||
t.Fatal("видалення мапи забрало топологію")
|
||
}
|
||
}
|
||
|
||
// Автопобудова перетворює виявлену топологію на полотно — і не псує
|
||
// розкладку при повторному запуску.
|
||
func TestBuildFromTopologyIsIdempotent(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
code, body := f.send(t, http.MethodPost, "/api/v1/maps", f.token,
|
||
`{"name":"Авто","slug":"avto"}`)
|
||
if code != http.StatusCreated {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
var m store.MapSummary
|
||
_ = json.Unmarshal(body, &m)
|
||
|
||
code, body = f.send(t, http.MethodPost, "/api/v1/maps/"+m.ID+"/build", f.token, "")
|
||
if code != http.StatusOK {
|
||
t.Fatalf("код %d: %s", code, body)
|
||
}
|
||
var st store.BuildStats
|
||
if err := json.Unmarshal(body, &st); err != nil {
|
||
t.Fatalf("розбір: %v", err)
|
||
}
|
||
if st.NodesAdded != 2 || st.EdgesAdded != 1 {
|
||
t.Fatalf("побудовано: %+v", st)
|
||
}
|
||
|
||
// Оператор посунув вузол.
|
||
code, body = f.get(t, "/api/v1/maps/"+m.ID, f.token)
|
||
if code != http.StatusOK {
|
||
t.Fatalf("код %d", code)
|
||
}
|
||
var built store.MapState
|
||
_ = json.Unmarshal(body, &built)
|
||
|
||
raw, _ := json.Marshal(map[string]any{
|
||
"revision": built.Revision,
|
||
"nodes": map[string]any{
|
||
"upsert": []map[string]any{{"id": built.Nodes[0].ID, "x": 1234, "y": 4321}},
|
||
},
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+m.ID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("правка: код %d", code)
|
||
}
|
||
|
||
// Повторна побудова нічого не додає й не скидає розкладку.
|
||
code, body = f.send(t, http.MethodPost, "/api/v1/maps/"+m.ID+"/build", f.token, "")
|
||
if code != http.StatusOK {
|
||
t.Fatalf("код %d", code)
|
||
}
|
||
_ = json.Unmarshal(body, &st)
|
||
if st.NodesAdded != 0 || st.EdgesAdded != 0 {
|
||
t.Fatalf("повторна побудова додала: %+v", st)
|
||
}
|
||
|
||
code, body = f.get(t, "/api/v1/maps/"+m.ID, f.token)
|
||
if code != http.StatusOK {
|
||
t.Fatalf("код %d", code)
|
||
}
|
||
var again store.MapState
|
||
_ = json.Unmarshal(body, &again)
|
||
for _, n := range again.Nodes {
|
||
if n.ID == built.Nodes[0].ID && (n.X != 1234 || n.Y != 4321) {
|
||
t.Fatalf("автопобудова скинула ручну розкладку: %v,%v", n.X, n.Y)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Правка має долетіти до інших відкритих полотен: у NOC над однією
|
||
// мапою часто працюють кілька людей.
|
||
func TestPatchBroadcastsToOtherViewers(t *testing.T) {
|
||
f := setup(t)
|
||
|
||
ctx, cancel := context.WithTimeout(f.ctx, 20*time.Second)
|
||
defer cancel()
|
||
|
||
conn := dialWS(t, ctx, f, f.token)
|
||
waitFor(t, ctx, conn, "hello")
|
||
|
||
if err := conn.Write(ctx, websocket.MessageText,
|
||
[]byte(`{"type":"subscribe","map_id":"`+f.mapID+`"}`)); err != nil {
|
||
t.Fatalf("підписка: %v", err)
|
||
}
|
||
waitFor(t, ctx, conn, "subscribed")
|
||
|
||
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": 99}},
|
||
},
|
||
})
|
||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||
t.Fatalf("патч: код %d", code)
|
||
}
|
||
|
||
msg := waitFor(t, ctx, conn, "map.updated")
|
||
if msg["map_id"] != f.mapID {
|
||
t.Fatalf("подія про іншу мапу: %v", msg)
|
||
}
|
||
if rev, ok := msg["revision"].(float64); !ok || int64(rev) != before.Revision+1 {
|
||
t.Fatalf("ревізія в події: %v", msg["revision"])
|
||
}
|
||
}
|