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 TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
193 client := &Client{authenticatedUsername: "user/name"}
194
195 got := client.userPath("/api/user/{username}/shopping-list")
196 if got != "/api/user/user%2Fname/shopping-list" {
197 t.Fatalf("userPath() = %q, want escaped username", got)
198 }
199}
200
201func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
202 baseURL, err := url.Parse("https://example.invalid")
203 if err != nil {
204 t.Fatalf("parse base URL: %v", err)
205 }
206 client := &Client{baseURL: baseURL}
207
208 request, err := client.newRequest(
209 context.Background(),
210 http.MethodGet,
211 "/api/user/user%2Fname/recipes?page=2&page-count=5",
212 nil,
213 )
214 if err != nil {
215 t.Fatalf("newRequest() error = %v", err)
216 }
217
218 if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
219 t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
220 }
221}
222
223func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
224 t.Helper()
225
226 server := httptest.NewServer(handler)
227 baseURL, err := url.Parse(server.URL)
228 if err != nil {
229 server.Close()
230 t.Fatalf("parse server URL: %v", err)
231 }
232 client, err := NewClient(baseURL, "configured-user", "configured-password")
233 if err != nil {
234 server.Close()
235 t.Fatalf("NewClient() error = %v", err)
236 }
237
238 return client, server.Close
239}
240
241func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
242 t.Helper()
243
244 return func(w http.ResponseWriter, r *http.Request) {
245 switch r.URL.Path {
246 case "/api/public/login":
247 requireLoginRequest(t, r)
248 writeLoginResponse(t, w)
249 case "/api/user/returned-user/shopping-list":
250 requireSessionCookie(t, r)
251 *sawSessionCookie = true
252 writeShoppingListResponse(t, w)
253 default:
254 t.Fatalf("unexpected path %s", r.URL.Path)
255 }
256 }
257}
258
259func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
260 t.Helper()
261
262 return func(w http.ResponseWriter, r *http.Request) {
263 switch r.URL.Path {
264 case "/api/public/login":
265 writeLoginResponse(t, w)
266 case "/api/user/returned-user/recipes":
267 requireQueryValue(t, r, "page", "2")
268 requireQueryValue(t, r, "page-count", "5")
269 writeRecipeListResponse(t, w)
270 default:
271 t.Fatalf("unexpected path %s", r.URL.Path)
272 }
273 }
274}
275
276func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
277 t.Helper()
278
279 return func(w http.ResponseWriter, r *http.Request) {
280 switch r.URL.Path {
281 case "/api/public/login":
282 writeLoginResponse(t, w)
283 case "/api/user/returned-user/recipes/search":
284 requireMethod(t, r, http.MethodGet, "search")
285 requireQueryValue(t, r, "q", "pasta & tomato")
286 requireQueryValue(t, r, "page", "3")
287 writeRecipeListResponse(t, w)
288 default:
289 t.Fatalf("unexpected path %s", r.URL.Path)
290 }
291 }
292}
293
294func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
295 t.Helper()
296
297 return func(w http.ResponseWriter, r *http.Request) {
298 switch r.URL.EscapedPath() {
299 case "/api/public/login":
300 writeLoginResponse(t, w)
301 case "/api/recipe/recipe%2Fwith%20space/metadata":
302 requireMethod(t, r, http.MethodGet, "metadata")
303 requireSessionCookie(t, r)
304 writeRecipeMetadataResponse(t, w)
305 default:
306 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
307 }
308 }
309}
310
311func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
312 t.Helper()
313
314 return func(w http.ResponseWriter, r *http.Request) {
315 switch r.URL.EscapedPath() {
316 case "/api/public/login":
317 writeLoginResponse(t, w)
318 case "/api/recipe/recipe%2Fwith%20space/content":
319 requireMethod(t, r, http.MethodGet, "content")
320 requireSessionCookie(t, r)
321 writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
322 default:
323 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
324 }
325 }
326}
327
328func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerFunc {
329 t.Helper()
330
331 return func(w http.ResponseWriter, r *http.Request) {
332 switch r.URL.Path {
333 case "/api/public/login":
334 writeLoginResponse(t, w)
335 case "/api/new/from-text/preview":
336 requireMethod(t, r, http.MethodPost, "preview")
337 requireSessionCookie(t, r)
338 requirePreviewRecipeTextRequest(t, r)
339 (*previewCalls)++
340 writeJSON(t, w, RecipeTextPreview{
341 Title: "Pasta with Tomato Sauce",
342 Markdown: "# Pasta\n\n1. Boil pasta.",
343 Portions: 2,
344 })
345 default:
346 t.Fatalf("unexpected path %s", r.URL.Path)
347 }
348 }
349}
350
351func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
352 t.Helper()
353
354 return func(w http.ResponseWriter, r *http.Request) {
355 switch r.URL.Path {
356 case "/api/public/login":
357 writeLoginResponse(t, w)
358 case "/api/recipe/import/save":
359 requireMethod(t, r, http.MethodPost, "save prepared recipe")
360 requireSessionCookie(t, r)
361 requireSavePreparedRecipeRequest(t, r)
362 (*saveCalls)++
363 writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
364 default:
365 t.Fatalf("unexpected path %s", r.URL.Path)
366 }
367 }
368}
369
370func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
371 t.Helper()
372
373 return func(w http.ResponseWriter, r *http.Request) {
374 switch r.URL.EscapedPath() {
375 case "/api/public/login":
376 writeLoginResponse(t, w)
377 case "/api/recipe/recipe%2Fwith%20space/content":
378 requireMethod(t, r, http.MethodPost, "update recipe content")
379 requireSessionCookie(t, r)
380 requireUpdateRecipeContentRequest(t, r)
381 (*updateCalls)++
382 w.WriteHeader(http.StatusNoContent)
383 default:
384 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
385 }
386 }
387}
388
389func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc {
390 t.Helper()
391
392 return func(w http.ResponseWriter, r *http.Request) {
393 switch r.URL.EscapedPath() {
394 case "/api/public/login":
395 writeLoginResponse(t, w)
396 case "/api/recipe/recipe%2Fwith%20space":
397 requireMethod(t, r, http.MethodDelete, "delete recipe")
398 requireSessionCookie(t, r)
399 (*deleteCalls)++
400 w.WriteHeader(http.StatusNoContent)
401 default:
402 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
403 }
404 }
405}
406
407func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFunc {
408 t.Helper()
409
410 return func(w http.ResponseWriter, r *http.Request) {
411 switch r.URL.EscapedPath() {
412 case "/api/public/login":
413 writeLoginResponse(t, w)
414 case "/api/user/returned-user/shopping-list":
415 requireMethod(t, r, http.MethodDelete, "clear shopping list")
416 requireSessionCookie(t, r)
417 (*clearCalls)++
418 w.WriteHeader(http.StatusNoContent)
419 default:
420 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
421 }
422 }
423}
424
425func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
426 t.Helper()
427
428 var request previewRecipeTextRequest
429 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
430 t.Fatalf("decode preview request: %v", err)
431 }
432 if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
433 t.Fatalf("preview request = %#v, want configured title/text", request)
434 }
435}
436
437func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
438 t.Helper()
439
440 var request savePreparedRecipeRequest
441 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
442 t.Fatalf("decode save prepared recipe request: %v", err)
443 }
444 if request.Title != "Pasta" {
445 t.Fatalf("save title = %q, want Pasta", request.Title)
446 }
447 if request.Description != "# Pasta\n\n1. Boil pasta." {
448 t.Fatalf("save description = %q, want markdown", request.Description)
449 }
450 if request.Portions != 2 {
451 t.Fatalf("save portions = %d, want 2", request.Portions)
452 }
453}
454
455func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) {
456 t.Helper()
457
458 var request updateRecipeContentRequest
459 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
460 t.Fatalf("decode update recipe content request: %v", err)
461 }
462 if request.Description != "# Pasta\n\n1. Boil pasta." {
463 t.Fatalf("update description = %q, want markdown", request.Description)
464 }
465 if request.Portions != 4 {
466 t.Fatalf("update portions = %d, want 4", request.Portions)
467 }
468}
469
470func requireLoginRequest(t *testing.T, r *http.Request) {
471 t.Helper()
472 requireMethod(t, r, http.MethodPost, "login")
473
474 var request loginRequest
475 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
476 t.Fatalf("decode login request: %v", err)
477 }
478 if request.Username != "configured-user" || request.Password != "configured-password" {
479 t.Fatalf("login request = %#v, want configured credentials", request)
480 }
481}
482
483func requireMethod(t *testing.T, r *http.Request, want string, label string) {
484 t.Helper()
485
486 if r.Method != want {
487 t.Fatalf("%s method = %s, want %s", label, r.Method, want)
488 }
489}
490
491func requireSessionCookie(t *testing.T, r *http.Request) {
492 t.Helper()
493
494 cookie, err := r.Cookie("cooked_session")
495 if err != nil {
496 t.Fatalf("missing session cookie: %v", err)
497 }
498 if cookie.Value != "session-value" {
499 t.Fatalf("session cookie = %q, want session-value", cookie.Value)
500 }
501}
502
503func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
504 t.Helper()
505
506 if got := r.URL.Query().Get(name); got != want {
507 t.Fatalf("%s query = %q, want %q", name, got, want)
508 }
509}
510
511func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
512 t.Helper()
513
514 http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
515 writeJSON(t, w, loginResponse{Username: "returned-user"})
516}
517
518func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
519 t.Helper()
520
521 writeJSON(t, w, shoppingListResponse{
522 ShoppingList: ShoppingList{
523 Aisles: []Aisle{{
524 ID: "pantry",
525 Name: "Pantry",
526 ProductGroups: []ProductGroup{{
527 ID: "pasta",
528 Name: "Pasta",
529 Quantity: "200g",
530 }},
531 }},
532 },
533 Recipes: []string{"recipe-id"},
534 })
535}
536
537func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
538 t.Helper()
539
540 writeJSON(t, w, recipeListResponse{
541 Recipes: []RecipeCard{{
542 ID: "recipe-1",
543 Title: "Pasta",
544 ThumbnailURL: "https://example.invalid/thumb.jpg",
545 }},
546 })
547}
548
549func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
550 t.Helper()
551
552 w.Header().Set("Content-Type", "application/json")
553 if _, err := w.Write([]byte(`{
554 "title": "Pasta with Tomato Sauce",
555 "image-urls": ["https://example.invalid/pasta.jpg"],
556 "owner": "returned-user",
557 "edit-permission": true
558 }`)); err != nil {
559 t.Fatalf("write metadata response: %v", err)
560 }
561}
562
563func writeJSON[
564 T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse,
565](
566 t *testing.T,
567 w http.ResponseWriter,
568 value T,
569) {
570 t.Helper()
571
572 w.Header().Set("Content-Type", "application/json")
573 if err := json.NewEncoder(w).Encode(value); err != nil {
574 t.Fatalf("write JSON: %v", err)
575 }
576}