Files
fit/internal/web/web_test.go
scott 3168a4ef96
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
Rename the app from tracker to fit
The directory, the repo and the hostname are all fit; the module path,
the image, the namespace and the data file were still tracker. Nothing
is deployed yet, so this is free now and would not be later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:03:33 -07:00

273 lines
7.8 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/scottyah/fit/internal/config"
"github.com/scottyah/fit/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(), "fit.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, `<p class="masthead-count">42</p>`) {
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"), "fit-2026-07-31.json") {
t.Errorf("download name = %q", rec.Header().Get("Content-Disposition"))
}
fresh, err := store.Open(filepath.Join(t.TempDir(), "fit.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, `<p class="masthead-count">1</p>`) {
t.Error("it should land on day 1 rather than a date outside the challenge")
}
}
func intp(n int) *int { return &n }