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>
296 lines
7.1 KiB
Go
296 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"tefter/server/store"
|
|
)
|
|
|
|
// client is a minimal reimplementation of the frontend sync loop (pull-then-push,
|
|
// last-write-wins with conflict copies) used to prove two devices converge.
|
|
type client struct {
|
|
t *testing.T
|
|
base string
|
|
token string
|
|
cursor int64
|
|
notes map[string]*clientNote
|
|
}
|
|
|
|
type clientNote struct {
|
|
store.Note
|
|
BaseVersion int64
|
|
Dirty bool
|
|
}
|
|
|
|
func newClient(t *testing.T, base, token string) *client {
|
|
return &client{t: t, base: base, token: token, notes: map[string]*clientNote{}}
|
|
}
|
|
|
|
func (c *client) req(method, path string, body any, out any) int {
|
|
var rdr *bytes.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(b)
|
|
} else {
|
|
rdr = bytes.NewReader(nil)
|
|
}
|
|
req, _ := http.NewRequest(method, c.base+path, rdr)
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if out != nil {
|
|
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
}
|
|
return res.StatusCode
|
|
}
|
|
|
|
func (c *client) edit(id, content string) {
|
|
n, ok := c.notes[id]
|
|
if !ok {
|
|
n = &clientNote{}
|
|
n.ID = id
|
|
c.notes[id] = n
|
|
}
|
|
n.Content = content
|
|
n.Dirty = true
|
|
}
|
|
|
|
func (c *client) pull() {
|
|
for {
|
|
var body struct {
|
|
Notes []store.Note `json:"notes"`
|
|
Cursor int64 `json:"cursor"`
|
|
More bool `json:"more"`
|
|
}
|
|
if code := c.req("GET", fmt.Sprintf("/api/v1/changes?since=%d&limit=2", c.cursor), nil, &body); code != 200 {
|
|
c.t.Fatalf("pull HTTP %d", code)
|
|
}
|
|
for _, sn := range body.Notes {
|
|
if local, ok := c.notes[sn.ID]; ok && local.Dirty {
|
|
continue // dirty local copy wins until pushed
|
|
}
|
|
c.notes[sn.ID] = &clientNote{Note: sn, BaseVersion: sn.Version}
|
|
}
|
|
c.cursor = body.Cursor
|
|
if !body.More {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *client) push() {
|
|
var dirty []map[string]any
|
|
for _, n := range c.notes {
|
|
if n.Dirty {
|
|
dirty = append(dirty, map[string]any{
|
|
"id": n.ID, "content": n.Content, "tags": n.Tags,
|
|
"created_at": n.CreatedAt, "modified_at": n.ModifiedAt,
|
|
"deleted": n.Deleted, "baseVersion": n.BaseVersion,
|
|
})
|
|
}
|
|
}
|
|
if dirty == nil {
|
|
return
|
|
}
|
|
var body struct {
|
|
Results []store.PushResult `json:"results"`
|
|
}
|
|
if code := c.req("POST", "/api/v1/notes/batch", map[string]any{"notes": dirty}, &body); code != 200 {
|
|
c.t.Fatalf("push HTTP %d", code)
|
|
}
|
|
for _, r := range body.Results {
|
|
n := c.notes[r.ID]
|
|
switch r.Status {
|
|
case "accepted":
|
|
n.Dirty = false
|
|
n.BaseVersion = r.Version
|
|
n.Version = r.Version
|
|
case "conflict":
|
|
if r.ServerNote != nil {
|
|
c.notes[r.ID] = &clientNote{Note: *r.ServerNote, BaseVersion: r.ServerNote.Version}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *client) sync() { c.pull(); c.push(); c.pull() }
|
|
|
|
func (c *client) visible() map[string]string {
|
|
out := map[string]string{}
|
|
for id, n := range c.notes {
|
|
if !n.Deleted {
|
|
out[id] = n.Content
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newTestServer(t *testing.T) (*httptest.Server, string) {
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "api.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
tok, err := st.EnsureToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer((&Server{Store: st, Version: "test"}).Handler())
|
|
t.Cleanup(srv.Close)
|
|
return srv, tok
|
|
}
|
|
|
|
func TestAuthRequired(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
res, err := http.Get(srv.URL + "/api/v1/changes?since=0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("want 401, got %d", res.StatusCode)
|
|
}
|
|
res, err = http.Get(srv.URL + "/api/v1/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("health must be open, got %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTwoClientsConverge(t *testing.T) {
|
|
srv, tok := newTestServer(t)
|
|
a := newClient(t, srv.URL, tok)
|
|
b := newClient(t, srv.URL, tok)
|
|
|
|
// A creates three notes, B pulls them.
|
|
a.edit("n1", "groceries\nmilk")
|
|
a.edit("n2", "ideas")
|
|
a.edit("n3", "scratch")
|
|
a.sync()
|
|
b.sync()
|
|
if len(b.visible()) != 3 {
|
|
t.Fatalf("B should see 3 notes, sees %d", len(b.visible()))
|
|
}
|
|
|
|
// Independent edits on different notes converge cleanly.
|
|
a.edit("n1", "groceries\nmilk, bread")
|
|
b.edit("n2", "ideas\nbuild tefter")
|
|
a.sync()
|
|
b.sync()
|
|
a.sync()
|
|
if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) {
|
|
t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible())
|
|
}
|
|
|
|
// Concurrent edit of the SAME note → conflict copy, never silent overwrite.
|
|
a.edit("n3", "scratch from A")
|
|
b.edit("n3", "scratch from B")
|
|
a.sync()
|
|
b.sync() // B's push conflicts; server truth restored + copy created
|
|
a.sync()
|
|
b.sync()
|
|
|
|
av, bv := a.visible(), b.visible()
|
|
if fmt.Sprint(av) != fmt.Sprint(bv) {
|
|
t.Fatalf("divergence after conflict:\nA=%v\nB=%v", av, bv)
|
|
}
|
|
if len(av) != 4 {
|
|
t.Fatalf("want 4 notes (3 + conflict copy), got %d: %v", len(av), av)
|
|
}
|
|
foundCopy := false
|
|
for _, content := range av {
|
|
if strings.Contains(content, "conflicted copy") && strings.Contains(content, "scratch from B") {
|
|
foundCopy = true
|
|
}
|
|
}
|
|
if !foundCopy {
|
|
t.Fatalf("conflict copy missing: %v", av)
|
|
}
|
|
// Both edits survived somewhere — no data loss.
|
|
joined := fmt.Sprint(av)
|
|
if !strings.Contains(joined, "scratch from A") || !strings.Contains(joined, "scratch from B") {
|
|
t.Fatalf("an edit was silently lost: %v", av)
|
|
}
|
|
}
|
|
|
|
func TestHistoryEndpoint(t *testing.T) {
|
|
srv, tok := newTestServer(t)
|
|
a := newClient(t, srv.URL, tok)
|
|
|
|
a.edit("n1", "v1")
|
|
a.sync()
|
|
a.edit("n1", "v2")
|
|
a.notes["n1"].ModifiedAt = 2
|
|
a.sync()
|
|
|
|
var body struct {
|
|
Revisions []store.Revision `json:"revisions"`
|
|
}
|
|
if code := a.req("GET", "/api/v1/notes/n1/history", nil, &body); code != 200 {
|
|
t.Fatalf("history HTTP %d", code)
|
|
}
|
|
if len(body.Revisions) != 1 || body.Revisions[0].Content != "v1" {
|
|
t.Fatalf("history: %+v", body.Revisions)
|
|
}
|
|
|
|
// Unknown note → empty list, not an error.
|
|
if code := a.req("GET", "/api/v1/notes/nope/history", nil, &body); code != 200 {
|
|
t.Fatalf("unknown note HTTP %d", code)
|
|
}
|
|
if len(body.Revisions) != 0 {
|
|
t.Fatalf("want empty history, got %+v", body.Revisions)
|
|
}
|
|
|
|
// Auth required.
|
|
res, err := http.Get(srv.URL + "/api/v1/notes/n1/history")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("want 401, got %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestDeleteEditConflictAcrossClients(t *testing.T) {
|
|
srv, tok := newTestServer(t)
|
|
a := newClient(t, srv.URL, tok)
|
|
b := newClient(t, srv.URL, tok)
|
|
|
|
a.edit("n1", "keep me")
|
|
a.sync()
|
|
b.sync()
|
|
|
|
// A deletes while B edits concurrently; edit must win.
|
|
an := a.notes["n1"]
|
|
an.Deleted = true
|
|
an.Dirty = true
|
|
b.edit("n1", "keep me — edited")
|
|
b.sync() // B's edit lands first
|
|
a.sync() // A's stale delete is dropped, server truth restored
|
|
b.sync()
|
|
|
|
if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) {
|
|
t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible())
|
|
}
|
|
if a.visible()["n1"] != "keep me — edited" {
|
|
t.Fatalf("edit did not win: %v", a.visible())
|
|
}
|
|
}
|