···11+package main
22+33+import (
44+ "context"
55+ "io"
66+ "net/http"
77+ "net/http/cookiejar"
88+ "net/http/httptest"
99+ "net/url"
1010+ "path/filepath"
1111+ "regexp"
1212+ "strings"
1313+ "testing"
1414+ "time"
1515+1616+ "careme/internal/cache"
1717+ "careme/internal/config"
1818+ "careme/internal/locations"
1919+ "careme/internal/recipes"
2020+ "careme/internal/users"
2121+)
2222+2323+func TestWebEndToEndFlowWithMocks(t *testing.T) {
2424+ srv := newTestServer(t)
2525+ defer srv.Close()
2626+2727+ client := newTestClient(t)
2828+2929+ // Step 1: query locations for 90005 and ensure it returns a /recipes?location link.
3030+ locationsBody := mustGetBody(t, client, srv.URL+"/locations?zip=90005")
3131+ locationID := extractLocationID(t, locationsBody)
3232+3333+ // Log in to avoid redirect back to home when hitting /recipes.
3434+ login(t, client, srv.URL, "test@example.com")
3535+3636+ // Step 2: go to /recipes?location=<id> and follow redirects until recipes render.
3737+ initialRecipesURL := srv.URL + "/recipes?location=" + url.QueryEscape(locationID)
3838+ _, recipesBody := followUntilRecipes(t, client, initialRecipesURL, true /*expectSpinner*/)
3939+4040+ // Step 3: select one recipe to save and two to dismiss.
4141+ conversationID := extractHiddenValue(t, recipesBody, "conversation_id")
4242+ date := extractHiddenValue(t, recipesBody, "date")
4343+ location := extractHiddenValue(t, recipesBody, "location")
4444+ recipeHashes := extractRecipeHashes(t, recipesBody)
4545+ if len(recipeHashes) < 3 {
4646+ t.Fatalf("expected at least 3 recipes, got %d", len(recipeHashes))
4747+ }
4848+4949+ savedHash := recipeHashes[0]
5050+ dismissedHashes := recipeHashes[1:3]
5151+5252+ //step 4 todo regenrate again with commentary then save two more
5353+5454+ // Step 5: finalize with the saved/dismissed selections.
5555+ finalizeURL := buildRecipesURL(srv.URL, location, date, conversationID, savedHash, dismissedHashes, true)
5656+ _, finalizedBody := followUntilRecipes(t, client, finalizeURL, false /*expectSpinner*/)
5757+ recipeHashes = extractRecipeHashes(t, finalizedBody)
5858+ if len(recipeHashes) != 1 {
5959+ t.Fatalf("expected finalized page to show 1 recipe, got %d", len(recipeHashes))
6060+ }
6161+ if recipeHashes[0] != savedHash {
6262+ t.Fatalf("expected finalized recipe to be %s, got %s", savedHash, recipeHashes[0])
6363+ }
6464+ for _, dismissed := range dismissedHashes {
6565+ if recipeHashes[0] == dismissed {
6666+ t.Fatalf("finalized recipe %s was in dismissed list", dismissed)
6767+ }
6868+ }
6969+7070+ //TODO step 6 make sure recipes are saved to user page?
7171+7272+}
7373+7474+func newTestServer(t *testing.T) *httptest.Server {
7575+ t.Helper()
7676+7777+ cfg := &config.Config{Mocks: config.MockConfig{Enable: true}}
7878+ cacheDir := filepath.Join(t.TempDir(), "cache")
7979+ cacheStore := cache.NewFileCache(cacheDir)
8080+ userStorage := users.NewStorage(cacheStore)
8181+8282+ generator, err := recipes.NewGenerator(cfg, cacheStore)
8383+ if err != nil {
8484+ t.Fatalf("failed to create generator: %v", err)
8585+ }
8686+ locationServer, err := locations.New(context.Background(), cfg)
8787+ if err != nil {
8888+ t.Fatalf("failed to create location server: %v", err)
8989+ }
9090+9191+ mux := http.NewServeMux()
9292+ locations.Register(locationServer, mux)
9393+ users.NewHandler(userStorage, locationServer).Register(mux)
9494+ recipes.NewHandler(cfg, userStorage, generator, locationServer, cacheStore).Register(mux)
9595+9696+ //todo find a better way to mock this or move it to web.go?
9797+ mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
9898+ if r.Method != http.MethodPost {
9999+ w.WriteHeader(http.StatusMethodNotAllowed)
100100+ return
101101+ }
102102+ if err := r.ParseForm(); err != nil {
103103+ http.Error(w, "invalid form submission", http.StatusBadRequest)
104104+ return
105105+ }
106106+ email := strings.TrimSpace(r.FormValue("email"))
107107+ if email == "" {
108108+ http.Error(w, "email is required", http.StatusBadRequest)
109109+ return
110110+ }
111111+ user, err := userStorage.FindOrCreateByEmail(email)
112112+ if err != nil {
113113+ http.Error(w, "unable to sign in", http.StatusInternalServerError)
114114+ return
115115+ }
116116+ users.SetCookie(w, user.ID, sessionDuration)
117117+ w.WriteHeader(http.StatusOK)
118118+ })
119119+120120+ return httptest.NewServer(WithMiddleware(mux))
121121+}
122122+123123+func newTestClient(t *testing.T) *http.Client {
124124+ t.Helper()
125125+ jar, err := cookiejar.New(nil)
126126+ if err != nil {
127127+ t.Fatalf("failed to create cookie jar: %v", err)
128128+ }
129129+ return &http.Client{
130130+ Jar: jar,
131131+ }
132132+}
133133+134134+func login(t *testing.T, client *http.Client, baseURL, email string) {
135135+ t.Helper()
136136+ form := url.Values{}
137137+ form.Set("email", email)
138138+ resp, err := client.PostForm(baseURL+"/login", form)
139139+ if err != nil {
140140+ t.Fatalf("login request failed: %v", err)
141141+ }
142142+ defer resp.Body.Close()
143143+ if resp.StatusCode != http.StatusOK {
144144+ body := readAll(t, resp.Body)
145145+ t.Fatalf("expected login 200, got %d: %s", resp.StatusCode, body)
146146+ }
147147+}
148148+149149+func mustGet(t *testing.T, client *http.Client, url string) *http.Response {
150150+ t.Helper()
151151+ resp, err := client.Get(url)
152152+ if err != nil {
153153+ t.Fatalf("GET %s failed: %v", url, err)
154154+ }
155155+ return resp
156156+}
157157+158158+func mustGetBody(t *testing.T, client *http.Client, url string) string {
159159+ t.Helper()
160160+ resp := mustGet(t, client, url)
161161+ defer resp.Body.Close()
162162+ if resp.StatusCode != http.StatusOK {
163163+ body := readAll(t, resp.Body)
164164+ t.Fatalf("GET %s expected 200, got %d: %s", url, resp.StatusCode, body)
165165+ }
166166+ return readAll(t, resp.Body)
167167+}
168168+169169+func followUntilRecipes(t *testing.T, client *http.Client, startURL string, expectSpinner bool) (string, string) {
170170+ t.Helper()
171171+ deadline := time.Now().Add(10 * time.Second)
172172+ current := startURL
173173+ sawSpinner := false
174174+ for {
175175+ if time.Now().After(deadline) {
176176+ t.Fatalf("timed out waiting for recipes page starting at %s", startURL)
177177+ }
178178+179179+ resp := mustGet(t, client, current)
180180+181181+ body := readAll(t, resp.Body)
182182+ resp.Body.Close()
183183+184184+ if isSpinner(body) {
185185+ sawSpinner = true
186186+ time.Sleep(100 * time.Millisecond)
187187+ continue
188188+ }
189189+190190+ if resp.StatusCode != http.StatusOK {
191191+ t.Fatalf("expected recipes page 200, got %d: %s", resp.StatusCode, body)
192192+ }
193193+194194+ if sawSpinner != expectSpinner {
195195+ t.Fatal("expected spinner but never got one")
196196+ }
197197+198198+ return current, body
199199+ }
200200+}
201201+202202+func isSpinner(body string) bool {
203203+ return strings.Contains(body, "<title>Generating") || strings.Contains(body, "Please wait")
204204+}
205205+206206+func extractLocationID(t *testing.T, body string) string {
207207+ t.Helper()
208208+ re := regexp.MustCompile(`href="/recipes\?location=([^"]+)"`)
209209+ match := re.FindStringSubmatch(body)
210210+ if len(match) < 2 {
211211+ t.Fatalf("expected locations page to include /recipes?location link")
212212+ }
213213+ return match[1]
214214+}
215215+216216+func extractHiddenValue(t *testing.T, body, name string) string {
217217+ t.Helper()
218218+ re := regexp.MustCompile(`name="` + regexp.QuoteMeta(name) + `" value="([^"]*)"`)
219219+ match := re.FindStringSubmatch(body)
220220+ if len(match) < 2 {
221221+ t.Fatalf("expected hidden input %q in page", name)
222222+ }
223223+ return match[1]
224224+}
225225+226226+func extractRecipeHashes(t *testing.T, body string) []string {
227227+ t.Helper()
228228+ re := regexp.MustCompile(`id="save-([^"]+)"`)
229229+ matches := re.FindAllStringSubmatch(body, -1)
230230+ if len(matches) == 0 {
231231+ t.Fatalf("expected recipe save inputs in page")
232232+ }
233233+ seen := make(map[string]struct{})
234234+ var hashes []string
235235+ for _, match := range matches {
236236+ if len(match) < 2 {
237237+ continue
238238+ }
239239+ if _, ok := seen[match[1]]; ok {
240240+ continue
241241+ }
242242+ seen[match[1]] = struct{}{}
243243+ hashes = append(hashes, match[1])
244244+ }
245245+ return hashes
246246+}
247247+248248+func buildRecipesURL(base, location, date, conversationID, savedHash string, dismissedHashes []string, finalize bool) string {
249249+ params := url.Values{}
250250+ params.Set("location", location)
251251+ params.Set("date", date)
252252+ params.Set("conversation_id", conversationID)
253253+ params.Add("saved", savedHash)
254254+ for _, hash := range dismissedHashes {
255255+ params.Add("dismissed", hash)
256256+ }
257257+ if finalize {
258258+ params.Set("finalize", "true")
259259+ }
260260+ return base + "/recipes?" + params.Encode()
261261+}
262262+263263+func readAll(t *testing.T, r io.Reader) string {
264264+ t.Helper()
265265+ data, err := io.ReadAll(r)
266266+ if err != nil {
267267+ t.Fatalf("failed to read response body: %v", err)
268268+ }
269269+ return string(data)
270270+}
+2
internal/recipes/mock.go
···349349 if id == "" {
350350 id = uuid.NewString()
351351 }
352352+ // fake like we're taking time to call an LLM so we get the spinner.
353353+ time.Sleep(100 * time.Millisecond)
352354353355 // Select 3 random recipes from the pool of 20
354356 // Create a new random generator with current time as seed