ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

e2e tests of a sort

+294
+294
cmd/careme/web_e2e_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "io" 6 + "net/http" 7 + "net/http/cookiejar" 8 + "net/http/httptest" 9 + "net/url" 10 + "path/filepath" 11 + "regexp" 12 + "strings" 13 + "testing" 14 + "time" 15 + 16 + "careme/internal/cache" 17 + "careme/internal/config" 18 + "careme/internal/locations" 19 + "careme/internal/recipes" 20 + "careme/internal/users" 21 + ) 22 + 23 + func TestWebEndToEndFlowWithMocks(t *testing.T) { 24 + srv := newTestServer(t) 25 + defer srv.Close() 26 + 27 + client := newTestClient(t) 28 + 29 + // Step 1: query locations for 90005 and ensure it returns a /recipes?location link. 30 + locationsBody := mustGetBody(t, client, srv.URL+"/locations?zip=90005") 31 + locationID := extractLocationID(t, locationsBody) 32 + 33 + // Log in to avoid redirect back to home when hitting /recipes. 34 + login(t, client, srv.URL, "test@example.com") 35 + 36 + // Step 2: go to /recipes?location=<id> and follow redirects until recipes render. 37 + initialRecipesURL := srv.URL + "/recipes?location=" + url.QueryEscape(locationID) 38 + resp := mustGet(t, client, initialRecipesURL) 39 + if resp.StatusCode != http.StatusSeeOther { 40 + resp.Body.Close() 41 + t.Fatalf("expected initial /recipes redirect (303), got %d", resp.StatusCode) 42 + } 43 + redirectTo := resp.Header.Get("Location") 44 + resp.Body.Close() 45 + if redirectTo == "" { 46 + t.Fatal("expected redirect location header from /recipes") 47 + } 48 + finalRecipesURL := resolveURL(t, initialRecipesURL, redirectTo) 49 + _, recipesBody := followUntilRecipes(t, client, finalRecipesURL) 50 + 51 + // Step 3: select one recipe to save and two to dismiss. 52 + conversationID := extractHiddenValue(t, recipesBody, "conversation_id") 53 + date := extractHiddenValue(t, recipesBody, "date") 54 + location := extractHiddenValue(t, recipesBody, "location") 55 + recipeHashes := extractRecipeHashes(t, recipesBody) 56 + if len(recipeHashes) < 3 { 57 + t.Fatalf("expected at least 3 recipes, got %d", len(recipeHashes)) 58 + } 59 + 60 + savedHash := recipeHashes[0] 61 + dismissedHashes := recipeHashes[1:3] 62 + 63 + // Step 4: finalize with the saved/dismissed selections. 64 + finalizeURL := buildRecipesURL(srv.URL, location, date, conversationID, savedHash, dismissedHashes, true) 65 + _, finalizedBody := followUntilRecipes(t, client, finalizeURL) 66 + if countArticles(finalizedBody) != 1 { 67 + t.Fatalf("expected finalized page to show 1 recipe, got %d", countArticles(finalizedBody)) 68 + } 69 + } 70 + 71 + func newTestServer(t *testing.T) *httptest.Server { 72 + t.Helper() 73 + 74 + cfg := &config.Config{Mocks: config.MockConfig{Enable: true}} 75 + cacheDir := filepath.Join(t.TempDir(), "cache") 76 + cacheStore := cache.NewFileCache(cacheDir) 77 + userStorage := users.NewStorage(cacheStore) 78 + 79 + generator, err := recipes.NewGenerator(cfg, cacheStore) 80 + if err != nil { 81 + t.Fatalf("failed to create generator: %v", err) 82 + } 83 + locationServer, err := locations.New(context.Background(), cfg) 84 + if err != nil { 85 + t.Fatalf("failed to create location server: %v", err) 86 + } 87 + 88 + mux := http.NewServeMux() 89 + locations.Register(locationServer, mux) 90 + users.NewHandler(userStorage, locationServer).Register(mux) 91 + recipes.NewHandler(cfg, userStorage, generator, locationServer, cacheStore).Register(mux) 92 + 93 + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { 94 + if r.Method != http.MethodPost { 95 + w.WriteHeader(http.StatusMethodNotAllowed) 96 + return 97 + } 98 + if err := r.ParseForm(); err != nil { 99 + http.Error(w, "invalid form submission", http.StatusBadRequest) 100 + return 101 + } 102 + email := strings.TrimSpace(r.FormValue("email")) 103 + if email == "" { 104 + http.Error(w, "email is required", http.StatusBadRequest) 105 + return 106 + } 107 + user, err := userStorage.FindOrCreateByEmail(email) 108 + if err != nil { 109 + http.Error(w, "unable to sign in", http.StatusInternalServerError) 110 + return 111 + } 112 + users.SetCookie(w, user.ID, sessionDuration) 113 + http.Redirect(w, r, "/", http.StatusSeeOther) 114 + }) 115 + 116 + return httptest.NewServer(WithMiddleware(mux)) 117 + } 118 + 119 + func newTestClient(t *testing.T) *http.Client { 120 + t.Helper() 121 + jar, err := cookiejar.New(nil) 122 + if err != nil { 123 + t.Fatalf("failed to create cookie jar: %v", err) 124 + } 125 + return &http.Client{ 126 + Jar: jar, 127 + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { 128 + return http.ErrUseLastResponse 129 + }, 130 + } 131 + } 132 + 133 + func login(t *testing.T, client *http.Client, baseURL, email string) { 134 + t.Helper() 135 + form := url.Values{} 136 + form.Set("email", email) 137 + resp, err := client.PostForm(baseURL+"/login", form) 138 + if err != nil { 139 + t.Fatalf("login request failed: %v", err) 140 + } 141 + defer resp.Body.Close() 142 + if resp.StatusCode != http.StatusSeeOther { 143 + body := readAll(t, resp.Body) 144 + t.Fatalf("expected login redirect (303), got %d: %s", resp.StatusCode, body) 145 + } 146 + } 147 + 148 + func mustGet(t *testing.T, client *http.Client, url string) *http.Response { 149 + t.Helper() 150 + resp, err := client.Get(url) 151 + if err != nil { 152 + t.Fatalf("GET %s failed: %v", url, err) 153 + } 154 + return resp 155 + } 156 + 157 + func mustGetBody(t *testing.T, client *http.Client, url string) string { 158 + t.Helper() 159 + resp := mustGet(t, client, url) 160 + defer resp.Body.Close() 161 + if resp.StatusCode != http.StatusOK { 162 + body := readAll(t, resp.Body) 163 + t.Fatalf("GET %s expected 200, got %d: %s", url, resp.StatusCode, body) 164 + } 165 + return readAll(t, resp.Body) 166 + } 167 + 168 + func followUntilRecipes(t *testing.T, client *http.Client, startURL string) (string, string) { 169 + t.Helper() 170 + deadline := time.Now().Add(10 * time.Second) 171 + current := startURL 172 + 173 + for { 174 + if time.Now().After(deadline) { 175 + t.Fatalf("timed out waiting for recipes page starting at %s", startURL) 176 + } 177 + 178 + resp := mustGet(t, client, current) 179 + if isRedirect(resp.StatusCode) { 180 + location := resp.Header.Get("Location") 181 + resp.Body.Close() 182 + if location == "" { 183 + t.Fatalf("redirect from %s missing Location header", current) 184 + } 185 + current = resolveURL(t, current, location) 186 + continue 187 + } 188 + 189 + body := readAll(t, resp.Body) 190 + resp.Body.Close() 191 + 192 + if isSpinner(body) { 193 + time.Sleep(100 * time.Millisecond) 194 + continue 195 + } 196 + 197 + if resp.StatusCode != http.StatusOK { 198 + t.Fatalf("expected recipes page 200, got %d: %s", resp.StatusCode, body) 199 + } 200 + 201 + return current, body 202 + } 203 + } 204 + 205 + func isRedirect(status int) bool { 206 + return status == http.StatusMovedPermanently || status == http.StatusFound || status == http.StatusSeeOther || status == http.StatusTemporaryRedirect || status == http.StatusPermanentRedirect 207 + } 208 + 209 + func isSpinner(body string) bool { 210 + return strings.Contains(body, "<title>Generating") || strings.Contains(body, "Please wait") 211 + } 212 + 213 + func resolveURL(t *testing.T, base, ref string) string { 214 + t.Helper() 215 + baseURL, err := url.Parse(base) 216 + if err != nil { 217 + t.Fatalf("invalid base URL %s: %v", base, err) 218 + } 219 + refURL, err := url.Parse(ref) 220 + if err != nil { 221 + t.Fatalf("invalid redirect URL %s: %v", ref, err) 222 + } 223 + return baseURL.ResolveReference(refURL).String() 224 + } 225 + 226 + func extractLocationID(t *testing.T, body string) string { 227 + t.Helper() 228 + re := regexp.MustCompile(`href="/recipes\?location=([^"]+)"`) 229 + match := re.FindStringSubmatch(body) 230 + if len(match) < 2 { 231 + t.Fatalf("expected locations page to include /recipes?location link") 232 + } 233 + return match[1] 234 + } 235 + 236 + func extractHiddenValue(t *testing.T, body, name string) string { 237 + t.Helper() 238 + re := regexp.MustCompile(`name="` + regexp.QuoteMeta(name) + `" value="([^"]*)"`) 239 + match := re.FindStringSubmatch(body) 240 + if len(match) < 2 { 241 + t.Fatalf("expected hidden input %q in page", name) 242 + } 243 + return match[1] 244 + } 245 + 246 + func extractRecipeHashes(t *testing.T, body string) []string { 247 + t.Helper() 248 + re := regexp.MustCompile(`id="save-([^"]+)"`) 249 + matches := re.FindAllStringSubmatch(body, -1) 250 + if len(matches) == 0 { 251 + t.Fatalf("expected recipe save inputs in page") 252 + } 253 + seen := make(map[string]struct{}) 254 + var hashes []string 255 + for _, match := range matches { 256 + if len(match) < 2 { 257 + continue 258 + } 259 + if _, ok := seen[match[1]]; ok { 260 + continue 261 + } 262 + seen[match[1]] = struct{}{} 263 + hashes = append(hashes, match[1]) 264 + } 265 + return hashes 266 + } 267 + 268 + func buildRecipesURL(base, location, date, conversationID, savedHash string, dismissedHashes []string, finalize bool) string { 269 + params := url.Values{} 270 + params.Set("location", location) 271 + params.Set("date", date) 272 + params.Set("conversation_id", conversationID) 273 + params.Add("saved", savedHash) 274 + for _, hash := range dismissedHashes { 275 + params.Add("dismissed", hash) 276 + } 277 + if finalize { 278 + params.Set("finalize", "true") 279 + } 280 + return base + "/recipes?" + params.Encode() 281 + } 282 + 283 + func countArticles(body string) int { 284 + return strings.Count(body, "<article") 285 + } 286 + 287 + func readAll(t *testing.T, r io.Reader) string { 288 + t.Helper() 289 + data, err := io.ReadAll(r) 290 + if err != nil { 291 + t.Fatalf("failed to read response body: %v", err) 292 + } 293 + return string(data) 294 + }