package web import ( "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "github.com/scottyah/tracker/internal/config" "github.com/scottyah/tracker/internal/store" ) // newServer builds a server whose "today" is fixed, so the day arithmetic is // the same in January as it is in July. func newServer(t *testing.T, today string) (*Server, *store.Store) { t.Helper() tz, err := time.LoadLocation("America/Los_Angeles") if err != nil { t.Fatal(err) } st, err := store.Open(filepath.Join(t.TempDir(), "tracker.json")) if err != nil { t.Fatal(err) } srv, err := New(&config.Config{Addr: ":0", BaseURL: "http://test", TZ: tz}, st) if err != nil { t.Fatal(err) } srv.now = func() time.Time { d, err := time.ParseInLocation(store.DateLayout, today, tz) if err != nil { t.Fatal(err) } return d.Add(9 * time.Hour) // mid-morning, well inside the local day } return srv, st } func configured() store.Settings { s := store.Defaults() s.StartDate = "2026-06-20" return s } func get(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) return rec } func post(t *testing.T, srv *Server, path string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) return rec } func TestHomeAsksForSetupFirst(t *testing.T) { srv, _ := newServer(t, "2026-07-31") rec := get(t, srv, "/") if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Start the challenge") { t.Error("an unconfigured tracker should open on the setup form") } } func TestHomeShowsToday(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) body := get(t, srv, "/").Body.String() if !strings.Contains(body, "Friday 31 July") { t.Error("today's date is missing from the page") } // 2026-06-20 is day 1, so 2026-07-31 is day 42. if !strings.Contains(body, `
42
`) { t.Error("the day number is wrong or missing") } } func TestSaveDayRoundTrips(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) rec := post(t, srv, "/d/2026-07-30", url.Values{ "workout": {"1"}, "protein": {"186"}, "calories": {"2140"}, "water": {"132"}, "gratitude1": {"Coffee"}, "gratitude2": {" Dog "}, "gratitude3": {"Squats"}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("status %d, want a redirect back to the day", rec.Code) } day := st.Day("2026-07-30") if !day.Workout || *day.Protein != 186 || *day.Calories != 2140 || *day.Water != 132 { t.Fatalf("day did not save: %+v", day) } if day.Gratitude[1] != "Dog" { t.Errorf("gratitude kept its whitespace: %q", day.Gratitude[1]) } if !configured().Marks(day).Complete() { t.Error("a day hitting all five did not score 5") } if body := get(t, srv, "/d/2026-07-30").Body.String(); !strings.Contains(body, `value="186"`) { t.Error("the saved protein is not back in the form") } } func TestBackgroundSaveAnswersWithTheScore(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) req := httptest.NewRequest(http.MethodPost, "/d/2026-07-31", strings.NewReader(url.Values{"workout": {"1"}, "water": {"130"}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Sync", "1") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } if got := rec.Body.String(); !strings.Contains(got, `"score":2`) { t.Errorf("body %s, want a score of 2", got) } } func TestSaveRejectsNonNumbers(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) rec := post(t, srv, "/d/2026-07-31", url.Values{"protein": {"lots"}}) if rec.Code != http.StatusBadRequest { t.Fatalf("status %d, want 400", rec.Code) } if st.Day("2026-07-31").Logged() { t.Error("a rejected form still wrote to the file") } } func TestBadDatesAreNotFound(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) for _, date := range []string{"2026-13-01", "2026-2-03", "tuesday", "2026-02-30"} { if rec := get(t, srv, "/d/"+date); rec.Code != http.StatusNotFound { t.Errorf("GET /d/%s = %d, want 404", date, rec.Code) } } } func TestNavigationStopsAtTodayAndAtTheEnds(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) if got := srv.step(configured(), "2026-06-20", -1); got != "" { t.Errorf("stepped back past day 1 to %q", got) } if got := srv.step(configured(), "2026-07-31", +1); got != "" { t.Errorf("stepped forward into tomorrow, to %q", got) } if got := srv.step(configured(), "2026-07-30", +1); got != "2026-07-31" { t.Errorf("step forward = %q, want 2026-07-31", got) } } func TestStreakIgnoresAnUnfinishedToday(t *testing.T) { srv, st := newServer(t, "2026-07-31") settings := configured() st.SaveSettings(settings) perfect := func(date string) { st.SaveDay(store.Day{Date: date, Workout: true, Protein: intp(200), Calories: intp(2000), Water: intp(130), Gratitude: [3]string{"a", "b", "c"}}) } perfect("2026-07-28") perfect("2026-07-29") perfect("2026-07-30") // Nothing logged for today yet: the streak is still three, not broken. if got := srv.stats(settings, srv.card(settings)).Streak; got != 3 { t.Errorf("streak = %d, want 3", got) } perfect("2026-07-31") if got := srv.stats(settings, srv.card(settings)).Streak; got != 4 { t.Errorf("streak after finishing today = %d, want 4", got) } // A gap two days back ends the run at yesterday and today. st.SaveDay(store.Day{Date: "2026-07-30"}) if got := srv.stats(settings, srv.card(settings)).Streak; got != 1 { t.Errorf("streak across a missed day = %d, want 1", got) } } func TestCrossSiteWritesAreRefused(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) req := httptest.NewRequest(http.MethodPost, "/d/2026-07-31", strings.NewReader("workout=1")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Sec-Fetch-Site", "cross-site") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status %d, want 403", rec.Code) } if st.Day("2026-07-31").Logged() { t.Error("a cross-site post still wrote to the log") } } func TestExportThenImport(t *testing.T) { srv, st := newServer(t, "2026-07-31") st.SaveSettings(configured()) st.SaveDay(store.Day{Date: "2026-07-30", Workout: true}) rec := get(t, srv, "/export.json") if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } if !strings.Contains(rec.Header().Get("Content-Disposition"), "tracker-2026-07-31.json") { t.Errorf("download name = %q", rec.Header().Get("Content-Disposition")) } fresh, err := store.Open(filepath.Join(t.TempDir(), "tracker.json")) if err != nil { t.Fatal(err) } if err := fresh.Import(rec.Body.Bytes()); err != nil { t.Fatalf("import: %v", err) } if !fresh.Day("2026-07-30").Workout || fresh.Settings().StartDate != "2026-06-20" { t.Error("the export did not carry the log and the settings") } } func TestLandingBeforeTheChallengeStarts(t *testing.T) { srv, st := newServer(t, "2026-06-01") st.SaveSettings(configured()) body := get(t, srv, "/").Body.String() if !strings.Contains(body, "Nothing counts until then") { t.Error("a challenge that has not started should say so") } if !strings.Contains(body, `1
`) { t.Error("it should land on day 1 rather than a date outside the challenge") } } func intp(n int) *int { return &n }