client_test.go

  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}