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