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