package store import ( "fmt" "path/filepath" "strings" "testing" "time" ) func newTestStore(t *testing.T) *Store { t.Helper() s, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { s.Close() }) return s } func push(t *testing.T, s *Store, n IncomingNote) PushResult { t.Helper() res, err := s.ApplyBatch([]IncomingNote{n}, time.Date(2026, 7, 12, 10, 30, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } if len(res) != 1 { t.Fatalf("want 1 result, got %d", len(res)) } return res[0] } func TestInsertAndFastForward(t *testing.T) { s := newTestStore(t) r := push(t, s, IncomingNote{ID: "a", Content: "hello\nworld", CreatedAt: 1, ModifiedAt: 1}) if r.Status != "accepted" || r.Version != 1 { t.Fatalf("insert: %+v", r) } r = push(t, s, IncomingNote{ID: "a", Content: "hello v2", ModifiedAt: 2, BaseVersion: r.Version}) if r.Status != "accepted" || r.Version != 2 { t.Fatalf("fast-forward: %+v", r) } notes, cursor, more, err := s.Changes(0, 500) if err != nil { t.Fatal(err) } if len(notes) != 1 || notes[0].Content != "hello v2" || cursor != 2 || more { t.Fatalf("changes: %+v cursor=%d more=%v", notes, cursor, more) } } func TestEditEditConflictCreatesCopy(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "base note", ModifiedAt: 1}) // device B fast-forwards push(t, s, IncomingNote{ID: "a", Content: "B's edit", ModifiedAt: 2, BaseVersion: r1.Version}) // device A pushes a stale edit r := push(t, s, IncomingNote{ID: "a", Content: "A's edit", Tags: []string{"x"}, ModifiedAt: 3, BaseVersion: r1.Version}) if r.Status != "conflict" || r.ConflictCopyID == "" { t.Fatalf("want conflict with copy, got %+v", r) } if r.ServerNote == nil || r.ServerNote.Content != "B's edit" { t.Fatalf("server truth missing: %+v", r.ServerNote) } notes, _, _, err := s.Changes(0, 500) if err != nil { t.Fatal(err) } if len(notes) != 2 { t.Fatalf("want original + conflict copy, got %d notes", len(notes)) } var copyNote *Note for i := range notes { if notes[i].ID == r.ConflictCopyID { copyNote = ¬es[i] } } if copyNote == nil { t.Fatal("conflict copy not in changes feed") } if !strings.Contains(copyNote.Content, "A's edit (conflicted copy 2026-07-12 10:30)") { t.Fatalf("conflict marker missing: %q", copyNote.Content) } if !containsStr(copyNote.Tags, "conflict") || !containsStr(copyNote.Tags, "x") { t.Fatalf("conflict tags wrong: %v", copyNote.Tags) } } func TestStaleDeleteLosesToEdit(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1}) push(t, s, IncomingNote{ID: "a", Content: "v2 edited", ModifiedAt: 2, BaseVersion: r1.Version}) r := push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: 3, BaseVersion: r1.Version}) if r.Status != "conflict" || r.ConflictCopyID != "" { t.Fatalf("stale delete must be dropped without a copy: %+v", r) } notes, _, _, _ := s.Changes(0, 500) if len(notes) != 1 || notes[0].Deleted || notes[0].Content != "v2 edited" { t.Fatalf("edit must survive: %+v", notes) } } func TestStaleEditBeatsTombstone(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1}) push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version}) r := push(t, s, IncomingNote{ID: "a", Content: "resurrected", ModifiedAt: 3, BaseVersion: r1.Version}) if r.Status != "accepted" { t.Fatalf("edit must overwrite tombstone: %+v", r) } notes, _, _, _ := s.Changes(0, 500) if len(notes) != 1 || notes[0].Deleted || notes[0].Content != "resurrected" { t.Fatalf("tombstone must be overwritten: %+v", notes) } } func TestCleanDeleteAccepted(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1}) r := push(t, s, IncomingNote{ID: "a", Content: "v1", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version}) if r.Status != "accepted" { t.Fatalf("clean delete: %+v", r) } notes, _, _, _ := s.Changes(0, 500) if len(notes) != 1 || !notes[0].Deleted { t.Fatalf("tombstone expected: %+v", notes) } } func TestChangesPaging(t *testing.T) { s := newTestStore(t) for i := 0; i < 7; i++ { push(t, s, IncomingNote{ID: string(rune('a' + i)), Content: "n", ModifiedAt: int64(i)}) } var got int var cursor int64 for { notes, c, more, err := s.Changes(cursor, 3) if err != nil { t.Fatal(err) } got += len(notes) cursor = c if !more { break } } if got != 7 { t.Fatalf("paged total = %d, want 7", got) } } func TestHistoryRecordsSupersededRevisions(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", Tags: []string{"t"}, ModifiedAt: 1}) if revs, _ := s.History("a"); len(revs) != 0 { t.Fatalf("insert must not create history, got %d", len(revs)) } r2 := push(t, s, IncomingNote{ID: "a", Content: "v2", ModifiedAt: 2, BaseVersion: r1.Version}) r3 := push(t, s, IncomingNote{ID: "a", Content: "v3", ModifiedAt: 3, BaseVersion: r2.Version}) revs, err := s.History("a") if err != nil { t.Fatal(err) } if len(revs) != 2 { t.Fatalf("want 2 revisions, got %d", len(revs)) } // Newest first: the v2 state (superseded by the v3 push), then v1. if revs[0].Content != "v2" || revs[0].Version != r2.Version { t.Fatalf("revs[0] = %+v", revs[0]) } if revs[1].Content != "v1" || revs[1].Version != r1.Version || !containsStr(revs[1].Tags, "t") { t.Fatalf("revs[1] = %+v", revs[1]) } if revs[0].ReplacedAt == 0 { t.Fatal("replaced_at not stamped") } _ = r3 } func TestHistoryRecordedOnDeleteAndResurrect(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "precious", ModifiedAt: 1}) r2 := push(t, s, IncomingNote{ID: "a", Content: "precious", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version}) // Stale edit overwrites the tombstone (rule 4b) — tombstone state goes to history. push(t, s, IncomingNote{ID: "a", Content: "resurrected", ModifiedAt: 3, BaseVersion: r1.Version}) revs, err := s.History("a") if err != nil { t.Fatal(err) } if len(revs) != 2 || revs[0].Version != r2.Version || revs[1].Content != "precious" { t.Fatalf("history after delete+resurrect: %+v", revs) } } func TestHistoryNotRecordedOnConflict(t *testing.T) { s := newTestStore(t) r1 := push(t, s, IncomingNote{ID: "a", Content: "base", ModifiedAt: 1}) push(t, s, IncomingNote{ID: "a", Content: "B's edit", ModifiedAt: 2, BaseVersion: r1.Version}) // Stale edit → conflict copy; server note untouched, so no new history entry. push(t, s, IncomingNote{ID: "a", Content: "A's edit", ModifiedAt: 3, BaseVersion: r1.Version}) revs, _ := s.History("a") if len(revs) != 1 || revs[0].Content != "base" { t.Fatalf("conflict must not record history: %+v", revs) } } func TestHistoryPrunedToCap(t *testing.T) { s := newTestStore(t) r := push(t, s, IncomingNote{ID: "a", Content: "v0", ModifiedAt: 0}) for i := 1; i <= historyKeep+10; i++ { r = push(t, s, IncomingNote{ID: "a", Content: fmt.Sprintf("v%d", i), ModifiedAt: int64(i), BaseVersion: r.Version}) } revs, err := s.History("a") if err != nil { t.Fatal(err) } if len(revs) != historyKeep { t.Fatalf("want %d revisions after prune, got %d", historyKeep, len(revs)) } if revs[0].Content != fmt.Sprintf("v%d", historyKeep+9) { t.Fatalf("newest revision wrong: %q", revs[0].Content) } } func TestCompactPurgesOrphanHistory(t *testing.T) { s := newTestStore(t) old := time.Now().AddDate(0, 0, -200).UnixMilli() r1 := push(t, s, IncomingNote{ID: "a", Content: "x", ModifiedAt: old}) push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: old, BaseVersion: r1.Version}) if _, err := s.Compact(90); err != nil { t.Fatal(err) } revs, err := s.History("a") if err != nil { t.Fatal(err) } if len(revs) != 0 { t.Fatalf("history must be purged with the note, got %d", len(revs)) } } func TestTokenRoundtrip(t *testing.T) { s := newTestStore(t) tok, err := s.EnsureToken() if err != nil || tok == "" { t.Fatalf("EnsureToken: %q %v", tok, err) } if again, _ := s.EnsureToken(); again != "" { t.Fatal("EnsureToken must not regenerate") } if !s.CheckToken(tok) { t.Fatal("valid token rejected") } if s.CheckToken("nope") { t.Fatal("invalid token accepted") } tok2, err := s.RotateToken() if err != nil { t.Fatal(err) } if s.CheckToken(tok) || !s.CheckToken(tok2) { t.Fatal("rotation did not invalidate old token") } } func TestCompact(t *testing.T) { s := newTestStore(t) old := time.Now().AddDate(0, 0, -200).UnixMilli() r1 := push(t, s, IncomingNote{ID: "a", Content: "x", ModifiedAt: old}) push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: old, BaseVersion: r1.Version}) push(t, s, IncomingNote{ID: "b", Content: "keep", ModifiedAt: time.Now().UnixMilli()}) n, err := s.Compact(90) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("purged %d, want 1", n) } total, deleted, _ := s.NoteCount() if total != 1 || deleted != 0 { t.Fatalf("after compact: total=%d deleted=%d", total, deleted) } } func containsStr(ss []string, s string) bool { for _, x := range ss { if x == s { return true } } return false }