1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5package cooked
6
7import (
8 "context"
9 "encoding/json"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strings"
14 "testing"
15)
16
17func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
18 var sawSessionCookie bool
19 client, closeServer := newTestClient(t, shoppingListTestHandler(t, &sawSessionCookie))
20 defer closeServer()
21
22 shoppingList, err := client.ReadShoppingList(context.Background())
23 if err != nil {
24 t.Fatalf("ReadShoppingList() error = %v", err)
25 }
26
27 if !sawSessionCookie {
28 t.Fatal("shopping-list request did not receive session cookie")
29 }
30 if len(shoppingList.Aisles) != 1 || shoppingList.Aisles[0].ProductGroups[0].Name != "Pasta" {
31 t.Fatalf("shopping list = %#v, want one Pasta item", shoppingList)
32 }
33}
34
35func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
36 client, closeServer := newTestClient(t, listRecipesTestHandler(t))
37 defer closeServer()
38
39 recipes, err := client.ListRecipes(context.Background(), 2, 5)
40 if err != nil {
41 t.Fatalf("ListRecipes() error = %v", err)
42 }
43
44 if len(recipes) != 1 {
45 t.Fatalf("recipes length = %d, want 1", len(recipes))
46 }
47 if recipes[0].ID != "recipe-1" || recipes[0].Title != "Pasta" {
48 t.Fatalf("recipe = %#v, want recipe-1 Pasta", recipes[0])
49 }
50}
51
52func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(t *testing.T) {
53 client, closeServer := newTestClient(t, searchRecipesTestHandler(t))
54 defer closeServer()
55
56 recipes, err := client.SearchRecipes(context.Background(), "pasta & tomato", 3)
57 if err != nil {
58 t.Fatalf("SearchRecipes() error = %v", err)
59 }
60
61 if len(recipes) != 1 {
62 t.Fatalf("recipes length = %d, want 1", len(recipes))
63 }
64 if recipes[0].ID != "recipe-1" || recipes[0].Title != "Pasta" {
65 t.Fatalf("recipe = %#v, want recipe-1 Pasta", recipes[0])
66 }
67}
68
69func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
70 client, closeServer := newTestClient(t, recipeMetadataTestHandler(t))
71 defer closeServer()
72
73 metadata, err := client.ReadRecipeMetadata(context.Background(), "recipe/with space")
74 if err != nil {
75 t.Fatalf("ReadRecipeMetadata() error = %v", err)
76 }
77
78 if metadata.Title != "Pasta with Tomato Sauce" {
79 t.Fatalf("metadata title = %q, want Pasta with Tomato Sauce", metadata.Title)
80 }
81 if len(metadata.ImageURLs) != 1 || metadata.ImageURLs[0] != "https://example.invalid/pasta.jpg" {
82 t.Fatalf("metadata image URLs = %#v, want one pasta image", metadata.ImageURLs)
83 }
84 if metadata.Owner != "returned-user" || !metadata.EditPermission {
85 t.Fatalf("metadata owner/edit = %q/%v, want returned-user/true", metadata.Owner, metadata.EditPermission)
86 }
87}
88
89func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing.T) {
90 client, closeServer := newTestClient(t, recipeContentTestHandler(t))
91 defer closeServer()
92
93 content, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
94 if err != nil {
95 t.Fatalf("ReadRecipeContent() error = %v", err)
96 }
97
98 if content.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." {
99 t.Fatalf("content = %q, want recipe markdown", content.Content)
100 }
101 if content.Portions != 2 {
102 t.Fatalf("portions = %g, want 2", content.Portions)
103 }
104}
105
106func TestPreviewRecipeTextACIDRecipesPreviewText1To4PreviewsWithoutSaving(t *testing.T) {
107 var previewCalls int
108 client, closeServer := newTestClient(t, previewRecipeTextTestHandler(t, &previewCalls))
109 defer closeServer()
110
111 preview, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.")
112 if err != nil {
113 t.Fatalf("PreviewRecipeText() error = %v", err)
114 }
115
116 if previewCalls != 1 {
117 t.Fatalf("preview calls = %d, want 1", previewCalls)
118 }
119 if preview.Title != "Pasta with Tomato Sauce" {
120 t.Fatalf("preview title = %q, want Pasta with Tomato Sauce", preview.Title)
121 }
122 if preview.Markdown != "# Pasta\n\n1. Boil pasta." {
123 t.Fatalf("preview markdown = %q, want recipe markdown", preview.Markdown)
124 }
125 if preview.Portions != 2 {
126 t.Fatalf("preview portions = %g, want 2", preview.Portions)
127 }
128}
129
130// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9 recipes.SAVE.13
131func TestSavePreparedRecipeACIDRecipesSave2And8_4And9And13SavesMarkdownAsDescription(t *testing.T) {
132 var saveCalls int
133 client, closeServer := newTestClient(t, savePreparedRecipeTestHandler(t, &saveCalls))
134 defer closeServer()
135
136 recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2.5)
137 if err != nil {
138 t.Fatalf("SavePreparedRecipe() error = %v", err)
139 }
140
141 if saveCalls != 1 {
142 t.Fatalf("save calls = %d, want 1", saveCalls)
143 }
144 if recipeID != "recipe-1" {
145 t.Fatalf("recipe ID = %q, want recipe-1", recipeID)
146 }
147}
148
149// recipes.SAVE.7 recipes.SAVE.9 recipes.SAVE.13
150func TestSaveRecipeDraftACIDRecipesSave7And9And13SavesReviewedExtractionDraft(t *testing.T) {
151 var saveCalls int
152 client, closeServer := newTestClient(t, saveRecipeDraftTestHandler(t, &saveCalls))
153 defer closeServer()
154
155 recipeID, err := client.SaveRecipeDraft(context.Background(), "draft/with space", "# Pasta\n\n1. Boil pasta.", 4)
156 if err != nil {
157 t.Fatalf("SaveRecipeDraft() error = %v", err)
158 }
159
160 if saveCalls != 1 {
161 t.Fatalf("save calls = %d, want 1", saveCalls)
162 }
163 if recipeID != "recipe-1" {
164 t.Fatalf("recipe ID = %q, want recipe-1", recipeID)
165 }
166}
167
168func TestUpdateRecipeContentACIDRecipesSave8And9And13UpdatesExistingRecipe(t *testing.T) {
169 var updateCalls int
170 client, closeServer := newTestClient(t, updateRecipeContentTestHandler(t, &updateCalls))
171 defer closeServer()
172
173 err := client.UpdateRecipeContent(context.Background(), "recipe/with space", "# Pasta\n\n1. Boil pasta.", 4)
174 if err != nil {
175 t.Fatalf("UpdateRecipeContent() error = %v", err)
176 }
177
178 if updateCalls != 1 {
179 t.Fatalf("update calls = %d, want 1", updateCalls)
180 }
181}
182
183func TestImportRecipeURLACIDRecipesSave3And4ImportsURLAndReturnsSavedRecipeID(t *testing.T) {
184 var importCalls int
185 client, closeServer := newTestClient(t, importRecipeURLTestHandler(t, &importCalls, importRecipeURLResponse{
186 RecipeID: "recipe-1",
187 }))
188 defer closeServer()
189
190 result, err := client.ImportRecipeURL(context.Background(), "https://example.com/recipe")
191 if err != nil {
192 t.Fatalf("ImportRecipeURL() error = %v", err)
193 }
194
195 if importCalls != 1 {
196 t.Fatalf("import calls = %d, want 1", importCalls)
197 }
198 if result.RecipeID != "recipe-1" {
199 t.Fatalf("recipe ID = %q, want recipe-1", result.RecipeID)
200 }
201 if result.DraftID != "" {
202 t.Fatalf("draft ID = %q, want empty", result.DraftID)
203 }
204}
205
206func TestImportRecipeURLACIDRecipesSave3And5And6ImportsURLAndReturnsDraftIDWithoutSaving(t *testing.T) {
207 var importCalls int
208 client, closeServer := newTestClient(t, importRecipeURLTestHandler(t, &importCalls, importRecipeURLResponse{
209 ExtractionID: "draft-1",
210 }))
211 defer closeServer()
212
213 result, err := client.ImportRecipeURL(context.Background(), "https://example.com/recipe")
214 if err != nil {
215 t.Fatalf("ImportRecipeURL() error = %v", err)
216 }
217
218 if importCalls != 1 {
219 t.Fatalf("import calls = %d, want 1", importCalls)
220 }
221 if result.RecipeID != "" {
222 t.Fatalf("recipe ID = %q, want empty", result.RecipeID)
223 }
224 if result.DraftID != "draft-1" {
225 t.Fatalf("draft ID = %q, want draft-1", result.DraftID)
226 }
227}
228
229func TestDeleteRecipeDeletesSavedRecipe(t *testing.T) {
230 var deleteCalls int
231 client, closeServer := newTestClient(t, deleteRecipeTestHandler(t, &deleteCalls))
232 defer closeServer()
233
234 err := client.DeleteRecipe(context.Background(), "recipe/with space")
235 if err != nil {
236 t.Fatalf("DeleteRecipe() error = %v", err)
237 }
238
239 if deleteCalls != 1 {
240 t.Fatalf("delete calls = %d, want 1", deleteCalls)
241 }
242}
243
244func TestClearShoppingListACIDShoppingListClear1ClearsAllItems(t *testing.T) {
245 var clearCalls int
246 client, closeServer := newTestClient(t, clearShoppingListTestHandler(t, &clearCalls))
247 defer closeServer()
248
249 err := client.ClearShoppingList(context.Background())
250 if err != nil {
251 t.Fatalf("ClearShoppingList() error = %v", err)
252 }
253
254 if clearCalls != 1 {
255 t.Fatalf("clear calls = %d, want 1", clearCalls)
256 }
257}
258
259func TestAddShoppingListIngredientsACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
260 var addCalls int
261 client, closeServer := newTestClient(t, addShoppingListIngredientsTestHandler(t, &addCalls))
262 defer closeServer()
263
264 result, err := client.AddShoppingListIngredients(
265 context.Background(),
266 "200g pasta\n1 cup tomato sauce",
267 "recipe-1",
268 )
269 if err != nil {
270 t.Fatalf("AddShoppingListIngredients() error = %v", err)
271 }
272
273 if addCalls != 1 {
274 t.Fatalf("add calls = %d, want 1", addCalls)
275 }
276 if result.AddedCount != 2 {
277 t.Fatalf("added count = %d, want 2", result.AddedCount)
278 }
279 if len(result.Ingredients) != 2 || result.Ingredients[0] != "200g pasta" {
280 t.Fatalf("added ingredients = %#v, want two added ingredients", result.Ingredients)
281 }
282}
283
284func TestAddShoppingListIngredientsACIDShoppingListAdd6RejectsZeroAddedCount(t *testing.T) {
285 var addCalls int
286 client, closeServer := newTestClient(t, addShoppingListIngredientsResponseTestHandler(
287 t,
288 &addCalls,
289 "200g pasta",
290 "",
291 `{"success":true,"added-count":0,"ingredients":[]}`,
292 ))
293 defer closeServer()
294
295 _, err := client.AddShoppingListIngredients(context.Background(), "200g pasta", "")
296 if err == nil {
297 t.Fatal("AddShoppingListIngredients() error = nil, want zero added-count error")
298 }
299 if !strings.Contains(err.Error(), "added-count 0") {
300 t.Fatalf("AddShoppingListIngredients() error = %v, want added-count 0 error", err)
301 }
302 if addCalls != 1 {
303 t.Fatalf("add calls = %d, want 1", addCalls)
304 }
305}
306
307func TestRemoveShoppingListProductGroupsACIDShoppingListRemove1RemovesProductGroups(t *testing.T) {
308 var removeCalls int
309 client, closeServer := newTestClient(t, removeShoppingListProductGroupsTestHandler(t, &removeCalls))
310 defer closeServer()
311
312 err := client.RemoveShoppingListProductGroups(context.Background(), []string{"pasta", "tomato"})
313 if err != nil {
314 t.Fatalf("RemoveShoppingListProductGroups() error = %v", err)
315 }
316
317 if removeCalls != 1 {
318 t.Fatalf("remove calls = %d, want 1", removeCalls)
319 }
320}
321
322func TestReplaceShoppingListSelectionACIDShoppingListSelection1And8ReplacesSelectedSet(t *testing.T) {
323 var replaceCalls int
324 client, closeServer := newTestClient(t, replaceShoppingListSelectionTestHandler(t, &replaceCalls))
325 defer closeServer()
326
327 err := client.ReplaceShoppingListSelection(context.Background(), []string{"pasta", "tomato"})
328 if err != nil {
329 t.Fatalf("ReplaceShoppingListSelection() error = %v", err)
330 }
331
332 if replaceCalls != 1 {
333 t.Fatalf("replace calls = %d, want 1", replaceCalls)
334 }
335}
336
337func TestUpdateShoppingListProductGroupACIDShoppingListUpdateItem1UpdatesProductGroup(t *testing.T) {
338 var updateCalls int
339 client, closeServer := newTestClient(t, updateShoppingListProductGroupTestHandler(t, &updateCalls))
340 defer closeServer()
341
342 err := client.UpdateShoppingListProductGroup(
343 context.Background(),
344 "product/with space",
345 ShoppingListProductGroupUpdate{
346 Name: "Pasta",
347 Quantity: "250g",
348 AisleID: "pantry",
349 Selected: false,
350 },
351 )
352 if err != nil {
353 t.Fatalf("UpdateShoppingListProductGroup() error = %v", err)
354 }
355
356 if updateCalls != 1 {
357 t.Fatalf("update calls = %d, want 1", updateCalls)
358 }
359}
360
361func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
362 client := &Client{authenticatedUsername: "user/name"}
363
364 got := client.userPath("/api/user/{username}/shopping-list")
365 if got != "/api/user/user%2Fname/shopping-list" {
366 t.Fatalf("userPath() = %q, want escaped username", got)
367 }
368}
369
370func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
371 baseURL, err := url.Parse("https://example.invalid")
372 if err != nil {
373 t.Fatalf("parse base URL: %v", err)
374 }
375 client := &Client{baseURL: baseURL}
376
377 request, err := client.newRequest(
378 context.Background(),
379 http.MethodGet,
380 "/api/user/user%2Fname/recipes?page=2&page-count=5",
381 nil,
382 )
383 if err != nil {
384 t.Fatalf("newRequest() error = %v", err)
385 }
386
387 if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
388 t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
389 }
390}
391
392func TestNewRequestSetsUserAgent(t *testing.T) {
393 baseURL, err := url.Parse("https://example.invalid")
394 if err != nil {
395 t.Fatalf("parse base URL: %v", err)
396 }
397 client := &Client{baseURL: baseURL, userAgent: "cooked-mcp/test"}
398
399 request, err := client.newRequest(
400 context.Background(),
401 http.MethodGet,
402 "/api/public/login",
403 nil,
404 )
405 if err != nil {
406 t.Fatalf("newRequest() error = %v", err)
407 }
408
409 if got := request.Header.Get("User-Agent"); got != "cooked-mcp/test" {
410 t.Fatalf("User-Agent = %q, want %q", got, "cooked-mcp/test")
411 }
412}
413
414func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
415 t.Helper()
416
417 server := httptest.NewServer(handler)
418 baseURL, err := url.Parse(server.URL)
419 if err != nil {
420 server.Close()
421 t.Fatalf("parse server URL: %v", err)
422 }
423 client, err := NewClient(baseURL, "configured-user", "configured-password", "cooked-mcp/test")
424 if err != nil {
425 server.Close()
426 t.Fatalf("NewClient() error = %v", err)
427 }
428
429 return client, server.Close
430}
431
432func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
433 t.Helper()
434
435 return func(w http.ResponseWriter, r *http.Request) {
436 switch r.URL.Path {
437 case "/api/public/login":
438 requireLoginRequest(t, r)
439 writeLoginResponse(t, w)
440 case "/api/user/returned-user/shopping-list":
441 requireSessionCookie(t, r)
442 *sawSessionCookie = true
443 writeShoppingListResponse(t, w)
444 default:
445 t.Fatalf("unexpected path %s", r.URL.Path)
446 }
447 }
448}
449
450func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
451 t.Helper()
452
453 return func(w http.ResponseWriter, r *http.Request) {
454 switch r.URL.Path {
455 case "/api/public/login":
456 writeLoginResponse(t, w)
457 case "/api/user/returned-user/recipes":
458 requireQueryValue(t, r, "page", "2")
459 requireQueryValue(t, r, "page-count", "5")
460 writeRecipeListResponse(t, w)
461 default:
462 t.Fatalf("unexpected path %s", r.URL.Path)
463 }
464 }
465}
466
467func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
468 t.Helper()
469
470 return func(w http.ResponseWriter, r *http.Request) {
471 switch r.URL.Path {
472 case "/api/public/login":
473 writeLoginResponse(t, w)
474 case "/api/user/returned-user/recipes/search":
475 requireMethod(t, r, http.MethodGet, "search")
476 requireQueryValue(t, r, "q", "pasta & tomato")
477 requireQueryValue(t, r, "page", "3")
478 writeRecipeListResponse(t, w)
479 default:
480 t.Fatalf("unexpected path %s", r.URL.Path)
481 }
482 }
483}
484
485func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
486 t.Helper()
487
488 return func(w http.ResponseWriter, r *http.Request) {
489 switch r.URL.EscapedPath() {
490 case "/api/public/login":
491 writeLoginResponse(t, w)
492 case "/api/recipe/recipe%2Fwith%20space/metadata":
493 requireMethod(t, r, http.MethodGet, "metadata")
494 requireSessionCookie(t, r)
495 writeRecipeMetadataResponse(t, w)
496 default:
497 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
498 }
499 }
500}
501
502func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
503 t.Helper()
504
505 return func(w http.ResponseWriter, r *http.Request) {
506 switch r.URL.EscapedPath() {
507 case "/api/public/login":
508 writeLoginResponse(t, w)
509 case "/api/recipe/recipe%2Fwith%20space/content":
510 requireMethod(t, r, http.MethodGet, "content")
511 requireSessionCookie(t, r)
512 writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
513 default:
514 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
515 }
516 }
517}
518
519func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerFunc {
520 t.Helper()
521
522 return func(w http.ResponseWriter, r *http.Request) {
523 switch r.URL.Path {
524 case "/api/public/login":
525 writeLoginResponse(t, w)
526 case "/api/new/from-text/preview":
527 requireMethod(t, r, http.MethodPost, "preview")
528 requireSessionCookie(t, r)
529 requirePreviewRecipeTextRequest(t, r)
530 (*previewCalls)++
531 writeJSON(t, w, RecipeTextPreview{
532 Title: "Pasta with Tomato Sauce",
533 Markdown: "# Pasta\n\n1. Boil pasta.",
534 Portions: 2,
535 })
536 default:
537 t.Fatalf("unexpected path %s", r.URL.Path)
538 }
539 }
540}
541
542func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
543 t.Helper()
544
545 return func(w http.ResponseWriter, r *http.Request) {
546 switch r.URL.Path {
547 case "/api/public/login":
548 writeLoginResponse(t, w)
549 case "/api/recipe/import/save":
550 requireMethod(t, r, http.MethodPost, "save prepared recipe")
551 requireSessionCookie(t, r)
552 requireSavePreparedRecipeRequest(t, r)
553 (*saveCalls)++
554 writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
555 default:
556 t.Fatalf("unexpected path %s", r.URL.Path)
557 }
558 }
559}
560
561func saveRecipeDraftTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
562 t.Helper()
563
564 return func(w http.ResponseWriter, r *http.Request) {
565 switch r.URL.EscapedPath() {
566 case "/api/public/login":
567 writeLoginResponse(t, w)
568 case "/api/extract/draft%2Fwith%20space/save":
569 requireMethod(t, r, http.MethodPost, "save recipe draft")
570 requireSessionCookie(t, r)
571 requireRecipeContentRequest(t, r, "draft save")
572 (*saveCalls)++
573 writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
574 default:
575 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
576 }
577 }
578}
579
580func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
581 t.Helper()
582
583 return func(w http.ResponseWriter, r *http.Request) {
584 switch r.URL.EscapedPath() {
585 case "/api/public/login":
586 writeLoginResponse(t, w)
587 case "/api/recipe/recipe%2Fwith%20space/content":
588 requireMethod(t, r, http.MethodPost, "update recipe content")
589 requireSessionCookie(t, r)
590 requireRecipeContentRequest(t, r, "update")
591 (*updateCalls)++
592 w.WriteHeader(http.StatusNoContent)
593 default:
594 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
595 }
596 }
597}
598
599func importRecipeURLTestHandler(
600 t *testing.T,
601 importCalls *int,
602 response importRecipeURLResponse,
603) http.HandlerFunc {
604 t.Helper()
605
606 return func(w http.ResponseWriter, r *http.Request) {
607 switch r.URL.Path {
608 case "/api/public/login":
609 writeLoginResponse(t, w)
610 case "/api/new":
611 requireMethod(t, r, http.MethodPost, "import recipe URL")
612 requireSessionCookie(t, r)
613 requireImportRecipeURLRequest(t, r)
614 (*importCalls)++
615 writeJSON(t, w, response)
616 case "/api/extract/draft-1/save":
617 t.Fatal("unexpected draft save call")
618 default:
619 t.Fatalf("unexpected path %s", r.URL.Path)
620 }
621 }
622}
623
624func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc {
625 t.Helper()
626
627 return func(w http.ResponseWriter, r *http.Request) {
628 switch r.URL.EscapedPath() {
629 case "/api/public/login":
630 writeLoginResponse(t, w)
631 case "/api/recipe/recipe%2Fwith%20space":
632 requireMethod(t, r, http.MethodDelete, "delete recipe")
633 requireSessionCookie(t, r)
634 (*deleteCalls)++
635 w.WriteHeader(http.StatusNoContent)
636 default:
637 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
638 }
639 }
640}
641
642func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFunc {
643 t.Helper()
644
645 return func(w http.ResponseWriter, r *http.Request) {
646 switch r.URL.EscapedPath() {
647 case "/api/public/login":
648 writeLoginResponse(t, w)
649 case "/api/user/returned-user/shopping-list":
650 requireMethod(t, r, http.MethodDelete, "clear shopping list")
651 requireSessionCookie(t, r)
652 (*clearCalls)++
653 w.WriteHeader(http.StatusNoContent)
654 default:
655 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
656 }
657 }
658}
659
660func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.HandlerFunc {
661 t.Helper()
662
663 return addShoppingListIngredientsResponseTestHandler(
664 t,
665 addCalls,
666 "200g pasta\n1 cup tomato sauce",
667 "recipe-1",
668 `{"success":true,"added-count":2,"ingredients":["200g pasta","1 cup tomato sauce"]}`,
669 )
670}
671
672func addShoppingListIngredientsResponseTestHandler(
673 t *testing.T,
674 addCalls *int,
675 expectedIngredients, expectedRecipeID, response string,
676) http.HandlerFunc {
677 t.Helper()
678
679 return func(w http.ResponseWriter, r *http.Request) {
680 switch r.URL.EscapedPath() {
681 case "/api/public/login":
682 writeLoginResponse(t, w)
683 case "/api/user/shopping-list":
684 requireMethod(t, r, http.MethodPut, "add shopping-list ingredients")
685 requireSessionCookie(t, r)
686 requireAddShoppingListIngredientsRequest(t, r, expectedIngredients, expectedRecipeID)
687 (*addCalls)++
688 writeAddShoppingListIngredientsResponse(t, w, response)
689 default:
690 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
691 }
692 }
693}
694
695func requireAddShoppingListIngredientsRequest(
696 t *testing.T,
697 r *http.Request,
698 expectedIngredients, expectedRecipeID string,
699) {
700 t.Helper()
701
702 var request struct {
703 Ingredients string `json:"ingredients"`
704 RecipeID string `json:"recipe-id"`
705 }
706 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
707 t.Fatalf("decode add shopping-list ingredients request: %v", err)
708 }
709 if request.Ingredients != expectedIngredients {
710 t.Fatalf("ingredients = %q, want %q", request.Ingredients, expectedIngredients)
711 }
712 if request.RecipeID != expectedRecipeID {
713 t.Fatalf("recipe ID = %q, want %q", request.RecipeID, expectedRecipeID)
714 }
715}
716
717func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter, response string) {
718 t.Helper()
719
720 w.Header().Set("Content-Type", "application/json")
721 if _, err := w.Write([]byte(response)); err != nil {
722 t.Fatalf("write add shopping-list ingredients response: %v", err)
723 }
724}
725
726func removeShoppingListProductGroupsTestHandler(t *testing.T, removeCalls *int) http.HandlerFunc {
727 t.Helper()
728
729 return func(w http.ResponseWriter, r *http.Request) {
730 switch r.URL.EscapedPath() {
731 case "/api/public/login":
732 writeLoginResponse(t, w)
733 case "/api/user/returned-user/shopping-list/product-groups":
734 requireMethod(t, r, http.MethodDelete, "remove shopping-list product groups")
735 requireSessionCookie(t, r)
736 requireRemoveShoppingListProductGroupsRequest(t, r)
737 (*removeCalls)++
738 w.WriteHeader(http.StatusNoContent)
739 default:
740 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
741 }
742 }
743}
744
745func replaceShoppingListSelectionTestHandler(t *testing.T, replaceCalls *int) http.HandlerFunc {
746 t.Helper()
747
748 return func(w http.ResponseWriter, r *http.Request) {
749 switch r.URL.EscapedPath() {
750 case "/api/public/login":
751 writeLoginResponse(t, w)
752 case "/api/user/returned-user/shopping-list/product-groups/selection":
753 requireMethod(t, r, http.MethodPut, "replace shopping-list selection")
754 requireSessionCookie(t, r)
755 requireReplaceShoppingListSelectionRequest(t, r)
756 (*replaceCalls)++
757 w.WriteHeader(http.StatusNoContent)
758 default:
759 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
760 }
761 }
762}
763
764func updateShoppingListProductGroupTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
765 t.Helper()
766
767 return func(w http.ResponseWriter, r *http.Request) {
768 switch r.URL.EscapedPath() {
769 case "/api/public/login":
770 writeLoginResponse(t, w)
771 case "/api/user/returned-user/shopping-list/product-groups/product%2Fwith%20space":
772 requireMethod(t, r, http.MethodPut, "update shopping-list product group")
773 requireSessionCookie(t, r)
774 requireUpdateShoppingListProductGroupRequest(t, r)
775 (*updateCalls)++
776 w.WriteHeader(http.StatusNoContent)
777 default:
778 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
779 }
780 }
781}
782
783func requireRemoveShoppingListProductGroupsRequest(t *testing.T, r *http.Request) {
784 t.Helper()
785
786 var request struct {
787 IDs []string `json:"ids"`
788 }
789 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
790 t.Fatalf("decode remove shopping-list product groups request: %v", err)
791 }
792 if len(request.IDs) != 2 || request.IDs[0] != "pasta" || request.IDs[1] != "tomato" {
793 t.Fatalf("remove IDs = %#v, want pasta and tomato", request.IDs)
794 }
795}
796
797func requireReplaceShoppingListSelectionRequest(t *testing.T, r *http.Request) {
798 t.Helper()
799
800 var request struct {
801 Selected []string `json:"selected"`
802 }
803 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
804 t.Fatalf("decode replace shopping-list selection request: %v", err)
805 }
806 if len(request.Selected) != 2 || request.Selected[0] != "pasta" || request.Selected[1] != "tomato" {
807 t.Fatalf("selected IDs = %#v, want pasta and tomato", request.Selected)
808 }
809}
810
811func requireUpdateShoppingListProductGroupRequest(t *testing.T, r *http.Request) {
812 t.Helper()
813
814 var request struct {
815 Name string `json:"name"`
816 Quantity string `json:"quantity"`
817 AisleID string `json:"aisle-id"`
818 Selected *bool `json:"selected"`
819 }
820 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
821 t.Fatalf("decode update shopping-list product group request: %v", err)
822 }
823 if request.Name != "Pasta" || request.Quantity != "250g" || request.AisleID != "pantry" {
824 t.Fatalf("update request = %#v, want configured name, quantity, and aisle", request)
825 }
826 if request.Selected == nil {
827 t.Fatal("update request selected missing, want explicit false")
828 }
829 if *request.Selected {
830 t.Fatal("update request selected = true, want false")
831 }
832}
833
834func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
835 t.Helper()
836
837 var request previewRecipeTextRequest
838 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
839 t.Fatalf("decode preview request: %v", err)
840 }
841 if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
842 t.Fatalf("preview request = %#v, want configured title/text", request)
843 }
844}
845
846func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
847 t.Helper()
848
849 var request savePreparedRecipeRequest
850 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
851 t.Fatalf("decode save prepared recipe request: %v", err)
852 }
853 if request.Title != "Pasta" {
854 t.Fatalf("save title = %q, want Pasta", request.Title)
855 }
856 if request.Description != "# Pasta\n\n1. Boil pasta." {
857 t.Fatalf("save description = %q, want markdown", request.Description)
858 }
859 if request.Portions != 2.5 {
860 t.Fatalf("save portions = %g, want 2.5", request.Portions)
861 }
862}
863
864func requireRecipeContentRequest(t *testing.T, r *http.Request, description string) {
865 t.Helper()
866
867 var request recipeContentRequest
868 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
869 t.Fatalf("decode %s recipe content request: %v", description, err)
870 }
871 if request.Description != "# Pasta\n\n1. Boil pasta." {
872 t.Fatalf("%s description = %q, want markdown", description, request.Description)
873 }
874 if request.Portions != 4 {
875 t.Fatalf("%s portions = %g, want 4", description, request.Portions)
876 }
877}
878
879func requireImportRecipeURLRequest(t *testing.T, r *http.Request) {
880 t.Helper()
881
882 var request importRecipeURLRequest
883 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
884 t.Fatalf("decode import recipe URL request: %v", err)
885 }
886 if request.URL != "https://example.com/recipe" {
887 t.Fatalf("import recipe URL = %q, want configured URL", request.URL)
888 }
889}
890
891func requireLoginRequest(t *testing.T, r *http.Request) {
892 t.Helper()
893 requireMethod(t, r, http.MethodPost, "login")
894
895 var request loginRequest
896 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
897 t.Fatalf("decode login request: %v", err)
898 }
899 if request.Username != "configured-user" || request.Password != "configured-password" {
900 t.Fatalf("login request = %#v, want configured credentials", request)
901 }
902}
903
904func requireMethod(t *testing.T, r *http.Request, want string, label string) {
905 t.Helper()
906
907 if r.Method != want {
908 t.Fatalf("%s method = %s, want %s", label, r.Method, want)
909 }
910}
911
912func requireSessionCookie(t *testing.T, r *http.Request) {
913 t.Helper()
914
915 cookie, err := r.Cookie("cooked_session")
916 if err != nil {
917 t.Fatalf("missing session cookie: %v", err)
918 }
919 if cookie.Value != "session-value" {
920 t.Fatalf("session cookie = %q, want session-value", cookie.Value)
921 }
922}
923
924func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
925 t.Helper()
926
927 if got := r.URL.Query().Get(name); got != want {
928 t.Fatalf("%s query = %q, want %q", name, got, want)
929 }
930}
931
932func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
933 t.Helper()
934
935 http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
936 writeJSON(t, w, loginResponse{Username: "returned-user"})
937}
938
939func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
940 t.Helper()
941
942 shoppingList := ShoppingList{
943 Aisles: []Aisle{{
944 ID: "pantry",
945 Name: "Pantry",
946 ProductGroups: []ProductGroup{{
947 ID: "pasta",
948 Name: "Pasta",
949 Quantity: "200g",
950 }},
951 }},
952 }
953
954 writeJSON(t, w, shoppingListResponse{
955 ShoppingList: &shoppingList,
956 Recipes: []string{"recipe-id"},
957 })
958}
959
960func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
961 t.Helper()
962
963 writeJSON(t, w, []RecipeCard{{
964 ID: "recipe-1",
965 Title: "Pasta",
966 ThumbnailURL: "https://example.invalid/thumb.jpg",
967 }})
968}
969
970func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
971 t.Helper()
972
973 w.Header().Set("Content-Type", "application/json")
974 if _, err := w.Write([]byte(`{
975 "title": "Pasta with Tomato Sauce",
976 "image-urls": ["https://example.invalid/pasta.jpg"],
977 "owner": "returned-user",
978 "edit-permission": true
979 }`)); err != nil {
980 t.Fatalf("write metadata response: %v", err)
981 }
982}
983
984func writeJSON[
985 T loginResponse | shoppingListResponse | []RecipeCard | RecipeContent | RecipeTextPreview |
986 saveRecipeResponse | importRecipeURLResponse,
987](
988 t *testing.T,
989 w http.ResponseWriter,
990 value T,
991) {
992 t.Helper()
993
994 w.Header().Set("Content-Type", "application/json")
995 if err := json.NewEncoder(w).Encode(value); err != nil {
996 t.Fatalf("write JSON: %v", err)
997 }
998}