client_test.go

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