Files
foodster/cmd/foodster/main_test.go
T
Esa Kataja e64c4aa212 Refuse to log meals in the future
The log is a record of what was eaten, so there is nothing to write down for
a dinner that has not happened, and a stray entry dated next year would sit
at the top of the history forever. A future date is now clamped to today.

The clamp lives in the one function every read and write already goes
through, so ?pvm=, the date picker, saving and deleting are all covered. The
picker also gets max=today, which avoids offering the dead end at all.

Fixes a latent bug found alongside it: today() returned the current instant
with its time of day, while dates parsed from ?pvm= are midnight, so the two
never compared equal. After saving today's dinner the redirect landed on
/?pvm=... and the card then read "la 5.9. kirjattu" instead of "Tänään
kirjattu", and "Tänään" stopped highlighting whenever the date was spelled
out. today() now truncates to midnight in the configured location.
2026-09-05 21:31:23 +03:00

314 lines
9.3 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func TestNormalizeName(t *testing.T) {
cases := []struct{ in, want string }{
{"kanacurry", "Kanacurry"},
{" jauhelihakastike ", "Jauhelihakastike"},
// Sentence case: only the first word is capitalised (Finnish, PRD §6).
{"lohikeitto ja ruisleipä", "Lohikeitto ja ruisleipä"},
{"keitetyt perunat", "Keitetyt perunat"},
{"äyriäispata", "Äyriäispata"}, // Finnish diacritics must upper-case
{"öljyssä paistettu", "Öljyssä paistettu"},
{"BBQ-kylkeä", "BBQ-kylkeä"}, // an existing capital survives
{"Keitetyt Perunat", "Keitetyt Perunat"}, // deliberate capitals are kept
{"kana\t\ncurry", "Kana curry"}, // any whitespace collapses
{"", ""},
}
for _, c := range cases {
if got := normalizeName(c.in); got != c.want {
t.Errorf("normalizeName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTodayIsMidnight(t *testing.T) {
now := today(time.UTC)
if h, m, s := now.Clock(); h != 0 || m != 0 || s != 0 {
t.Errorf("today() = %s, want midnight", now)
}
// A date parsed from a URL must compare equal to it, or the UI stops
// recognising today as today whenever the date is spelled out.
parsed, err := time.ParseInLocation(dateLayout, now.Format(dateLayout), time.UTC)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !parsed.Equal(now) {
t.Errorf("parsed %s != today %s", parsed, now)
}
}
func TestDateRejectsTheFuture(t *testing.T) {
a := &app{loc: time.UTC}
now := today(time.UTC)
cases := []struct {
name string
pvm string
want time.Time
}{
{"no parameter", "", now},
{"today", now.Format(dateLayout), now},
{"yesterday", now.AddDate(0, 0, -1).Format(dateLayout), now.AddDate(0, 0, -1)},
// Nothing was eaten tomorrow, and a stray entry dated next year would
// sit at the top of the history forever.
{"tomorrow", now.AddDate(0, 0, 1).Format(dateLayout), now},
{"next year", now.AddDate(1, 0, 0).Format(dateLayout), now},
{"nonsense", "eilen", now},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?pvm="+c.pvm, nil)
if got := a.date(r); !got.Equal(c.want) {
t.Errorf("date = %s, want %s", got.Format(dateLayout), c.want.Format(dateLayout))
}
})
}
}
func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
for _, table := range []string{
"main_dishes", "main_dish_categories", "side_dishes",
"meal_log", "meal_log_sides", "schema_migrations",
} {
var n int
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil {
t.Errorf("table %s: %v", table, err)
}
}
var applied int
if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&applied); err != nil {
t.Fatalf("count migrations: %v", err)
}
if applied == 0 {
t.Fatal("no migrations recorded")
}
// A second run must be a no-op: migrate() runs on every start.
if err := migrate(db); err != nil {
t.Fatalf("second migrate: %v", err)
}
var again int
if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&again); err != nil {
t.Fatalf("recount: %v", err)
}
if again != applied {
t.Errorf("migrations reapplied: %d then %d", applied, again)
}
}
func TestImportBundle(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
const bundle = `{
"mains": [
{"name": "kanacurry", "categories": ["chicken"], "has_sides": true},
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"]},
{"name": "Kanacurry", "categories": ["chicken"]},
{"name": "Rikkinäinen", "categories": ["kana"]},
{"name": "Kategoriaton", "categories": []},
{"name": " "}
],
"sides": [{"name": "riisi"}, {"name": "Riisi"}]
}`
report, err := importBundle(db, strings.NewReader(bundle))
if err != nil {
t.Fatalf("importBundle: %v", err)
}
// 3 mains + 1 side land; the duplicate main, bad category, empty category
// list, blank name and duplicate side are all skipped but reported.
if report.Added != 4 {
t.Errorf("added = %d, want 4 (%s)", report.Added, report)
}
if report.Skipped != 5 {
t.Errorf("skipped = %d, want 5 (%s)", report.Skipped, report)
}
if len(report.Notes) != report.Skipped {
t.Errorf("got %d notes for %d skips", len(report.Notes), report.Skipped)
}
// Names are normalised on the way in.
var name string
if err := db.QueryRow(
`SELECT name FROM main_dishes WHERE lower(name) = 'kanacurry'`).Scan(&name); err != nil {
t.Fatalf("lookup: %v", err)
}
if name != "Kanacurry" {
t.Errorf("stored name = %q, want %q", name, "Kanacurry")
}
// has_sides defaults to true when the field is omitted.
var hasSides bool
if err := db.QueryRow(
`SELECT has_sides FROM main_dishes WHERE name = 'Tortillat'`).Scan(&hasSides); err != nil {
t.Fatalf("lookup Tortillat: %v", err)
}
if !hasSides {
t.Error("omitted has_sides = false, want true")
}
// A multi-category main keeps every category (PRD §6).
var cats int
if err := db.QueryRow(`SELECT count(*) FROM main_dish_categories c
JOIN main_dishes m ON m.id = c.main_dish_id
WHERE m.name = 'Tortillat'`).Scan(&cats); err != nil {
t.Fatalf("count categories: %v", err)
}
if cats != 4 {
t.Errorf("Tortillat has %d categories, want 4", cats)
}
}
func TestImportSeedFile(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
// The committed seed bundle has to stay importable — it is the fixture
// used to reset a scratch database while testing.
f, err := os.Open("../../seeds/testi.json")
if err != nil {
t.Fatalf("open seed: %v", err)
}
defer f.Close()
report, err := importBundle(db, f)
if err != nil {
t.Fatalf("importBundle: %v", err)
}
if report.Skipped != 0 {
t.Errorf("seed bundle has bad rows: %s", report)
}
if report.Added == 0 {
t.Error("seed bundle imported nothing")
}
}
func TestMealLogOneEntryPerDate(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Lohikeitto'), (2, 'Lihapullat')`); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 1)`); err != nil {
t.Fatalf("first entry: %v", err)
}
// PRD §6: a second dinner for the same day must be refused.
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 2)`); err == nil {
t.Error("second entry for the same date was accepted, want a unique violation")
}
}
func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (1, 'Kanacurry')`); err != nil {
t.Fatalf("first insert: %v", err)
}
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err == nil {
t.Error("case-variant duplicate was accepted, want a unique violation")
}
// Soft-deleting the original frees the name again (PRD §7.3).
if _, err := db.Exec(`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
t.Fatalf("soft delete: %v", err)
}
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (2, 'kanacurry')`); err != nil {
t.Errorf("name still blocked after soft delete: %v", err)
}
}
func TestAuth(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler
}))
cases := []struct {
name string
user string
pass string
withAuth bool
want int
}{
{"correct password", "", "hunter2", true, http.StatusTeapot},
{"username is ignored", "anyone", "hunter2", true, http.StatusTeapot},
{"wrong password", "", "wrong", true, http.StatusUnauthorized},
{"empty password", "", "", true, http.StatusUnauthorized},
{"no credentials", "", "", false, http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if c.withAuth {
r.SetBasicAuth(c.user, c.pass)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != c.want {
t.Errorf("status = %d, want %d", w.Code, c.want)
}
if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
t.Error("401 without a WWW-Authenticate header; the browser will not prompt")
}
})
}
}
func TestHealthzSkipsAuth(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
h := routes(db, time.UTC, "hunter2")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if w.Code != http.StatusOK {
t.Errorf("/healthz without credentials = %d, want 200", w.Code)
}
// Everything else must still be gated.
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
if w.Code != http.StatusUnauthorized {
t.Errorf("/ without credentials = %d, want 401", w.Code)
}
}