client_test.go

  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}