Files
foodster/cmd/foodster/main_test.go
T
KessinenandEsa Kataja d8810d288e release: repair the catalog 404 and stop the page jumping (#3)
Showstopper. Adding, editing or deleting a dish redirected to /ruoat, which stopped existing when the tab was renamed to Ruuat — every one of those actions ended on a 404. Live in v20260905-4. The tests missed it because they asserted only a 303; a redirect to a dead URL is still a 303. They now assert the target.

The page no longer jumps. Deleting a dish partway down the catalog, or opening a day in Kirjaa, sent the browser to the top. Both now patch in place via Datastar — bin, pencil, day rows, dish pills, save, delete, cancel and Näytä lisää. Links stay links and forms stay forms, so it works without JavaScript.

Non-production tabs are labelled. ENV=dev gives dev · Foodster.

Contributing guide added, and commits now take Conventional Commit types.

⚠️ Breaking: rewrite the server's .env in this deploy. Environment variables lost the FOODSTER_ prefix; the app refuses to start on an unset PASSWORD.

REPO=…  TAG=latest  PASSWORD=…  HOST=foodster.kessinen.com
ENV=prod  PUID=1000  PGID=1000  TZ=Europe/Helsinki

PUID/PGID rather than UID/GID — UID is read-only in bash and would be silently overwritten.

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #3
2026-09-05 21:03:04 +00:00

369 lines
11 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"net/url"
"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 TestReadSignals(t *testing.T) {
cases := []struct {
name string
query string
want string
wantErr bool
}{
{"a signal", `/etsi?datastar=` + url.QueryEscape(`{"haku":"keitto"}`), "keitto", false},
{"other signals are ignored", `/etsi?datastar=` + url.QueryEscape(`{"haku":"kala","muu":1}`), "kala", false},
// The first request carries no signals at all; that is not a failure.
{"no parameter", "/etsi", "", false},
{"empty parameter", "/etsi?datastar=", "", false},
{"malformed json", "/etsi?datastar=%7Bnope", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got searchSignals
err := readSignals(httptest.NewRequest(http.MethodGet, c.query, nil), &got)
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
}
if got.Haku != c.want {
t.Errorf("haku = %q, want %q", got.Haku, c.want)
}
})
}
}
func TestEnvTagMarksNonProduction(t *testing.T) {
t.Cleanup(func() { envTag = "" })
cases := []struct{ env, want string }{
// Production is the default and must stay unmarked: the tag exists to
// pick the dev tab out of two identical ones.
{"", "Foodster"},
{"prod", "Foodster"},
{"PRODUCTION", "Foodster"},
{" ", "Foodster"},
{"dev", "dev · Foodster"},
{"DEV", "dev · Foodster"},
{"staging", "staging · Foodster"},
}
for _, c := range cases {
setEnvTag(c.env)
if got := pageTitle("Foodster"); got != c.want {
t.Errorf("ENV=%q: title = %q, want %q", c.env, got, c.want)
}
}
}
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()
// Ids well clear of anything the migrations create.
if _, err := db.Exec(
`INSERT INTO main_dishes (id, name) VALUES (101, 'Lohikeitto'), (102, 'Lihapullat')`); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Exec(`INSERT INTO meal_log (date, main_dish_id) VALUES ('2026-09-05', 101)`); 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', 102)`); 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 (101, 'Kanacurry')`); err != nil {
t.Fatalf("first insert: %v", err)
}
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, '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 = 101`); err != nil {
t.Fatalf("soft delete: %v", err)
}
if _, err := db.Exec(`INSERT INTO main_dishes (id, name) VALUES (102, '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)
}
}