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 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()) } }