Files
foodster/cmd/foodster/store_test.go
T
KessinenandEsa Kataja e9754488db Release: one log page, grouped dishes, live search (#1)
Structure
- Kirjaa and Historia are one page. They were two views of the same thing — every history row already linked into the logger, and the logger had a day switcher. Two tabs instead of three. Also closed a gap: on an already-logged day there was no way to swap to a different dish, only to re-pick its sides.
- Ruoat → Ruuat, label and route.
- The catalog has a structure. It had no top-level headings at all — the mains simply began with "Liha". Both halves now carry a heading and a count, categories are visibly subordinate, and the add/edit forms collapse instead of filling the screen before any content.

Finding things
- Dishes grouped by category on both screens, Sekalaiset for multi-category ones. Derived from the stored set, not a fifth category, so one Tortillat still covers all four for the §8.1 suggester later.
- Live search on both lists, 250 ms after typing stops. Both remain plain GET forms, so they still filter with JavaScript off.
- History is paged 30 days at a time — it previously rendered every day back to the first entry, forever.

Correctness
- Future meals refused. The picker offered them and ?pvm= accepted them.
- today() wasn't midnight, so it never equalled a date parsed from ?pvm= — after saving, the card read "la 5.9. kirjattu" instead of "Tänään kirjattu".
- Deletes ask first, for dishes and logged meals. The meal is the more destructive: a dish is only soft-deleted.
- DB open failures name the path and uid, instead of unable to open database file (14).

Visual
- Category icons replace colour dots — steak, drumstick, fish, leaf, quartered circle.
- Row actions are a pencil and a bin; the header has a surface.

Housekeeping
- Datastar SDK dropped — one JSON decode was pulling in four modules including an HTTP compression stack. Five lines replace it.
- Release policy documented: main protected, releases arrive as PRs.

Co-authored-by: Esa Kataja <[email protected]>
Reviewed-on: #1
2026-09-05 19:28:54 +00:00

319 lines
8.4 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)
}
page, err := history(h.db, loc, now, 60)
if err != nil {
t.Fatalf("history: %v", err)
}
rows := page.Rows
// 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)
}
if page.More {
t.Error("More is set although the window reached the oldest entry")
}
}
func TestHistoryPagesInWindows(t *testing.T) {
h := seeded(t)
loc := time.UTC
now := today(loc)
// Entries today and 9 days back, with a 5-day window over 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, -9), h.mainNamed(t, "Lihapullat"), nil); err != nil {
t.Fatalf("save -9: %v", err)
}
first, err := history(h.db, loc, now, 5)
if err != nil {
t.Fatalf("first window: %v", err)
}
if len(first.Rows) != 5 {
t.Errorf("%d rows in the first window, want 5", len(first.Rows))
}
if !first.More {
t.Error("More should be set: older entries exist")
}
if want := now.AddDate(0, 0, -5); !first.Next.Equal(want) {
t.Errorf("Next = %s, want %s", first.Next.Format(dateLayout), want.Format(dateLayout))
}
// The windows must meet exactly: no day repeated, none skipped.
second, err := history(h.db, loc, first.Next, 5)
if err != nil {
t.Fatalf("second window: %v", err)
}
if len(second.Rows) != 5 {
t.Errorf("%d rows in the second window, want 5", len(second.Rows))
}
if second.More {
t.Error("the second window reaches the oldest entry, so More should be clear")
}
last := second.Rows[len(second.Rows)-1]
if last.Entry == nil || last.Entry.Main.Name != "Lihapullat" {
t.Errorf("last row should be the oldest entry, got %+v", last.Entry)
}
}
func TestHistoryEmptyWithoutEntries(t *testing.T) {
h := seeded(t)
page, err := history(h.db, time.UTC, today(time.UTC), 60)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(page.Rows) != 0 {
t.Errorf("%d rows for an empty log, want 0", len(page.Rows))
}
if page.More {
t.Error("More is set although there is no history at all")
}
}