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