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 TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
148 client := &Client{authenticatedUsername: "user/name"}
149
150 got := client.userPath("/api/user/{username}/shopping-list")
151 if got != "/api/user/user%2Fname/shopping-list" {
152 t.Fatalf("userPath() = %q, want escaped username", got)
153 }
154}
155
156func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
157 baseURL, err := url.Parse("https://example.invalid")
158 if err != nil {
159 t.Fatalf("parse base URL: %v", err)
160 }
161 client := &Client{baseURL: baseURL}
162
163 request, err := client.newRequest(
164 context.Background(),
165 http.MethodGet,
166 "/api/user/user%2Fname/recipes?page=2&page-count=5",
167 nil,
168 )
169 if err != nil {
170 t.Fatalf("newRequest() error = %v", err)
171 }
172
173 if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
174 t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
175 }
176}
177
178func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
179 t.Helper()
180
181 server := httptest.NewServer(handler)
182 baseURL, err := url.Parse(server.URL)
183 if err != nil {
184 server.Close()
185 t.Fatalf("parse server URL: %v", err)
186 }
187 client, err := NewClient(baseURL, "configured-user", "configured-password")
188 if err != nil {
189 server.Close()
190 t.Fatalf("NewClient() error = %v", err)
191 }
192
193 return client, server.Close
194}
195
196func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
197 t.Helper()
198
199 return func(w http.ResponseWriter, r *http.Request) {
200 switch r.URL.Path {
201 case "/api/public/login":
202 requireLoginRequest(t, r)
203 writeLoginResponse(t, w)
204 case "/api/user/returned-user/shopping-list":
205 requireSessionCookie(t, r)
206 *sawSessionCookie = true
207 writeShoppingListResponse(t, w)
208 default:
209 t.Fatalf("unexpected path %s", r.URL.Path)
210 }
211 }
212}
213
214func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
215 t.Helper()
216
217 return func(w http.ResponseWriter, r *http.Request) {
218 switch r.URL.Path {
219 case "/api/public/login":
220 writeLoginResponse(t, w)
221 case "/api/user/returned-user/recipes":
222 requireQueryValue(t, r, "page", "2")
223 requireQueryValue(t, r, "page-count", "5")
224 writeRecipeListResponse(t, w)
225 default:
226 t.Fatalf("unexpected path %s", r.URL.Path)
227 }
228 }
229}
230
231func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
232 t.Helper()
233
234 return func(w http.ResponseWriter, r *http.Request) {
235 switch r.URL.Path {
236 case "/api/public/login":
237 writeLoginResponse(t, w)
238 case "/api/user/returned-user/recipes/search":
239 requireMethod(t, r, http.MethodGet, "search")
240 requireQueryValue(t, r, "q", "pasta & tomato")
241 requireQueryValue(t, r, "page", "3")
242 writeRecipeListResponse(t, w)
243 default:
244 t.Fatalf("unexpected path %s", r.URL.Path)
245 }
246 }
247}
248
249func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
250 t.Helper()
251
252 return func(w http.ResponseWriter, r *http.Request) {
253 switch r.URL.EscapedPath() {
254 case "/api/public/login":
255 writeLoginResponse(t, w)
256 case "/api/recipe/recipe%2Fwith%20space/metadata":
257 requireMethod(t, r, http.MethodGet, "metadata")
258 requireSessionCookie(t, r)
259 writeRecipeMetadataResponse(t, w)
260 default:
261 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
262 }
263 }
264}
265
266func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
267 t.Helper()
268
269 return func(w http.ResponseWriter, r *http.Request) {
270 switch r.URL.EscapedPath() {
271 case "/api/public/login":
272 writeLoginResponse(t, w)
273 case "/api/recipe/recipe%2Fwith%20space/content":
274 requireMethod(t, r, http.MethodGet, "content")
275 requireSessionCookie(t, r)
276 writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
277 default:
278 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
279 }
280 }
281}
282
283func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerFunc {
284 t.Helper()
285
286 return func(w http.ResponseWriter, r *http.Request) {
287 switch r.URL.Path {
288 case "/api/public/login":
289 writeLoginResponse(t, w)
290 case "/api/new/from-text/preview":
291 requireMethod(t, r, http.MethodPost, "preview")
292 requireSessionCookie(t, r)
293 requirePreviewRecipeTextRequest(t, r)
294 (*previewCalls)++
295 writeJSON(t, w, RecipeTextPreview{
296 Title: "Pasta with Tomato Sauce",
297 Markdown: "# Pasta\n\n1. Boil pasta.",
298 Portions: 2,
299 })
300 default:
301 t.Fatalf("unexpected path %s", r.URL.Path)
302 }
303 }
304}
305
306func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
307 t.Helper()
308
309 return func(w http.ResponseWriter, r *http.Request) {
310 switch r.URL.Path {
311 case "/api/public/login":
312 writeLoginResponse(t, w)
313 case "/api/recipe/import/save":
314 requireMethod(t, r, http.MethodPost, "save prepared recipe")
315 requireSessionCookie(t, r)
316 requireSavePreparedRecipeRequest(t, r)
317 (*saveCalls)++
318 writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
319 default:
320 t.Fatalf("unexpected path %s", r.URL.Path)
321 }
322 }
323}
324
325func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
326 t.Helper()
327
328 var request previewRecipeTextRequest
329 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
330 t.Fatalf("decode preview request: %v", err)
331 }
332 if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
333 t.Fatalf("preview request = %#v, want configured title/text", request)
334 }
335}
336
337func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
338 t.Helper()
339
340 var request savePreparedRecipeRequest
341 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
342 t.Fatalf("decode save prepared recipe request: %v", err)
343 }
344 if request.Title != "Pasta" {
345 t.Fatalf("save title = %q, want Pasta", request.Title)
346 }
347 if request.Description != "# Pasta\n\n1. Boil pasta." {
348 t.Fatalf("save description = %q, want markdown", request.Description)
349 }
350 if request.Portions != 2 {
351 t.Fatalf("save portions = %d, want 2", request.Portions)
352 }
353}
354
355func requireLoginRequest(t *testing.T, r *http.Request) {
356 t.Helper()
357 requireMethod(t, r, http.MethodPost, "login")
358
359 var request loginRequest
360 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
361 t.Fatalf("decode login request: %v", err)
362 }
363 if request.Username != "configured-user" || request.Password != "configured-password" {
364 t.Fatalf("login request = %#v, want configured credentials", request)
365 }
366}
367
368func requireMethod(t *testing.T, r *http.Request, want string, label string) {
369 t.Helper()
370
371 if r.Method != want {
372 t.Fatalf("%s method = %s, want %s", label, r.Method, want)
373 }
374}
375
376func requireSessionCookie(t *testing.T, r *http.Request) {
377 t.Helper()
378
379 cookie, err := r.Cookie("cooked_session")
380 if err != nil {
381 t.Fatalf("missing session cookie: %v", err)
382 }
383 if cookie.Value != "session-value" {
384 t.Fatalf("session cookie = %q, want session-value", cookie.Value)
385 }
386}
387
388func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
389 t.Helper()
390
391 if got := r.URL.Query().Get(name); got != want {
392 t.Fatalf("%s query = %q, want %q", name, got, want)
393 }
394}
395
396func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
397 t.Helper()
398
399 http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
400 writeJSON(t, w, loginResponse{Username: "returned-user"})
401}
402
403func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
404 t.Helper()
405
406 writeJSON(t, w, shoppingListResponse{
407 ShoppingList: ShoppingList{
408 Aisles: []Aisle{{
409 ID: "pantry",
410 Name: "Pantry",
411 ProductGroups: []ProductGroup{{
412 ID: "pasta",
413 Name: "Pasta",
414 Quantity: "200g",
415 }},
416 }},
417 },
418 Recipes: []string{"recipe-id"},
419 })
420}
421
422func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
423 t.Helper()
424
425 writeJSON(t, w, recipeListResponse{
426 Recipes: []RecipeCard{{
427 ID: "recipe-1",
428 Title: "Pasta",
429 ThumbnailURL: "https://example.invalid/thumb.jpg",
430 }},
431 })
432}
433
434func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
435 t.Helper()
436
437 w.Header().Set("Content-Type", "application/json")
438 if _, err := w.Write([]byte(`{
439 "title": "Pasta with Tomato Sauce",
440 "image-urls": ["https://example.invalid/pasta.jpg"],
441 "owner": "returned-user",
442 "edit-permission": true
443 }`)); err != nil {
444 t.Fatalf("write metadata response: %v", err)
445 }
446}
447
448func writeJSON[
449 T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse,
450](
451 t *testing.T,
452 w http.ResponseWriter,
453 value T,
454) {
455 t.Helper()
456
457 w.Header().Set("Content-Type", "application/json")
458 if err := json.NewEncoder(w).Encode(value); err != nil {
459 t.Fatalf("write JSON: %v", err)
460 }
461}