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