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.
314 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|