Files
foodster/cmd/foodster/store_test.go
T
Esa Kataja e4503e8433 Build the Kirjaa log flow and Historia list
Stage 1's point is collecting eating history, so the logging path is the one
that has to be frictionless: pick a dish, tick sides, done.

Kirjaa:
- dishes ordered and sized by how often they have been eaten, so the likely
  answer is the biggest target on the screen
- picking a dish opens the sides step; a dish with has_sides false says so
  instead of offering an empty list
- saving redirects, so a refresh cannot double-post
- a day already logged shows the entry with edit and delete. Editing reopens
  the sides step with the existing sides ticked, which makes editing and
  creating the same screen
- day switcher and a server-side search over the catalog

Historia walks back day by day to the oldest entry, so a day nobody wrote
down appears as an explicit gap rather than quietly missing.

No JavaScript is involved: every interaction is a link or a form, and the
checked-chip styling is :has(input:checked). Datastar stays loaded but unused
until an interaction genuinely needs to avoid a page load.

Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet`
could run against templ output that was being rewritten. check now runs its
phases as sub-makes so `make -j` cannot interleave them.

Records two features that are specified but not built: dish CRUD in the UI
(§7.3, only import exists today) and a per-device light/dark switch (§7.4).
The switch must show the state that is active, not the one clicking produces.
2026-09-05 18:29:38 +03:00

268 lines
6.8 KiB
Go

package main
import (
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
)
// dbHandle wraps a scratch database with lookups the tests need, so a test
// reads as "log Lohikeitto" rather than juggling row ids.
type dbHandle struct{ db *sql.DB }
func (h *dbHandle) mainNamed(t *testing.T, name string) int64 {
t.Helper()
var id int64
if err := h.db.QueryRow(
`SELECT id FROM main_dishes WHERE name = ?`, name).Scan(&id); err != nil {
t.Fatalf("main dish %q: %v", name, err)
}
return id
}
func (h *dbHandle) sideNamed(t *testing.T, name string) int64 {
t.Helper()
var id int64
if err := h.db.QueryRow(
`SELECT id FROM side_dishes WHERE name = ?`, name).Scan(&id); err != nil {
t.Fatalf("side dish %q: %v", name, err)
}
return id
}
func containsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
}
// seeded opens a scratch database populated from the committed bundle.
func seeded(t *testing.T) *dbHandle {
t.Helper()
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
t.Cleanup(func() { db.Close() })
f, err := os.Open("../../seeds/testi.json")
if err != nil {
t.Fatalf("open seed: %v", err)
}
defer f.Close()
if _, err := importBundle(db, f); err != nil {
t.Fatalf("import seed: %v", err)
}
return &dbHandle{db}
}
func day(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.ParseInLocation(dateLayout, s, time.UTC)
if err != nil {
t.Fatalf("bad date %q: %v", s, err)
}
return d
}
func TestSaveAndReadEntry(t *testing.T) {
h := seeded(t)
date := day(t, "2026-09-05")
if e, err := entryFor(h.db, date); err != nil || e != nil {
t.Fatalf("empty day: entry = %v, err = %v; want nil, nil", e, err)
}
main := h.mainNamed(t, "Lohikeitto")
side := h.sideNamed(t, "Ruisleipä")
if err := saveEntry(h.db, date, main, []int64{side}); err != nil {
t.Fatalf("saveEntry: %v", err)
}
e, err := entryFor(h.db, date)
if err != nil || e == nil {
t.Fatalf("entryFor: %v, %v", e, err)
}
if e.Main.Name != "Lohikeitto" {
t.Errorf("main = %q, want Lohikeitto", e.Main.Name)
}
if e.SidesLabel() != "Ruisleipä" {
t.Errorf("sides = %q, want Ruisleipä", e.SidesLabel())
}
}
func TestSaveEntryReplacesTheDay(t *testing.T) {
h := seeded(t)
date := day(t, "2026-09-05")
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"),
[]int64{h.sideNamed(t, "Ruisleipä")}); err != nil {
t.Fatalf("first save: %v", err)
}
// PRD §6 allows one entry per date, so a second save is an edit.
if err := saveEntry(h.db, date, h.mainNamed(t, "Lihapullat"),
[]int64{h.sideNamed(t, "Perunamuusi"), h.sideNamed(t, "Vihersalaatti")}); err != nil {
t.Fatalf("second save: %v", err)
}
var rows int
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log WHERE date = ?`,
date.Format(dateLayout)).Scan(&rows); err != nil {
t.Fatalf("count: %v", err)
}
if rows != 1 {
t.Errorf("%d rows for one date, want 1", rows)
}
e, _ := entryFor(h.db, date)
if e.Main.Name != "Lihapullat" {
t.Errorf("main = %q, want Lihapullat", e.Main.Name)
}
if len(e.Sides) != 2 {
t.Errorf("%d sides, want 2 (%s)", len(e.Sides), e.SidesLabel())
}
// The replaced entry's sides must go with it, not linger orphaned.
var orphans int
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log_sides ls
WHERE NOT EXISTS (SELECT 1 FROM meal_log l WHERE l.id = ls.meal_log_id)`,
).Scan(&orphans); err != nil {
t.Fatalf("orphan check: %v", err)
}
if orphans != 0 {
t.Errorf("%d orphaned sides after replace", orphans)
}
}
func TestDeleteEntry(t *testing.T) {
h := seeded(t)
date := day(t, "2026-09-05")
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
t.Fatalf("save: %v", err)
}
if err := deleteEntry(h.db, date); err != nil {
t.Fatalf("delete: %v", err)
}
if e, _ := entryFor(h.db, date); e != nil {
t.Errorf("entry survived delete: %v", e)
}
}
func TestListDishesOrdersByTimesEaten(t *testing.T) {
h := seeded(t)
lihapullat := h.mainNamed(t, "Lihapullat")
for i := 1; i <= 3; i++ {
d := day(t, fmt.Sprintf("2026-09-%02d", i))
if err := saveEntry(h.db, d, lihapullat, nil); err != nil {
t.Fatalf("save %d: %v", i, err)
}
}
dishes, err := listDishes(h.db, "")
if err != nil {
t.Fatalf("listDishes: %v", err)
}
if len(dishes) == 0 {
t.Fatal("no dishes")
}
if dishes[0].Name != "Lihapullat" {
t.Errorf("first dish = %q, want the most-eaten Lihapullat", dishes[0].Name)
}
if dishes[0].TimesEaten != 3 {
t.Errorf("timesEaten = %d, want 3", dishes[0].TimesEaten)
}
if got := dishes[0].Size(); got != "md" {
t.Errorf("size for 3 = %q, want md", got)
}
}
func TestListDishesSearchIsCaseInsensitive(t *testing.T) {
h := seeded(t)
dishes, err := listDishes(h.db, "KEITTO")
if err != nil {
t.Fatalf("listDishes: %v", err)
}
if len(dishes) == 0 {
t.Fatal("no matches for KEITTO, want the soups")
}
for _, d := range dishes {
if !containsFold(d.Name, "keitto") {
t.Errorf("%q does not match the search", d.Name)
}
}
}
func TestMultiCategoryDishUsesMixedMarker(t *testing.T) {
h := seeded(t)
tortillat, err := dishByID(h.db, h.mainNamed(t, "Tortillat"))
if err != nil {
t.Fatalf("dishByID: %v", err)
}
if len(tortillat.Categories) != 4 {
t.Fatalf("%d categories, want 4", len(tortillat.Categories))
}
if got := tortillat.CategoryKey(); got != "sek" {
t.Errorf("CategoryKey = %q, want sek", got)
}
lohi, err := dishByID(h.db, h.mainNamed(t, "Lohikeitto"))
if err != nil {
t.Fatalf("dishByID: %v", err)
}
if got := lohi.CategoryKey(); got != "kala" {
t.Errorf("CategoryKey = %q, want kala", got)
}
}
func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
h := seeded(t)
loc := time.UTC
now := today(loc)
// Log today and three days ago, leaving two gaps between them.
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
t.Fatalf("save today: %v", err)
}
if err := saveEntry(h.db, now.AddDate(0, 0, -3), h.mainNamed(t, "Lihapullat"), nil); err != nil {
t.Fatalf("save -3: %v", err)
}
rows, err := history(h.db, loc, 60)
if err != nil {
t.Fatalf("history: %v", err)
}
// Walks back to the oldest entry only: today, -1, -2, -3.
if len(rows) != 4 {
t.Fatalf("%d rows, want 4", len(rows))
}
if rows[0].Entry == nil || rows[0].Entry.Main.Name != "Lohikeitto" {
t.Errorf("first row should be today's Lohikeitto, got %+v", rows[0].Entry)
}
if rows[1].Entry != nil || rows[2].Entry != nil {
t.Error("the two unlogged days should be gaps")
}
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
}
}
func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(t)
rows, err := history(h.db, time.UTC, 60)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(rows) != 0 {
t.Errorf("%d rows for an empty log, want 0", len(rows))
}
}