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