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 TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
106 client := &Client{authenticatedUsername: "user/name"}
107
108 got := client.userPath("/api/user/{username}/shopping-list")
109 if got != "/api/user/user%2Fname/shopping-list" {
110 t.Fatalf("userPath() = %q, want escaped username", got)
111 }
112}
113
114func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
115 baseURL, err := url.Parse("https://example.invalid")
116 if err != nil {
117 t.Fatalf("parse base URL: %v", err)
118 }
119 client := &Client{baseURL: baseURL}
120
121 request, err := client.newRequest(
122 context.Background(),
123 http.MethodGet,
124 "/api/user/user%2Fname/recipes?page=2&page-count=5",
125 nil,
126 )
127 if err != nil {
128 t.Fatalf("newRequest() error = %v", err)
129 }
130
131 if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
132 t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
133 }
134}
135
136func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
137 t.Helper()
138
139 server := httptest.NewServer(handler)
140 baseURL, err := url.Parse(server.URL)
141 if err != nil {
142 server.Close()
143 t.Fatalf("parse server URL: %v", err)
144 }
145 client, err := NewClient(baseURL, "configured-user", "configured-password")
146 if err != nil {
147 server.Close()
148 t.Fatalf("NewClient() error = %v", err)
149 }
150
151 return client, server.Close
152}
153
154func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
155 t.Helper()
156
157 return func(w http.ResponseWriter, r *http.Request) {
158 switch r.URL.Path {
159 case "/api/public/login":
160 requireLoginRequest(t, r)
161 writeLoginResponse(t, w)
162 case "/api/user/returned-user/shopping-list":
163 requireSessionCookie(t, r)
164 *sawSessionCookie = true
165 writeShoppingListResponse(t, w)
166 default:
167 t.Fatalf("unexpected path %s", r.URL.Path)
168 }
169 }
170}
171
172func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
173 t.Helper()
174
175 return func(w http.ResponseWriter, r *http.Request) {
176 switch r.URL.Path {
177 case "/api/public/login":
178 writeLoginResponse(t, w)
179 case "/api/user/returned-user/recipes":
180 requireQueryValue(t, r, "page", "2")
181 requireQueryValue(t, r, "page-count", "5")
182 writeRecipeListResponse(t, w)
183 default:
184 t.Fatalf("unexpected path %s", r.URL.Path)
185 }
186 }
187}
188
189func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
190 t.Helper()
191
192 return func(w http.ResponseWriter, r *http.Request) {
193 switch r.URL.Path {
194 case "/api/public/login":
195 writeLoginResponse(t, w)
196 case "/api/user/returned-user/recipes/search":
197 requireMethod(t, r, http.MethodGet, "search")
198 requireQueryValue(t, r, "q", "pasta & tomato")
199 requireQueryValue(t, r, "page", "3")
200 writeRecipeListResponse(t, w)
201 default:
202 t.Fatalf("unexpected path %s", r.URL.Path)
203 }
204 }
205}
206
207func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
208 t.Helper()
209
210 return func(w http.ResponseWriter, r *http.Request) {
211 switch r.URL.EscapedPath() {
212 case "/api/public/login":
213 writeLoginResponse(t, w)
214 case "/api/recipe/recipe%2Fwith%20space/metadata":
215 requireMethod(t, r, http.MethodGet, "metadata")
216 requireSessionCookie(t, r)
217 writeRecipeMetadataResponse(t, w)
218 default:
219 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
220 }
221 }
222}
223
224func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
225 t.Helper()
226
227 return func(w http.ResponseWriter, r *http.Request) {
228 switch r.URL.EscapedPath() {
229 case "/api/public/login":
230 writeLoginResponse(t, w)
231 case "/api/recipe/recipe%2Fwith%20space/content":
232 requireMethod(t, r, http.MethodGet, "content")
233 requireSessionCookie(t, r)
234 writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
235 default:
236 t.Fatalf("unexpected path %s", r.URL.EscapedPath())
237 }
238 }
239}
240
241func requireLoginRequest(t *testing.T, r *http.Request) {
242 t.Helper()
243 requireMethod(t, r, http.MethodPost, "login")
244
245 var request loginRequest
246 if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
247 t.Fatalf("decode login request: %v", err)
248 }
249 if request.Username != "configured-user" || request.Password != "configured-password" {
250 t.Fatalf("login request = %#v, want configured credentials", request)
251 }
252}
253
254func requireMethod(t *testing.T, r *http.Request, want string, label string) {
255 t.Helper()
256
257 if r.Method != want {
258 t.Fatalf("%s method = %s, want %s", label, r.Method, want)
259 }
260}
261
262func requireSessionCookie(t *testing.T, r *http.Request) {
263 t.Helper()
264
265 cookie, err := r.Cookie("cooked_session")
266 if err != nil {
267 t.Fatalf("missing session cookie: %v", err)
268 }
269 if cookie.Value != "session-value" {
270 t.Fatalf("session cookie = %q, want session-value", cookie.Value)
271 }
272}
273
274func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
275 t.Helper()
276
277 if got := r.URL.Query().Get(name); got != want {
278 t.Fatalf("%s query = %q, want %q", name, got, want)
279 }
280}
281
282func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
283 t.Helper()
284
285 http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
286 writeJSON(t, w, loginResponse{Username: "returned-user"})
287}
288
289func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
290 t.Helper()
291
292 writeJSON(t, w, shoppingListResponse{
293 ShoppingList: ShoppingList{
294 Aisles: []Aisle{{
295 ID: "pantry",
296 Name: "Pantry",
297 ProductGroups: []ProductGroup{{
298 ID: "pasta",
299 Name: "Pasta",
300 Quantity: "200g",
301 }},
302 }},
303 },
304 Recipes: []string{"recipe-id"},
305 })
306}
307
308func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
309 t.Helper()
310
311 writeJSON(t, w, recipeListResponse{
312 Recipes: []RecipeCard{{
313 ID: "recipe-1",
314 Title: "Pasta",
315 ThumbnailURL: "https://example.invalid/thumb.jpg",
316 }},
317 })
318}
319
320func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
321 t.Helper()
322
323 w.Header().Set("Content-Type", "application/json")
324 if _, err := w.Write([]byte(`{
325 "title": "Pasta with Tomato Sauce",
326 "image-urls": ["https://example.invalid/pasta.jpg"],
327 "owner": "returned-user",
328 "edit-permission": true
329 }`)); err != nil {
330 t.Fatalf("write metadata response: %v", err)
331 }
332}
333
334func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent](
335 t *testing.T,
336 w http.ResponseWriter,
337 value T,
338) {
339 t.Helper()
340
341 w.Header().Set("Content-Type", "application/json")
342 if err := json.NewEncoder(w).Encode(value); err != nil {
343 t.Fatalf("write JSON: %v", err)
344 }
345}