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 shoppingList := ShoppingList{
920 Aisles: []Aisle{{
921 ID: "pantry",
922 Name: "Pantry",
923 ProductGroups: []ProductGroup{{
924 ID: "pasta",
925 Name: "Pasta",
926 Quantity: "200g",
927 }},
928 }},
929 }
930
931 writeJSON(t, w, shoppingListResponse{
932 ShoppingList: &shoppingList,
933 Recipes: []string{"recipe-id"},
934 })
935}
936
937func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
938 t.Helper()
939
940 writeJSON(t, w, []RecipeCard{{
941 ID: "recipe-1",
942 Title: "Pasta",
943 ThumbnailURL: "https://example.invalid/thumb.jpg",
944 }})
945}
946
947func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
948 t.Helper()
949
950 w.Header().Set("Content-Type", "application/json")
951 if _, err := w.Write([]byte(`{
952 "title": "Pasta with Tomato Sauce",
953 "image-urls": ["https://example.invalid/pasta.jpg"],
954 "owner": "returned-user",
955 "edit-permission": true
956 }`)); err != nil {
957 t.Fatalf("write metadata response: %v", err)
958 }
959}
960
961func writeJSON[
962 T loginResponse | shoppingListResponse | []RecipeCard | RecipeContent | RecipeTextPreview |
963 saveRecipeResponse | importRecipeURLResponse,
964](
965 t *testing.T,
966 w http.ResponseWriter,
967 value T,
968) {
969 t.Helper()
970
971 w.Header().Set("Content-Type", "application/json")
972 if err := json.NewEncoder(w).Encode(value); err != nil {
973 t.Fatalf("write JSON: %v", err)
974 }
975}