The workout, the protein, the calories, the water, and three lines of gratitude. One page per day, a card of seventy-five tiles that fill by how many of the five landed, and a missed day left as a gap rather than a reset. Single Go binary with no dependencies. The log is one JSON file on a mounted volume, written atomically and never silently replaced when it fails to parse, since it is the one thing here that cannot be recreated. Auth is Traefik basic auth at the ingress; the app has no login of its own, so every POST checks Sec-Fetch-Site to stop another origin posting with those credentials. 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/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, `<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"), "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, `<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 }
|