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 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}