Tähteet is leftovers. It is not food: it exists so a day can be recorded as "we ate what was already there" without inventing a meal nobody cooked. Modelled as a row flagged `special` rather than a nullable main_dish_id on the log, so every foreign key and join carries on working. Migration 0002 creates it; the household never adds, edits or deletes it, and it never appears in the catalog. It is loggable exactly like a real meal and sits on the board apart from the categories, muted and at fixed size — it will be among the most-logged entries and should not tower over the actual cooking. It carries no category, which is why it could not be an ordinary main: those must have at least one. That also exposed a bug in CategoryKey, which returned the mixed Sekalaiset mark for anything that was not exactly one category — so *no* categories drew the same icon as *several*. Zero now means not food and gets its own mark, a lidded tub in grey rather than a category colour. The fix reaches the board, the sides step, the logged card and the history at once, since they all ask the same function. PRD §6 records the hard requirement: the stage 2 suggester must never propose it, so cooldown, coverage and weighting all skip it despite how often it is logged. The seed bundle is now the household's real list, taken from a JSON file written in December 2024: 37 mains and 9 sides. Letut and Pannari were dropped as not being dinners, Kanakintut folded into Broilerin koipireidet, and has_sides assigned by rule — soups, casseroles, laatikko, kiusaus, risotto and pasta dishes take none; sauces, patties and roasts do. Two tests failed immediately on the migration because they hardcoded id 1, which the new row now occupies. Moved clear of it.
271 lines
9.9 KiB
Bash
Executable File
271 lines
9.9 KiB
Bash
Executable File
#!/bin/sh
|
|
# End-to-end check of a running Foodster: auth, static assets and the bundle
|
|
# import flow. Builds its own binary, uses a scratch database and a spare
|
|
# port, and cleans up after itself, so it never touches a real instance.
|
|
#
|
|
# Run it with `make smoke`.
|
|
|
|
set -eu
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
addr=127.0.0.1:8099
|
|
pass=smoke
|
|
tmp=$(mktemp -d)
|
|
trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
|
|
|
|
go build -o "$tmp/foodster" ./cmd/foodster
|
|
|
|
FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \
|
|
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
|
srv=$!
|
|
|
|
i=0
|
|
while ! curl -sf "http://$addr/healthz" >/dev/null 2>&1; do
|
|
i=$((i + 1))
|
|
if [ "$i" -gt 50 ]; then
|
|
echo "server did not start:"
|
|
cat "$tmp/server.log"
|
|
exit 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
|
|
fail=0
|
|
|
|
# check <name> <haystack> <needle>
|
|
check() {
|
|
if printf '%s' "$2" | grep -qF -- "$3"; then
|
|
echo " ok $1"
|
|
else
|
|
echo " FAIL $1 (expected to find: $3)"
|
|
fail=1
|
|
fi
|
|
}
|
|
|
|
# refute <name> <haystack> <needle>
|
|
refute() {
|
|
if printf '%s' "$2" | grep -qF -- "$3"; then
|
|
echo " FAIL $1 (should not contain: $3)"
|
|
fail=1
|
|
else
|
|
echo " ok $1"
|
|
fi
|
|
}
|
|
|
|
echo "smoke: http://$addr"
|
|
|
|
check "unauthenticated request is refused" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/")" "401"
|
|
|
|
check "healthz needs no password" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' "http://$addr/healthz")" "200"
|
|
|
|
check "datastar client is served" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/datastar.js")" "200"
|
|
|
|
check "favicon is served" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/favicon.svg")" "200"
|
|
|
|
check "theme script is served" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/theme.js")" "200"
|
|
|
|
home=$(curl -s -u ":$pass" "http://$addr/")
|
|
check "the header carries the brand" "$home" "Foodster"
|
|
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
|
|
check "the theme toggle is present" "$home" "data-theme-toggle"
|
|
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
|
|
|
|
check "apple touch icon is served" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/apple-touch-icon.png")" "200"
|
|
|
|
# A manifest served as octet-stream is silently ignored by the browser.
|
|
check "manifest has the right content type" \
|
|
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" "http://$addr/static/manifest.webmanifest")" \
|
|
"application/manifest+json"
|
|
|
|
check "catalog starts empty" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "0 pääruokaa"
|
|
|
|
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")
|
|
check "file upload imports the seed bundle" "$out" "Lisätty 22, ohitettu 0"
|
|
check "counts update after import" "$out" "16 pääruokaa, 6 lisuketta"
|
|
|
|
check "re-import refuses duplicates" \
|
|
"$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruuat/tuonti")" \
|
|
"jo listalla"
|
|
|
|
check "pasted JSON imports" \
|
|
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
|
|
"http://$addr/ruuat/tuonti")" "Lisätty 1"
|
|
|
|
check "unknown category is reported" \
|
|
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
|
|
"http://$addr/ruuat/tuonti")" "tuntematon kategoria"
|
|
|
|
check "empty submit is explained" \
|
|
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruuat/tuonti")" "Ei tuotavaa"
|
|
|
|
check "malformed JSON is explained" \
|
|
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruuat/tuonti")" "JSON ei kelpaa"
|
|
|
|
# ---- the log flow, against the dishes imported above --------------------
|
|
|
|
board=$(curl -s -u ":$pass" "http://$addr/")
|
|
check "board lists imported dishes" "$board" "Lihapullat"
|
|
|
|
# Tähteet is loggable but is not food: on the board, never in the catalog.
|
|
check "leftovers are on the board" "$board" "Tähteet"
|
|
refute "leftovers are not in the catalog" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Tähteet"
|
|
|
|
# Pull a real dish id out of the board rather than assuming one.
|
|
ruoka=$(printf '%s' "$board" | grep -o 'ruoka=[0-9]*' | head -n1 | cut -d= -f2)
|
|
if [ -z "$ruoka" ]; then
|
|
echo " FAIL could not find a dish link on the board"
|
|
fail=1
|
|
ruoka=1
|
|
fi
|
|
|
|
check "picking a dish opens the sides step" \
|
|
"$(curl -s -u ":$pass" "http://$addr/?ruoka=$ruoka")" "Tallenna"
|
|
|
|
check "saving redirects back to the day" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
|
-d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
|
|
|
|
check "the saved day shows what was eaten" \
|
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
|
|
|
check "history is on the same page as the logger" \
|
|
"$(curl -s -u ":$pass" "http://$addr/")" "Aiemmin"
|
|
|
|
# Deleting a logged meal drops the row outright, so it asks first.
|
|
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
|
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
|
|
refute "and does not delete while asking" "$saved" "Ei merkintää"
|
|
|
|
check "deleting redirects back" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
|
-d "pvm=2026-09-05" "http://$addr/poista")" "303"
|
|
|
|
check "the day is empty again" \
|
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "Etsi"
|
|
|
|
check "search filters the board" \
|
|
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
|
|
|
# ---- live search: Datastar sends signals as JSON in ?datastar= -----------
|
|
|
|
live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")
|
|
check "live search returns the board fragment" "$live" 'id="lauta"'
|
|
check "live search applies the term" "$live" "keitto"
|
|
refute "live search excludes non-matches" "$live" "Lihapullat"
|
|
refute "the fragment is not a whole page" "$live" "<html"
|
|
|
|
check "live search is served as html for Datastar to patch" \
|
|
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" \
|
|
--get --data-urlencode 'datastar={"haku":"keitto"}' "http://$addr/etsi")" \
|
|
"text/html"
|
|
|
|
cat_live=$(curl -s -u ":$pass" --get --data-urlencode 'datastar={"haku":"riisi"}' "http://$addr/ruuat/etsi")
|
|
check "catalog live search returns its fragment" "$cat_live" 'id="ruokalista"'
|
|
check "catalog live search matches sides too" "$cat_live" "Riisi"
|
|
refute "catalog live search excludes non-matches" "$cat_live" "Lihapullat"
|
|
|
|
# The plain form still works without JavaScript.
|
|
check "catalog search works as a plain form too" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?haku=riisi")" "Riisi"
|
|
|
|
# Nothing was eaten tomorrow. A future date is clamped rather than logged.
|
|
future=$(date -d '+30 days' +%Y-%m-%d)
|
|
check "a future date falls back to today" \
|
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
|
|
|
|
check "saving a future date is clamped too" \
|
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
-d "pvm=$future&ruoka=$ruoka" "http://$addr/kirjaa")" "/"
|
|
|
|
check "tomorrow was not written to the log" \
|
|
"$(curl -s -u ":$pass" "http://$addr/?pvm=$future")" "$(date +%-d.%-m.%Y)"
|
|
|
|
# Clean up the entry that clamped onto today.
|
|
curl -s -o /dev/null -u ":$pass" -d "pvm=$(date +%Y-%m-%d)" "http://$addr/poista"
|
|
|
|
# ---- adding a dish without leaving Kirjaa --------------------------------
|
|
|
|
miss=$(curl -s -u ":$pass" "http://$addr/?haku=Poronkariste")
|
|
check "a search with no hits offers to add it" "$miss" "Ei osumia. Lisätäänkö?"
|
|
check "the add form is prefilled with the search" "$miss" 'value="Poronkariste"'
|
|
|
|
check "quick add goes straight to the sides step" \
|
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
-d 'nimi=Poronkariste&kategoria=meat&lisukkeita=1' "http://$addr/lisaa")" \
|
|
"ruoka="
|
|
|
|
check "quick add rejects a dish with no category" \
|
|
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/lisaa")" \
|
|
"Valitse vähintään yksi kategoria."
|
|
|
|
check "the quick-added dish is on the board" \
|
|
"$(curl -s -u ":$pass" "http://$addr/")" "Poronkariste"
|
|
|
|
# ---- catalog CRUD from the UI -------------------------------------------
|
|
|
|
check "adding a main redirects" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
|
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303"
|
|
|
|
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
|
|
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
|
|
|
check "a duplicate name is refused" \
|
|
"$(curl -s -u ":$pass" -d 'nimi=UUNIKALA&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
|
"Nimi on jo listalla."
|
|
|
|
check "a main with no category is refused" \
|
|
"$(curl -s -u ":$pass" -d 'nimi=Kategoriaton' "http://$addr/ruuat/paaruoka")" \
|
|
"Valitse vähintään yksi kategoria."
|
|
|
|
check "a nameless dish is refused" \
|
|
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
|
"Anna nimi."
|
|
|
|
check "adding a side redirects" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
|
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303"
|
|
|
|
check "the new side is listed" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
|
|
|
|
# The id of Uunikala specifically: the catalog is grouped and alphabetical, so
|
|
# the first id on the page belongs to some other dish entirely.
|
|
uusi=$(printf '%s' "$catalog" | grep -o 'Uunikala.*' | grep -o 'muokkaa=[0-9]*' | head -n1 | cut -d= -f2)
|
|
if [ -z "$uusi" ]; then
|
|
echo " FAIL could not find Uunikala's id in the catalog"
|
|
fail=1
|
|
uusi=0
|
|
fi
|
|
check "the edit form is prefilled" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?muokkaa=$uusi")" "Muokkaa pääruokaa"
|
|
|
|
# A bin icon is easy to hit by accident, so the row asks before anything goes.
|
|
check "the bin asks before deleting" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Poista?"
|
|
|
|
check "the dish is still there while it asks" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
|
|
|
|
check "confirming the delete redirects" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
|
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303"
|
|
|
|
refute "the dish is gone once confirmed" \
|
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
|
|
|
|
if [ "$fail" -ne 0 ]; then
|
|
echo "smoke: FAILED"
|
|
exit 1
|
|
fi
|
|
echo "smoke: all passed"
|