Netpulse_SasS/server/internal/httpapi/maps_write_test.go
zotac 8bf2902522 Етап 3: запис у мапу — редактор полотна
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>
2026-08-14 15:58:16 +03:00

477 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"])
}
}