Implement Tefter per SPEC.md: NV-style notes with server, sync, PWA, desktop
- frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then- push sync engine with conflict handling, versioned cache-first service worker + manifest (installable PWA) - server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with- conflict-copies push rules, subcommands: init, token rotate, import simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle - desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray), global Ctrl+Shift+Space hotkey, quit-on-close flag - Simplenote import (CLI + web upload), idempotent via source_id dedupe; verified against a real 242-note export - Makefile (frontend/server/server-all/desktop/test), GitHub release workflow, README with systemd/Caddy/backup docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
213
server/store/store_test.go
Normal file
213
server/store/store_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"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 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
|
||||
}
|
||||
Reference in New Issue
Block a user