Files
MindSift/internal/api/docsync_test.go
T
sandrodachandClaude Opus 5.5 1ea0c78b41
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
feat(notes): operation log, block leases and sync API for collaborative editing
Phase 1 of the collaboration plan (server side):
- internal/collab: block tree with parent IDs and fractional order keys,
  operations with conflict rules (version check, block leases, conflict
  copies instead of lost text, restore on late edits, cycle checks),
  in-memory leases/sessions and long-poll notifier.
- Storage: per-note lock + transaction, append-only effect log (doc_ops),
  idempotent results per opId (doc_op_results), snapshot, log paging,
  central DocRole. Old flat block data is converted to the tree.
- The existing whole-document save (PUT/PATCH) runs as an adapter through
  the same engine, so the current editor keeps working and writes to the log.
- API: GET /docs/{id}/snapshot, POST /docs/{id}/sync, GET /docs/{id}/changes
  (long poll), POST /docs/{id}/leave; DOC_SEQ poke for WebSocket/SSE rooms.
- Backend accepts editor (write) shares for notes and folders; the forced
  read-only migration is gone. The share dialog still offers read only.
- Tests: conflict matrix, order keys, leases, storage adapter round trip,
  idempotency, API protocol incl. long poll, and a randomized simulation
  (4 clients, lost/duplicated requests and responses, offline phases,
  lease expiry) checking convergence, no lost text and exactly-once results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:47:56 +01:00

199 lines
7.6 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
func syncDoc(t *testing.T, ts *httptest.Server, token, docID string, body map[string]interface{}) (int, syncResponse) {
t.Helper()
resp := doJSON(t, ts, http.MethodPost, "/api/v1/docs/"+docID+"/sync", token, body)
var out syncResponse
if resp.StatusCode == http.StatusOK {
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode sync response: %v", err)
}
}
return resp.StatusCode, out
}
func strp(s string) *string { return &s }
type collabFixture struct {
ts *httptest.Server
docID string
ownerTok, editorTok, viewerTok string
outsiderTok string
}
func newCollabFixture(t *testing.T) collabFixture {
t.Helper()
_, store, ts, owner, ownerTok := startRealtimeServer(t)
editor, editorTok := newTestUser(t, store, "editor@example.com")
viewer, viewerTok := newTestUser(t, store, "viewer@example.com")
_, outsiderTok := newTestUser(t, store, "outsider2@example.com")
doc, err := store.CreateDocument(owner.ID, "", "Gemeinsam", "📄", "", []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Erster Punkt"}}, nil, false)
if err != nil {
t.Fatalf("create doc: %v", err)
}
if _, err := store.CreateDocShare(owner.ID, doc.ID, editor.ID, "write"); err != nil {
t.Fatalf("share editor: %v", err)
}
if _, err := store.CreateDocShare(owner.ID, doc.ID, viewer.ID, "read"); err != nil {
t.Fatalf("share viewer: %v", err)
}
return collabFixture{ts: ts, docID: doc.ID, ownerTok: ownerTok, editorTok: editorTok, viewerTok: viewerTok, outsiderTok: outsiderTok}
}
func TestDocSyncBetweenTwoEditors(t *testing.T) {
f := newCollabFixture(t)
// Owner inserts a block and edits the existing one in one round trip
code, a := syncDoc(t, f.ts, f.ownerTok, f.docID, map[string]interface{}{
"clientId": "owner-tab", "lastSeq": 0,
"ops": []collab.Op{
{OpID: "o1", Kind: collab.OpInsert, BlockID: "b2", ParentID: strp(""), OrderKey: "a5", Content: strp("Zweiter Punkt")},
{OpID: "o2", Kind: collab.OpSetContent, BlockID: "b1", BaseVersion: 1, Content: strp("Erster Punkt (bearbeitet)")},
},
})
if code != http.StatusOK || a.Results[0].Status != collab.StatusApplied || a.Results[1].Status != collab.StatusApplied {
t.Fatalf("owner sync: %d %+v", code, a.Results)
}
if len(a.Leases.Granted) == 0 || a.Leases.Granted[0] != "b2" {
t.Fatalf("inserted block must be leased to its author: %+v", a.Leases)
}
// Editor pulls everything since 0 and replays it
code, b := syncDoc(t, f.ts, f.editorTok, f.docID, map[string]interface{}{"clientId": "editor-tab", "lastSeq": 0})
if code != http.StatusOK || b.HeadSeq != a.HeadSeq || b.Role != "editor" || !b.Shared {
t.Fatalf("editor pull: %d head=%d role=%s shared=%v", code, b.HeadSeq, b.Role, b.Shared)
}
replay := collab.NewDoc()
for _, e := range b.Ops {
replay.ApplyEffect(e.Effect)
}
flat := replay.Flatten()
if len(flat) != 2 || flat[0].Content != "Erster Punkt (bearbeitet)" || flat[1].Content != "Zweiter Punkt" {
t.Fatalf("replayed state wrong: %+v", flat)
}
if len(b.Leases.Others) == 0 {
t.Fatal("editor must see the owner's lease on b2")
}
// Editor writes into the leased block: owner keeps it, editor's text becomes a conflict copy
_, c := syncDoc(t, f.ts, f.editorTok, f.docID, map[string]interface{}{
"clientId": "editor-tab", "lastSeq": b.HeadSeq,
"ops": []collab.Op{{OpID: "e1", Kind: collab.OpSetContent, BlockID: "b2", BaseVersion: 1, Content: strp("Editor war hier")}},
})
if c.Results[0].Status != collab.StatusConflict || c.Results[0].ConflictBlockID == "" {
t.Fatalf("write into foreign lease must conflict: %+v", c.Results)
}
}
func TestDocSyncRetryIsIdempotent(t *testing.T) {
f := newCollabFixture(t)
body := map[string]interface{}{
"clientId": "tab", "lastSeq": 0,
"ops": []collab.Op{{OpID: "same-op", Kind: collab.OpSetContent, BlockID: "b1", BaseVersion: 1, Content: strp("Einmal")}},
}
_, first := syncDoc(t, f.ts, f.ownerTok, f.docID, body)
_, second := syncDoc(t, f.ts, f.ownerTok, f.docID, body) // response of the first was "lost"
if first.Results[0] != second.Results[0] || first.HeadSeq != second.HeadSeq {
t.Fatalf("retry applied twice: %+v / %+v", first, second)
}
}
func TestDocSyncPermissions(t *testing.T) {
f := newCollabFixture(t)
op := map[string]interface{}{
"clientId": "tab", "lastSeq": 0,
"ops": []collab.Op{{OpID: "v1", Kind: collab.OpSetContent, BlockID: "b1", BaseVersion: 1, Content: strp("Leser schreibt")}},
}
if code, _ := syncDoc(t, f.ts, f.viewerTok, f.docID, op); code != http.StatusForbidden {
t.Errorf("viewer sending ops: want 403, got %d", code)
}
code, pull := syncDoc(t, f.ts, f.viewerTok, f.docID, map[string]interface{}{"clientId": "tab", "lastSeq": 0, "leases": map[string]interface{}{"hold": []string{"b1"}}})
if code != http.StatusOK || pull.Role != "viewer" || len(pull.Leases.Granted) != 0 {
t.Errorf("viewer pull: %d role=%s granted=%v", code, pull.Role, pull.Leases.Granted)
}
if code, _ := syncDoc(t, f.ts, f.outsiderTok, f.docID, map[string]interface{}{"clientId": "tab"}); code != http.StatusNotFound {
t.Errorf("outsider: want 404, got %d", code)
}
for _, path := range []string{"/snapshot", "/changes?wait=0"} {
if resp := doJSON(t, f.ts, http.MethodGet, "/api/v1/docs/"+f.docID+path, f.outsiderTok, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider %s: want 404, got %d", path, resp.StatusCode)
}
}
}
func TestDocSnapshotMatchesLog(t *testing.T) {
f := newCollabFixture(t)
syncDoc(t, f.ts, f.ownerTok, f.docID, map[string]interface{}{
"clientId": "tab", "lastSeq": 0,
"ops": []collab.Op{{OpID: "t1", Kind: collab.OpSetTitle, BaseVersion: 0, Title: strp("Neuer Titel")}},
})
resp := doJSON(t, f.ts, http.MethodGet, "/api/v1/docs/"+f.docID+"/snapshot", f.editorTok, nil)
var snap struct {
Title string `json:"title"`
HeadSeq int64 `json:"headSeq"`
Blocks []collab.Block `json:"blocks"`
Role string `json:"role"`
}
if err := json.NewDecoder(resp.Body).Decode(&snap); err != nil {
t.Fatal(err)
}
if snap.Title != "Neuer Titel" || snap.HeadSeq == 0 || len(snap.Blocks) != 1 || snap.Role != "editor" {
t.Fatalf("snapshot: %+v", snap)
}
}
func TestDocChangesLongPollWakesOnChange(t *testing.T) {
f := newCollabFixture(t)
_, base := syncDoc(t, f.ts, f.editorTok, f.docID, map[string]interface{}{"clientId": "editor-tab", "lastSeq": 0})
type pollResult struct {
Changed bool `json:"changed"`
HeadSeq int64 `json:"headSeq"`
}
done := make(chan pollResult, 1)
start := time.Now()
go func() {
req, _ := http.NewRequest(http.MethodGet, f.ts.URL+"/api/v1/docs/"+f.docID+"/changes?wait=20&since="+itoa(base.HeadSeq), nil)
req.Header.Set("Authorization", "Bearer "+f.editorTok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
done <- pollResult{}
return
}
defer resp.Body.Close()
var r pollResult
_ = json.NewDecoder(resp.Body).Decode(&r)
done <- r
}()
time.Sleep(300 * time.Millisecond)
syncDoc(t, f.ts, f.ownerTok, f.docID, map[string]interface{}{
"clientId": "owner-tab", "lastSeq": 0,
"ops": []collab.Op{{OpID: "lp1", Kind: collab.OpSetContent, BlockID: "b1", BaseVersion: 1, Content: strp("Weckruf")}},
})
select {
case r := <-done:
if !r.Changed || r.HeadSeq <= base.HeadSeq || time.Since(start) > 5*time.Second {
t.Fatalf("long poll result: %+v after %v", r, time.Since(start))
}
case <-time.After(10 * time.Second):
t.Fatal("long poll did not wake up")
}
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}