Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
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>
273 lines
7.8 KiB
Go
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 }
|