oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
4 "encoding/json"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "strconv"
9 "strings"
10 "testing"
11)
12
13// bearerRequest builds the request an agent's HTTP client makes: an
14// Authorization header and nothing else.
15//
16// What it deliberately does NOT send is the point of most of this
17// file: no cookie, no Origin, no Sec-Fetch-Site. A non-browser caller
18// has none of those to send, so the API has to be reachable without
19// them β€” and csrf.Protect refuses a POST carrying none of the three,
20// which is why /api/v1 is mounted outside it (api.go says so at
21// length).
22func bearerRequest(t *testing.T, method, target, token, body string) *http.Request {
23 t.Helper()
24 var r io.Reader
25 if body != "" {
26 r = strings.NewReader(body)
27 }
28 req := httptest.NewRequest(method, target, r)
29 if token != "" {
30 req.Header.Set("Authorization", "Bearer "+token)
31 }
32 if body != "" {
33 req.Header.Set("Content-Type", "application/json")
34 }
35 return req
36}
37
38// send runs one built request against the whole app.
39func send(t *testing.T, a *testApp, req *http.Request) *httptest.ResponseRecorder {
40 t.Helper()
41 rec := httptest.NewRecorder()
42 a.ServeHTTP(rec, req)
43 return rec
44}
45
46// apiJSON makes one API call, insists on the status, and decodes the
47// JSON body. Every API answer with a body is JSON, including every
48// error, so one helper covers the whole surface.
49func apiJSON(t *testing.T, a *testApp, method, target, token, body string, want int) map[string]any {
50 t.Helper()
51 rec := send(t, a, bearerRequest(t, method, target, token, body))
52 if rec.Code != want {
53 t.Fatalf("%s %s: status %d, want %d\n%s", method, target, rec.Code, want, rec.Body)
54 }
55 if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
56 t.Fatalf("%s %s: Content-Type = %q, want application/json", method, target, ct)
57 }
58 var out map[string]any
59 if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
60 t.Fatalf("%s %s: body is not a JSON object: %v\n%s", method, target, err, rec.Body)
61 }
62 return out
63}
64
65// apiDeck creates a deck over the API and returns its sqid.
66func apiDeck(t *testing.T, a *testApp, token, title string) string {
67 t.Helper()
68 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token,
69 `{"title":`+quote(title)+`}`, http.StatusCreated)
70 sqid, _ := out["sqid"].(string)
71 if len(sqid) != 10 {
72 t.Fatalf("POST /api/v1/decks: sqid = %v, want ten characters", out["sqid"])
73 }
74 return sqid
75}
76
77// quote is a JSON string literal, for building request bodies.
78func quote(s string) string {
79 b, _ := json.Marshal(s)
80 return string(b)
81}
82
83// TestApiNeverReadsCookie: the API is a bearer surface and only a
84// bearer surface. A request carrying a perfectly good session cookie
85// and no token is 401 β€” otherwise every page in a signed-in person's
86// browser would be able to drive their decks cross-site.
87func TestApiNeverReadsCookie(t *testing.T) {
88 a := newApp(t)
89 cookies := signedIn(t, a, allowedAddress)
90
91 req := bearerRequest(t, http.MethodGet, "/api/v1/decks", "", "")
92 for _, c := range cookies {
93 req.AddCookie(c)
94 }
95 rec := send(t, a, req)
96 if rec.Code != http.StatusUnauthorized {
97 t.Fatalf("GET /api/v1/decks with a session cookie: status %d, want 401\n%s", rec.Code, rec.Body)
98 }
99 var out map[string]any
100 if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
101 t.Fatalf("the 401 body is not JSON: %v\n%s", err, rec.Body)
102 }
103 if out["error"] != "unauthorized" {
104 t.Errorf("error = %v, want %q", out["error"], "unauthorized")
105 }
106
107 // The mirror of the same rule: an HTML route never reads a bearer.
108 token := mintAgent(t, a, cookies, "claude")
109 page := send(t, a, bearerRequest(t, http.MethodGet, "/", token, ""))
110 if page.Code != http.StatusSeeOther {
111 t.Errorf("GET / with a bearer and no cookie: status %d, want a redirect to sign-in", page.Code)
112 }
113
114 // A garbage bearer is the same 401 as none at all.
115 bad := apiJSON(t, a, http.MethodGet, "/api/v1/decks", "sl_not-a-real-token", "", http.StatusUnauthorized)
116 if bad["error"] != "unauthorized" {
117 t.Errorf("a bad token: error = %v, want %q", bad["error"], "unauthorized")
118 }
119}
120
121// TestApiCreateDeckRecordsAgentActor: a deck the API makes is the
122// operator's, and every write it carries names the AGENT β€” never the
123// person whose token it is holding.
124func TestApiCreateDeckRecordsAgentActor(t *testing.T) {
125 a := newApp(t)
126 cookies := signedIn(t, a, allowedAddress)
127 token := mintAgent(t, a, cookies, "claude")
128
129 sqid := apiDeck(t, a, token, "Q3 review")
130
131 var got struct {
132 Actor string
133 OwnerID int64
134 }
135 err := a.db.G.Raw(`SELECT u.actor AS actor, d.owner_id AS owner_id
136 FROM deck_updates u JOIN decks d ON d.id = u.deck_id
137 WHERE d.sqid = ? ORDER BY u.seq DESC LIMIT 1`, sqid).Scan(&got).Error
138 if err != nil {
139 t.Fatalf("read deck_updates: %v", err)
140 }
141 var agentID int64
142 if err := a.db.G.Raw("SELECT id FROM agents").Scan(&agentID).Error; err != nil {
143 t.Fatalf("read agents: %v", err)
144 }
145 if want := "agent:" + strconv.FormatInt(agentID, 10); got.Actor != want {
146 t.Errorf("deck_updates.actor = %q, want %q", got.Actor, want)
147 }
148 var operator int64
149 if err := a.db.G.Raw("SELECT id FROM users WHERE email = ?", allowedAddress).Scan(&operator).Error; err != nil {
150 t.Fatalf("read users: %v", err)
151 }
152 if got.OwnerID != operator {
153 t.Errorf("decks.owner_id = %d, want the operator %d", got.OwnerID, operator)
154 }
155
156 // The deck the agent made is the operator's to open, and the row
157 // says who last touched it: the robot mark plus the principal,
158 // never a bare id.
159 page := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
160 if !strings.Contains(page, "Q3 review") {
161 t.Fatalf("the deck the agent made is not in the operator's list:\n%s", page)
162 }
163 if want := "πŸ€– claude~" + allowedAddress; !strings.Contains(page, want) {
164 t.Errorf("the deck list does not badge the agent's write with %q:\n%s", want, page)
165 }
166 // And the sample deck the operator's own sign-in seeded is badged
167 // the other way β€” a person's address, with no robot mark. Matched
168 // with the separator in front of it so this cannot pass on the
169 // account menu, which shows the same address.
170 if want := "Β· by " + allowedAddress; !strings.Contains(page, want) {
171 t.Errorf("the deck list does not name the person behind a person's write with %q:\n%s", want, page)
172 }
173}
174
175// TestApiOpsMintIdsAndReturnThem: an agent may leave the ids out, and
176// the answer tells it what they were β€” otherwise it could never
177// address the slide it just made.
178func TestApiOpsMintIdsAndReturnThem(t *testing.T) {
179 a := newApp(t)
180 cookies := signedIn(t, a, allowedAddress)
181 token := mintAgent(t, a, cookies, "claude")
182 sqid := apiDeck(t, a, token, "Q3 review")
183
184 before := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
185
186 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[
187 {"op":"add_slide","layout":"flow","template":"section"},
188 {"op":"set_meta","title":"Q3 review"}
189 ]}`, http.StatusOK)
190
191 ids, ok := out["ids"].(map[string]any)
192 if !ok {
193 t.Fatalf("ops answered no ids map: %v", out)
194 }
195 slide, _ := ids["0"].(string)
196 if len(slide) != 12 {
197 t.Fatalf(`ids["0"] = %v, want a twelve-character slide id`, ids["0"])
198 }
199 if _, minted := ids["1"]; minted {
200 t.Errorf("set_meta needs no id, but one was minted for it: %v", ids)
201 }
202 if out["seq"].(float64) <= before["seq"].(float64) {
203 t.Errorf("seq did not move: %v then %v", before["seq"], out["seq"])
204 }
205
206 // The id it was handed is the id the document holds, so the next
207 // op an agent sends can address the slide it just made.
208 after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
209 if !strings.Contains(mustJSON(t, after["slides"]), slide) {
210 t.Errorf("the minted slide id %q is not in the deck:\n%s", slide, mustJSON(t, after["slides"]))
211 }
212
213 // And a block goes on it the same way.
214 blockOut := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
215 `{"ops":[{"op":"add_block","slide":`+quote(slide)+`,"type":"heading","region":"heading","markdown":"Revenue"}]}`,
216 http.StatusOK)
217 blockIDs, _ := blockOut["ids"].(map[string]any)
218 block, _ := blockIDs["0"].(string)
219 if len(block) != 12 {
220 t.Fatalf("add_block minted no id: %v", blockOut)
221 }
222
223 // The rendered section is how an agent looks at what it made.
224 html := send(t, a, bearerRequest(t, http.MethodGet,
225 "/api/v1/decks/"+sqid+"/slides/"+slide+".html?vp=phone", token, ""))
226 if html.Code != http.StatusOK {
227 t.Fatalf("GET the slide html: status %d\n%s", html.Code, html.Body)
228 }
229 if ct := html.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
230 t.Errorf("slide html Content-Type = %q, want text/html", ct)
231 }
232 body := html.Body.String()
233 if !strings.HasPrefix(strings.TrimSpace(body), "<section") {
234 t.Errorf("the slide endpoint answered a whole page rather than the section:\n%s", body)
235 }
236 if !strings.Contains(body, "Revenue") {
237 t.Errorf("the rendered slide does not carry the block the agent added:\n%s", body)
238 }
239 if !strings.Contains(body, `data-vp="phone"`) {
240 t.Errorf("?vp=phone did not reach the renderer:\n%s", body)
241 }
242}
243
244// TestApiDuplicateSlideMintsEveryBlockId: duplicate_slide is the op
245// that needs more than one id β€” a new slide id and exactly one new id
246// per block being copied β€” and an agent should not have to count the
247// blocks itself to use it.
248func TestApiDuplicateSlideMintsEveryBlockId(t *testing.T) {
249 a := newApp(t)
250 cookies := signedIn(t, a, allowedAddress)
251 token := mintAgent(t, a, cookies, "claude")
252 sqid := apiDeck(t, a, token, "Q3 review")
253
254 // The deck opens on one slide carrying one heading; add a second
255 // block so the copy has two ids to mint rather than one.
256 deck := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
257 slides, _ := deck["slides"].([]any)
258 if len(slides) != 1 {
259 t.Fatalf("a new deck has %d slides, want 1", len(slides))
260 }
261 first, _ := slides[0].(map[string]any)
262 source, _ := first["id"].(string)
263
264 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
265 `{"ops":[{"op":"add_block","slide":`+quote(source)+`,"type":"text","region":"body","markdown":"Up 14%"}]}`,
266 http.StatusOK)
267
268 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
269 `{"ops":[{"op":"duplicate_slide","id":`+quote(source)+`}]}`, http.StatusOK)
270 ids, _ := out["ids"].(map[string]any)
271 copyID, _ := ids["0"].(string)
272 if len(copyID) != 12 {
273 t.Fatalf("duplicate_slide minted no new slide id: %v", out)
274 }
275
276 after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
277 list, _ := after["slides"].([]any)
278 if len(list) != 2 {
279 t.Fatalf("after duplicate_slide the deck has %d slides, want 2", len(list))
280 }
281 // The copy's blocks are new ids, not the originals: two slides
282 // sharing a block id would be one block in two places.
283 seen := map[string]bool{}
284 for _, raw := range list {
285 s, _ := raw.(map[string]any)
286 blocks, _ := s["blocks"].([]any)
287 if len(blocks) != 2 {
288 t.Fatalf("slide %v has %d blocks, want 2", s["id"], len(blocks))
289 }
290 for _, b := range blocks {
291 id, _ := b.(map[string]any)["id"].(string)
292 if seen[id] {
293 t.Errorf("block id %q appears on two slides", id)
294 }
295 seen[id] = true
296 }
297 }
298}
299
300// TestApiRejectedBatchAppliesNothing: the core refuses a batch as one
301// thing, so a batch whose second op is wrong must leave the deck
302// exactly as it was β€” including the good op in front of it.
303func TestApiRejectedBatchAppliesNothing(t *testing.T) {
304 a := newApp(t)
305 cookies := signedIn(t, a, allowedAddress)
306 token := mintAgent(t, a, cookies, "claude")
307 sqid := apiDeck(t, a, token, "Q3 review")
308
309 before := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
310
311 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[
312 {"op":"add_slide","layout":"flow","template":"section"},
313 {"op":"set_text","slide":"nosuchslide","id":"nosuchblock","markdown":"x"}
314 ]}`, http.StatusUnprocessableEntity)
315
316 if out["error"] != "invalid" {
317 t.Errorf("a refused batch: error = %v, want %q", out["error"], "invalid")
318 }
319 if op, ok := out["op"].(float64); !ok || op != 1 {
320 t.Errorf("a refused batch: op = %v, want the index 1", out["op"])
321 }
322 if msg, _ := out["message"].(string); msg == "" {
323 t.Errorf("a refused batch carries no message: %v", out)
324 }
325
326 after := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
327 if after["seq"] != before["seq"] {
328 t.Errorf("a refused batch moved the seq: %v then %v", before["seq"], after["seq"])
329 }
330 if mustJSON(t, after["slides"]) != mustJSON(t, before["slides"]) {
331 t.Errorf("a refused batch changed the slides:\n%s\n%s",
332 mustJSON(t, before["slides"]), mustJSON(t, after["slides"]))
333 }
334
335 // An op the vocabulary has never heard of is the same answer.
336 unknown := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
337 `{"ops":[{"op":"delete_everything"}]}`, http.StatusUnprocessableEntity)
338 if unknown["error"] != "invalid" {
339 t.Errorf("an unknown op: error = %v, want %q", unknown["error"], "invalid")
340 }
341}
342
343// TestApiGetHonoursEtag: the seq is the deck's version, so an agent
344// that already has it can be told so instead of being handed the whole
345// document again.
346func TestApiGetHonoursEtag(t *testing.T) {
347 a := newApp(t)
348 cookies := signedIn(t, a, allowedAddress)
349 token := mintAgent(t, a, cookies, "claude")
350 sqid := apiDeck(t, a, token, "Q3 review")
351
352 rec := send(t, a, bearerRequest(t, http.MethodGet, "/api/v1/decks/"+sqid, token, ""))
353 if rec.Code != http.StatusOK {
354 t.Fatalf("GET the deck: status %d\n%s", rec.Code, rec.Body)
355 }
356 etag := rec.Header().Get("ETag")
357 if etag == "" || !strings.HasPrefix(etag, `"`) {
358 t.Fatalf("GET the deck: ETag = %q, want a quoted seq", etag)
359 }
360
361 req := bearerRequest(t, http.MethodGet, "/api/v1/decks/"+sqid, token, "")
362 req.Header.Set("If-None-Match", etag)
363 cached := send(t, a, req)
364 if cached.Code != http.StatusNotModified {
365 t.Fatalf("If-None-Match with the current ETag: status %d, want 304\n%s", cached.Code, cached.Body)
366 }
367 if cached.Body.Len() != 0 {
368 t.Errorf("a 304 carries a body: %s", cached.Body)
369 }
370
371 // One write later the same If-None-Match is stale and the whole
372 // document comes back.
373 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
374 `{"ops":[{"op":"add_slide","layout":"flow","template":"section"}]}`, http.StatusOK)
375 stale := send(t, a, req)
376 if stale.Code != http.StatusOK {
377 t.Errorf("If-None-Match with a stale ETag: status %d, want 200", stale.Code)
378 }
379 if stale.Header().Get("ETag") == etag {
380 t.Errorf("the ETag did not move with the deck: still %q", etag)
381 }
382}
383
384// TestApiListsWhatTheOperatorCanSee: the API's visibility is the
385// operator's visibility, not a wider one β€” an agent is exactly as
386// powerful as the person who minted it.
387func TestApiListsWhatTheOperatorCanSee(t *testing.T) {
388 a := newSharedApp(t)
389 mine := signedIn(t, a, allowedAddress)
390 theirs := signedIn(t, a, otherAddress)
391 token := mintAgent(t, a, mine, "claude")
392
393 hidden := createDeck(t, a, theirs, "Their private deck")
394 apiDeck(t, a, token, "Q3 review")
395
396 out := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK)
397 listed := mustJSON(t, out["decks"])
398 if !strings.Contains(listed, "Q3 review") {
399 t.Errorf("the list is missing the operator's own deck:\n%s", listed)
400 }
401 if strings.Contains(listed, "Their private deck") {
402 t.Errorf("the list carries another person's private deck:\n%s", listed)
403 }
404
405 // And addressing it directly is a 404, never a 403: a deck that is
406 // not yours does not exist (decks.go's visibleDecks).
407 sqid := strings.TrimPrefix(hidden, "/d/")
408 gone := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusNotFound)
409 if gone["error"] != "not_found" {
410 t.Errorf("a deck the operator cannot see: error = %v, want %q", gone["error"], "not_found")
411 }
412}
413
414// TestApiPresentStartsATalk: an agent can put a deck on a projector,
415// and gets back the three addresses that reach the session.
416func TestApiPresentStartsATalk(t *testing.T) {
417 a := newApp(t)
418 cookies := signedIn(t, a, allowedAddress)
419 token := mintAgent(t, a, cookies, "claude")
420 sqid := apiDeck(t, a, token, "Q3 review")
421
422 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/present", token, "", http.StatusOK)
423 session, _ := out["session"].(string)
424 if len(session) != 10 {
425 t.Fatalf("present: session = %v, want a sqid", out["session"])
426 }
427 for _, key := range []string{"present_url", "notes_url", "remote_url"} {
428 got, _ := out[key].(string)
429 if !strings.HasPrefix(got, testOrigin+"/") {
430 t.Errorf("present: %s = %q, want an absolute URL on %s", key, got, testOrigin)
431 }
432 }
433 if want := testOrigin + "/p/" + session; out["present_url"] != want {
434 t.Errorf("present_url = %v, want %q", out["present_url"], want)
435 }
436
437 // The surface it named is live for somebody with no session at all
438 // β€” which is the whole point of putting it on a projector.
439 rec := get(t, a, "/p/"+session)
440 if rec.Code != http.StatusOK {
441 t.Errorf("the present URL an agent was handed: status %d, want 200", rec.Code)
442 }
443
444 // Pressing it again picks the same talk up rather than starting a
445 // second one.
446 twice := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/present", token, "", http.StatusOK)
447 if twice["session"] != out["session"] {
448 t.Errorf("presenting twice made a second session: %v then %v", out["session"], twice["session"])
449 }
450}
451
452// TestApiRefusesBodiesItCannotRead: the three refusals a JSON surface
453// owes a caller β€” the wrong media type, a body too big to be a deck
454// edit, and JSON that is not what the endpoint takes.
455func TestApiRefusesBodiesItCannotRead(t *testing.T) {
456 a := newApp(t)
457 cookies := signedIn(t, a, allowedAddress)
458 token := mintAgent(t, a, cookies, "claude")
459 sqid := apiDeck(t, a, token, "Q3 review")
460
461 form := bearerRequest(t, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":[]}`)
462 form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
463 if rec := send(t, a, form); rec.Code != http.StatusUnsupportedMediaType {
464 t.Errorf("a form-encoded body: status %d, want 415\n%s", rec.Code, rec.Body)
465 }
466
467 huge := `{"ops":[{"op":"set_meta","title":"` + strings.Repeat("x", 1<<20) + `"}]}`
468 if rec := send(t, a, bearerRequest(t, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, huge)); rec.Code != http.StatusRequestEntityTooLarge {
469 t.Errorf("a body over a mebibyte: status %d, want 413", rec.Code)
470 }
471
472 bad := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token, `{"ops":"nope"}`, http.StatusUnprocessableEntity)
473 if bad["error"] != "invalid" {
474 t.Errorf("ops that are not a list: error = %v, want %q", bad["error"], "invalid")
475 }
476 empty := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token, `{"title":""}`, http.StatusUnprocessableEntity)
477 if empty["error"] != "invalid" {
478 t.Errorf("a deck with no title: error = %v, want %q", empty["error"], "invalid")
479 }
480 theme := apiJSON(t, a, http.MethodPost, "/api/v1/decks", token, `{"title":"x","theme":"neon"}`, http.StatusUnprocessableEntity)
481 if theme["error"] != "invalid" {
482 t.Errorf("a theme that is not one of the six: error = %v, want %q", theme["error"], "invalid")
483 }
484
485 // Two JSON values in one body is not one request with something
486 // extra after it, it is a caller whose encoder is wrong or whose
487 // batch got concatenated β€” and reading only the first one would
488 // silently apply half of what was sent.
489 trailing := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
490 `{"ops":[{"op":"set_meta","title":"one"}]}{"ops":[{"op":"set_meta","title":"two"}]}`,
491 http.StatusUnprocessableEntity)
492 if trailing["error"] != "invalid" {
493 t.Errorf("a body with bytes after the JSON value: error = %v, want %q", trailing["error"], "invalid")
494 }
495 // And it applied nothing: the second title is not the deck's, and
496 // neither is the first.
497 got := apiJSON(t, a, http.MethodGet, "/api/v1/decks/"+sqid, token, "", http.StatusOK)
498 if s := mustJSON(t, got["meta"]); strings.Contains(s, `"one"`) || strings.Contains(s, `"two"`) {
499 t.Errorf("a refused body changed the document: meta = %s", s)
500 }
501
502 // A trailing newline is not trailing bytes: every `curl -d @file`
503 // sends one.
504 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
505 `{"ops":[{"op":"set_meta","title":"fine"}]}`+"\n", http.StatusOK)
506}
507
508// TestApiSetMetaUpdatesTheDeckRow: a title and a theme live in two
509// places β€” the document everyone reads and the decks row a list, a
510// link and the settings form read β€” and an agent's set_meta has to
511// move both, exactly as the settings screen's own save does.
512//
513// It did not: apiOps applied the batch with no rowUpdates at all, so
514// an agent could rename a deck and the deck list would go on showing
515// the old name forever.
516func TestApiSetMetaUpdatesTheDeckRow(t *testing.T) {
517 a := newApp(t)
518 cookies := signedIn(t, a, allowedAddress)
519 token := mintAgent(t, a, cookies, "claude")
520 sqid := apiDeck(t, a, token, "Working title")
521
522 apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
523 `{"ops":[{"op":"set_meta","title":"The real title","theme":"forest"}]}`,
524 http.StatusOK)
525
526 list := apiJSON(t, a, http.MethodGet, "/api/v1/decks", token, "", http.StatusOK)
527 decks, _ := list["decks"].([]any)
528 var found map[string]any
529 for _, row := range decks {
530 if m, ok := row.(map[string]any); ok && m["sqid"] == sqid {
531 found = m
532 }
533 }
534 if found == nil {
535 t.Fatalf("GET /api/v1/decks does not list %s:\n%s", sqid, mustJSON(t, list))
536 }
537 if found["title"] != "The real title" {
538 t.Errorf("the deck list shows title %v, want %q", found["title"], "The real title")
539 }
540
541 // And the settings form pre-fills from the same row, so what a
542 // person sees when they open it is what the agent wrote.
543 page := mustGet(t, a, "/d/"+sqid+"/settings", http.StatusOK, cookies).Body.String()
544 if !strings.Contains(page, `value="The real title"`) {
545 t.Errorf("the settings form does not pre-fill the agent's title:\n%s", page)
546 }
547 if !strings.Contains(page, `<option value="forest" selected>`) {
548 t.Errorf("the settings form does not pre-select the agent's theme:\n%s", page)
549 }
550}
551
552// TestApiOpsConflictIs409: two writers landing at once is a 409 with
553// the documented body, never a 500.
554//
555// The race is staged rather than run: the decks row is put one seq
556// BEHIND its own update log, which is precisely the state a request
557// holds when somebody else's batch landed between it reading the row
558// and writing its own. Both of applyAgentOps' attempts re-read that
559// row, so both lose, which is the case docs/api.md describes β€” and no
560// goroutine and no sleep are involved, so the test cannot flake.
561func TestApiOpsConflictIs409(t *testing.T) {
562 a := newApp(t)
563 cookies := signedIn(t, a, allowedAddress)
564 token := mintAgent(t, a, cookies, "claude")
565 sqid := apiDeck(t, a, token, "Contended")
566
567 if err := a.db.G.Exec("UPDATE decks SET seq = seq - 1 WHERE sqid = ?", sqid).Error; err != nil {
568 t.Fatalf("stage the lost race: %v", err)
569 }
570
571 out := apiJSON(t, a, http.MethodPost, "/api/v1/decks/"+sqid+"/ops", token,
572 `{"ops":[{"op":"set_meta","title":"Mine"}]}`, http.StatusConflict)
573 if out["error"] != "conflict" {
574 t.Errorf("a lost race: error = %v, want %q", out["error"], "conflict")
575 }
576}
577
578// mustJSON re-encodes a decoded fragment so a test can compare or
579// search it as text.
580func mustJSON(t *testing.T, v any) string {
581 t.Helper()
582 b, err := json.Marshal(v)
583 if err != nil {
584 t.Fatalf("re-encode: %v", err)
585 }
586 return string(b)
587}
588