ai cooking
0
fork

Configure Feed

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

Merge pull request #151 from paulgmiller/pmiller/e2etests

e2e tests of a sort

authored by

Paul Miller and committed by
GitHub
09a158a2 eee2f2c2

+272
.beads/issues.jsonl

This is a binary file and will not be displayed.

+270
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 + _, recipesBody := followUntilRecipes(t, client, initialRecipesURL, true /*expectSpinner*/) 39 + 40 + // Step 3: select one recipe to save and two to dismiss. 41 + conversationID := extractHiddenValue(t, recipesBody, "conversation_id") 42 + date := extractHiddenValue(t, recipesBody, "date") 43 + location := extractHiddenValue(t, recipesBody, "location") 44 + recipeHashes := extractRecipeHashes(t, recipesBody) 45 + if len(recipeHashes) < 3 { 46 + t.Fatalf("expected at least 3 recipes, got %d", len(recipeHashes)) 47 + } 48 + 49 + savedHash := recipeHashes[0] 50 + dismissedHashes := recipeHashes[1:3] 51 + 52 + //step 4 todo regenrate again with commentary then save two more 53 + 54 + // Step 5: finalize with the saved/dismissed selections. 55 + finalizeURL := buildRecipesURL(srv.URL, location, date, conversationID, savedHash, dismissedHashes, true) 56 + _, finalizedBody := followUntilRecipes(t, client, finalizeURL, false /*expectSpinner*/) 57 + recipeHashes = extractRecipeHashes(t, finalizedBody) 58 + if len(recipeHashes) != 1 { 59 + t.Fatalf("expected finalized page to show 1 recipe, got %d", len(recipeHashes)) 60 + } 61 + if recipeHashes[0] != savedHash { 62 + t.Fatalf("expected finalized recipe to be %s, got %s", savedHash, recipeHashes[0]) 63 + } 64 + for _, dismissed := range dismissedHashes { 65 + if recipeHashes[0] == dismissed { 66 + t.Fatalf("finalized recipe %s was in dismissed list", dismissed) 67 + } 68 + } 69 + 70 + //TODO step 6 make sure recipes are saved to user page? 71 + 72 + } 73 + 74 + func newTestServer(t *testing.T) *httptest.Server { 75 + t.Helper() 76 + 77 + cfg := &config.Config{Mocks: config.MockConfig{Enable: true}} 78 + cacheDir := filepath.Join(t.TempDir(), "cache") 79 + cacheStore := cache.NewFileCache(cacheDir) 80 + userStorage := users.NewStorage(cacheStore) 81 + 82 + generator, err := recipes.NewGenerator(cfg, cacheStore) 83 + if err != nil { 84 + t.Fatalf("failed to create generator: %v", err) 85 + } 86 + locationServer, err := locations.New(context.Background(), cfg) 87 + if err != nil { 88 + t.Fatalf("failed to create location server: %v", err) 89 + } 90 + 91 + mux := http.NewServeMux() 92 + locations.Register(locationServer, mux) 93 + users.NewHandler(userStorage, locationServer).Register(mux) 94 + recipes.NewHandler(cfg, userStorage, generator, locationServer, cacheStore).Register(mux) 95 + 96 + //todo find a better way to mock this or move it to web.go? 97 + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { 98 + if r.Method != http.MethodPost { 99 + w.WriteHeader(http.StatusMethodNotAllowed) 100 + return 101 + } 102 + if err := r.ParseForm(); err != nil { 103 + http.Error(w, "invalid form submission", http.StatusBadRequest) 104 + return 105 + } 106 + email := strings.TrimSpace(r.FormValue("email")) 107 + if email == "" { 108 + http.Error(w, "email is required", http.StatusBadRequest) 109 + return 110 + } 111 + user, err := userStorage.FindOrCreateByEmail(email) 112 + if err != nil { 113 + http.Error(w, "unable to sign in", http.StatusInternalServerError) 114 + return 115 + } 116 + users.SetCookie(w, user.ID, sessionDuration) 117 + w.WriteHeader(http.StatusOK) 118 + }) 119 + 120 + return httptest.NewServer(WithMiddleware(mux)) 121 + } 122 + 123 + func newTestClient(t *testing.T) *http.Client { 124 + t.Helper() 125 + jar, err := cookiejar.New(nil) 126 + if err != nil { 127 + t.Fatalf("failed to create cookie jar: %v", err) 128 + } 129 + return &http.Client{ 130 + Jar: jar, 131 + } 132 + } 133 + 134 + func login(t *testing.T, client *http.Client, baseURL, email string) { 135 + t.Helper() 136 + form := url.Values{} 137 + form.Set("email", email) 138 + resp, err := client.PostForm(baseURL+"/login", form) 139 + if err != nil { 140 + t.Fatalf("login request failed: %v", err) 141 + } 142 + defer resp.Body.Close() 143 + if resp.StatusCode != http.StatusOK { 144 + body := readAll(t, resp.Body) 145 + t.Fatalf("expected login 200, got %d: %s", resp.StatusCode, body) 146 + } 147 + } 148 + 149 + func mustGet(t *testing.T, client *http.Client, url string) *http.Response { 150 + t.Helper() 151 + resp, err := client.Get(url) 152 + if err != nil { 153 + t.Fatalf("GET %s failed: %v", url, err) 154 + } 155 + return resp 156 + } 157 + 158 + func mustGetBody(t *testing.T, client *http.Client, url string) string { 159 + t.Helper() 160 + resp := mustGet(t, client, url) 161 + defer resp.Body.Close() 162 + if resp.StatusCode != http.StatusOK { 163 + body := readAll(t, resp.Body) 164 + t.Fatalf("GET %s expected 200, got %d: %s", url, resp.StatusCode, body) 165 + } 166 + return readAll(t, resp.Body) 167 + } 168 + 169 + func followUntilRecipes(t *testing.T, client *http.Client, startURL string, expectSpinner bool) (string, string) { 170 + t.Helper() 171 + deadline := time.Now().Add(10 * time.Second) 172 + current := startURL 173 + sawSpinner := false 174 + for { 175 + if time.Now().After(deadline) { 176 + t.Fatalf("timed out waiting for recipes page starting at %s", startURL) 177 + } 178 + 179 + resp := mustGet(t, client, current) 180 + 181 + body := readAll(t, resp.Body) 182 + resp.Body.Close() 183 + 184 + if isSpinner(body) { 185 + sawSpinner = true 186 + time.Sleep(100 * time.Millisecond) 187 + continue 188 + } 189 + 190 + if resp.StatusCode != http.StatusOK { 191 + t.Fatalf("expected recipes page 200, got %d: %s", resp.StatusCode, body) 192 + } 193 + 194 + if sawSpinner != expectSpinner { 195 + t.Fatal("expected spinner but never got one") 196 + } 197 + 198 + return current, body 199 + } 200 + } 201 + 202 + func isSpinner(body string) bool { 203 + return strings.Contains(body, "<title>Generating") || strings.Contains(body, "Please wait") 204 + } 205 + 206 + func extractLocationID(t *testing.T, body string) string { 207 + t.Helper() 208 + re := regexp.MustCompile(`href="/recipes\?location=([^"]+)"`) 209 + match := re.FindStringSubmatch(body) 210 + if len(match) < 2 { 211 + t.Fatalf("expected locations page to include /recipes?location link") 212 + } 213 + return match[1] 214 + } 215 + 216 + func extractHiddenValue(t *testing.T, body, name string) string { 217 + t.Helper() 218 + re := regexp.MustCompile(`name="` + regexp.QuoteMeta(name) + `" value="([^"]*)"`) 219 + match := re.FindStringSubmatch(body) 220 + if len(match) < 2 { 221 + t.Fatalf("expected hidden input %q in page", name) 222 + } 223 + return match[1] 224 + } 225 + 226 + func extractRecipeHashes(t *testing.T, body string) []string { 227 + t.Helper() 228 + re := regexp.MustCompile(`id="save-([^"]+)"`) 229 + matches := re.FindAllStringSubmatch(body, -1) 230 + if len(matches) == 0 { 231 + t.Fatalf("expected recipe save inputs in page") 232 + } 233 + seen := make(map[string]struct{}) 234 + var hashes []string 235 + for _, match := range matches { 236 + if len(match) < 2 { 237 + continue 238 + } 239 + if _, ok := seen[match[1]]; ok { 240 + continue 241 + } 242 + seen[match[1]] = struct{}{} 243 + hashes = append(hashes, match[1]) 244 + } 245 + return hashes 246 + } 247 + 248 + func buildRecipesURL(base, location, date, conversationID, savedHash string, dismissedHashes []string, finalize bool) string { 249 + params := url.Values{} 250 + params.Set("location", location) 251 + params.Set("date", date) 252 + params.Set("conversation_id", conversationID) 253 + params.Add("saved", savedHash) 254 + for _, hash := range dismissedHashes { 255 + params.Add("dismissed", hash) 256 + } 257 + if finalize { 258 + params.Set("finalize", "true") 259 + } 260 + return base + "/recipes?" + params.Encode() 261 + } 262 + 263 + func readAll(t *testing.T, r io.Reader) string { 264 + t.Helper() 265 + data, err := io.ReadAll(r) 266 + if err != nil { 267 + t.Fatalf("failed to read response body: %v", err) 268 + } 269 + return string(data) 270 + }
+2
internal/recipes/mock.go
··· 349 349 if id == "" { 350 350 id = uuid.NewString() 351 351 } 352 + // fake like we're taking time to call an LLM so we get the spinner. 353 + time.Sleep(100 * time.Millisecond) 352 354 353 355 // Select 3 random recipes from the pool of 20 354 356 // Create a new random generator with current time as seed