Files
tefter/server/store/store_test.go
Senad c389bab46e
All checks were successful
ci / test (push) Successful in 3m8s
ci / release (push) Successful in 11m21s
Add version history with rollback and per-note created/synced info
Server (schema v2, auto-migrates): every accepted overwrite snapshots the
superseded revision into note_history (capped at 50 per note); new
GET /api/v1/notes/{id}/history endpoint; compact purges orphaned history.

Client: notes get a synced_at stamp on every confirmed server exchange;
an info footer under the editor and a Ctrl/Cmd+I panel show created/
modified/last-synced plus the revision list. Restoring a revision applies
it as a normal edit through the sync path, so rollback is non-destructive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 22:20:19 +02:00

313 lines
9.1 KiB
Go

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 = &notes[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
}