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