MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m14s
Phase 5 of the collaboration plan:
- Remove PATCH /documents/{id}/blocks, PatchDocumentBlocks, the
DOC_BLOCKS_PATCHED event and its types, FlushDocumentState and
LoadDocumentState. PUT /documents/{id} rejects blocks (400) and only
changes title, icon, folder and pin; editors may now change title/icon.
- The to-do overview ticks via a single operation on /docs/{id}/sync
(new skipLog flag). Task links set the block tick with a targeted
operation and notify open editors; creating a linked task needs edit
rights.
- Keep the three share tables: DocRole already decides centrally, and
rewriting the visibility queries would only add risk (documented in plan).
- Tests adapted to the new paths; share e2e test now also ticks a to-do in
the overview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.6 KiB
Go
237 lines
8.6 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)
|
|
s.replaceDocBlocksInDB(doc.ID, u.ID, initial, "")
|
|
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"},
|
|
)
|
|
s.replaceDocBlocksInDB(doc.ID, u.ID, changed, "")
|
|
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")
|
|
}
|
|
}
|