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 TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
233 client := &Client{authenticatedUsername: "user/name"}
234
235 got := client.userPath("/api/user/{username}/shopping-list")
236 if got != "/api/user/user%2Fname/shopping-list" {
237 t.Fatalf("userPath() = %q, want escaped username", got)
238 }
239}
240
241func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
242 baseURL, err := url.Parse("https://example.invalid")
243 if err != nil {
244 t.Fatalf("parse base URL: %v", err)
245 }
246 client := &Client{baseURL: baseURL}
247
248 request, err := client.newRequest(
249 context.Background(),
250 http.MethodGet,
251 "/api/user/user%2Fname/recipes?page=2&page-count=5",
252 nil,
253 )
254 if err != nil {
255 t.Fatalf("newRequest() error = %v", err)
256 }
257
258 if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
259 t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
260 }
261}
262
263func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
264 t.Helper()
265
266 server := httptest.NewServer(handler)
267 baseURL, err := url.Parse(server.URL)
268 if err != nil {
269 server.Close()
270 t.Fatalf("parse server URL: %v", err)
271 }
272 client, err := NewClient(baseURL, "configured-user", "configured-password")
273 if err != nil {
274 server.Close()
275 t.Fatalf("NewClient() error = %v", err)
276 }
277
278 return client, server.Close
279}
280
281func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
282 t.Helper()
283
284 return func(w http.ResponseWriter, r *http.Request) {
285 switch r.URL.Path {
286 case "/api/public/login":
287 requireLoginRequest(t, r)
288 writeLoginResponse(t, w)
289 case "/api/user/returned-user/shopping-list":
290 requireSessionCookie(t, r)
291 *sawSessionCookie = true
292 writeShoppingListResponse(t, w)
293 default:
294 t.Fatalf("unexpected path %s", r.URL.Path)
295 }
296 }
297}
298
299func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
300 t.Helper()
301
302 return func(w http.ResponseWriter, r *http.Request) {
303 switch r.URL.Path {
304 case "/api/public/login":
305 writeLoginResponse(t, w)
306 case "/api/user/returned-user/recipes":
307 requireQueryValue(t, r, "page", "2")
308 requireQueryValue(t, r, "page-count", "5")
309 writeRecipeListResponse(t, w)
310 default:
311 t.Fatalf("unexpected path %s", r.URL.Path)
312 }
313 }
314}
315
316func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
317 t.Helper()
318
319 return func(w http.ResponseWriter, r *http.Request) {
320 switch r.URL.Path {
321 case "/api/public/login":
322 writeLoginResponse(t, w)
323 case "/api/user/returned-user/recipes/search":
324 requireMethod(t, r, http.MethodGet, "search")
325 requireQueryValue(t, r, "q", "pasta & tomato")
326 requireQueryValue(t, r, "page", "3")
327 writeRecipeListResponse(t, w)
328 default:
329 t.Fatalf("unexpected path %s", r.URL.Path)
330 }
331 }
332}
333
334func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
335 t.Helper()
336
337 return func(w http.ResponseWriter, r *http.Request) {
338 switch r.URL.EscapedPath() {
339 case "/api/public/login":
340 writeLoginResponse(t, w)
341 case "/api/recipe/recipe%2Fwith%20space/metadata":
342 requireMethod(t, r, http.MethodGet, "metadata")
343 requireSessionCookie(t, r)
344 writeRecipeMetadataResponse(t, w)
345 default:
346 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
347 }
348 }
349}
350
351func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
352 t.Helper()
353
354 return func(w http.ResponseWriter, r *http.Request) {
355 switch r.URL.EscapedPath() {
356 case "/api/public/login":
357 writeLoginResponse(t, w)
358 case "/api/recipe/recipe%2Fwith%20space/content":
359 requireMethod(t, r, http.MethodGet, "content")
360 requireSessionCookie(t, r)
361 writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
362 default:
363 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
364 }
365 }
366}
367
368func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerFunc {
369 t.Helper()
370
371 return func(w http.ResponseWriter, r *http.Request) {
372 switch r.URL.Path {
373 case "/api/public/login":
374 writeLoginResponse(t, w)
375 case "/api/new/from-text/preview":
376 requireMethod(t, r, http.MethodPost, "preview")
377 requireSessionCookie(t, r)
378 requirePreviewRecipeTextRequest(t, r)
379 (*previewCalls)++
380 writeJSON(t, w, RecipeTextPreview{
381 Title: "Pasta with Tomato Sauce",
382 Markdown: "# Pasta\n\n1. Boil pasta.",
383 Portions: 2,
384 })
385 default:
386 t.Fatalf("unexpected path %s", r.URL.Path)
387 }
388 }
389}
390
391func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
392 t.Helper()
393
394 return func(w http.ResponseWriter, r *http.Request) {
395 switch r.URL.Path {
396 case "/api/public/login":
397 writeLoginResponse(t, w)
398 case "/api/recipe/import/save":
399 requireMethod(t, r, http.MethodPost, "save prepared recipe")
400 requireSessionCookie(t, r)
401 requireSavePreparedRecipeRequest(t, r)
402 (*saveCalls)++
403 writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
404 default:
405 t.Fatalf("unexpected path %s", r.URL.Path)
406 }
407 }
408}
409
410func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
411 t.Helper()
412
413 return func(w http.ResponseWriter, r *http.Request) {
414 switch r.URL.EscapedPath() {
415 case "/api/public/login":
416 writeLoginResponse(t, w)
417 case "/api/recipe/recipe%2Fwith%20space/content":
418 requireMethod(t, r, http.MethodPost, "update recipe content")
419 requireSessionCookie(t, r)
420 requireUpdateRecipeContentRequest(t, r)
421 (*updateCalls)++
422 w.WriteHeader(http.StatusNoContent)
423 default:
424 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
425 }
426 }
427}
428
429func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc {
430 t.Helper()
431
432 return func(w http.ResponseWriter, r *http.Request) {
433 switch r.URL.EscapedPath() {
434 case "/api/public/login":
435 writeLoginResponse(t, w)
436 case "/api/recipe/recipe%2Fwith%20space":
437 requireMethod(t, r, http.MethodDelete, "delete recipe")
438 requireSessionCookie(t, r)
439 (*deleteCalls)++
440 w.WriteHeader(http.StatusNoContent)
441 default:
442 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
443 }
444 }
445}
446
447func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFunc {
448 t.Helper()
449
450 return func(w http.ResponseWriter, r *http.Request) {
451 switch r.URL.EscapedPath() {
452 case "/api/public/login":
453 writeLoginResponse(t, w)
454 case "/api/user/returned-user/shopping-list":
455 requireMethod(t, r, http.MethodDelete, "clear shopping list")
456 requireSessionCookie(t, r)
457 (*clearCalls)++
458 w.WriteHeader(http.StatusNoContent)
459 default:
460 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
461 }
462 }
463}
464
465func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.HandlerFunc {
466 t.Helper()
467
468 return func(w http.ResponseWriter, r *http.Request) {
469 switch r.URL.EscapedPath() {
470 case "/api/public/login":
471 writeLoginResponse(t, w)
472 case "/api/user/shopping-list":
473 requireMethod(t, r, http.MethodPut, "add shopping-list ingredients")
474 requireSessionCookie(t, r)
475 requireAddShoppingListIngredientsRequest(t, r)
476 (*addCalls)++
477 writeAddShoppingListIngredientsResponse(t, w)
478 default:
479 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
480 }
481 }
482}
483
484func requireAddShoppingListIngredientsRequest(t *testing.T, r *http.Request) {
485 t.Helper()
486
487 var request struct {
488 Ingredients string `json:"ingredients"`
489 RecipeID string `json:"recipe-id"`
490 }
491 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
492 t.Fatalf("decode add shopping-list ingredients request: %v", err)
493 }
494 if request.Ingredients != "200g pasta\n1 cup tomato sauce" {
495 t.Fatalf("ingredients = %q, want newline-separated ingredients", request.Ingredients)
496 }
497 if request.RecipeID != "recipe-1" {
498 t.Fatalf("recipe ID = %q, want recipe-1", request.RecipeID)
499 }
500}
501
502func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter) {
503 t.Helper()
504
505 w.Header().Set("Content-Type", "application/json")
506 if _, err := w.Write([]byte(`{
507 "success": true,
508 "added-count": 2,
509 "ingredients": ["200g pasta", "1 cup tomato sauce"]
510 }`)); err != nil {
511 t.Fatalf("write add shopping-list ingredients response: %v", err)
512 }
513}
514
515func removeShoppingListProductGroupsTestHandler(t *testing.T, removeCalls *int) http.HandlerFunc {
516 t.Helper()
517
518 return func(w http.ResponseWriter, r *http.Request) {
519 switch r.URL.EscapedPath() {
520 case "/api/public/login":
521 writeLoginResponse(t, w)
522 case "/api/user/returned-user/shopping-list/product-groups":
523 requireMethod(t, r, http.MethodDelete, "remove shopping-list product groups")
524 requireSessionCookie(t, r)
525 requireRemoveShoppingListProductGroupsRequest(t, r)
526 (*removeCalls)++
527 w.WriteHeader(http.StatusNoContent)
528 default:
529 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
530 }
531 }
532}
533
534func requireRemoveShoppingListProductGroupsRequest(t *testing.T, r *http.Request) {
535 t.Helper()
536
537 var request struct {
538 IDs []string `json:"ids"`
539 }
540 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
541 t.Fatalf("decode remove shopping-list product groups request: %v", err)
542 }
543 if len(request.IDs) != 2 || request.IDs[0] != "pasta" || request.IDs[1] != "tomato" {
544 t.Fatalf("remove IDs = %#v, want pasta and tomato", request.IDs)
545 }
546}
547
548func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
549 t.Helper()
550
551 var request previewRecipeTextRequest
552 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
553 t.Fatalf("decode preview request: %v", err)
554 }
555 if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
556 t.Fatalf("preview request = %#v, want configured title/text", request)
557 }
558}
559
560func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
561 t.Helper()
562
563 var request savePreparedRecipeRequest
564 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
565 t.Fatalf("decode save prepared recipe request: %v", err)
566 }
567 if request.Title != "Pasta" {
568 t.Fatalf("save title = %q, want Pasta", request.Title)
569 }
570 if request.Description != "# Pasta\n\n1. Boil pasta." {
571 t.Fatalf("save description = %q, want markdown", request.Description)
572 }
573 if request.Portions != 2 {
574 t.Fatalf("save portions = %d, want 2", request.Portions)
575 }
576}
577
578func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) {
579 t.Helper()
580
581 var request updateRecipeContentRequest
582 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
583 t.Fatalf("decode update recipe content request: %v", err)
584 }
585 if request.Description != "# Pasta\n\n1. Boil pasta." {
586 t.Fatalf("update description = %q, want markdown", request.Description)
587 }
588 if request.Portions != 4 {
589 t.Fatalf("update portions = %d, want 4", request.Portions)
590 }
591}
592
593func requireLoginRequest(t *testing.T, r *http.Request) {
594 t.Helper()
595 requireMethod(t, r, http.MethodPost, "login")
596
597 var request loginRequest
598 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
599 t.Fatalf("decode login request: %v", err)
600 }
601 if request.Username != "configured-user" || request.Password != "configured-password" {
602 t.Fatalf("login request = %#v, want configured credentials", request)
603 }
604}
605
606func requireMethod(t *testing.T, r *http.Request, want string, label string) {
607 t.Helper()
608
609 if r.Method != want {
610 t.Fatalf("%s method = %s, want %s", label, r.Method, want)
611 }
612}
613
614func requireSessionCookie(t *testing.T, r *http.Request) {
615 t.Helper()
616
617 cookie, err := r.Cookie("cooked_session")
618 if err != nil {
619 t.Fatalf("missing session cookie: %v", err)
620 }
621 if cookie.Value != "session-value" {
622 t.Fatalf("session cookie = %q, want session-value", cookie.Value)
623 }
624}
625
626func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
627 t.Helper()
628
629 if got := r.URL.Query().Get(name); got != want {
630 t.Fatalf("%s query = %q, want %q", name, got, want)
631 }
632}
633
634func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
635 t.Helper()
636
637 http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
638 writeJSON(t, w, loginResponse{Username: "returned-user"})
639}
640
641func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
642 t.Helper()
643
644 writeJSON(t, w, shoppingListResponse{
645 ShoppingList: ShoppingList{
646 Aisles: []Aisle{{
647 ID: "pantry",
648 Name: "Pantry",
649 ProductGroups: []ProductGroup{{
650 ID: "pasta",
651 Name: "Pasta",
652 Quantity: "200g",
653 }},
654 }},
655 },
656 Recipes: []string{"recipe-id"},
657 })
658}
659
660func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
661 t.Helper()
662
663 writeJSON(t, w, recipeListResponse{
664 Recipes: []RecipeCard{{
665 ID: "recipe-1",
666 Title: "Pasta",
667 ThumbnailURL: "https://example.invalid/thumb.jpg",
668 }},
669 })
670}
671
672func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
673 t.Helper()
674
675 w.Header().Set("Content-Type", "application/json")
676 if _, err := w.Write([]byte(`{
677 "title": "Pasta with Tomato Sauce",
678 "image-urls": ["https://example.invalid/pasta.jpg"],
679 "owner": "returned-user",
680 "edit-permission": true
681 }`)); err != nil {
682 t.Fatalf("write metadata response: %v", err)
683 }
684}
685
686func writeJSON[
687 T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse,
688](
689 t *testing.T,
690 w http.ResponseWriter,
691 value T,
692) {
693 t.Helper()
694
695 w.Header().Set("Content-Type", "application/json")
696 if err := json.NewEncoder(w).Encode(value); err != nil {
697 t.Fatalf("write JSON: %v", err)
698 }
699}