With dev and prod open side by side in the same browser, the tabs were indistinguishable. ENV is written into the title of every page unless it says prod, so "dev · Foodster" picks itself out. The value is used verbatim, so ENV=staging labels itself too, and prod and production both count as unmarked so a stray capital cannot tag the real instance. Environment variables lose their prefix: PASSWORD, DB, ENV, ADDR, HOST, REPO, TAG. The container namespaces them already, and this matches how the other services here are configured. PUID/PGID are the exception rather than UID/GID. UID is read-only in bash, so a value set in .env would be silently replaced by the invoking shell's own and compose's user: would ignore what was asked for. Breaking for a running instance: the deployed .env has to be rewritten in the same deploy, or the app will refuse to start on an unset PASSWORD.
329 lines
13 KiB
Bash
Executable File
329 lines
13 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
|
|
|
|
PASSWORD="$pass" DB="$tmp/smoke.db" 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"
|
|
# ENV is unset here, so this instance is production and unmarked.
|
|
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
|
|
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"
|
|
|
|
# The selected day expands inside the list rather than in a panel above it,
|
|
# so the rows below do not shift when one is tapped.
|
|
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
|
check "the selected day expands in place" "$day" 'class="open"'
|
|
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
|
|
|
# ---- the day list patches in place instead of navigating ----------------
|
|
|
|
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=2026-09-05")
|
|
check "opening a day patches the list" "$dayp" 'id="paivat"'
|
|
refute "and returns a fragment, not a page" "$dayp" "<html"
|
|
|
|
check "picking a dish patches to the sides step" \
|
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
|
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna"
|
|
|
|
check "saving from Datastar patches back" \
|
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
|
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
|
|
|
|
check "deleting from Datastar patches back" \
|
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
|
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"'
|
|
|
|
# Without the header it must still redirect, for no JavaScript.
|
|
check "a plain save still redirects to the day" \
|
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=2026-09-04"
|
|
|
|
# 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 -------------------------------------------
|
|
|
|
# Assert where it redirects, not just that it does: these pointed at the old
|
|
# /ruoat spelling for a while and every 303-only check was happy.
|
|
check "adding a main redirects back to the catalog" \
|
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
|
|
"/ruuat"
|
|
|
|
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 back to the catalog" \
|
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
|
|
"/ruuat"
|
|
|
|
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"
|
|
|
|
# ---- the catalog patches in place instead of navigating -----------------
|
|
|
|
# A delete confirmation halfway down a long list must not send the browser
|
|
# back to the top, so these answer with a Datastar patch rather than a page.
|
|
patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
|
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
|
|
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
|
|
check "the patch carries the list" "$patch" 'id="ruokalista"'
|
|
check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
|
|
check "the row it patches in is asking" "$patch" "Poista?"
|
|
|
|
check "patches are served as an event stream" \
|
|
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \
|
|
"http://$addr/ruuat/nayta")" "text/event-stream"
|
|
|
|
check "deleting from Datastar patches too" \
|
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
|
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
|
|
"event: datastar-patch-elements"
|
|
|
|
refute "and the dish is gone from the patched list" \
|
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
|
|
"Uunikala"
|
|
|
|
# Without the header it must still be an ordinary redirect, for no JavaScript.
|
|
check "a plain form post still redirects" \
|
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
|
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
|
"/ruuat"
|
|
|
|
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"
|