Scaffold the Go app: auth, migrations, bundle import

Bring up the stage 1 skeleton described in the PRD, enough that the app
builds, serves, and can be populated with dishes.

- net/http server with shared-password Basic auth, /healthz outside it,
  graceful shutdown, and TZ-aware calendar days
- SQLite via modernc (pure Go, static binary), opened with WAL and a single
  connection
- migration runner: numbered SQL files embedded and applied once each inside
  a transaction, recorded in schema_migrations
- bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or
  upload a file, get a per-row Finnish report. The same importer is reachable
  as `foodster -import` for repopulating a scratch database
- templ views and hand-written CSS with light-dark() theming; the Datastar
  v1.0.3 client is vendored, since the Go SDK ships no browser asset and a
  CDN would break an offline LAN

Names are normalized to sentence case rather than title case: Finnish
capitalizes only the first word of a phrase, so "Keitetyt perunat" is right
and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match.

Testing is behind make targets rather than ad-hoc commands: `make check` runs
lint, unit tests and scripts/smoke.sh, which exercises auth, static assets
and every import path against a scratch database on a spare port.
This commit is contained in:
Esa Kataja
2026-09-05 18:15:17 +03:00
parent 6ed047aafd
commit c9a63bdd9e
17 changed files with 1503 additions and 12 deletions
+20 -2
View File
@@ -4,6 +4,10 @@ COMPOSE ?= podman compose
BIN := foodster BIN := foodster
PKG := ./cmd/foodster PKG := ./cmd/foodster
# Vendored Datastar client. Bump, run `make vendor`, commit the result.
DATASTAR_VERSION ?= v1.0.3
SEED ?= seeds/testi.json
# Registry coordinates, shared password and TZ live here. Gitignored. # Registry coordinates, shared password and TZ live here. Gitignored.
ifneq (,$(wildcard .env)) ifneq (,$(wildcard .env))
include .env include .env
@@ -14,7 +18,7 @@ endif
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null) GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help generate build run test lint fix image push release up down logs clean .PHONY: help generate build run seed test smoke check lint fix vendor image push release up down logs clean
help: ## Show this help help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ @grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
@@ -32,9 +36,23 @@ run: generate ## Run locally on :8080
FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \ FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \
go run $(PKG) go run $(PKG)
test: generate ## Run tests seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED)
test: generate ## Run unit tests
go test ./... go test ./...
smoke: generate ## End-to-end check: auth, static assets, bundle import
./scripts/smoke.sh
check: lint test smoke ## Everything that must pass before a commit
@echo "check: all passed"
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
curl -sSfL -o cmd/foodster/static/datastar.js \
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
@head -1 cmd/foodster/static/datastar.js
lint: ## go vet, gofmt check, golangci-lint when installed lint: ## go vet, gofmt check, golangci-lint when installed
go vet ./... go vet ./...
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \ @bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
+29 -10
View File
@@ -84,7 +84,11 @@ in English.
### Main dish (stage 1) ### Main dish (stage 1)
- `id` - `id`
- `name` — stored in Title Case (server normalizes on save). - `name` — stored in sentence case: the server collapses whitespace and
capitalizes the first letter only, leaving the rest as typed. Finnish
capitalizes just the first word of a phrase, so "Keitetyt perunat" is
correct and "Keitetyt Perunat" is not. Existing capitals are preserved, so
"BBQ-kylkeä" and "Kotipizza" survive.
- `categories` — non-empty set drawn from `meat`, `chicken`, `fish`, - `categories` — non-empty set drawn from `meat`, `chicken`, `fish`,
`vegetarian`. Most mains will have a single category. Dishes where each `vegetarian`. Most mains will have a single category. Dishes where each
diner builds their own plate from a shared spread (e.g., tortillas, diner builds their own plate from a shared spread (e.g., tortillas,
@@ -101,7 +105,7 @@ in English.
### Side dish (stage 1) ### Side dish (stage 1)
- `id` - `id`
- `name` — stored in Title Case. - `name` — stored in sentence case, as for mains.
- `created_at` - `created_at`
- `deleted_at` — nullable. Same soft-delete semantics as mains. - `deleted_at` — nullable. Same soft-delete semantics as mains.
@@ -168,8 +172,8 @@ regenerates or swaps.
### 7.3 Admin (stage 1 — catalog; stage 2 — settings) ### 7.3 Admin (stage 1 — catalog; stage 2 — settings)
- Add / edit / delete main dishes (with categories and `has_sides`). Names - Add / edit / delete main dishes (with categories and `has_sides`). Names
are normalized to Title Case on save. Delete is a soft-delete. are normalized to sentence case on save. Delete is a soft-delete.
- Add / edit / delete side dishes. Same Title Case normalization and - Add / edit / delete side dishes. Same sentence-case normalization and
soft-delete behavior. soft-delete behavior.
- Duplicate detection on create/edit is **case-insensitive** against the - Duplicate detection on create/edit is **case-insensitive** against the
set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are set of non-soft-deleted items ("chicken curry" and "Chicken Curry" are
@@ -191,8 +195,8 @@ regenerates or swaps.
``` ```
- Import is **best-effort, not atomic**: valid rows are inserted, invalid - Import is **best-effort, not atomic**: valid rows are inserted, invalid
rows (bad categories, missing fields, duplicates) are skipped and rows (bad categories, missing fields, duplicates) are skipped and
reported back to the user in a per-row summary. Names are Title-Cased reported back to the user in a per-row summary. Names are normalized to
on import; duplicate detection is case-insensitive. sentence case on import; duplicate detection is case-insensitive.
- Settings page for the cooldown window (default **14 days**, configurable). - Settings page for the cooldown window (default **14 days**, configurable).
*(Stage 2 — only relevant once the suggester exists.)* *(Stage 2 — only relevant once the suggester exists.)*
@@ -290,9 +294,20 @@ build and no asset bundler.
volume; there is no separate database service. A single household writing volume; there is no separate database service. A single household writing
one row per day does not need Postgres. one row per day does not need Postgres.
- **Data access**: `database/sql` and hand-written SQL. No ORM. - **Data access**: `database/sql` and hand-written SQL. No ORM.
- **Schema**: a single `schema.sql` embedded with `embed.FS` and applied on - **Migrations**: numbered `.sql` files under `cmd/foodster/migrations`,
startup with `CREATE TABLE IF NOT EXISTS`. A migration tool arrives the embedded with `embed.FS` and applied in filename order on startup. Each one
first time a live table genuinely needs altering, not before. runs inside a transaction and is recorded in a `schema_migrations` table, so
it applies exactly once. No migration library — the runner is about sixty
lines of `database/sql`. There are no down-migrations: restoring the
database file is the rollback for a single-household app.
- **Bundle import**: the §7.3 mass import is a live feature of the running
app, on the Ruoat tab — paste JSON or upload a file, get a per-row report
back. A plain multipart form rather than a Datastar round trip, since the
response is a whole-page report and a form needs no client code. Uploads
are capped at 1 MiB. The same importer is also reachable as
`foodster -import <file.json>` for repopulating a scratch database without
starting the server; `seeds/testi.json` is the committed fixture. One code
path serves both, so the format is exercised twice.
- **Time**: the `TZ` environment variable, defaulting to `Europe/Helsinki`, - **Time**: the `TZ` environment variable, defaulting to `Europe/Helsinki`,
loaded via `time.LoadLocation` and fatal on a bad value — a silent fallback loaded via `time.LoadLocation` and fatal on a bad value — a silent fallback
to UTC would shift logged dinners to the wrong calendar day. `time/tzdata` to UTC would shift logged dinners to the wrong calendar day. `time/tzdata`
@@ -342,7 +357,11 @@ on the server and run with Docker Compose.
has no shell to run one and `restart: unless-stopped` already covers a dead has no shell to run one and `restart: unless-stopped` already covers a dead
process. Adding one would mean giving the binary a `-healthcheck` flag that process. Adding one would mean giving the binary a `-healthcheck` flag that
calls its own endpoint. calls its own endpoint.
- Schema is applied on app start. - Pending migrations are applied on app start.
- The Datastar client is vendored at `cmd/foodster/static/datastar.js` and
served from the app's own origin — the SDK ships no browser asset, and a
CDN link would break an offline LAN. `make vendor` refreshes it; the pinned
version lives in the `Makefile` and in the file's first line.
- No internet exposure; the server binds to the LAN. - No internet exposure; the server binds to the LAN.
## 11. Licensing ## 11. Licensing
+45
View File
@@ -46,12 +46,57 @@ make fix gofmt, templ fmt, go mod tidy
make lint go vet, gofmt check, golangci-lint when installed make lint go vet, gofmt check, golangci-lint when installed
make test go test ./... make test go test ./...
make build ./foodster make build ./foodster
make seed import a dish bundle (SEED=seeds/testi.json)
make vendor re-download the Datastar client
make image build and tag vYYYYMMDD-N (creates a git tag) make image build and tag vYYYYMMDD-N (creates a git tag)
make push push the newest tag and :latest make push push the newest tag and :latest
make release image + push make release image + push
make up/down/logs compose make up/down/logs compose
``` ```
## Importing dishes
The **Ruoat** tab takes a bundle of mains and sides: paste the JSON or upload
a file, and the app reports row by row what it did.
```json
{
"mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}],
"sides": [{"name": "Riisi"}]
}
```
Categories are `meat`, `chicken`, `fish` and `vegetarian` — stored in English
even though the UI is Finnish. A dish may list several, which is how
build-your-own meals like tortillas cover every category at once. `has_sides`
defaults to `true` when omitted.
Import is best-effort, never atomic. Valid rows land; invalid ones are skipped
and named (`tuntematon kategoria`, `ei kategorioita`, `jo listalla`).
Names are normalized to sentence case on the way in — whitespace collapses and
the first letter is capitalized, the rest is left as typed, because Finnish
capitalizes only the first word of a phrase. `keitetyt perunat` is stored as
`Keitetyt perunat`, while `BBQ-kylkeä` and `Kotipizza` keep their capitals.
Duplicates are caught case-insensitively, so re-importing a bundle is safe.
The same importer runs from the command line when you just want to repopulate
a scratch database:
```sh
make seed # or: SEED=seeds/other.json make seed
```
## Migrations
Numbered SQL files in `cmd/foodster/migrations`, embedded in the binary and
applied in filename order at startup. Each runs in a transaction and is
recorded in `schema_migrations`, so it applies exactly once.
Adding one means dropping a new `NNNN_what_it_does.sql` into that directory.
Never edit a migration that has already shipped — a released file has run on
a live database and will not run again.
## Configuration ## Configuration
Everything is environment variables. `.env` is gitignored; start from Everything is environment variables. `.env` is gitignored; start from
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"strings"
)
// A Bundle is a set of dishes in the mass-import shape from PRD §7.3. The
// same format backs the admin paste/upload and the -import flag, so a test
// fixture and a real import travel identically.
//
// {
// "mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}],
// "sides": [{"name": "Riisi"}]
// }
type Bundle struct {
Mains []BundleMain `json:"mains"`
Sides []BundleSide `json:"sides"`
}
type BundleMain struct {
Name string `json:"name"`
Categories []string `json:"categories"`
// HasSides is a pointer so an omitted field is distinguishable from an
// explicit false, and defaults to true like the column does.
HasSides *bool `json:"has_sides"`
}
type BundleSide struct {
Name string `json:"name"`
}
// validCategories mirrors the CHECK constraint in 0001_init.sql. Stored values
// are English even though the UI is Finnish.
var validCategories = map[string]bool{
"meat": true, "chicken": true, "fish": true, "vegetarian": true,
}
// ImportReport is the per-row summary PRD §7.3 asks for. Import is
// best-effort, not atomic: good rows land, bad rows are skipped and named.
type ImportReport struct {
Added int
Skipped int
Notes []string
}
func (r *ImportReport) skip(format string, args ...any) {
r.Skipped++
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
func (r ImportReport) String() string {
var b strings.Builder
fmt.Fprintf(&b, "lisätty %d, ohitettu %d", r.Added, r.Skipped)
for _, n := range r.Notes {
fmt.Fprintf(&b, "\n - %s", n)
}
return b.String()
}
// importBundle reads a JSON bundle and inserts what it can. Each dish gets its
// own transaction so one bad row cannot discard the others.
func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) {
var bundle Bundle
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&bundle); err != nil {
// Unwrapped: callers add their own prefix, and the UI one is Finnish.
return nil, err
}
report := &ImportReport{}
for _, m := range bundle.Mains {
name := normalizeName(m.Name)
if name == "" {
report.skip("pääruoka ilman nimeä")
continue
}
if len(m.Categories) == 0 {
report.skip("%s: ei kategorioita", name)
continue
}
bad := ""
for _, c := range m.Categories {
if !validCategories[c] {
bad = c
break
}
}
if bad != "" {
report.skip("%s: tuntematon kategoria %q", name, bad)
continue
}
hasSides := true
if m.HasSides != nil {
hasSides = *m.HasSides
}
if err := insertMain(db, name, m.Categories, hasSides); err != nil {
report.skip("%s: %s", name, reason(err))
continue
}
report.Added++
}
for _, s := range bundle.Sides {
name := normalizeName(s.Name)
if name == "" {
report.skip("lisuke ilman nimeä")
continue
}
if _, err := db.Exec(`INSERT INTO side_dishes (name) VALUES (?)`, name); err != nil {
report.skip("%s: %s", name, reason(err))
continue
}
report.Added++
}
return report, nil
}
// reason turns a driver error into something worth showing a person. The
// only failure a normal import hits is a name already in the catalog.
//
// ponytail: string match rather than unwrapping a driver-specific error type,
// so this keeps working if the driver is ever swapped.
func reason(err error) string {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return "jo listalla"
}
return err.Error()
}
func insertMain(db *sql.DB, name string, categories []string, hasSides bool) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(
`INSERT INTO main_dishes (name, has_sides) VALUES (?, ?)`, name, hasSides)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
for _, c := range categories {
if _, err := tx.Exec(
`INSERT OR IGNORE INTO main_dish_categories (main_dish_id, category) VALUES (?, ?)`,
id, c,
); err != nil {
return err
}
}
return tx.Commit()
}
// runImport loads a bundle file, prints the report, and is what `-import`
// calls. Handy for reseeding a scratch database between test runs.
func runImport(db *sql.DB, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
report, err := importBundle(db, f)
if err != nil {
return err
}
fmt.Println(report)
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"database/sql"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/a-h/templ"
)
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
// kilobytes; a megabyte is already absurd generosity.
const maxUpload = 1 << 20
type app struct {
db *sql.DB
loc *time.Location
}
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
log.Printf("render %s: %v", r.URL.Path, err)
}
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, r, indexPage(today(a.loc)))
}
func (a *app) history(w http.ResponseWriter, r *http.Request) {
render(w, r, historyPage())
}
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
a.renderCatalog(w, r, nil)
}
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, report *ImportReport) {
var mains, sides int
err := a.db.QueryRow(`
SELECT (SELECT count(*) FROM main_dishes WHERE deleted_at IS NULL),
(SELECT count(*) FROM side_dishes WHERE deleted_at IS NULL)`,
).Scan(&mains, &sides)
if err != nil {
log.Printf("catalog counts: %v", err)
}
render(w, r, catalogPage(mains, sides, report))
}
// importDishes takes a bundle either pasted into the textarea or uploaded as a
// file, and reports row by row what happened (PRD §7.3).
//
// ponytail: a plain multipart form rather than a Datastar round trip. The
// result is a whole-page report, not a fragment, and a form needs no client
// code at all.
func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
if err := r.ParseMultipartForm(maxUpload); err != nil {
a.renderCatalog(w, r, failed("Tiedosto on liian suuri tai vioittunut."))
return
}
var src io.Reader
if file, _, err := r.FormFile("tiedosto"); err == nil {
defer file.Close()
src = file
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
src = strings.NewReader(pasted)
} else {
a.renderCatalog(w, r, failed("Ei tuotavaa: liitä JSON tai valitse tiedosto."))
return
}
report, err := importBundle(a.db, src)
if err != nil {
a.renderCatalog(w, r, failed("JSON ei kelpaa: "+err.Error()))
return
}
a.renderCatalog(w, r, report)
}
// failed builds a report for a whole-request failure, so the view only ever
// has one shape to render.
func failed(note string) *ImportReport {
return &ImportReport{Skipped: 1, Notes: []string{note}}
}
+194
View File
@@ -0,0 +1,194 @@
// Command foodster is a household dinner log and meal suggester.
//
// Stage 1 (the eating history) is what exists today; the suggester in PRD §8
// arrives once there is history to weight against.
package main
import (
"cmp"
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"embed"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"unicode"
_ "modernc.org/sqlite"
_ "time/tzdata" // the runtime image is FROM scratch and carries no zoneinfo
)
//go:embed static
var staticFS embed.FS
// version is replaced at build time with the CalVer tag (see `make image`).
var version = "dev"
const (
listenAddr = ":8080"
defaultTZ = "Europe/Helsinki"
defaultDB = "./foodster.db"
// failDelay throttles password guessing.
// ponytail: a fixed sleep is enough for a LAN-only app; swap in
// golang.org/x/time/rate keyed by IP if this is ever exposed.
failDelay = 500 * time.Millisecond
)
func main() {
if err := run(); err != nil {
log.Fatalf("foodster: %v", err)
}
}
func run() error {
importPath := flag.String("import", "",
"import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB))
if err != nil {
return err
}
defer db.Close()
// Importing is an offline chore: no password needed, no server started.
if *importPath != "" {
return runImport(db, *importPath)
}
password := os.Getenv("FOODSTER_PASSWORD")
if password == "" {
return errors.New("FOODSTER_PASSWORD is not set")
}
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
// dinners onto the wrong calendar day, which is invisible until the
// history is already corrupt.
loc, err := time.LoadLocation(cmp.Or(os.Getenv("TZ"), defaultTZ))
if err != nil {
return fmt.Errorf("TZ: %w", err)
}
// The container always publishes :8080; FOODSTER_ADDR exists so tests and
// a second local instance can pick another port.
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
srv := &http.Server{
Addr: addr,
Handler: routes(db, loc, password),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
}()
log.Printf("foodster %s listening on %s (%s)", version, addr, loc)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// openDB opens the SQLite file and brings its schema up to date.
func openDB(path string) (*sql.DB, error) {
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(ON)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
// ponytail: one household writing one row a day; a single connection
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
db.SetMaxOpenConns(1)
if err := migrate(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
a := &app{db: db, loc: loc}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(staticFS))
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("GET /historia", a.history)
mux.HandleFunc("GET /ruoat", a.catalog)
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux()
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, version)
})
root.Handle("/", auth(password, mux))
return root
}
// auth gates everything behind one shared household password. There are no
// accounts, so the username is ignored (PRD §9).
func auth(password string, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(password))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, given, ok := r.BasicAuth()
// Hashing first keeps the comparison a fixed length, so neither the
// password nor its length leaks through timing.
got := sha256.Sum256([]byte(given))
if !ok || subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
time.Sleep(failDelay)
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// today is the current calendar day in the configured location. Every date in
// this app goes through here rather than time.Local, which would be UTC
// whenever TZ is unset and quietly shift evening entries to the day before.
func today(loc *time.Location) time.Time {
return time.Now().In(loc)
}
// normalizeName collapses whitespace and capitalises the first letter for
// storage (PRD §6): " keitetyt perunat " becomes "Keitetyt perunat".
//
// Sentence case, not title case: Finnish capitalises only the first word of a
// phrase, so "Keitetyt Perunat" would read as an anglicism.
//
// ponytail: everything after the first rune is left exactly as typed. Forcing
// the remainder lower would mangle "BBQ-kylkeä" and "Kotipizza", and the
// household can type what it means.
func normalizeName(s string) string {
name := strings.Join(strings.Fields(s), " ")
if name == "" {
return ""
}
r := []rune(name)
r[0] = unicode.ToUpper(r[0])
return string(r)
}
+268
View File
@@ -0,0 +1,268 @@
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 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)
}
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log"
"path"
"sort"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// migrate applies every embedded migration that has not run yet, in filename
// order, and records it. Migrations are named NNNN_description.sql; the
// numeric prefix is the ordering, so never renumber one that has shipped.
//
// SQLite runs DDL inside transactions, so a migration either lands whole or
// not at all. There is no down-migration: for a single-household app,
// restoring the database file is the rollback.
func migrate(db *sql.DB) error {
const create = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
if _, err := db.Exec(create); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(files)
for _, file := range files {
name := path.Base(file)
var applied int
if err := db.QueryRow(
`SELECT count(*) FROM schema_migrations WHERE name = ?`, name,
).Scan(&applied); err != nil {
return fmt.Errorf("check %s: %w", name, err)
}
if applied > 0 {
continue
}
body, err := migrationsFS.ReadFile(file)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
log.Printf("migration applied: %s", name)
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
-- Initial Foodster schema. Runs once and is then recorded in
-- schema_migrations; never edit this file after it has shipped, add a new
-- numbered migration instead.
--
-- Stage 1 only (PRD §6): mains, sides and the meal log. The stage 2
-- suggestion cache is not here yet.
--
-- Dates are TEXT in 'YYYY-MM-DD' form. SQLite has no DATE type, and the
-- domain is pure calendar days with no time or timezone component.
CREATE TABLE IF NOT EXISTS main_dishes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
has_sides INTEGER NOT NULL DEFAULT 1 CHECK (has_sides IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
-- Duplicate detection is case-insensitive and only applies to live rows, so
-- a soft-deleted "Kanacurry" does not block adding it back (PRD §7.3).
CREATE UNIQUE INDEX IF NOT EXISTS main_dishes_name_live
ON main_dishes (lower(name)) WHERE deleted_at IS NULL;
-- A main belongs to one or more categories. Multi-category mains (tortillas,
-- build-your-own pizza) cover every listed category at once (PRD §6).
-- Non-emptiness is enforced in application code; SQLite cannot express it.
CREATE TABLE IF NOT EXISTS main_dish_categories (
main_dish_id INTEGER NOT NULL REFERENCES main_dishes (id) ON DELETE CASCADE,
category TEXT NOT NULL
CHECK (category IN ('meat', 'chicken', 'fish', 'vegetarian')),
PRIMARY KEY (main_dish_id, category)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS side_dishes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS side_dishes_name_live
ON side_dishes (lower(name)) WHERE deleted_at IS NULL;
-- One entry per calendar day: editing a day replaces it, and no second
-- dinner can be logged for the same date (PRD §6).
CREATE TABLE IF NOT EXISTS meal_log (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL UNIQUE,
main_dish_id INTEGER NOT NULL REFERENCES main_dishes (id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS meal_log_by_date ON meal_log (date DESC);
-- Foreign keys survive soft deletes, so historical entries still resolve
-- their dish names after the dish is removed from the pickers.
CREATE TABLE IF NOT EXISTS meal_log_sides (
meal_log_id INTEGER NOT NULL REFERENCES meal_log (id) ON DELETE CASCADE,
side_dish_id INTEGER NOT NULL REFERENCES side_dishes (id),
PRIMARY KEY (meal_log_id, side_dish_id)
) WITHOUT ROWID;
+136
View File
@@ -0,0 +1,136 @@
/* Foodster. Hand-written, no framework, no build step.
Themes come from color-scheme + light-dark(), which also gets native form
controls, scrollbars and focus rings themed for free. */
:root {
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--sunk: light-dark(#E3E4DE, #17181B);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
--accent: light-dark(#15616D, #58C6D2);
--onacc: light-dark(#FFFFFF, #0C1417);
/* Category colours, keyed by the Finnish names used in the UI. */
--liha: light-dark(#AF4230, #DE7561);
--kana: light-dark(#B57E10, #DFA83B);
--kala: light-dark(#25688F, #63AAD8);
--kasvis: light-dark(#457A3C, #82BE7A);
--tap: 48px; /* minimum touch target */
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: system-ui, sans-serif;
font-size: 16px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
button, input, select { font: inherit; }
:focus-visible { outline: 2.5px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
.appbar {
position: sticky;
top: 0;
z-index: 5;
background: var(--paper);
border-bottom: 1px solid var(--line);
padding: 14px 16px 12px;
}
.appbar h2 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
.appbar .meta { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
.pad { padding: 16px 16px calc(80px + env(safe-area-inset-bottom)); }
.muted { color: var(--muted); }
/* Bottom bar: primary navigation belongs in the thumb zone on a phone. */
.tabbar {
position: fixed;
inset: auto 0 0 0;
display: flex;
background: var(--card);
border-top: 1px solid var(--line);
padding-bottom: env(safe-area-inset-bottom);
}
.tabbar a {
flex: 1;
min-height: var(--tap);
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.tabbar a[aria-current] { color: var(--accent); }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
margin-bottom: 16px;
}
.card h3 { margin: 0 0 4px; font-size: 18px; letter-spacing: -0.025em; }
.small { font-size: 13.5px; }
.field { display: block; margin: 0 0 14px; }
.field > span {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.field textarea,
.field input[type="file"] {
width: 100%;
background: var(--sunk);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
font-size: 16px; /* below 16px iOS zooms on focus */
}
.field textarea {
font-family: ui-monospace, monospace;
font-size: 13px;
line-height: 1.4;
resize: vertical;
}
.primary {
width: 100%;
min-height: var(--tap);
background: var(--accent);
color: var(--onacc);
border: 0;
border-radius: 11px;
font-size: 16.5px;
font-weight: 700;
cursor: pointer;
}
.report { border-left: 4px solid var(--accent); }
.report.bad { border-left-color: var(--liha); }
.report .tally { margin: 0; font-weight: 700; }
.report .notes {
margin: 10px 0 0;
padding-left: 18px;
font-size: 13.5px;
color: var(--muted);
}
.report .notes li { margin-bottom: 3px; }
File diff suppressed because one or more lines are too long
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"fmt"
"time"
)
// Finnish weekday names. Go formats dates in English only, and this app has
// exactly one locale (PRD §5).
var (
weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko",
"torstai", "perjantai", "lauantai"}
weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"}
)
func longDateFI(t time.Time) string {
return weekdaysFI[t.Weekday()] + " " + t.Format("2.1.2006")
}
func dayLabelFI(t time.Time) string {
return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.")
}
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
// after every number except one.
func countFI(n int, one, many string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, one)
}
return fmt.Sprintf("%d %s", n, many)
}
templ page(title, current string) {
<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/app.css"/>
<script type="module" src="/static/datastar.js"></script>
</head>
<body>
{ children... }
@tabbar(current)
</body>
</html>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/historia", "Historia", current)
@tab("/ruoat", "Ruoat", current)
</nav>
}
templ tab(href, label, current string) {
if href == current {
<a href={ templ.SafeURL(href) } aria-current="page">{ label }</a>
} else {
<a href={ templ.SafeURL(href) }>{ label }</a>
}
}
templ indexPage(now time.Time) {
@page("Foodster", "/") {
<header class="appbar">
<h2>Mitä syötiin?</h2>
<p class="meta">{ longDateFI(now) }</p>
</header>
<main class="pad">
<p class="muted">Ruokien kirjaus tulee tähän.</p>
</main>
}
}
templ historyPage() {
@page("Historia — Foodster", "/historia") {
<header class="appbar">
<h2>Historia</h2>
</header>
<main class="pad">
<p class="muted">Merkinnät tulevat tähän.</p>
</main>
}
}
templ catalogPage(mains, sides int, report *ImportReport) {
@page("Ruoat — Foodster", "/ruoat") {
<header class="appbar">
<h2>Ruoat</h2>
<p class="meta">
{ countFI(mains, "pääruoka", "pääruokaa") }, { countFI(sides, "lisuke", "lisuketta") }
</p>
</header>
<main class="pad">
if report != nil {
@importReport(report)
}
@importForm()
</main>
}
}
templ importForm() {
<section class="card">
<h3>Tuo ruokia</h3>
<p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p>
<form method="post" action="/ruoat/tuonti" enctype="multipart/form-data">
<label class="field">
<span>JSON</span>
<textarea
name="json"
rows="8"
spellcheck="false"
placeholder={ `{"mains": [{"name": "Kanacurry", "categories": ["chicken"]}], "sides": [{"name": "Riisi"}]}` }
></textarea>
</label>
<label class="field">
<span>tai tiedosto</span>
<input type="file" name="tiedosto" accept="application/json,.json"/>
</label>
<button class="primary" type="submit">Tuo</button>
</form>
</section>
}
templ importReport(r *ImportReport) {
<section class={ "card", "report", templ.KV("bad", r.Added == 0 && r.Skipped > 0) }>
<p class="tally">{ fmt.Sprintf("Lisätty %d, ohitettu %d", r.Added, r.Skipped) }</p>
if len(r.Notes) > 0 {
<ul class="notes">
for _, note := range r.Notes {
<li>{ note }</li>
}
</ul>
}
</section>
}
+34
View File
@@ -0,0 +1,34 @@
module foodster
go 1.27.1
tool github.com/a-h/templ/cmd/templ
require (
github.com/a-h/templ v0.3.1020
modernc.org/sqlite v1.58.0
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+84
View File
@@ -0,0 +1,84 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+86
View File
@@ -0,0 +1,86 @@
#!/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
}
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 "catalog starts empty" \
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa"
out=$(curl -s -u ":$pass" -F "tiedosto=@seeds/testi.json" "http://$addr/ruoat/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/ruoat/tuonti")" \
"jo listalla"
check "pasted JSON imports" \
"$(curl -s -u ":$pass" -F 'json={"mains":[],"sides":[{"name":"Perunasalaatti"}]}' \
"http://$addr/ruoat/tuonti")" "Lisätty 1"
check "unknown category is reported" \
"$(curl -s -u ":$pass" -F 'json={"mains":[{"name":"Rikki","categories":["kana"]}],"sides":[]}' \
"http://$addr/ruoat/tuonti")" "tuntematon kategoria"
check "empty submit is explained" \
"$(curl -s -u ":$pass" -F 'json=' "http://$addr/ruoat/tuonti")" "Ei tuotavaa"
check "malformed JSON is explained" \
"$(curl -s -u ":$pass" -F 'json={nope' "http://$addr/ruoat/tuonti")" "JSON ei kelpaa"
if [ "$fail" -ne 0 ]; then
echo "smoke: FAILED"
exit 1
fi
echo "smoke: all passed"
+18
View File
@@ -0,0 +1,18 @@
{
"mains": [
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
{"name": "Lasagnette", "categories": ["meat"], "has_sides": false},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Risotto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Pakastepizza", "categories": ["meat"], "has_sides": false},
{"name": "Kanakeitto", "categories": ["chicken"], "has_sides": false},
{"name": "Kasvissosekeitto", "categories": ["vegetarian"], "has_sides": false}
],
"sides": [
{"name": "Keitetyt perunat"},
{"name": "Ranskalaiset"},
{"name": "Muussi"},
{"name": "Riisi"},
{"name": "Pasta"}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"mains": [
{"name": "Lihapullat", "categories": ["meat"], "has_sides": true},
{"name": "Jauhelihakastike", "categories": ["meat"], "has_sides": true},
{"name": "Makaronilaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Kaalilaatikko", "categories": ["meat"], "has_sides": false},
{"name": "Uunimakkara", "categories": ["meat"], "has_sides": true},
{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true},
{"name": "Broilerikastike", "categories": ["chicken"], "has_sides": true},
{"name": "Kanawokki", "categories": ["chicken"], "has_sides": true},
{"name": "Lohikeitto", "categories": ["fish"], "has_sides": true},
{"name": "Uunilohi", "categories": ["fish"], "has_sides": true},
{"name": "Kalapuikot", "categories": ["fish"], "has_sides": true},
{"name": "Kasvispyörykät", "categories": ["vegetarian"], "has_sides": true},
{"name": "Hernekeitto", "categories": ["vegetarian"], "has_sides": false},
{"name": "Kasviscurry", "categories": ["vegetarian"], "has_sides": true},
{"name": "Tortillat", "categories": ["meat", "chicken", "fish", "vegetarian"], "has_sides": false},
{"name": "Kotipizza", "categories": ["meat", "chicken", "vegetarian"], "has_sides": false}
],
"sides": [
{"name": "Perunamuusi"},
{"name": "Riisi"},
{"name": "Keitetyt perunat"},
{"name": "Vihersalaatti"},
{"name": "Höyrytetyt porkkanat"},
{"name": "Ruisleipä"}
]
}