Files
MindSift/internal/storage/docsync_test.go
T
sandrodachandClaude Opus 5.5 51f90eb7e4
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s
feat(notes): browser sync engine with offline queue; editor saves through the operation log
Phase 2 of the collaboration plan:
- frontend/src/lib/docSync: TypeScript port of the block tree, operations,
  effects and order keys (kept identical to Go via shared test vectors),
  diff from the editor's flat list to operations, IndexedDB persistence,
  HTTP transport and DocSyncEngine (one request in flight, idempotent
  resends, backoff, 3 s/30 s polling, keepalive flush and leave on exit,
  block leases for focus, 20 s grace and new blocks).
- Text changes carry the version the user saw when typing (first change
  wins when coalescing; a stale focused field keeps its old base), so a
  concurrent edit becomes a conflict copy instead of a silent overwrite.
- DocumentEditor uses the engine instead of the 800 ms whole-document PUT;
  editors (not only owners) can edit, remote changes appear live.
- Server: task IDs of linked blocks are delivered again, linked tasks follow
  ticked/deleted blocks after sync, note tags are derived, title changes
  notify other users; block IDs already used in another note are renamed
  (starter content with fixed IDs was empty for every further user).
- Fix: unlinking tasks from blocks wrote to non-existent columns and never
  worked (error was swallowed).
- Tests: Go/TS order key vectors, diff round trips, engine races (lost
  responses, offline + reload, edits arriving between typing and sending,
  stale focused field, duplicate first block), plus a Playwright test with
  two browsers against the real image (e2e/run.sh).

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

241 lines
8.7 KiB
Go

package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
func newSyncTestStore(t *testing.T) (*Store, *domain.User) {
t.Helper()
store, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
u, err := store.CreateUser("owner@example.com", "hash", "Owner", "member")
if err != nil {
t.Fatalf("user: %v", err)
}
return store, u
}
func flatBlocks(spec ...[3]interface{}) []domain.DocumentBlock { // {id, level, content}
out := make([]domain.DocumentBlock, len(spec))
for i, s := range spec {
out[i] = domain.DocumentBlock{ID: s[0].(string), Level: s[1].(int), Content: s[2].(string), Type: "bullet"}
}
return out
}
func outline(blocks []domain.DocumentBlock) [][2]interface{} {
out := make([][2]interface{}, len(blocks))
for i, b := range blocks {
out[i] = [2]interface{}{b.ID, b.Level}
}
return out
}
func sameOutline(t *testing.T, got []domain.DocumentBlock, want []domain.DocumentBlock) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %v, want %v", outline(got), outline(want))
}
for i := range got {
if got[i].ID != want[i].ID || got[i].Level != want[i].Level || got[i].Content != want[i].Content {
t.Fatalf("block %d: got %v, want %v", i, outline(got), outline(want))
}
}
}
func headSeq(t *testing.T, s *Store, docID string) int64 {
t.Helper()
_, head, err := s.GetDocOpsSince(docID, 0, 1)
if err != nil {
t.Fatal(err)
}
return head
}
func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) {
s, u := newSyncTestStore(t)
initial := flatBlocks(
[3]interface{}{"a", 0, "Projekt"},
[3]interface{}{"b", 1, "Aufgabe 1"},
[3]interface{}{"c", 2, "Detail"},
[3]interface{}{"d", 1, "Aufgabe 2"},
[3]interface{}{"e", 0, "Anderes"},
)
doc, err := s.CreateDocument(u.ID, "", "Notiz", "📄", "", initial, nil, false)
if err != nil {
t.Fatal(err)
}
got, _ := s.GetDocumentByID(u.ID, doc.ID)
sameOutline(t, got.Blocks, initial)
// Saving the same content again must not produce any log entries
head := headSeq(t, s, doc.ID)
if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &initial, nil, nil); err != nil {
t.Fatal(err)
}
if h := headSeq(t, s, doc.ID); h != head {
t.Fatalf("unchanged save wrote %d log entries", h-head)
}
// Reorder, outdent, edit and delete in one save
changed := flatBlocks(
[3]interface{}{"e", 0, "Anderes"},
[3]interface{}{"a", 0, "Projekt (neu)"},
[3]interface{}{"d", 1, "Aufgabe 2"},
[3]interface{}{"c", 1, "Detail"},
[3]interface{}{"n", 2, "Neu"},
)
if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &changed, nil, nil); err != nil {
t.Fatal(err)
}
got, _ = s.GetDocumentByID(u.ID, doc.ID)
sameOutline(t, got.Blocks, changed)
// The log alone must rebuild exactly this state
entries, _, err := s.GetDocOpsSince(doc.ID, 0, 10_000)
if err != nil {
t.Fatal(err)
}
replay := collab.NewDoc()
for _, e := range entries {
replay.ApplyEffect(e.Effect)
}
sameOutline(t, flatDocumentBlocks(doc.ID, replay), changed)
}
func TestApplyDocOpsIsIdempotent(t *testing.T) {
s, u := newSyncTestStore(t)
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", flatBlocks([3]interface{}{"a", 0, "Start"}), nil, false)
a := collab.Actor{UserID: u.ID, ClientID: "tab1", Name: "Owner"}
text := "Geändert"
op := collab.Op{OpID: "op-1", Kind: collab.OpSetContent, BlockID: "a", BaseVersion: 1, Content: &text}
first, head1, err := s.ApplyDocOps(doc.ID, a, []collab.Op{op}, nil)
if err != nil || first[0].Status != collab.StatusApplied {
t.Fatalf("first: %+v %v", first, err)
}
// Resend after a lost response: same result, nothing applied twice
again, head2, err := s.ApplyDocOps(doc.ID, a, []collab.Op{op}, nil)
if err != nil || again[0] != first[0] || head2 != head1 {
t.Fatalf("retry changed something: %+v head %d -> %d", again, head1, head2)
}
}
func TestInsertCannotReuseBlockIDOfAnotherNote(t *testing.T) {
s, u := newSyncTestStore(t)
other, _ := s.CreateDocument(u.ID, "", "Andere", "📄", "", flatBlocks([3]interface{}{"shared_id", 0, "Fremd"}), nil, false)
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", nil, nil, false)
a := collab.Actor{UserID: u.ID, ClientID: "tab1"}
content, root := "Übernahme", ""
res, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "x", Kind: collab.OpInsert, BlockID: "shared_id", ParentID: &root, OrderKey: "a0", Content: &content}}, nil)
if err != nil || res[0].Reason != "exists" {
t.Fatalf("got %+v %v", res, err)
}
got, _ := s.GetDocumentByID(u.ID, other.ID)
if got.Blocks[0].Content != "Fremd" {
t.Fatal("block of another note was modified")
}
}
func TestDocRole(t *testing.T) {
s, owner := newSyncTestStore(t)
reader, _ := s.CreateUser("reader@example.com", "hash", "Reader", "member")
writer, _ := s.CreateUser("writer@example.com", "hash", "Writer", "member")
stranger, _ := s.CreateUser("stranger@example.com", "hash", "Stranger", "member")
doc, _ := s.CreateDocument(owner.ID, "", "Notiz", "📄", "", nil, nil, false)
now := domain.NowISO()
if _, err := s.db.Exec(`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by) VALUES ('s1', ?, ?, 'read', ?, ?), ('s2', ?, ?, 'write', ?, ?)`,
doc.ID, reader.ID, now, owner.ID, doc.ID, writer.ID, now, owner.ID); err != nil {
t.Fatal(err)
}
for user, want := range map[string]string{owner.ID: DocRoleOwner, writer.ID: DocRoleEditor, reader.ID: DocRoleViewer, stranger.ID: ""} {
if got := s.DocRole(user, doc.ID); got != want {
t.Errorf("role of %s: got %q, want %q", user, got, want)
}
}
if !s.IsDocShared(doc.ID) {
t.Error("note with shares must count as shared")
}
}
func TestBackfillConvertsFlatBlocksToTree(t *testing.T) {
s, u := newSyncTestStore(t)
doc, _ := s.CreateDocument(u.ID, "", "Alt", "📄", "", nil, nil, false)
if _, err := s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, doc.ID); err != nil {
t.Fatal(err)
}
// Rows as written by the old schema: only level and position
for i, row := range [][2]interface{}{{"x", 0}, {"y", 1}, {"z", 1}, {"w", 0}} {
if _, err := s.db.Exec(`INSERT INTO blocks (id, document_id, content, level, position, type) VALUES (?, ?, ?, ?, ?, 'bullet')`,
row[0], doc.ID, row[0], row[1], (i+1)*1000); err != nil {
t.Fatal(err)
}
}
s.backfillBlockTree()
got, _ := s.GetDocumentByID(u.ID, doc.ID)
want := flatBlocks([3]interface{}{"x", 0, "x"}, [3]interface{}{"y", 1, "y"}, [3]interface{}{"z", 1, "z"}, [3]interface{}{"w", 0, "w"})
sameOutline(t, got.Blocks, want)
}
func TestTaskLinksFollowTheirBlock(t *testing.T) {
s, u := newSyncTestStore(t)
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", []domain.DocumentBlock{{ID: "todo1", Type: "todo", Content: "Anrufen"}}, nil, false)
task, err := s.CreateTaskFromDocBlock(u.ID, doc.ID, "todo1", "Anrufen", false)
if err != nil {
t.Fatal(err)
}
got, _ := s.GetDocumentByID(u.ID, doc.ID)
if got.Blocks[0].TaskID != task.ID {
t.Fatalf("block must carry its task ID after loading, got %q", got.Blocks[0].TaskID)
}
a := collab.Actor{UserID: u.ID, ClientID: "tab"}
yes := true
if _, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "chk", Kind: collab.OpSetProps, BlockID: "todo1", Patch: &collab.PropsPatch{Checked: &yes}}}, nil); err != nil {
t.Fatal(err)
}
if changed := s.ReconcileDocTaskLinks(doc.ID); len(changed) != 1 {
t.Fatalf("ticking the block must complete the task, changed=%v", changed)
}
if th, _ := s.GetThoughtByID(u.ID, task.ID); !th.IsCompleted {
t.Fatal("task not completed")
}
if _, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "del", Kind: collab.OpDelete, BlockID: "todo1"}}, nil); err != nil {
t.Fatal(err)
}
s.ReconcileDocTaskLinks(doc.ID)
th, err := s.GetThoughtByID(u.ID, task.ID)
if err != nil {
t.Fatal("deleting the block must not delete the task")
}
if th.ConvertedType == "document_block" {
t.Fatal("deleting the block must unlink the task")
}
}
func TestFixedBlockIDsWorkInSeveralNotes(t *testing.T) {
s, u := newSyncTestStore(t)
other, _ := s.CreateUser("second@example.com", "hash", "Second", "member")
blocks := flatBlocks([3]interface{}{"blk_1", 0, "Willkommen"}, [3]interface{}{"blk_2", 1, "Unterpunkt"})
first, _ := s.CreateDocument(u.ID, "", "Start", "📄", "", blocks, nil, false)
second, _ := s.CreateDocument(other.ID, "", "Start", "📄", "", blocks, nil, false)
a, _ := s.GetDocumentByID(u.ID, first.ID)
b, _ := s.GetDocumentByID(other.ID, second.ID)
if len(a.Blocks) != 2 || len(b.Blocks) != 2 || b.Blocks[0].Content != "Willkommen" || b.Blocks[1].Level != 1 {
t.Fatalf("second note must get its own copies of the blocks: %+v", b.Blocks)
}
if a.Blocks[0].ID == b.Blocks[0].ID {
t.Fatal("block IDs must differ between notes")
}
}