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