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 TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
233	client := &Client{authenticatedUsername: "user/name"}
234
235	got := client.userPath("/api/user/{username}/shopping-list")
236	if got != "/api/user/user%2Fname/shopping-list" {
237		t.Fatalf("userPath() = %q, want escaped username", got)
238	}
239}
240
241func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
242	baseURL, err := url.Parse("https://example.invalid")
243	if err != nil {
244		t.Fatalf("parse base URL: %v", err)
245	}
246	client := &Client{baseURL: baseURL}
247
248	request, err := client.newRequest(
249		context.Background(),
250		http.MethodGet,
251		"/api/user/user%2Fname/recipes?page=2&page-count=5",
252		nil,
253	)
254	if err != nil {
255		t.Fatalf("newRequest() error = %v", err)
256	}
257
258	if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
259		t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
260	}
261}
262
263func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
264	t.Helper()
265
266	server := httptest.NewServer(handler)
267	baseURL, err := url.Parse(server.URL)
268	if err != nil {
269		server.Close()
270		t.Fatalf("parse server URL: %v", err)
271	}
272	client, err := NewClient(baseURL, "configured-user", "configured-password")
273	if err != nil {
274		server.Close()
275		t.Fatalf("NewClient() error = %v", err)
276	}
277
278	return client, server.Close
279}
280
281func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
282	t.Helper()
283
284	return func(w http.ResponseWriter, r *http.Request) {
285		switch r.URL.Path {
286		case "/api/public/login":
287			requireLoginRequest(t, r)
288			writeLoginResponse(t, w)
289		case "/api/user/returned-user/shopping-list":
290			requireSessionCookie(t, r)
291			*sawSessionCookie = true
292			writeShoppingListResponse(t, w)
293		default:
294			t.Fatalf("unexpected path %s", r.URL.Path)
295		}
296	}
297}
298
299func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
300	t.Helper()
301
302	return func(w http.ResponseWriter, r *http.Request) {
303		switch r.URL.Path {
304		case "/api/public/login":
305			writeLoginResponse(t, w)
306		case "/api/user/returned-user/recipes":
307			requireQueryValue(t, r, "page", "2")
308			requireQueryValue(t, r, "page-count", "5")
309			writeRecipeListResponse(t, w)
310		default:
311			t.Fatalf("unexpected path %s", r.URL.Path)
312		}
313	}
314}
315
316func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
317	t.Helper()
318
319	return func(w http.ResponseWriter, r *http.Request) {
320		switch r.URL.Path {
321		case "/api/public/login":
322			writeLoginResponse(t, w)
323		case "/api/user/returned-user/recipes/search":
324			requireMethod(t, r, http.MethodGet, "search")
325			requireQueryValue(t, r, "q", "pasta & tomato")
326			requireQueryValue(t, r, "page", "3")
327			writeRecipeListResponse(t, w)
328		default:
329			t.Fatalf("unexpected path %s", r.URL.Path)
330		}
331	}
332}
333
334func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
335	t.Helper()
336
337	return func(w http.ResponseWriter, r *http.Request) {
338		switch r.URL.EscapedPath() {
339		case "/api/public/login":
340			writeLoginResponse(t, w)
341		case "/api/recipe/recipe%2Fwith%20space/metadata":
342			requireMethod(t, r, http.MethodGet, "metadata")
343			requireSessionCookie(t, r)
344			writeRecipeMetadataResponse(t, w)
345		default:
346			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
347		}
348	}
349}
350
351func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
352	t.Helper()
353
354	return func(w http.ResponseWriter, r *http.Request) {
355		switch r.URL.EscapedPath() {
356		case "/api/public/login":
357			writeLoginResponse(t, w)
358		case "/api/recipe/recipe%2Fwith%20space/content":
359			requireMethod(t, r, http.MethodGet, "content")
360			requireSessionCookie(t, r)
361			writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
362		default:
363			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
364		}
365	}
366}
367
368func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerFunc {
369	t.Helper()
370
371	return func(w http.ResponseWriter, r *http.Request) {
372		switch r.URL.Path {
373		case "/api/public/login":
374			writeLoginResponse(t, w)
375		case "/api/new/from-text/preview":
376			requireMethod(t, r, http.MethodPost, "preview")
377			requireSessionCookie(t, r)
378			requirePreviewRecipeTextRequest(t, r)
379			(*previewCalls)++
380			writeJSON(t, w, RecipeTextPreview{
381				Title:    "Pasta with Tomato Sauce",
382				Markdown: "# Pasta\n\n1. Boil pasta.",
383				Portions: 2,
384			})
385		default:
386			t.Fatalf("unexpected path %s", r.URL.Path)
387		}
388	}
389}
390
391func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
392	t.Helper()
393
394	return func(w http.ResponseWriter, r *http.Request) {
395		switch r.URL.Path {
396		case "/api/public/login":
397			writeLoginResponse(t, w)
398		case "/api/recipe/import/save":
399			requireMethod(t, r, http.MethodPost, "save prepared recipe")
400			requireSessionCookie(t, r)
401			requireSavePreparedRecipeRequest(t, r)
402			(*saveCalls)++
403			writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
404		default:
405			t.Fatalf("unexpected path %s", r.URL.Path)
406		}
407	}
408}
409
410func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
411	t.Helper()
412
413	return func(w http.ResponseWriter, r *http.Request) {
414		switch r.URL.EscapedPath() {
415		case "/api/public/login":
416			writeLoginResponse(t, w)
417		case "/api/recipe/recipe%2Fwith%20space/content":
418			requireMethod(t, r, http.MethodPost, "update recipe content")
419			requireSessionCookie(t, r)
420			requireUpdateRecipeContentRequest(t, r)
421			(*updateCalls)++
422			w.WriteHeader(http.StatusNoContent)
423		default:
424			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
425		}
426	}
427}
428
429func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc {
430	t.Helper()
431
432	return func(w http.ResponseWriter, r *http.Request) {
433		switch r.URL.EscapedPath() {
434		case "/api/public/login":
435			writeLoginResponse(t, w)
436		case "/api/recipe/recipe%2Fwith%20space":
437			requireMethod(t, r, http.MethodDelete, "delete recipe")
438			requireSessionCookie(t, r)
439			(*deleteCalls)++
440			w.WriteHeader(http.StatusNoContent)
441		default:
442			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
443		}
444	}
445}
446
447func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFunc {
448	t.Helper()
449
450	return func(w http.ResponseWriter, r *http.Request) {
451		switch r.URL.EscapedPath() {
452		case "/api/public/login":
453			writeLoginResponse(t, w)
454		case "/api/user/returned-user/shopping-list":
455			requireMethod(t, r, http.MethodDelete, "clear shopping list")
456			requireSessionCookie(t, r)
457			(*clearCalls)++
458			w.WriteHeader(http.StatusNoContent)
459		default:
460			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
461		}
462	}
463}
464
465func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.HandlerFunc {
466	t.Helper()
467
468	return func(w http.ResponseWriter, r *http.Request) {
469		switch r.URL.EscapedPath() {
470		case "/api/public/login":
471			writeLoginResponse(t, w)
472		case "/api/user/shopping-list":
473			requireMethod(t, r, http.MethodPut, "add shopping-list ingredients")
474			requireSessionCookie(t, r)
475			requireAddShoppingListIngredientsRequest(t, r)
476			(*addCalls)++
477			writeAddShoppingListIngredientsResponse(t, w)
478		default:
479			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
480		}
481	}
482}
483
484func requireAddShoppingListIngredientsRequest(t *testing.T, r *http.Request) {
485	t.Helper()
486
487	var request struct {
488		Ingredients string `json:"ingredients"`
489		RecipeID    string `json:"recipe-id"`
490	}
491	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
492		t.Fatalf("decode add shopping-list ingredients request: %v", err)
493	}
494	if request.Ingredients != "200g pasta\n1 cup tomato sauce" {
495		t.Fatalf("ingredients = %q, want newline-separated ingredients", request.Ingredients)
496	}
497	if request.RecipeID != "recipe-1" {
498		t.Fatalf("recipe ID = %q, want recipe-1", request.RecipeID)
499	}
500}
501
502func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter) {
503	t.Helper()
504
505	w.Header().Set("Content-Type", "application/json")
506	if _, err := w.Write([]byte(`{
507		"success": true,
508		"added-count": 2,
509		"ingredients": ["200g pasta", "1 cup tomato sauce"]
510	}`)); err != nil {
511		t.Fatalf("write add shopping-list ingredients response: %v", err)
512	}
513}
514
515func removeShoppingListProductGroupsTestHandler(t *testing.T, removeCalls *int) http.HandlerFunc {
516	t.Helper()
517
518	return func(w http.ResponseWriter, r *http.Request) {
519		switch r.URL.EscapedPath() {
520		case "/api/public/login":
521			writeLoginResponse(t, w)
522		case "/api/user/returned-user/shopping-list/product-groups":
523			requireMethod(t, r, http.MethodDelete, "remove shopping-list product groups")
524			requireSessionCookie(t, r)
525			requireRemoveShoppingListProductGroupsRequest(t, r)
526			(*removeCalls)++
527			w.WriteHeader(http.StatusNoContent)
528		default:
529			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
530		}
531	}
532}
533
534func requireRemoveShoppingListProductGroupsRequest(t *testing.T, r *http.Request) {
535	t.Helper()
536
537	var request struct {
538		IDs []string `json:"ids"`
539	}
540	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
541		t.Fatalf("decode remove shopping-list product groups request: %v", err)
542	}
543	if len(request.IDs) != 2 || request.IDs[0] != "pasta" || request.IDs[1] != "tomato" {
544		t.Fatalf("remove IDs = %#v, want pasta and tomato", request.IDs)
545	}
546}
547
548func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
549	t.Helper()
550
551	var request previewRecipeTextRequest
552	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
553		t.Fatalf("decode preview request: %v", err)
554	}
555	if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
556		t.Fatalf("preview request = %#v, want configured title/text", request)
557	}
558}
559
560func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
561	t.Helper()
562
563	var request savePreparedRecipeRequest
564	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
565		t.Fatalf("decode save prepared recipe request: %v", err)
566	}
567	if request.Title != "Pasta" {
568		t.Fatalf("save title = %q, want Pasta", request.Title)
569	}
570	if request.Description != "# Pasta\n\n1. Boil pasta." {
571		t.Fatalf("save description = %q, want markdown", request.Description)
572	}
573	if request.Portions != 2 {
574		t.Fatalf("save portions = %d, want 2", request.Portions)
575	}
576}
577
578func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) {
579	t.Helper()
580
581	var request updateRecipeContentRequest
582	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
583		t.Fatalf("decode update recipe content request: %v", err)
584	}
585	if request.Description != "# Pasta\n\n1. Boil pasta." {
586		t.Fatalf("update description = %q, want markdown", request.Description)
587	}
588	if request.Portions != 4 {
589		t.Fatalf("update portions = %d, want 4", request.Portions)
590	}
591}
592
593func requireLoginRequest(t *testing.T, r *http.Request) {
594	t.Helper()
595	requireMethod(t, r, http.MethodPost, "login")
596
597	var request loginRequest
598	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
599		t.Fatalf("decode login request: %v", err)
600	}
601	if request.Username != "configured-user" || request.Password != "configured-password" {
602		t.Fatalf("login request = %#v, want configured credentials", request)
603	}
604}
605
606func requireMethod(t *testing.T, r *http.Request, want string, label string) {
607	t.Helper()
608
609	if r.Method != want {
610		t.Fatalf("%s method = %s, want %s", label, r.Method, want)
611	}
612}
613
614func requireSessionCookie(t *testing.T, r *http.Request) {
615	t.Helper()
616
617	cookie, err := r.Cookie("cooked_session")
618	if err != nil {
619		t.Fatalf("missing session cookie: %v", err)
620	}
621	if cookie.Value != "session-value" {
622		t.Fatalf("session cookie = %q, want session-value", cookie.Value)
623	}
624}
625
626func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
627	t.Helper()
628
629	if got := r.URL.Query().Get(name); got != want {
630		t.Fatalf("%s query = %q, want %q", name, got, want)
631	}
632}
633
634func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
635	t.Helper()
636
637	http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
638	writeJSON(t, w, loginResponse{Username: "returned-user"})
639}
640
641func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
642	t.Helper()
643
644	writeJSON(t, w, shoppingListResponse{
645		ShoppingList: ShoppingList{
646			Aisles: []Aisle{{
647				ID:   "pantry",
648				Name: "Pantry",
649				ProductGroups: []ProductGroup{{
650					ID:       "pasta",
651					Name:     "Pasta",
652					Quantity: "200g",
653				}},
654			}},
655		},
656		Recipes: []string{"recipe-id"},
657	})
658}
659
660func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
661	t.Helper()
662
663	writeJSON(t, w, recipeListResponse{
664		Recipes: []RecipeCard{{
665			ID:           "recipe-1",
666			Title:        "Pasta",
667			ThumbnailURL: "https://example.invalid/thumb.jpg",
668		}},
669	})
670}
671
672func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
673	t.Helper()
674
675	w.Header().Set("Content-Type", "application/json")
676	if _, err := w.Write([]byte(`{
677		"title": "Pasta with Tomato Sauce",
678		"image-urls": ["https://example.invalid/pasta.jpg"],
679		"owner": "returned-user",
680		"edit-permission": true
681	}`)); err != nil {
682		t.Fatalf("write metadata response: %v", err)
683	}
684}
685
686func writeJSON[
687	T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse,
688](
689	t *testing.T,
690	w http.ResponseWriter,
691	value T,
692) {
693	t.Helper()
694
695	w.Header().Set("Content-Type", "application/json")
696	if err := json.NewEncoder(w).Encode(value); err != nil {
697		t.Fatalf("write JSON: %v", err)
698	}
699}