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