Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf598a8405 | ||
|
|
5519676c79 | ||
|
|
048d5dd71a | ||
|
|
c899f07b1f | ||
|
|
998658b42c | ||
|
|
a453a84231 | ||
|
|
732aa92a8c | ||
|
|
02f1dd6491 | ||
|
|
496f2e5375 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 | ||
|
|
e050736178 | ||
|
|
3ba78d47bc | ||
|
|
8be5c56f6f | ||
|
|
ea4047a852 | ||
|
|
41ff2ab114 | ||
|
|
df692ef214 | ||
|
|
88f7d0a5fa | ||
|
|
5e78465d34 | ||
|
|
6d5c14f778 |
@@ -44,6 +44,7 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
|
||||
@@ -51,11 +52,16 @@ make dev
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE) and
|
||||
trusted proxy list (every entry must be an IP or CIDR), and
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR) and
|
||||
`MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||
with on first boot: until those settings exist, boot fails unless it is a
|
||||
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||
household settings instead).
|
||||
|
||||
## Common commands
|
||||
|
||||
|
||||
+281
-7
@@ -3,15 +3,22 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -24,6 +31,51 @@ func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||
t.Cleanup(func() { bootMigrations = saved })
|
||||
}
|
||||
|
||||
// loadConfig loads a configuration through config.Load whose secret key file
|
||||
// holds keyFile. Tests that call it must not run in parallel.
|
||||
func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
keyPath := filepath.Join(dir, "secret_key")
|
||||
if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientSecretPath := filepath.Join(dir, "oidc_client_secret")
|
||||
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
|
||||
t.Setenv(config.EnvPublicURL, "http://localhost")
|
||||
t.Setenv(config.EnvDataDir, t.TempDir())
|
||||
t.Setenv(config.EnvShutdownTimeout, "1s")
|
||||
t.Setenv(config.EnvSecretKeyFile, keyPath)
|
||||
t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/")
|
||||
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
||||
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||
t.Setenv(config.EnvDefaultTZ, "")
|
||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// validKeyFile returns secret key file contents as written by the README:
|
||||
// base64 of 32 random bytes followed by a newline.
|
||||
func validKeyFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key) + "\n"
|
||||
}
|
||||
|
||||
var brokenMigrations = fstest.MapFS{
|
||||
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||
@@ -73,12 +125,7 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := config.Config{
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
|
||||
DataDir: t.TempDir(),
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
@@ -94,3 +141,230 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) {
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
box, err := openSecretBox(cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := box.Encrypt([]byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := box.Decrypt(token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBadSecretKeyStopsBoot(t *testing.T) {
|
||||
short := base64.StdEncoding.EncodeToString(make([]byte, 31))
|
||||
tests := []struct {
|
||||
name string
|
||||
keyFile string
|
||||
want error
|
||||
}{
|
||||
{"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding},
|
||||
{"wrong length", short + "\n", secretbox.ErrKeyLength},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := loadConfig(t, tt.keyFile)
|
||||
var logs bytes.Buffer
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
var err error
|
||||
select {
|
||||
case err = <-errc:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going with a bad secret key")
|
||||
}
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("run error = %v, want %v", err, tt.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||
t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err)
|
||||
}
|
||||
if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error leaks the key: %v", err)
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening despite the bad key: %s", logs.String())
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) {
|
||||
t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
||||
loadConfig(t, validKeyFile(t)) // sets every other variable
|
||||
t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing"))
|
||||
_, err := config.Load()
|
||||
var cfgErr *config.Error
|
||||
if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) {
|
||||
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithoutValidDefaultZoneOnFirstBootStops(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultTZ *string // nil leaves MEALQUEST_DEFAULT_TZ unset
|
||||
want error
|
||||
}{
|
||||
{"unset", nil, household.ErrNoDefaultZone},
|
||||
{"not an IANA zone", ptr("Mars/Olympus_Mons"), household.ErrInvalidDefaultZone},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loadConfig(t, validKeyFile(t))
|
||||
if tt.defaultTZ != nil {
|
||||
t.Setenv(config.EnvDefaultTZ, *tt.defaultTZ)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs bytes.Buffer
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
select {
|
||||
case err = <-errc:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going with no household settings and no valid default zone")
|
||||
}
|
||||
if !errors.Is(err, tt.want) || !strings.Contains(err.Error(), config.EnvDefaultTZ) {
|
||||
t.Fatalf("run error = %v, want %v naming %s", err, tt.want, config.EnvDefaultTZ)
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening without household settings: %s", logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A reboot over existing household settings must not depend on
|
||||
// MEALQUEST_DEFAULT_TZ: config.Load accepts an invalid value and seeding
|
||||
// ignores it, keeping the stored zone.
|
||||
func TestRebootWithSettingsIgnoresInvalidDefaultZone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := loadConfig(t, validKeyFile(t))
|
||||
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv(config.EnvDefaultTZ, "Mars/Olympus_Mons")
|
||||
cfg, err = config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load with an invalid %s and existing settings: %v", config.EnvDefaultTZ, err)
|
||||
}
|
||||
db, err = openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
|
||||
t.Fatalf("reboot with %s=%q: %v", config.EnvDefaultTZ, cfg.DefaultTZ, err)
|
||||
}
|
||||
got, err := household.NewSettingsService(db).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Timezone != "America/Denver" {
|
||||
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestSeedSettingsAtBoot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds from the default zone.
|
||||
db, err := openStore(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedSettings(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot needs no default zone and keeps the stored one.
|
||||
db, err = openStore(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedSettings(ctx, db, ""); err != nil {
|
||||
t.Fatalf("reboot without %s: %v", config.EnvDefaultTZ, err)
|
||||
}
|
||||
got, err := household.NewSettingsService(db).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Timezone != "America/Denver" {
|
||||
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultsAtBoot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := seedDefaults(ctx, db, "America/Denver"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := household.NewSettingsService(db).Get(ctx); err != nil {
|
||||
t.Errorf("household settings: %v", err)
|
||||
}
|
||||
var provider string
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil {
|
||||
t.Errorf("AI settings: %v", err)
|
||||
} else if provider != household.SeedAIProvider {
|
||||
t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider)
|
||||
}
|
||||
var prefs int
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefs != 5 {
|
||||
t.Errorf("%d notification preferences, want 5", prefs)
|
||||
}
|
||||
|
||||
// A second boot seeds nothing new and succeeds.
|
||||
if err := seedDefaults(ctx, db, ""); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job:
|
||||
// a noop job enqueued before Start, as at boot, succeeds with its result.
|
||||
func TestNewJobRunnerRunsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
stopJobs()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
job, err := runner.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||
t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("noop job never finished; last %+v", job)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
+94
-1
@@ -17,6 +17,9 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -25,6 +28,9 @@ import (
|
||||
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||
var version = "dev"
|
||||
|
||||
// jobWorkers is the number of background jobs run at once.
|
||||
const jobWorkers = 2
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -43,6 +49,10 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if _, err := openSecretBox(cfg.SecretKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -53,6 +63,15 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil {
|
||||
return err
|
||||
}
|
||||
settings := household.NewSettingsService(db)
|
||||
runner, _, err := newJobRunner(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -69,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The workers stop, and are waited for, before the
|
||||
// database closes.
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
}()
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
@@ -104,6 +135,16 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openSecretBox builds the secret box from the configured key, naming the
|
||||
// variable whose file holds a bad key. The key itself is never reported.
|
||||
func openSecretBox(key config.Secret) (*secretbox.Box, error) {
|
||||
box, err := secretbox.New(key.Reveal())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err)
|
||||
}
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||
// prove a failing migration stops the boot.
|
||||
var bootMigrations fs.FS = migrations.FS
|
||||
@@ -121,6 +162,58 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// seedSettings creates the household settings on first boot, naming the
|
||||
// variable that supplies the zone when it is missing or invalid. Once the
|
||||
// settings exist the variable is ignored.
|
||||
func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||
err := household.SeedSettings(ctx, db, defaultTZ)
|
||||
if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) {
|
||||
return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// seedDefaults creates the household settings, AI settings and notification
|
||||
// preferences that do not exist yet. Existing rows are never changed.
|
||||
func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error {
|
||||
if err := seedSettings(ctx, db, defaultTZ); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := household.SeedAISettings(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return household.SeedNotificationPrefs(ctx, db)
|
||||
}
|
||||
|
||||
// jobKinds enqueues every job kind the server runs.
|
||||
type jobKinds struct {
|
||||
builtins *jobs.Builtins
|
||||
}
|
||||
|
||||
// newJobRunner returns the job runner with every job kind registered, ready
|
||||
// to Start.
|
||||
func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) {
|
||||
runner, err := jobs.NewRunner(db, logger, jobWorkers)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
kinds, err := registerJobKinds(runner)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return runner, kinds, nil
|
||||
}
|
||||
|
||||
// registerJobKinds registers every job kind's handler on runner. Each
|
||||
// feature's job kinds are registered here.
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jobKinds{builtins: builtins}, nil
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
+44
-3
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -107,9 +108,12 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, client), cfg.Issuer)
|
||||
discovery := *client
|
||||
recorder := &statusRecorder{next: http.DefaultTransport}
|
||||
discovery.Transport = recorder
|
||||
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, &discovery), cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
return nil, discoveryError(err, int(recorder.status.Load()))
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
var discovered struct {
|
||||
@@ -159,6 +163,32 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
}, nil
|
||||
}
|
||||
|
||||
// statusRecorder records the status of the last response it carries.
|
||||
type statusRecorder struct {
|
||||
next http.RoundTripper
|
||||
status atomic.Int32
|
||||
}
|
||||
|
||||
func (s *statusRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := s.next.RoundTrip(req)
|
||||
if err == nil {
|
||||
s.status.Store(int32(resp.StatusCode))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// discoveryError reports a failed discovery. go-oidc puts the provider's
|
||||
// response body in its error for a non-200 discovery response, so that
|
||||
// error is replaced by the status alone. Transport errors carry no response
|
||||
// content and keep their cause.
|
||||
func discoveryError(err error, status int) error {
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) && status != 0 && status != http.StatusOK {
|
||||
return fmt.Errorf("auth: OIDC discovery: provider answered HTTP %d", status)
|
||||
}
|
||||
return fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
}
|
||||
|
||||
// checkEndpoint applies secureurl.Check to the provider URL raw, naming the
|
||||
// endpoint in the error.
|
||||
func checkEndpoint(name, raw string) error {
|
||||
@@ -323,7 +353,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
|
||||
}
|
||||
idToken, err := a.verifier.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("verify ID token: %w", err)
|
||||
return Claims{}, verifyError(err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(state.Nonce)) != 1 {
|
||||
return Claims{}, errors.New("ID token nonce mismatch")
|
||||
@@ -374,6 +404,17 @@ func userinfoError(err error) error {
|
||||
return errors.New("userinfo: endpoint returned an unusable response")
|
||||
}
|
||||
|
||||
// verifyError reduces a failed ID token verification to a fixed category.
|
||||
// go-oidc's error text can carry the JWKS endpoint's response body or
|
||||
// token-supplied values, so only the expiry of an expired token is kept.
|
||||
func verifyError(err error) error {
|
||||
var expired *oidc.TokenExpiredError
|
||||
if errors.As(err, &expired) {
|
||||
return fmt.Errorf("verify ID token: token expired at %s", expired.Expiry.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return errors.New("verify ID token: token or provider key set rejected")
|
||||
}
|
||||
|
||||
// member reports whether groups contains an allowed group.
|
||||
func (a *Auth) member(groups []string) bool {
|
||||
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
|
||||
|
||||
+109
-1
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -59,6 +60,8 @@ type fakeProvider struct {
|
||||
userinfoHits int
|
||||
// userinfoReply, when set, is returned by the userinfo endpoint instead.
|
||||
userinfoReply *tokenReply
|
||||
// jwksReply, when set, is returned by the JWKS endpoint instead.
|
||||
jwksReply *tokenReply
|
||||
}
|
||||
|
||||
type tokenReply struct {
|
||||
@@ -109,6 +112,15 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (p *fakeProvider) jwks(w http.ResponseWriter, _ *http.Request) {
|
||||
p.mu.Lock()
|
||||
reply := p.jwksReply
|
||||
p.mu.Unlock()
|
||||
if reply != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(reply.status)
|
||||
io.WriteString(w, reply.body)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"keys": []map[string]string{{
|
||||
"kty": "RSA",
|
||||
"kid": testKID,
|
||||
@@ -307,6 +319,7 @@ func (m *memorySessions) started() []Claims {
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
auth *Auth
|
||||
provider *fakeProvider
|
||||
sessions *memorySessions
|
||||
router http.Handler
|
||||
@@ -349,7 +362,7 @@ func newHarness(t *testing.T) *harness {
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
|
||||
return &harness{auth: a, provider: p, sessions: sessions, router: r, logs: logs}
|
||||
}
|
||||
|
||||
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
|
||||
@@ -727,6 +740,66 @@ func TestCallbackExchangeFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackKeySetFailureDoesNotLogProviderResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-jwks-body-5c1e"
|
||||
tests := []struct {
|
||||
name string
|
||||
reply tokenReply
|
||||
}{
|
||||
{"error response", tokenReply{http.StatusInternalServerError, sentinel}},
|
||||
{"malformed success", tokenReply{http.StatusOK, `{"keys":"` + sentinel + `"`}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.provider.mu.Lock()
|
||||
h.provider.jwksReply = &tc.reply
|
||||
h.provider.mu.Unlock()
|
||||
|
||||
// The returned error, checked through verify directly.
|
||||
l := h.login(t, LoginPath)
|
||||
req := httptest.NewRequest(http.MethodGet, CallbackPath, nil)
|
||||
req.AddCookie(l.cookie)
|
||||
state, err := h.auth.cookie.read(req, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := h.provider.authorize(l.query.Get("code_challenge"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
_, err = h.auth.verify(context.Background(), code, state)
|
||||
if err == nil {
|
||||
t.Fatal("verify succeeded with an unusable key set")
|
||||
}
|
||||
if strings.Contains(err.Error(), sentinel) {
|
||||
t.Errorf("verify error contains the JWKS response: %v", err)
|
||||
}
|
||||
|
||||
// The logs and response, through the callback.
|
||||
l = h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), sentinel) {
|
||||
t.Errorf("response contains the JWKS response: %s", body)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if strings.Contains(logs, sentinel) {
|
||||
t.Errorf("logs contain the JWKS response: %s", logs)
|
||||
}
|
||||
if want := "token or provider key set rejected"; !strings.Contains(logs, want) {
|
||||
t.Errorf("logs = %q, want %q", logs, want)
|
||||
}
|
||||
if len(h.sessions.started()) != 0 {
|
||||
t.Error("a session started")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCookieExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newLoginCookie([]byte("server-secret-key"))
|
||||
@@ -789,6 +862,41 @@ func TestNewFailsWhenDiscoveryFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDiscoveryErrorOmitsProviderBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "sentinel-discovery-body-7f3a"
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, sentinel)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
logs := &syncBuffer{}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: srv.URL,
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err == nil {
|
||||
t.Fatal("New succeeded without a discovery document")
|
||||
}
|
||||
// cmd/server logs the returned error at startup.
|
||||
slog.New(slog.NewTextHandler(logs, nil)).Error("startup failed", "err", err)
|
||||
if strings.Contains(logs.String(), sentinel) {
|
||||
t.Errorf("logs contain the provider response body: %s", logs)
|
||||
}
|
||||
if want := fmt.Sprintf("auth: OIDC discovery: provider answered HTTP %d", status); err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFailsOnIssuerMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
|
||||
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -65,6 +67,12 @@ type Config struct {
|
||||
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
|
||||
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
||||
TrustedProxy forwarded.Trusted
|
||||
|
||||
// DefaultTZ is the IANA zone the household settings are created with on
|
||||
// first boot. It is optional and not validated here: boot requires and
|
||||
// validates it only while the settings do not exist yet, and ignores it
|
||||
// once they do. Empty when unset.
|
||||
DefaultTZ string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -97,6 +105,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
|
||||
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
@@ -323,6 +333,12 @@ func (r *reader) parseDuration(name, v string) time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
// optional returns the variable's value, or "" when it is unset.
|
||||
func (r *reader) optional(name string) string {
|
||||
v, _ := r.lookup(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// secretFile reads a secret from the file named by name, which must end in
|
||||
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||
func (r *reader) secretFile(name string) Secret {
|
||||
|
||||
@@ -68,6 +68,16 @@ func TestLoad(t *testing.T) {
|
||||
name: "all required vars present",
|
||||
env: valid(),
|
||||
},
|
||||
{
|
||||
name: "default time zone set",
|
||||
env: with(map[string]string{EnvDefaultTZ: "America/New_York"}),
|
||||
},
|
||||
{
|
||||
// Validated only when seeding the household settings, so a
|
||||
// stale value cannot stop a boot that no longer uses it.
|
||||
name: "default time zone not an IANA zone",
|
||||
env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}),
|
||||
},
|
||||
{
|
||||
name: "one missing",
|
||||
env: with(nil, EnvHTTPAddr),
|
||||
@@ -410,6 +420,8 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCClientID: "mealquest",
|
||||
EnvOIDCClientSecretFile: clientSecretPath,
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -465,6 +477,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if !slices.Equal(cfg.TrustedProxy, want) {
|
||||
t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want)
|
||||
}
|
||||
if cfg.DefaultTZ != "Europe/London" {
|
||||
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: jobs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const claimNextJob = `-- name: ClaimNextJob :one
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = ?1, updated_at = ?1
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
`
|
||||
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
|
||||
row := q.db.QueryRowContext(ctx, claimNextJob, now)
|
||||
var i Job
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Status,
|
||||
&i.Payload,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createJob = `-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateJobParams struct {
|
||||
ID string
|
||||
Kind string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createJob,
|
||||
arg.ID,
|
||||
arg.Kind,
|
||||
arg.Payload,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const failJob = `-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'running'
|
||||
`
|
||||
|
||||
type FailJobParams struct {
|
||||
Error sql.NullString
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failRunningJobs = `-- name: FailRunningJobs :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE status = 'running'
|
||||
`
|
||||
|
||||
type FailRunningJobsParams struct {
|
||||
Error sql.NullString
|
||||
Now sql.NullString
|
||||
}
|
||||
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getJob = `-- name: GetJob :one
|
||||
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
|
||||
row := q.db.QueryRowContext(ctx, getJob, id)
|
||||
var i Job
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Status,
|
||||
&i.Payload,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.StartedAt,
|
||||
&i.FinishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const publishJobResult = `-- name: PublishJobResult :execrows
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
`
|
||||
|
||||
type PublishJobResultParams struct {
|
||||
Result sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Stores a partial result of a running job.
|
||||
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const succeedJob = `-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'running'
|
||||
`
|
||||
|
||||
type SucceedJobParams struct {
|
||||
Result sql.NullString
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -85,6 +85,7 @@ type HouseholdSetting struct {
|
||||
UpdatesSeenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
UpdatedBy sql.NullString
|
||||
}
|
||||
|
||||
type IntegrationsGoogle struct {
|
||||
|
||||
@@ -6,17 +6,25 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
@@ -26,6 +34,7 @@ type Querier interface {
|
||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
||||
GetJob(ctx context.Context, id string) (Job, error)
|
||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||
// unique, so the earliest-created (lowest id) match is returned.
|
||||
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
||||
@@ -36,8 +45,11 @@ type Querier interface {
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
|
||||
@@ -57,7 +57,7 @@ func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) {
|
||||
}
|
||||
|
||||
const getHouseholdSettings = `-- name: GetHouseholdSettings :one
|
||||
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at FROM household_settings
|
||||
SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by FROM household_settings
|
||||
WHERE id = 1
|
||||
`
|
||||
|
||||
@@ -81,6 +81,7 @@ func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, e
|
||||
&i.UpdatesSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UpdatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -248,12 +249,12 @@ INSERT INTO household_settings (
|
||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||
created_at, updated_at
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
timezone = excluded.timezone,
|
||||
@@ -268,8 +269,9 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
chat_autosend_time = excluded.chat_autosend_time,
|
||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||
updates_seen_at = excluded.updates_seen_at,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at
|
||||
RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by
|
||||
`
|
||||
|
||||
type UpsertHouseholdSettingsParams struct {
|
||||
@@ -285,6 +287,7 @@ type UpsertHouseholdSettingsParams struct {
|
||||
ChatAutosendTime string
|
||||
OnboardingCompletedAt sql.NullString
|
||||
UpdatesSeenAt sql.NullString
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
@@ -305,6 +308,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho
|
||||
arg.ChatAutosendTime,
|
||||
arg.OnboardingCompletedAt,
|
||||
arg.UpdatesSeenAt,
|
||||
arg.UpdatedBy,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
@@ -325,6 +329,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho
|
||||
&i.UpdatesSeenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.UpdatedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
|
||||
// SeedAISettings stores it on first boot and the Admin reset restores it.
|
||||
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
|
||||
similar to their regular rotation, nudging them to try something a little new each
|
||||
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
|
||||
Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations
|
||||
short, warm and specific — mention the household's own regular dinners when you
|
||||
explain why something fits. Never suggest a dinner they already make or have
|
||||
declined.`
|
||||
|
||||
// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model
|
||||
// are empty until the household picks a provider preset; there is no key.
|
||||
const (
|
||||
SeedAIProvider = "anthropic"
|
||||
SeedAIBaseURL = ""
|
||||
SeedAIModel = ""
|
||||
SeedAITemperature = 0.7
|
||||
)
|
||||
|
||||
// seedNotificationPrefs are the notification preferences for a new
|
||||
// household, one per event: push for every event, chat only for the plan
|
||||
// and the shopping list.
|
||||
var seedNotificationPrefs = []struct {
|
||||
Event string
|
||||
Push, Chat bool
|
||||
}{
|
||||
{Event: "plan_ready", Push: true, Chat: true},
|
||||
{Event: "list_ready", Push: true, Chat: true},
|
||||
{Event: "day_of_reminder", Push: true, Chat: false},
|
||||
{Event: "new_suggestions", Push: true, Chat: false},
|
||||
{Event: "rotation_review", Push: true, Chat: false},
|
||||
}
|
||||
|
||||
// SeedAISettings creates the AI settings row with the seed values and
|
||||
// DefaultPersonaPrompt if it does not exist. An existing row is never
|
||||
// changed.
|
||||
func SeedAISettings(ctx context.Context, st *store.Store) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
_, err := q.GetAISettings(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("household: read AI settings: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
_, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: SeedAIProvider,
|
||||
BaseUrl: SeedAIBaseURL,
|
||||
Model: SeedAIModel,
|
||||
Temperature: SeedAITemperature,
|
||||
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed AI settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SeedNotificationPrefs creates the seed notification preference row of
|
||||
// every event that has none. Existing rows are never changed.
|
||||
func SeedNotificationPrefs(ctx context.Context, st *store.Store) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
now := store.Now().String()
|
||||
for _, p := range seedNotificationPrefs {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO notification_prefs (event, push, chat, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (event) DO NOTHING`,
|
||||
p.Event, p.Push, p.Chat, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// notificationPrefs returns the stored notification preferences by event as
|
||||
// [push, chat].
|
||||
func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 {
|
||||
t.Helper()
|
||||
rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := make(map[string][2]int64)
|
||||
for rows.Next() {
|
||||
var event string
|
||||
var push, chat int64
|
||||
if err := rows.Scan(&event, &push, &chat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[event] = [2]int64{push, chat}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestSeedAISettingsCreatesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := db.AiSetting{
|
||||
ID: 1,
|
||||
Provider: "anthropic",
|
||||
BaseUrl: "",
|
||||
Model: "",
|
||||
Temperature: 0.7,
|
||||
SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true},
|
||||
CreatedAt: got.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl ||
|
||||
got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt ||
|
||||
got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid {
|
||||
t.Errorf("seeded AI settings = %+v\nwant %+v", got, want)
|
||||
}
|
||||
var created store.Timestamp
|
||||
if err := created.Scan(got.CreatedAt); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if got.UpdatedAt != got.CreatedAt {
|
||||
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPersonaPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" +
|
||||
"similar to their regular rotation, nudging them to try something a little new each\n" +
|
||||
"week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" +
|
||||
"Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" +
|
||||
"short, warm and specific — mention the household's own regular dinners when you\n" +
|
||||
"explain why something fits. Never suggest a dinner they already make or have\n" +
|
||||
"declined."
|
||||
if DefaultPersonaPrompt != want {
|
||||
t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedAISettingsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then configures a provider.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedAISettings(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeded, err := db.New(first.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: "ollama",
|
||||
BaseUrl: "http://ollama:11434",
|
||||
Model: "llama3",
|
||||
Temperature: 0.2,
|
||||
SystemPrompt: sql.NullString{String: "Be brief.", Valid: true},
|
||||
ApiKeyCt: []byte{1, 2, 3},
|
||||
ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true},
|
||||
KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
CreatedAt: seeded.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot reopens the same database and seeds again.
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
got, err := db.New(st.Reader).GetAISettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model ||
|
||||
got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt ||
|
||||
string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 ||
|
||||
got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt {
|
||||
t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedNotificationPrefsCreatesRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][2]int64{
|
||||
"plan_ready": {1, 1},
|
||||
"list_ready": {1, 1},
|
||||
"day_of_reminder": {1, 0},
|
||||
"new_suggestions": {1, 0},
|
||||
"rotation_review": {1, 0},
|
||||
}
|
||||
got := notificationPrefs(t, st)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for event, w := range want {
|
||||
if g, ok := got[event]; !ok || g != w {
|
||||
t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||
}
|
||||
}
|
||||
|
||||
var created, updated string
|
||||
err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(created); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if updated != created {
|
||||
t.Errorf("updated_at = %q, want created_at %q", updated, created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then changes two preferences, and one
|
||||
// event's row goes missing.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedNotificationPrefs(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`,
|
||||
`UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`,
|
||||
`DELETE FROM notification_prefs WHERE event = 'new_suggestions'`,
|
||||
} {
|
||||
if _, err := first.Writer.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A later boot keeps the changed rows and seeds only the missing one.
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatalf("reboot: %v", err)
|
||||
}
|
||||
want := map[string][2]int64{
|
||||
"plan_ready": {0, 0},
|
||||
"list_ready": {1, 1},
|
||||
"day_of_reminder": {1, 0},
|
||||
"new_suggestions": {1, 0},
|
||||
"rotation_review": {1, 1},
|
||||
}
|
||||
got := notificationPrefs(t, st)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for event, w := range want {
|
||||
if g, ok := got[event]; !ok || g != w {
|
||||
t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Seed values for a new household (CTXD CONTEXT.MD). Days count from
|
||||
// Sunday = 0, as time.Weekday does.
|
||||
const (
|
||||
SeedDinnerTime = "17:30"
|
||||
SeedReminderTime = "16:30"
|
||||
SeedReviewTime = "09:00" // rotation check
|
||||
SeedPlanDraftDow = 5 // Friday
|
||||
SeedPlanDraftTime = "09:00"
|
||||
SeedSuggestionsDow = 0 // Sunday
|
||||
SeedSuggestionsTime = "08:00"
|
||||
SeedChatAutosendDow = 6 // Saturday
|
||||
SeedChatAutosendTime = "08:00"
|
||||
)
|
||||
|
||||
// ErrNoDefaultZone is returned by SeedSettings when the household settings
|
||||
// do not exist yet and no default zone was configured to create them with.
|
||||
var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured")
|
||||
|
||||
// ErrInvalidDefaultZone is returned by SeedSettings when the household
|
||||
// settings do not exist yet and the configured default zone cannot be loaded.
|
||||
var ErrInvalidDefaultZone = errors.New("household settings do not exist yet and the default time zone is invalid")
|
||||
|
||||
// Settings are the shared household settings. Times are household-local
|
||||
// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0.
|
||||
type Settings struct {
|
||||
Timezone string `json:"timezone"`
|
||||
DinnerTime string `json:"dinner_time"`
|
||||
ReminderTime string `json:"reminder_time"`
|
||||
ReviewTime string `json:"review_time"`
|
||||
PlanDraftDow int `json:"plan_draft_dow"`
|
||||
PlanDraftTime string `json:"plan_draft_time"`
|
||||
SuggestionsDow int `json:"suggestions_dow"`
|
||||
SuggestionsTime string `json:"suggestions_time"`
|
||||
ChatAutosendDow int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime string `json:"chat_autosend_time"`
|
||||
OnboardingCompletedAt *string `json:"onboarding_completed_at"`
|
||||
UpdatesSeenAt *string `json:"updates_seen_at"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SettingsPatch changes the household settings. Nil fields are left as they
|
||||
// are.
|
||||
type SettingsPatch struct {
|
||||
Timezone *string
|
||||
DinnerTime *string
|
||||
ReminderTime *string
|
||||
ReviewTime *string
|
||||
PlanDraftDow *int
|
||||
PlanDraftTime *string
|
||||
SuggestionsDow *int
|
||||
SuggestionsTime *string
|
||||
ChatAutosendDow *int
|
||||
ChatAutosendTime *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a SettingsPatch.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid household settings: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// SeedSettings creates the household settings row with zone defaultTZ and the
|
||||
// seed times and days if it does not exist. An existing row is never changed,
|
||||
// and defaultTZ is then ignored, valid or not. With no row, an empty
|
||||
// defaultTZ is ErrNoDefaultZone and an unloadable one ErrInvalidDefaultZone.
|
||||
func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
_, err := q.GetHouseholdSettings(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
if defaultTZ == "" {
|
||||
return ErrNoDefaultZone
|
||||
}
|
||||
if _, err := LoadZone(defaultTZ); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidDefaultZone, err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
_, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{
|
||||
Timezone: defaultTZ,
|
||||
DinnerTime: SeedDinnerTime,
|
||||
ReminderTime: SeedReminderTime,
|
||||
ReviewTime: SeedReviewTime,
|
||||
PlanDraftDow: SeedPlanDraftDow,
|
||||
PlanDraftTime: SeedPlanDraftTime,
|
||||
SuggestionsDow: SeedSuggestionsDow,
|
||||
SuggestionsTime: SeedSuggestionsTime,
|
||||
ChatAutosendDow: SeedChatAutosendDow,
|
||||
ChatAutosendTime: SeedChatAutosendTime,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SettingsService reads and changes the household settings, and tells
|
||||
// in-process subscribers (the scheduler) about every change.
|
||||
type SettingsService struct {
|
||||
st *store.Store
|
||||
|
||||
// updateMu serialises Update with its publish, so subscribers see
|
||||
// changes in commit order.
|
||||
updateMu sync.Mutex
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs map[int]func(Settings)
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewSettingsService returns a service over st. Call SeedSettings first.
|
||||
func NewSettingsService(st *store.Store) *SettingsService {
|
||||
return &SettingsService{st: st, subs: make(map[int]func(Settings))}
|
||||
}
|
||||
|
||||
// Get returns the household settings.
|
||||
func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||
// member's subject, which must not be empty) and publishes the new settings
|
||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||
// nothing.
|
||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||
if updatedBy == "" {
|
||||
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
||||
}
|
||||
if err := patch.validate(); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
s.updateMu.Lock()
|
||||
defer s.updateMu.Unlock()
|
||||
|
||||
var row db.HouseholdSetting
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
p := db.UpsertHouseholdSettingsParams{
|
||||
Timezone: cur.Timezone,
|
||||
DinnerTime: cur.DinnerTime,
|
||||
ReminderTime: cur.ReminderTime,
|
||||
ReviewTime: cur.ReviewTime,
|
||||
PlanDraftDow: cur.PlanDraftDow,
|
||||
PlanDraftTime: cur.PlanDraftTime,
|
||||
SuggestionsDow: cur.SuggestionsDow,
|
||||
SuggestionsTime: cur.SuggestionsTime,
|
||||
ChatAutosendDow: cur.ChatAutosendDow,
|
||||
ChatAutosendTime: cur.ChatAutosendTime,
|
||||
OnboardingCompletedAt: cur.OnboardingCompletedAt,
|
||||
UpdatesSeenAt: cur.UpdatesSeenAt,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: cur.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
setString(&p.Timezone, patch.Timezone)
|
||||
setString(&p.DinnerTime, patch.DinnerTime)
|
||||
setString(&p.ReminderTime, patch.ReminderTime)
|
||||
setString(&p.ReviewTime, patch.ReviewTime)
|
||||
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
|
||||
setString(&p.PlanDraftTime, patch.PlanDraftTime)
|
||||
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
settings := fromRow(row)
|
||||
s.publish(settings)
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// Subscribe registers fn to receive the new settings after every successful
|
||||
// Update. fn runs synchronously on the updating goroutine, so it must return
|
||||
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subs[id] = fn
|
||||
return func() {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
delete(s.subs, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingsService) publish(settings Settings) {
|
||||
s.subsMu.Lock()
|
||||
fns := make([]func(Settings), 0, len(s.subs))
|
||||
for _, fn := range s.subs {
|
||||
fns = append(fns, fn)
|
||||
}
|
||||
s.subsMu.Unlock()
|
||||
for _, fn := range fns {
|
||||
fn(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func (p SettingsPatch) validate() error {
|
||||
var problems []Problem
|
||||
fail := func(field, reason string) {
|
||||
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||
}
|
||||
if p == (SettingsPatch{}) {
|
||||
fail("settings", "no settings to change")
|
||||
}
|
||||
if p.Timezone != nil {
|
||||
if _, err := LoadZone(*p.Timezone); err != nil {
|
||||
fail("timezone", err.Error())
|
||||
}
|
||||
}
|
||||
for _, t := range []struct {
|
||||
field string
|
||||
value *string
|
||||
}{
|
||||
{"dinner_time", p.DinnerTime},
|
||||
{"reminder_time", p.ReminderTime},
|
||||
{"review_time", p.ReviewTime},
|
||||
{"plan_draft_time", p.PlanDraftTime},
|
||||
{"suggestions_time", p.SuggestionsTime},
|
||||
{"chat_autosend_time", p.ChatAutosendTime},
|
||||
} {
|
||||
if t.value != nil && !clockTime.MatchString(*t.value) {
|
||||
fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value))
|
||||
}
|
||||
}
|
||||
for _, d := range []struct {
|
||||
field string
|
||||
value *int
|
||||
}{
|
||||
{"plan_draft_dow", p.PlanDraftDow},
|
||||
{"suggestions_dow", p.SuggestionsDow},
|
||||
{"chat_autosend_dow", p.ChatAutosendDow},
|
||||
} {
|
||||
if d.value != nil && (*d.value < 0 || *d.value > 6) {
|
||||
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clockTime matches a zero-padded 24-hour HH:MM time.
|
||||
var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
|
||||
func setString(dst *string, v *string) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
|
||||
func setDow(dst *int64, v *int) {
|
||||
if v != nil {
|
||||
*dst = int64(*v)
|
||||
}
|
||||
}
|
||||
|
||||
func fromRow(r db.HouseholdSetting) Settings {
|
||||
return Settings{
|
||||
Timezone: r.Timezone,
|
||||
DinnerTime: r.DinnerTime,
|
||||
ReminderTime: r.ReminderTime,
|
||||
ReviewTime: r.ReviewTime,
|
||||
PlanDraftDow: int(r.PlanDraftDow),
|
||||
PlanDraftTime: r.PlanDraftTime,
|
||||
SuggestionsDow: int(r.SuggestionsDow),
|
||||
SuggestionsTime: r.SuggestionsTime,
|
||||
ChatAutosendDow: int(r.ChatAutosendDow),
|
||||
ChatAutosendTime: r.ChatAutosendTime,
|
||||
OnboardingCompletedAt: nullable(r.OnboardingCompletedAt),
|
||||
UpdatesSeenAt: nullable(r.UpdatesSeenAt),
|
||||
UpdatedBy: nullable(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func nullable(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// migratedStore opens a temp store in dir migrated with the embedded
|
||||
// migrations, closed when the test ends.
|
||||
func migratedStore(t *testing.T, dir string) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// seededService returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func seededService(t *testing.T) *SettingsService {
|
||||
t.Helper()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewSettingsService(st)
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestSeedSettingsCreatesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
|
||||
if err := SeedSettings(ctx, st, "America/Chicago"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := NewSettingsService(st).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Settings{
|
||||
Timezone: "America/Chicago",
|
||||
DinnerTime: "17:30",
|
||||
ReminderTime: "16:30",
|
||||
ReviewTime: "09:00",
|
||||
PlanDraftDow: 5,
|
||||
PlanDraftTime: "09:00",
|
||||
SuggestionsDow: 0,
|
||||
SuggestionsTime: "08:00",
|
||||
ChatAutosendDow: 6,
|
||||
ChatAutosendTime: "08:00",
|
||||
CreatedAt: got.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if !equalSettings(got, want) {
|
||||
t.Errorf("seeded settings = %+v\nwant %+v", got, want)
|
||||
}
|
||||
var created store.Timestamp
|
||||
if err := created.Scan(got.CreatedAt); err != nil {
|
||||
t.Errorf("created_at: %v", err)
|
||||
}
|
||||
if got.UpdatedAt != got.CreatedAt {
|
||||
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSettingsNeverOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// First boot seeds; the household then changes a setting.
|
||||
first := migratedStore(t, dir)
|
||||
if err := SeedSettings(ctx, first, "America/Chicago"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Later boots reopen the same database with a different default zone,
|
||||
// an invalid one and none at all.
|
||||
for _, defaultTZ := range []string{"Europe/London", "Mars/Olympus_Mons", ""} {
|
||||
st := migratedStore(t, dir)
|
||||
if err := SeedSettings(ctx, st, defaultTZ); err != nil {
|
||||
t.Fatalf("reboot with default zone %q: %v", defaultTZ, err)
|
||||
}
|
||||
got, err := NewSettingsService(st).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equalSettings(got, changed) {
|
||||
t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedSettingsRequiresDefaultZone(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultTZ string
|
||||
is error
|
||||
}{
|
||||
{"unset", "", ErrNoDefaultZone},
|
||||
{"not an IANA zone", "Mars/Olympus_Mons", ErrInvalidDefaultZone},
|
||||
{"Local", "Local", ErrInvalidDefaultZone},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
err := SeedSettings(ctx, st, tt.defaultTZ)
|
||||
if err == nil {
|
||||
t.Fatal("SeedSettings succeeded")
|
||||
}
|
||||
if !errors.Is(err, tt.is) {
|
||||
t.Errorf("error = %v, want %v", err, tt.is)
|
||||
}
|
||||
if _, err := NewSettingsService(st).Get(ctx); err == nil {
|
||||
t.Error("a settings row was created despite the error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc := seededService(t)
|
||||
before, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.Update(ctx, SettingsPatch{
|
||||
Timezone: ptr("Europe/Paris"),
|
||||
DinnerTime: ptr("19:00"),
|
||||
ReminderTime: ptr("00:00"),
|
||||
ReviewTime: ptr("23:59"),
|
||||
PlanDraftDow: ptr(4),
|
||||
PlanDraftTime: ptr("10:30"),
|
||||
SuggestionsDow: ptr(1),
|
||||
SuggestionsTime: ptr("07:45"),
|
||||
ChatAutosendDow: ptr(0),
|
||||
ChatAutosendTime: ptr("12:00"),
|
||||
}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Settings{
|
||||
Timezone: "Europe/Paris",
|
||||
DinnerTime: "19:00",
|
||||
ReminderTime: "00:00",
|
||||
ReviewTime: "23:59",
|
||||
PlanDraftDow: 4,
|
||||
PlanDraftTime: "10:30",
|
||||
SuggestionsDow: 1,
|
||||
SuggestionsTime: "07:45",
|
||||
ChatAutosendDow: 0,
|
||||
ChatAutosendTime: "12:00",
|
||||
UpdatedBy: ptr("sam"),
|
||||
CreatedAt: before.CreatedAt,
|
||||
UpdatedAt: got.UpdatedAt,
|
||||
}
|
||||
if !equalSettings(got, want) {
|
||||
t.Errorf("Update = %+v\nwant %+v", got, want)
|
||||
}
|
||||
if got.UpdatedAt < before.UpdatedAt {
|
||||
t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt)
|
||||
}
|
||||
stored, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equalSettings(stored, got) {
|
||||
t.Errorf("stored = %+v\nwant %+v", stored, got)
|
||||
}
|
||||
|
||||
// A partial patch changes only its fields and records its own member.
|
||||
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "alex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want.DinnerTime = "18:00"
|
||||
want.UpdatedBy = ptr("alex")
|
||||
want.UpdatedAt = got.UpdatedAt
|
||||
if !equalSettings(got, want) {
|
||||
t.Errorf("partial Update = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc := seededService(t)
|
||||
notified := 0
|
||||
svc.Subscribe(func(Settings) { notified++ })
|
||||
|
||||
if _, err := svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
|
||||
t.Fatal("Update with no member succeeded")
|
||||
}
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DinnerTime != SeedDinnerTime || got.UpdatedBy != nil || notified != 0 {
|
||||
t.Errorf("Update with no member changed settings or notified: %+v, notified %d", got, notified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patch SettingsPatch
|
||||
fields []string
|
||||
}{
|
||||
{"empty patch", SettingsPatch{}, []string{"settings"}},
|
||||
{"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}},
|
||||
{"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}},
|
||||
{"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}},
|
||||
{"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}},
|
||||
{"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}},
|
||||
{"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}},
|
||||
{"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}},
|
||||
{"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}},
|
||||
{"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}},
|
||||
{"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}},
|
||||
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||
{
|
||||
"every problem is reported",
|
||||
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||
[]string{"timezone", "dinner_time", "plan_draft_dow"},
|
||||
},
|
||||
{
|
||||
"valid fields do not mask an invalid one",
|
||||
SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)},
|
||||
[]string{"chat_autosend_dow"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := seededService(t)
|
||||
before, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published := 0
|
||||
svc.Subscribe(func(Settings) { published++ })
|
||||
|
||||
_, err = svc.Update(ctx, tt.patch, "sam")
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("Update error = %v, want *ValidationError", err)
|
||||
}
|
||||
var fields []string
|
||||
for _, p := range verr.Problems {
|
||||
fields = append(fields, p.Field)
|
||||
}
|
||||
if !slices.Equal(fields, tt.fields) {
|
||||
t.Errorf("problem fields = %v, want %v", fields, tt.fields)
|
||||
}
|
||||
|
||||
after, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !equalSettings(after, before) {
|
||||
t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before)
|
||||
}
|
||||
if published != 0 {
|
||||
t.Errorf("invalid patch published %d times", published)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePublishesToSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc := seededService(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
var first, second []Settings
|
||||
unsubscribe := svc.Subscribe(func(s Settings) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
first = append(first, s)
|
||||
})
|
||||
svc.Subscribe(func(s Settings) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
second = append(second, s)
|
||||
})
|
||||
|
||||
updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(first) != 1 || !equalSettings(first[0], updated) {
|
||||
t.Errorf("first subscriber got %+v, want [%+v]", first, updated)
|
||||
}
|
||||
if len(second) != 1 || !equalSettings(second[0], updated) {
|
||||
t.Errorf("second subscriber got %+v, want [%+v]", second, updated)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
unsubscribe()
|
||||
updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(first) != 1 {
|
||||
t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first))
|
||||
}
|
||||
if len(second) != 2 || !equalSettings(second[1], updated) {
|
||||
t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithoutSeedFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := NewSettingsService(migratedStore(t, t.TempDir()))
|
||||
if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
|
||||
t.Fatal("Update succeeded with no settings row")
|
||||
}
|
||||
}
|
||||
|
||||
// equalSettings compares settings by value, following the nullable pointers.
|
||||
func equalSettings(a, b Settings) bool {
|
||||
eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) }
|
||||
if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) {
|
||||
return false
|
||||
}
|
||||
a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil
|
||||
b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil
|
||||
return a == b
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package household owns household-local calendar and clock logic. Every
|
||||
// function works in the household's IANA zone, passed explicitly, and never
|
||||
// in the process zone (time.Local).
|
||||
//
|
||||
// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period
|
||||
// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01".
|
||||
//
|
||||
// Calendar arithmetic is done on UTC midnights, which have no DST, so a date
|
||||
// plus seven days is always the same weekday a week later.
|
||||
package household
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Today returns the household-local date at instant now in zone loc.
|
||||
func Today(now time.Time, loc *time.Location) store.LocalDate {
|
||||
return store.LocalDateOf(now.In(loc))
|
||||
}
|
||||
|
||||
// WeekStart returns the ISO Monday of the week containing d. It panics if d is
|
||||
// the zero LocalDate.
|
||||
func WeekStart(d store.LocalDate) store.LocalDate {
|
||||
t := midnightUTC(d)
|
||||
// Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6.
|
||||
sinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday))
|
||||
}
|
||||
|
||||
// WeekDays returns the seven dates Monday to Sunday of the week starting on
|
||||
// weekStart. It is an error if weekStart is not a Monday.
|
||||
func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) {
|
||||
t, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return [7]store.LocalDate{}, err
|
||||
}
|
||||
var days [7]store.LocalDate
|
||||
for i := range days {
|
||||
days[i] = store.LocalDateOf(t.AddDate(0, 0, i))
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// WeekLabel returns the display label of the week starting on weekStart, such
|
||||
// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week
|
||||
// spans two months. It is an error if weekStart is not a Monday.
|
||||
func WeekLabel(weekStart store.LocalDate) (string, error) {
|
||||
start, err := monday(weekStart)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, week := start.ISOWeek()
|
||||
end := start.AddDate(0, 0, 6)
|
||||
if end.Month() == start.Month() {
|
||||
return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil
|
||||
}
|
||||
return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil
|
||||
}
|
||||
|
||||
// PeriodKey returns the ISO week key of the week containing d, such as
|
||||
// "2026-W41". The year is the ISO week-numbering year, which differs from the
|
||||
// calendar year around New Year. It panics if d is the zero LocalDate.
|
||||
func PeriodKey(d store.LocalDate) string {
|
||||
year, week := midnightUTC(d).ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", year, week)
|
||||
}
|
||||
|
||||
// LocalTime returns the instant at which household clocks in zone loc read
|
||||
// hhmm (24-hour "HH:MM") on date d.
|
||||
//
|
||||
// DST rule: a wall time that does not exist (spring forward) or occurs twice
|
||||
// (fall back) is read with the UTC offset in effect before the transition. A
|
||||
// skipped time therefore moves forward by the length of the gap (02:30 on a
|
||||
// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first
|
||||
// occurrence. The rule assumes at most one offset change in the 48 hours
|
||||
// around the wall time, which holds for every real zone.
|
||||
func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) {
|
||||
if d.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero date")
|
||||
}
|
||||
clock, err := time.Parse("15:04", hhmm)
|
||||
if err != nil || clock.Format("15:04") != hhmm {
|
||||
return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm)
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
// wall is the wall-clock reading expressed as if it were UTC; the real
|
||||
// instant is wall minus the zone's UTC offset at that instant.
|
||||
wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC)
|
||||
before := offset(wall.Add(-24*time.Hour), loc)
|
||||
after := offset(wall.Add(24*time.Hour), loc)
|
||||
early := wall.Add(-before)
|
||||
late := wall.Add(-after)
|
||||
// Use the post-transition offset only when the pre-transition reading is
|
||||
// not a real wall time and the post-transition one is.
|
||||
if offset(early, loc) != before && offset(late, loc) == after {
|
||||
return late.In(loc), nil
|
||||
}
|
||||
return early.In(loc), nil
|
||||
}
|
||||
|
||||
// offset returns the UTC offset of zone loc at instant t.
|
||||
func offset(t time.Time, loc *time.Location) time.Duration {
|
||||
_, secs := t.In(loc).Zone()
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate.
|
||||
func midnightUTC(d store.LocalDate) time.Time {
|
||||
if d.IsZero() {
|
||||
panic("household: zero date")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// monday returns weekStart as midnight UTC, or an error if it is not a Monday.
|
||||
func monday(weekStart store.LocalDate) (time.Time, error) {
|
||||
if weekStart.IsZero() {
|
||||
return time.Time{}, errors.New("household: zero week start")
|
||||
}
|
||||
t := midnightUTC(weekStart)
|
||||
if t.Weekday() != time.Monday {
|
||||
return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday())
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// TestMain runs the package with the process zone set to UTC, so any use of
|
||||
// time.Local in place of the household zone shows up as a failure.
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.UTC
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func loadZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation(%q): %v", name, err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func date(t *testing.T, s string) store.LocalDate {
|
||||
t.Helper()
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func utc(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
ts, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestTodayUsesHouseholdZone(t *testing.T) {
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
auckland := loadZone(t, "Pacific/Auckland")
|
||||
for _, tc := range []struct {
|
||||
now string
|
||||
loc *time.Location
|
||||
want string
|
||||
}{
|
||||
// 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and
|
||||
// Monday 17:30 in Auckland (NZDT, UTC+13).
|
||||
{"2026-10-05T04:30:00Z", chicago, "2026-10-04"},
|
||||
{"2026-10-05T04:30:00Z", auckland, "2026-10-05"},
|
||||
// 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday
|
||||
// 00:30 in Auckland.
|
||||
{"2026-10-04T11:30:00Z", chicago, "2026-10-04"},
|
||||
{"2026-10-04T11:30:00Z", auckland, "2026-10-05"},
|
||||
} {
|
||||
if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want {
|
||||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSundayNightMondayMorningWeekAssignment(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
zone, now string
|
||||
wantDay string
|
||||
wantWeekStart string
|
||||
wantPeriodKey string
|
||||
wantWeekday time.Weekday
|
||||
}{
|
||||
// Sunday 23:59 local belongs to the week that started the previous
|
||||
// Monday; one minute later, Monday 00:00 starts a new week.
|
||||
{"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||
{"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||
{"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday},
|
||||
{"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday},
|
||||
} {
|
||||
loc := loadZone(t, tc.zone)
|
||||
now := utc(t, tc.now)
|
||||
if wd := now.In(loc).Weekday(); wd != tc.wantWeekday {
|
||||
t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday)
|
||||
}
|
||||
today := Today(now, loc)
|
||||
if today.String() != tc.wantDay {
|
||||
t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay)
|
||||
}
|
||||
if got := WeekStart(today).String(); got != tc.wantWeekStart {
|
||||
t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart)
|
||||
}
|
||||
if got := PeriodKey(today); got != tc.wantPeriodKey {
|
||||
t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartEveryWeekday(t *testing.T) {
|
||||
for _, d := range []string{
|
||||
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08",
|
||||
"2026-10-09", "2026-10-10", "2026-10-11",
|
||||
} {
|
||||
if got := WeekStart(date(t, d)).String(); got != "2026-10-05" {
|
||||
t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got)
|
||||
}
|
||||
}
|
||||
if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" {
|
||||
t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDays(t *testing.T) {
|
||||
days, err := WeekDays(date(t, "2026-09-28"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01",
|
||||
"2026-10-02", "2026-10-03", "2026-10-04",
|
||||
}
|
||||
for i, d := range days {
|
||||
if d.String() != want[i] {
|
||||
t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDaysAcrossDSTChange(t *testing.T) {
|
||||
// Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on
|
||||
// Sunday 2026-09-27; neither shifts the dates of those weeks.
|
||||
for _, tc := range []struct{ start, last string }{
|
||||
{"2026-10-26", "2026-11-01"},
|
||||
{"2026-09-21", "2026-09-27"},
|
||||
{"2026-03-02", "2026-03-08"},
|
||||
} {
|
||||
days, err := WeekDays(date(t, tc.start))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days[0].String() != tc.start || days[6].String() != tc.last {
|
||||
t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestISOWeekYearBoundary(t *testing.T) {
|
||||
for _, tc := range []struct{ day, weekStart, key string }{
|
||||
{"2026-12-27", "2026-12-21", "2026-W52"},
|
||||
{"2026-12-28", "2026-12-28", "2026-W53"},
|
||||
{"2026-12-31", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-01", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-03", "2026-12-28", "2026-W53"},
|
||||
{"2027-01-04", "2027-01-04", "2027-W01"},
|
||||
{"2027-01-10", "2027-01-04", "2027-W01"},
|
||||
{"2026-01-01", "2025-12-29", "2026-W01"},
|
||||
{"2026-10-07", "2026-10-05", "2026-W41"},
|
||||
} {
|
||||
d := date(t, tc.day)
|
||||
if got := WeekStart(d).String(); got != tc.weekStart {
|
||||
t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart)
|
||||
}
|
||||
if got := PeriodKey(d); got != tc.key {
|
||||
t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key)
|
||||
}
|
||||
}
|
||||
days, err := WeekDays(date(t, "2026-12-28"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if days[6].String() != "2027-01-03" {
|
||||
t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekLabel(t *testing.T) {
|
||||
for _, tc := range []struct{ start, want string }{
|
||||
{"2026-10-05", "Week 41 · Oct 5 – 11"},
|
||||
{"2026-09-28", "Week 40 · Sep 28 – Oct 4"},
|
||||
{"2026-12-28", "Week 53 · Dec 28 – Jan 3"},
|
||||
{"2027-01-04", "Week 1 · Jan 4 – 10"},
|
||||
} {
|
||||
got, err := WeekLabel(date(t, tc.start))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartRequired(t *testing.T) {
|
||||
for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} {
|
||||
if _, err := WeekDays(d); err == nil {
|
||||
t.Errorf("WeekDays(%s) succeeded, want error", d)
|
||||
}
|
||||
if _, err := WeekLabel(d); err == nil {
|
||||
t.Errorf("WeekLabel(%s) succeeded, want error", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroDatePanics(t *testing.T) {
|
||||
for name, fn := range map[string]func(){
|
||||
"WeekStart": func() { WeekStart(store.LocalDate{}) },
|
||||
"PeriodKey": func() { PeriodKey(store.LocalDate{}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s(zero) did not panic", name)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTime(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
zone, day, hhmm string
|
||||
want string // UTC instant
|
||||
wantWall string // local wall clock of the result
|
||||
}{
|
||||
// Ordinary days.
|
||||
{"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"},
|
||||
{"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"},
|
||||
{"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"},
|
||||
|
||||
// Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT.
|
||||
{"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"},
|
||||
{"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"},
|
||||
{"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"},
|
||||
|
||||
// Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so
|
||||
// 01:00–01:59 happens twice; the first (CDT) occurrence is chosen.
|
||||
{"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"},
|
||||
{"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"},
|
||||
|
||||
// Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT.
|
||||
{"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"},
|
||||
{"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"},
|
||||
|
||||
// Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so
|
||||
// 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen.
|
||||
{"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"},
|
||||
{"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"},
|
||||
{"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"},
|
||||
} {
|
||||
loc := loadZone(t, tc.zone)
|
||||
got, err := LocalTime(loc, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err)
|
||||
}
|
||||
if !got.Equal(utc(t, tc.want)) {
|
||||
t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want)
|
||||
}
|
||||
if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall {
|
||||
t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall)
|
||||
}
|
||||
if got.Location() != loc {
|
||||
t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTimeDeterministic(t *testing.T) {
|
||||
// The same ambiguous or skipped input always resolves to the same instant.
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} {
|
||||
first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 100 {
|
||||
again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Equal(first) {
|
||||
t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTimeRejectsInvalid(t *testing.T) {
|
||||
chicago := loadZone(t, "America/Chicago")
|
||||
d := date(t, "2026-10-05")
|
||||
for _, hhmm := range []string{
|
||||
"", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00",
|
||||
} {
|
||||
if got, err := LocalTime(chicago, d, hhmm); err == nil {
|
||||
t.Errorf("LocalTime(%q) = %s, want error", hhmm, got)
|
||||
} else if !strings.Contains(err.Error(), "HH:MM") {
|
||||
t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil {
|
||||
t.Error("LocalTime(zero date) succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// Embeds the IANA zone database in the binary, so zones load the same
|
||||
// way in a minimal image with no /usr/share/zoneinfo.
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// LoadZone loads the IANA zone name, such as "America/New_York". The empty
|
||||
// name and "Local", which time.LoadLocation maps to UTC and the process zone,
|
||||
// are refused: the household zone is never implied.
|
||||
func LoadZone(name string) (*time.Location, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)")
|
||||
}
|
||||
if name == "Local" {
|
||||
return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`)
|
||||
}
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// KindNoop is the framework's built-in job kind: it does no work and
|
||||
// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature
|
||||
// 09's trivial test skill is the harness's, and the startup and readiness
|
||||
// job: a noop job reaching succeeded proves the runner claims, runs and
|
||||
// records jobs.
|
||||
const KindNoop = "noop"
|
||||
|
||||
// NoopPayload is the payload of a noop job.
|
||||
type NoopPayload struct{}
|
||||
|
||||
// NoopResult is the result of a noop job.
|
||||
type NoopResult struct{}
|
||||
|
||||
// PublicJSON returns {"ok":true}.
|
||||
func (NoopResult) PublicJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
OK bool `json:"ok"`
|
||||
}{OK: true})
|
||||
}
|
||||
|
||||
func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) {
|
||||
return NoopResult{}, nil
|
||||
}
|
||||
|
||||
// Builtins enqueues the framework's built-in job kinds.
|
||||
type Builtins struct {
|
||||
Noop *Kind[NoopPayload]
|
||||
}
|
||||
|
||||
// RegisterBuiltins registers the framework's built-in job kinds on r.
|
||||
func RegisterBuiltins(r *Runner) (*Builtins, error) {
|
||||
noop, err := Register(r, KindNoop, runNoop)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Builtins{Noop: noop}, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// Package jobs runs long work in the background. A job is a row in the jobs
|
||||
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
|
||||
// jobs oldest first, so the table itself is the queue and a job enqueued
|
||||
// before a restart still runs after it.
|
||||
//
|
||||
// Each job kind has one handler, registered before Start. A handler takes a
|
||||
// typed payload and returns a result implementing Public; it may publish
|
||||
// partial results while it runs. The runner stores only what a result's
|
||||
// PublicJSON returns and, for a failed job, only a failure code: either a
|
||||
// *Failure code the kind declared at registration or one of the runner's own
|
||||
// codes. Handler error text, which may quote a URL, token or response body
|
||||
// from a dependency, is never stored, returned by the API or logged.
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Status is the state of a job.
|
||||
type Status string
|
||||
|
||||
// Job states. A job moves queued → running → succeeded or failed.
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusRunning Status = "running"
|
||||
StatusSucceeded Status = "succeeded"
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
// Failure codes the runner stores itself. A kind cannot declare them.
|
||||
const (
|
||||
// CodeInterrupted is the error of a job a previous process left running.
|
||||
// It is fixed framework text, not a snake-case code, so no kind can
|
||||
// declare it.
|
||||
CodeInterrupted = "interrupted by restart"
|
||||
// CodeInternal is the error of a job whose handler failed with anything
|
||||
// but a *Failure with a code its kind declared, or whose payload or
|
||||
// result could not be encoded. The underlying error is neither stored
|
||||
// nor logged.
|
||||
CodeInternal = "internal_error"
|
||||
// CodeUnknownKind is the error of a job whose kind has no handler.
|
||||
CodeUnknownKind = "unknown_kind"
|
||||
)
|
||||
|
||||
// Claim retry backoff. A worker whose claim fails with a database error
|
||||
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
|
||||
// when the error clears is claimed without waiting for a wake token.
|
||||
const (
|
||||
claimRetryMin = 50 * time.Millisecond
|
||||
claimRetryMax = 5 * time.Second
|
||||
)
|
||||
|
||||
// codePattern is the form of every failure code: lower-case snake case.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
// ErrNotFound is returned by Get for an unknown job id.
|
||||
var ErrNotFound = errors.New("jobs: job not found")
|
||||
|
||||
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
||||
// registered handler.
|
||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||
|
||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||
// not decode into the kind's payload type.
|
||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||
|
||||
// Failure is a handler error reporting a failure code the household may
|
||||
// see. Code must be one the job's kind declared at registration; the runner
|
||||
// stores it as the job's error. Any other handler error, including a Failure
|
||||
// with an undeclared code, is stored as CodeInternal.
|
||||
type Failure struct {
|
||||
Code string
|
||||
}
|
||||
|
||||
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
|
||||
|
||||
// Public is a job result. PublicJSON returns the JSON the runner stores and
|
||||
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
|
||||
// data is public; nothing else of the value is ever encoded.
|
||||
type Public interface {
|
||||
PublicJSON() ([]byte, error)
|
||||
}
|
||||
|
||||
// Handler runs one job with its payload. It may call publish any number of
|
||||
// times with a partial result, which Get returns while the job runs; its
|
||||
// return value is the final result. ctx is cancelled when the runner stops.
|
||||
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
|
||||
|
||||
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
|
||||
// partial or final result, and is null until the handler publishes one.
|
||||
// Error is set only on a failed job.
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Status Status `json:"status"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
// runFunc is a registered Handler with its payload and results as JSON. It
|
||||
// returns the failure code to store, or "" on success; the logged reason
|
||||
// never quotes an error.
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, and check reports whether
|
||||
// a payload decodes into the kind's payload type.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
type Runner struct {
|
||||
st *store.Store
|
||||
logger *slog.Logger
|
||||
workers int
|
||||
// wake holds up to one token per worker; Enqueue adds one so an idle
|
||||
// worker looks for the new job.
|
||||
wake chan struct{}
|
||||
// claim claims the oldest queued job, marking it running at now.
|
||||
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
||||
|
||||
mu sync.Mutex
|
||||
handlers map[string]handler
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewRunner returns a runner over st with workers concurrent workers.
|
||||
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("jobs: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("jobs: nil logger")
|
||||
}
|
||||
if workers < 1 {
|
||||
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
|
||||
}
|
||||
return &Runner{
|
||||
st: st,
|
||||
logger: logger,
|
||||
workers: workers,
|
||||
wake: make(chan struct{}, workers),
|
||||
claim: db.New(st.Writer).ClaimNextJob,
|
||||
handlers: make(map[string]handler),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Kind enqueues jobs of one registered kind.
|
||||
type Kind[P any] struct {
|
||||
r *Runner
|
||||
name string
|
||||
}
|
||||
|
||||
// Register makes h the handler for kind and returns the Kind that enqueues
|
||||
// its jobs. failureCodes are the *Failure codes h may report. It must be
|
||||
// called before Start, once per kind.
|
||||
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
|
||||
if kind == "" {
|
||||
return nil, errors.New("jobs: register: empty kind")
|
||||
}
|
||||
if h == nil {
|
||||
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
|
||||
}
|
||||
declared := make(map[string]bool, len(failureCodes))
|
||||
for _, code := range failureCodes {
|
||||
if !codePattern.MatchString(code) {
|
||||
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
|
||||
}
|
||||
if code == CodeInternal || code == CodeUnknownKind {
|
||||
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
|
||||
}
|
||||
declared[code] = true
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.started {
|
||||
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
|
||||
}
|
||||
if _, ok := r.handlers[kind]; ok {
|
||||
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
||||
}
|
||||
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||
p, err := decodePayload[P]([]byte(payload))
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "payload does not decode"
|
||||
}
|
||||
result, err := h(ctx, p, func(partial R) error {
|
||||
data, err := publicJSON(partial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return publish(data)
|
||||
})
|
||||
if err != nil {
|
||||
var failure *Failure
|
||||
if !errors.As(err, &failure) {
|
||||
return nil, CodeInternal, "handler error"
|
||||
}
|
||||
if !declared[failure.Code] {
|
||||
return nil, CodeInternal, "undeclared failure code"
|
||||
}
|
||||
return nil, failure.Code, "handler failure"
|
||||
}
|
||||
data, err := publicJSON(result)
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "result does not encode"
|
||||
}
|
||||
return data, "", ""
|
||||
}
|
||||
check := func(payload []byte) bool {
|
||||
_, err := decodePayload[P](payload)
|
||||
return err == nil
|
||||
}
|
||||
r.handlers[kind] = handler{run: run, check: check}
|
||||
return &Kind[P]{r: r, name: kind}, nil
|
||||
}
|
||||
|
||||
// decodePayload decodes payload into P, refusing unknown fields.
|
||||
func decodePayload[P any](payload []byte) (P, error) {
|
||||
var p P
|
||||
dec := json.NewDecoder(bytes.NewReader(payload))
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(&p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
||||
// value. Its error never quotes PublicJSON's error.
|
||||
func publicJSON(v Public) (json.RawMessage, error) {
|
||||
data, err := v.PublicJSON()
|
||||
if err != nil || !json.Valid(data) {
|
||||
return nil, errors.New("jobs: result has no valid public JSON")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job with payload and returns its id. A job
|
||||
// enqueued before Start waits for it.
|
||||
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||
}
|
||||
return k.r.enqueue(ctx, k.name, data)
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||
// payload type; neither stores a job.
|
||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||
r.mu.Lock()
|
||||
h, ok := r.handlers[kind]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
return "", ErrUnknownKind
|
||||
}
|
||||
if !h.check(payload) {
|
||||
return "", ErrInvalidPayload
|
||||
}
|
||||
return r.enqueue(ctx, kind, payload)
|
||||
}
|
||||
|
||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||
// worker.
|
||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Payload: string(payload),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||
}
|
||||
select {
|
||||
case r.wake <- struct{}{}:
|
||||
default: // every worker already has a token and will look again
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Start fails every job a previous process left running with
|
||||
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
|
||||
// Wait waits for them. A job whose handler returns an error after ctx is
|
||||
// cancelled is left running, so the next Start fails it as interrupted.
|
||||
func (r *Runner) Start(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.started {
|
||||
return errors.New("jobs: runner already started")
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
|
||||
Error: sql.NullString{String: CodeInterrupted, Valid: true},
|
||||
Now: sql.NullString{String: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
|
||||
}
|
||||
r.started = true
|
||||
for range r.workers {
|
||||
r.wg.Add(1)
|
||||
go r.work(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until every worker has stopped.
|
||||
func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Job{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: read job: %w", err)
|
||||
}
|
||||
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
|
||||
if row.Result.Valid {
|
||||
job.Result = json.RawMessage(row.Result.String)
|
||||
}
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// work claims and runs queued jobs until ctx is cancelled, waiting for a
|
||||
// wake token whenever none is queued. A claim that fails with a database
|
||||
// error is retried with backoff rather than waiting for a token, so a job
|
||||
// already queued is not stranded by a transient error.
|
||||
func (r *Runner) work(ctx context.Context) {
|
||||
defer r.wg.Done()
|
||||
backoff := claimRetryMin
|
||||
for ctx.Err() == nil {
|
||||
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = claimRetryMin
|
||||
r.run(ctx, job)
|
||||
continue
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
backoff = claimRetryMin
|
||||
select {
|
||||
case <-r.wake:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
continue
|
||||
case ctx.Err() != nil:
|
||||
return
|
||||
}
|
||||
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, claimRetryMax)
|
||||
}
|
||||
}
|
||||
|
||||
// run runs a claimed job to completion. The final write is made even when
|
||||
// ctx has been cancelled, so a handler that finishes during shutdown is
|
||||
// recorded.
|
||||
func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
write := context.WithoutCancel(ctx)
|
||||
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
||||
q := db.New(r.st.Writer)
|
||||
|
||||
h, ok := r.handlers[job.Kind] // read-only once started
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
||||
r.fail(write, logger, job.ID, CodeUnknownKind)
|
||||
return
|
||||
}
|
||||
|
||||
publish := func(partial json.RawMessage) error {
|
||||
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
|
||||
Result: sql.NullString{String: string(partial), Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: job.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("jobs: publish result: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||
if code != "" {
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||
return
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
if code == CodeInternal {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
||||
r.fail(write, logger, job.ID, code)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(write, "record job success", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fail records the job as failed with code. The record errors it logs come
|
||||
// from the database, never from a handler.
|
||||
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
||||
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "record job failure", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// sentinelSecret stands in for a credential a handler holds while it runs.
|
||||
const sentinelSecret = "sk-sentinel-5f2c9a"
|
||||
|
||||
type countPayload struct {
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
type countResult struct {
|
||||
Done int `json:"done"`
|
||||
}
|
||||
|
||||
func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
func migratedStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func newRunner(t *testing.T, st *store.Store, workers int) *Runner {
|
||||
t.Helper()
|
||||
return newRunnerLogging(t, st, workers, io.Discard)
|
||||
}
|
||||
|
||||
func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner {
|
||||
t.Helper()
|
||||
r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// logBuffer collects log output written by concurrent workers.
|
||||
type logBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *logBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// start starts r and stops it when the test ends. The returned function
|
||||
// stops it early and waits for its workers.
|
||||
func start(t *testing.T, r *Runner) (stop func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := r.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop = func() {
|
||||
cancel()
|
||||
r.Wait()
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
return stop
|
||||
}
|
||||
|
||||
func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] {
|
||||
t.Helper()
|
||||
k, err := Register(r, kind, h, failureCodes...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func enqueue[P any](t *testing.T, k *Kind[P], payload P) string {
|
||||
t.Helper()
|
||||
id, err := k.Enqueue(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// waitFor polls Get until done accepts the job, failing after five seconds.
|
||||
func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
job, err := r.Get(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done(job) {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed }
|
||||
|
||||
// rawRow returns every column of the job's row joined, for leak checks.
|
||||
func rawRow(t *testing.T, st *store.Store, id string) string {
|
||||
t.Helper()
|
||||
var cols [10]any
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range cols {
|
||||
ptrs[i] = &cols[i]
|
||||
}
|
||||
if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fmt.Sprint(cols[:]...)
|
||||
}
|
||||
|
||||
func TestJobSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 2)
|
||||
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
id := enqueue(t, count, countPayload{Steps: 3})
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil {
|
||||
t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id)
|
||||
}
|
||||
if string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("result = %s, want {\"done\":3}", job.Result)
|
||||
}
|
||||
|
||||
var startedAt, finishedAt, createdAt, updatedAt, payload string
|
||||
if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id).
|
||||
Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(v); err != nil {
|
||||
t.Errorf("%s = %q: %v", name, v, err)
|
||||
}
|
||||
}
|
||||
if payload != `{"steps":3}` {
|
||||
t.Errorf("payload = %s, want {\"steps\":3}", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"})
|
||||
}, "no_regulars")
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" {
|
||||
t.Fatalf("job = %+v, want failed with code no_regulars", job)
|
||||
}
|
||||
if job.Result != nil {
|
||||
t.Errorf("result = %s, want none", job.Result)
|
||||
}
|
||||
var stored string
|
||||
if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != "no_regulars" {
|
||||
t.Errorf("stored error = %q, want only the code", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeclaredFailureCodeIsInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, &Failure{Code: "not_declared"}
|
||||
}, "no_regulars")
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, k, countPayload{}), finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal {
|
||||
t.Fatalf("job = %+v, want failed with %q", job, CodeInternal)
|
||||
}
|
||||
}
|
||||
|
||||
// credential holds a secret the way config.Secret does: unexported, so
|
||||
// encoding/json cannot see it.
|
||||
type credential struct{ value string }
|
||||
|
||||
func (credential) String() string { return "[REDACTED]" }
|
||||
|
||||
// leakyResult holds a secret in exported and unexported fields; only
|
||||
// Dinners is public.
|
||||
type leakyResult struct {
|
||||
Dinners []string
|
||||
Key credential
|
||||
Token string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func (l leakyResult) PublicJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Dinners []string `json:"dinners"`
|
||||
}{l.Dinners})
|
||||
}
|
||||
|
||||
// badResult has no valid public JSON; its encoding error quotes a secret.
|
||||
type badResult struct{}
|
||||
|
||||
func (badResult) PublicJSON() ([]byte, error) {
|
||||
return nil, fmt.Errorf("encode with key %s", sentinelSecret)
|
||||
}
|
||||
|
||||
// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot
|
||||
// surface it in the jobs row, Get's result or the log through its result,
|
||||
// partial results, *Failure, error text or result encoding error.
|
||||
func TestSecretsNeverStoredOrLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
var logs logBuffer
|
||||
r := newRunnerLogging(t, st, 1, &logs)
|
||||
secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret}
|
||||
typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) {
|
||||
if err := publish(secretResult); err != nil {
|
||||
return leakyResult{}, err
|
||||
}
|
||||
return secretResult, nil
|
||||
})
|
||||
leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret)
|
||||
})
|
||||
leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, &Failure{Code: sentinelSecret}
|
||||
}, "quota_exceeded")
|
||||
failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) {
|
||||
return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"})
|
||||
}, "quota_exceeded")
|
||||
badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) {
|
||||
return badResult{}, nil
|
||||
})
|
||||
stop := start(t, r)
|
||||
|
||||
cases := []struct {
|
||||
kind *Kind[countPayload]
|
||||
wantStatus Status
|
||||
wantResult string
|
||||
wantError string
|
||||
}{
|
||||
{typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""},
|
||||
{leakyError, StatusFailed, "", CodeInternal},
|
||||
{leakyFailure, StatusFailed, "", CodeInternal},
|
||||
{failure, StatusFailed, "", "quota_exceeded"},
|
||||
{badEncoding, StatusFailed, "", CodeInternal},
|
||||
}
|
||||
var ids []string
|
||||
for _, c := range cases {
|
||||
job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished)
|
||||
ids = append(ids, job.ID)
|
||||
gotError := ""
|
||||
if job.Error != nil {
|
||||
gotError = *job.Error
|
||||
}
|
||||
if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError {
|
||||
t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError)
|
||||
}
|
||||
body, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), sentinelSecret) {
|
||||
t.Errorf("%s job as served contains the secret: %s", job.Kind, body)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
for _, id := range ids {
|
||||
if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) {
|
||||
t.Errorf("job %s row contains the secret: %s", id, row)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs.String(), sentinelSecret) {
|
||||
t.Errorf("log contains the secret:\n%s", logs.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "code="+CodeInternal) {
|
||||
t.Errorf("log does not record the internal_error code:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressiveResultsVisibleMidRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
release := make(chan struct{})
|
||||
k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
for i := 1; i < p.Steps; i++ {
|
||||
if err := publish(countResult{Done: i}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
return countResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
for step := 1; step < 3; step++ {
|
||||
want := fmt.Sprintf(`{"done":%d}`, step)
|
||||
job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want })
|
||||
if job.Status != StatusRunning || job.Error != nil {
|
||||
t.Fatalf("mid-run job = %+v, want running with no error", job)
|
||||
}
|
||||
release <- struct{}{}
|
||||
}
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
|
||||
first := newRunner(t, st, 1)
|
||||
started := make(chan struct{})
|
||||
k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) {
|
||||
if err := publish(countResult{Done: 1}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return countResult{}, ctx.Err()
|
||||
})
|
||||
stop := start(t, first)
|
||||
interrupted := enqueue(t, k, countPayload{})
|
||||
<-started
|
||||
waiting := enqueue(t, k, countPayload{}) // the only worker is busy
|
||||
stop()
|
||||
|
||||
if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning {
|
||||
t.Fatalf("after shutdown job = %+v, want left running", job)
|
||||
}
|
||||
|
||||
second := newRunner(t, st, 1)
|
||||
register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: 9}, nil
|
||||
})
|
||||
start(t, second)
|
||||
|
||||
job := waitFor(t, second, interrupted, finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" {
|
||||
t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart")
|
||||
}
|
||||
if string(job.Result) != `{"done":1}` {
|
||||
t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result)
|
||||
}
|
||||
if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("queued job = %+v, want run by the new runner", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerPoolIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 2)
|
||||
var running, peak atomic.Int32
|
||||
release := make(chan struct{})
|
||||
k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
n := running.Add(1)
|
||||
for {
|
||||
p := peak.Load()
|
||||
if n <= p || peak.CompareAndSwap(p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
<-release
|
||||
running.Add(-1)
|
||||
return countResult{}, nil
|
||||
})
|
||||
start(t, r)
|
||||
|
||||
ids := make([]string, 5)
|
||||
for i := range ids {
|
||||
ids[i] = enqueue(t, k, countPayload{})
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for running.Load() < 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("two workers never ran at once")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
for _, id := range ids {
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("job = %+v, want succeeded", job)
|
||||
}
|
||||
}
|
||||
if got := peak.Load(); got != 2 {
|
||||
t.Errorf("peak concurrent jobs = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobWithoutHandlerFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
old := newRunner(t, st, 1)
|
||||
k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
})
|
||||
id := enqueue(t, k, countPayload{}) // never started
|
||||
|
||||
r := newRunner(t, st, 1)
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind {
|
||||
t.Errorf("job = %+v, want failed for a missing handler", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownID(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Get unknown id error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRefusesBadRegistrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
h := func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
}
|
||||
register(t, r, "count", h)
|
||||
|
||||
if _, err := Register(r, "count", h); err == nil {
|
||||
t.Error("duplicate kind registered")
|
||||
}
|
||||
if _, err := Register(r, "", h); err == nil {
|
||||
t.Error("empty kind registered")
|
||||
}
|
||||
if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil {
|
||||
t.Error("nil handler registered")
|
||||
}
|
||||
for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} {
|
||||
if _, err := Register(r, "code", h, code); err == nil {
|
||||
t.Errorf("failure code %q accepted", code)
|
||||
}
|
||||
}
|
||||
start(t, r)
|
||||
if _, err := Register(r, "late", h); err == nil {
|
||||
t.Error("kind registered after Start")
|
||||
}
|
||||
if err := r.Start(context.Background()); err == nil {
|
||||
t.Error("second Start succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunnerRefusesBadConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRunner(st, logger, 0); err == nil {
|
||||
t.Error("zero workers accepted")
|
||||
}
|
||||
if _, err := NewRunner(nil, logger, 1); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := NewRunner(st, nil, 1); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newRunner(t, migratedStore(t), 1)
|
||||
builtins, err := RegisterBuiltins(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished)
|
||||
if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil {
|
||||
t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimErrorIsRetried(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
logs := &logBuffer{}
|
||||
r := newRunnerLogging(t, st, 1, logs)
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
// Drop the wake token, as for a job queued before a restart, so only
|
||||
// the retry can find the job.
|
||||
<-r.wake
|
||||
|
||||
var claims atomic.Int32
|
||||
claim := r.claim
|
||||
r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) {
|
||||
if claims.Add(1) == 1 {
|
||||
return db.Job{}, errors.New("database is locked " + sentinelSecret)
|
||||
}
|
||||
return claim(ctx, now)
|
||||
}
|
||||
start(t, r)
|
||||
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
if n := claims.Load(); n < 2 {
|
||||
t.Fatalf("claims = %d, want a retry after the error", n)
|
||||
}
|
||||
out := logs.String()
|
||||
if !strings.Contains(out, "claim job failed; retrying") {
|
||||
t.Fatalf("claim error not logged: %s", out)
|
||||
}
|
||||
if strings.Contains(out, sentinelSecret) {
|
||||
t.Fatalf("claim error text logged: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
|
||||
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
|
||||
}
|
||||
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
|
||||
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("refused enqueues stored %d jobs, want 0", n)
|
||||
}
|
||||
|
||||
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only
|
||||
// owner of secret encryption: every _ct column holds a token produced by
|
||||
// Box.Encrypt, laid out as nonce ‖ ciphertext.
|
||||
//
|
||||
// Errors from this package never contain plaintext, ciphertext or key
|
||||
// material.
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeySize is the decoded key length in bytes (AES-256).
|
||||
const KeySize = 32
|
||||
|
||||
var (
|
||||
// ErrKeyEncoding means the key is not valid standard base64.
|
||||
ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64")
|
||||
// ErrKeyLength means the decoded key is not exactly KeySize bytes.
|
||||
ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes")
|
||||
// ErrMalformed means a token is too short to hold a nonce and tag.
|
||||
ErrMalformed = errors.New("secretbox: token is too short")
|
||||
// ErrDecrypt means a token failed authentication: it was corrupted or
|
||||
// sealed under a different key.
|
||||
ErrDecrypt = errors.New("secretbox: token failed authentication")
|
||||
)
|
||||
|
||||
// Box seals and opens secrets under one key. It is safe for concurrent use.
|
||||
type Box struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// New returns a Box for encodedKey, the standard-base64 encoding of a
|
||||
// KeySize-byte key.
|
||||
func New(encodedKey string) (*Box, error) {
|
||||
key, err := base64.StdEncoding.Strict().DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
// The decoder's error is not wrapped: it describes the key's content.
|
||||
return nil, ErrKeyEncoding
|
||||
}
|
||||
if len(key) != KeySize {
|
||||
return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secretbox: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secretbox: %w", err)
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
// Encrypt seals plaintext under a fresh random nonce and returns
|
||||
// nonce ‖ ciphertext.
|
||||
func (b *Box) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("secretbox: generate nonce: %w", err)
|
||||
}
|
||||
return b.aead.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// Decrypt authenticates and opens a token produced by Encrypt.
|
||||
func (b *Box) Decrypt(token []byte) ([]byte, error) {
|
||||
n := b.aead.NonceSize()
|
||||
if len(token) < n+b.aead.Overhead() {
|
||||
return nil, ErrMalformed
|
||||
}
|
||||
plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil)
|
||||
if err != nil {
|
||||
return nil, ErrDecrypt
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newKey returns a fresh random key and its standard-base64 encoding.
|
||||
func newKey(t *testing.T) ([]byte, string) {
|
||||
t.Helper()
|
||||
key := make([]byte, KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
func newBox(t *testing.T) (*Box, []byte, string) {
|
||||
t.Helper()
|
||||
key, encoded := newKey(t)
|
||||
box, err := New(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box, key, encoded
|
||||
}
|
||||
|
||||
// assertNoLeak fails if err's message contains any of the secrets, either
|
||||
// raw or base64-encoded.
|
||||
func assertNoLeak(t *testing.T, err error, secrets ...[]byte) {
|
||||
t.Helper()
|
||||
msg := err.Error()
|
||||
for _, s := range secrets {
|
||||
if len(s) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} {
|
||||
if strings.Contains(msg, form) {
|
||||
t.Errorf("error %q leaks secret material %q", msg, form)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
for _, plaintext := range [][]byte{
|
||||
[]byte("ya29.oauth-refresh-token"),
|
||||
{},
|
||||
bytes.Repeat([]byte{0x00, 0xff}, 4096),
|
||||
} {
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plaintext) > 0 && bytes.Contains(token, plaintext) {
|
||||
t.Errorf("token contains the plaintext")
|
||||
}
|
||||
got, err := box.Decrypt(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Errorf("Decrypt = %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenLayout(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
plaintext := []byte("secret")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16).
|
||||
if want := 12 + len(plaintext) + 16; len(token) != want {
|
||||
t.Errorf("token length = %d, want %d", len(token), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptUsesFreshNonce(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
plaintext := []byte("same plaintext")
|
||||
a, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two encryptions of the same plaintext are identical")
|
||||
}
|
||||
if bytes.Equal(a[:12], b[:12]) {
|
||||
t.Error("two encryptions reused the same nonce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsFlippedByte(t *testing.T) {
|
||||
box, key, encoded := newBox(t)
|
||||
plaintext := []byte("do-not-leak-me")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Flip one bit in every position: nonce, ciphertext and tag.
|
||||
for i := range token {
|
||||
tampered := bytes.Clone(token)
|
||||
tampered[i] ^= 0x01
|
||||
got, err := box.Decrypt(tampered)
|
||||
if !errors.Is(err, ErrDecrypt) {
|
||||
t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i)
|
||||
}
|
||||
assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsWrongKey(t *testing.T) {
|
||||
box, key, encoded := newBox(t)
|
||||
other, otherKey, otherEncoded := newBox(t)
|
||||
plaintext := []byte("do-not-leak-me")
|
||||
token, err := box.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := other.Decrypt(token)
|
||||
if !errors.Is(err, ErrDecrypt) {
|
||||
t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err)
|
||||
}
|
||||
assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token)
|
||||
}
|
||||
|
||||
func TestDecryptRejectsShortToken(t *testing.T) {
|
||||
box, _, _ := newBox(t)
|
||||
for _, n := range []int{0, 1, 12, 12 + 15} {
|
||||
token := bytes.Repeat([]byte{0xab}, n)
|
||||
if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) {
|
||||
t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadKey(t *testing.T) {
|
||||
key, _ := newKey(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
encoded string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrKeyLength},
|
||||
{"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding},
|
||||
{"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding},
|
||||
{"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding},
|
||||
{"raw key bytes", string(key), ErrKeyEncoding},
|
||||
{"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength},
|
||||
{"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength},
|
||||
{"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
box, err := New(tt.encoded)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("New error = %v, want %v", err, tt.want)
|
||||
}
|
||||
if box != nil {
|
||||
t.Error("New returned a Box alongside the error")
|
||||
}
|
||||
assertNoLeak(t, err, key, []byte(tt.encoded))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAcceptsBase64Key(t *testing.T) {
|
||||
key, encoded := newKey(t)
|
||||
box, err := New(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A Box built from the same key opens tokens sealed by the first.
|
||||
token, err := box.Encrypt([]byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := again.Decrypt(token); err != nil {
|
||||
t.Errorf("same key failed to decrypt: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// GET /api/v1/me.
|
||||
const CSRFHeader = "X-MealQuest-CSRF"
|
||||
|
||||
// requireCSRF passes GET, HEAD and OPTIONS requests to next; every other
|
||||
// requireCSRF passes GET requests to next; every other
|
||||
// request must carry CSRFHeader equal to its session's CSRF token or get 403
|
||||
// csrf. It runs behind requireSession, which puts the session in the context.
|
||||
func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
@@ -21,7 +21,7 @@ func requireCSRF(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
case http.MethodGet:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ func TestCSRFThroughRouter(t *testing.T) {
|
||||
{"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON},
|
||||
{"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON},
|
||||
// The right token passes the check and reaches the router, which has
|
||||
// no POST /api/v1/me.
|
||||
// no POST /api/v1/me, and the settings handler, which refuses the
|
||||
// empty body.
|
||||
{"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusNotFound, notFoundJSON},
|
||||
{"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body must be a JSON object","retryable":false}}`},
|
||||
{"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""},
|
||||
// HEAD and OPTIONS pass the check and reach the router, which
|
||||
// answers neither for /api/v1/me.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusMethodNotAllowed, ""},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusMethodNotAllowed, methodNotAllowedJSON},
|
||||
// Only GET is exempt; HEAD and OPTIONS need the header too.
|
||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
{"OPTIONS without header", http.MethodOptions, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -89,6 +89,8 @@ func TestRequireCSRF(t *testing.T) {
|
||||
{"right header reaches handler", http.MethodPost, &testSession, testSession.CSRFToken, http.StatusNoContent},
|
||||
{"wrong header", http.MethodPut, &testSession, "nope", http.StatusForbidden},
|
||||
{"GET needs no header", http.MethodGet, &testSession, "", http.StatusNoContent},
|
||||
{"HEAD needs the header", http.MethodHead, &testSession, "", http.StatusForbidden},
|
||||
{"OPTIONS needs the header", http.MethodOptions, &testSession, "", http.StatusForbidden},
|
||||
{"no session in context", http.MethodPost, nil, "", http.StatusInternalServerError},
|
||||
{"session without token", http.MethodPost, &auth.Session{Subject: "user-123"}, "", http.StatusInternalServerError},
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// maxJobBody bounds a POST /jobs request body.
|
||||
const maxJobBody = 64 << 10
|
||||
|
||||
// jobRequest is the POST /jobs body. Payload is required; a job kind that
|
||||
// takes no input is sent the empty object {}.
|
||||
type jobRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// jobCreated is the POST /jobs response.
|
||||
type jobCreated struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
||||
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
|
||||
switch {
|
||||
case errors.Is(err, jobs.ErrUnknownKind):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||
return
|
||||
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJobRequest reads a single JSON object with a non-empty kind and a
|
||||
// payload, refusing unknown fields, an omitted or null payload and trailing
|
||||
// data.
|
||||
func decodeJobRequest(body io.Reader) (jobRequest, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return jobRequest{}, errors.New("cannot read request body")
|
||||
}
|
||||
var req jobRequest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return jobRequest{}, errors.New("request body must be a single JSON object")
|
||||
}
|
||||
if req.Kind == "" {
|
||||
return jobRequest{}, errors.New("kind: required")
|
||||
}
|
||||
if req.Payload == nil {
|
||||
return jobRequest{}, errors.New("payload: required")
|
||||
}
|
||||
if bytes.Equal(req.Payload, []byte("null")) {
|
||||
return jobRequest{}, errors.New("payload: must not be null")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
||||
// result and error.
|
||||
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
job, err := runner.Get(r.Context(), chi.URLParam(r, "id"))
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get job", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
type draftPayload struct{}
|
||||
|
||||
type draftResult struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) }
|
||||
|
||||
// startedRunner registers a "draft" kind that publishes {"days":1}, waits
|
||||
// for release and returns {"days":7}, or fails with a *jobs.Failure when
|
||||
// fail is closed first. It starts the runner and stops it when the test ends.
|
||||
func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) {
|
||||
t.Helper()
|
||||
runner := newTestRunner(t)
|
||||
kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) {
|
||||
if err := publish(draftResult{Days: 1}); err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
return draftResult{Days: 7}, nil
|
||||
case <-fail:
|
||||
return draftResult{}, &jobs.Failure{Code: "no_regulars"}
|
||||
case <-ctx.Done():
|
||||
return draftResult{}, ctx.Err()
|
||||
}
|
||||
}, "no_regulars")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
return runner, kind
|
||||
}
|
||||
|
||||
// pollJob GETs the job until want accepts the body, failing after five
|
||||
// seconds.
|
||||
func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want(body) {
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) {
|
||||
t.Errorf("body fields = %v, want id, kind, status, result, error", keys)
|
||||
}
|
||||
return body
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never reached the expected state; last %v", id, body)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func resultDays(body map[string]any) float64 {
|
||||
result, _ := body["result"].(map[string]any)
|
||||
days, _ := result["days"].(float64)
|
||||
return days
|
||||
}
|
||||
|
||||
func TestGetJobShowsProgressThenResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, kind := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobShowsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fail := make(chan struct{})
|
||||
close(fail)
|
||||
runner, kind := startedRunner(t, nil, fail)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
id, err := kind.Enqueue(context.Background(), draftPayload{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" })
|
||||
if body["error"] != "no_regulars" {
|
||||
t.Errorf("error = %v, want the Failure code", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobUnknownIDIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostJobThenGetProgressAndResult drives the whole flow over the
|
||||
// router: POST enqueues a progressive "draft" job, GET reads its partial
|
||||
// result while the handler is held mid-run, then its final result.
|
||||
func TestPostJobThenGetProgressAndResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, _ := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
|
||||
t.Fatalf("body = %v, want only a job id", body)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner, _ := startedRunner(t, nil, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
|
||||
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
|
||||
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
|
||||
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
|
||||
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
const body = `{"kind":"noop","payload":{}}`
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
|
||||
}
|
||||
|
||||
for _, header := range []string{"", "wrong-token"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != "" {
|
||||
req.Header.Set(CSRFHeader, header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,8 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions,
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{},
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+19
-16
@@ -3,6 +3,7 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -26,10 +29,17 @@ type RouteMounter interface {
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; assets is the web build served for every other GET. /health, /auth
|
||||
// and the web build are public. Requests to /api/v1 other than GET, HEAD and
|
||||
// OPTIONS also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
|
||||
// /media; settings serves /api/v1/settings/household; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -56,6 +66,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
@@ -81,19 +95,8 @@ type apiError struct {
|
||||
|
||||
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
||||
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}})
|
||||
if err != nil {
|
||||
panic(err) // fixed string and bool fields cannot fail to marshal
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
}
|
||||
writeError(w, r, logger, status, code, message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -12,6 +13,11 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,13 +27,18 @@ const (
|
||||
)
|
||||
|
||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,6 +71,65 @@ func TestAuthRoutesMounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestStore returns a migrated temp database, closed when the test ends.
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return household.NewSettingsService(st)
|
||||
}
|
||||
|
||||
// newTestRunner returns an unstarted job runner over a migrated temp
|
||||
// database.
|
||||
func newTestRunner(t *testing.T) *jobs.Runner {
|
||||
t.Helper()
|
||||
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// maxSettingsBody bounds a PATCH /settings/household request body.
|
||||
const maxSettingsBody = 16 << 10
|
||||
|
||||
// settingsPatch is the PATCH /settings/household body. Every field is
|
||||
// optional; unknown fields and null values are refused.
|
||||
type settingsPatch struct {
|
||||
Timezone *string `json:"timezone"`
|
||||
DinnerTime *string `json:"dinner_time"`
|
||||
ReminderTime *string `json:"reminder_time"`
|
||||
ReviewTime *string `json:"review_time"`
|
||||
PlanDraftDow *int `json:"plan_draft_dow"`
|
||||
PlanDraftTime *string `json:"plan_draft_time"`
|
||||
SuggestionsDow *int `json:"suggestions_dow"`
|
||||
SuggestionsTime *string `json:"suggestions_time"`
|
||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
||||
}
|
||||
|
||||
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get household settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// patchHouseholdSettings records the session's actor subject as updated_by.
|
||||
func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := auth.ActorFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "PATCH /api/v1/settings/household without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
patch, err := decodeSettingsPatch(http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
settings, err := svc.Update(r.Context(), household.SettingsPatch(patch), actor.Subject)
|
||||
var invalid *household.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "update household settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSettingsPatch reads a single JSON object, refusing unknown fields,
|
||||
// null values and trailing data.
|
||||
func decodeSettingsPatch(body io.Reader) (settingsPatch, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return settingsPatch{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return settingsPatch{}, errors.New("cannot read request body")
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil || fields == nil {
|
||||
return settingsPatch{}, errors.New("request body must be a JSON object")
|
||||
}
|
||||
for name, raw := range fields {
|
||||
if bytes.Equal(raw, []byte("null")) {
|
||||
return settingsPatch{}, fmt.Errorf("%s: must not be null", name)
|
||||
}
|
||||
}
|
||||
|
||||
var patch settingsPatch
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&patch); err != nil {
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
return settingsPatch{}, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return settingsPatch{}, fmt.Errorf("invalid settings: %v", err)
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, v any) {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "encode response", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError responds with status and a non-retryable API error.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
|
||||
if err != nil {
|
||||
panic(err) // string and bool fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const settingsPath = "/api/v1/settings/household"
|
||||
|
||||
// signedIn returns a request carrying stubSessions' valid session cookie and
|
||||
// its CSRF token.
|
||||
func signedIn(method, target string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
req.AddCookie(validSessionCookie)
|
||||
req.Header.Set(CSRFHeader, testSession.CSRFToken)
|
||||
return req
|
||||
}
|
||||
|
||||
func serve(t *testing.T, h http.Handler, req *http.Request) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
func TestGetHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodGet, settingsPath, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
want := map[string]any{
|
||||
"timezone": "America/New_York",
|
||||
"dinner_time": "17:30",
|
||||
"reminder_time": "16:30",
|
||||
"review_time": "09:00",
|
||||
"plan_draft_dow": float64(5),
|
||||
"plan_draft_time": "09:00",
|
||||
"suggestions_dow": float64(0),
|
||||
"suggestions_time": "08:00",
|
||||
"chat_autosend_dow": float64(6),
|
||||
"chat_autosend_time": "08:00",
|
||||
"onboarding_completed_at": nil,
|
||||
"updates_seen_at": nil,
|
||||
"updated_by": nil,
|
||||
}
|
||||
for k, v := range want {
|
||||
got, ok := body[k]
|
||||
if !ok || got != v {
|
||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"created_at", "updated_at"} {
|
||||
if s, ok := body[k].(string); !ok || s == "" {
|
||||
t.Errorf("%s = %#v, want a timestamp", k, body[k])
|
||||
}
|
||||
}
|
||||
if len(body) != len(want)+2 {
|
||||
t.Errorf("body has %d fields, want %d: %v", len(body), len(want)+2, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
|
||||
var notified []household.Settings
|
||||
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath,
|
||||
strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"timezone": "Europe/Berlin",
|
||||
"dinner_time": "18:00",
|
||||
"plan_draft_dow": float64(4),
|
||||
"reminder_time": "16:30", // unchanged
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if body[k] != v {
|
||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||
}
|
||||
}
|
||||
if len(notified) != 1 || notified[0].Timezone != "Europe/Berlin" {
|
||||
t.Errorf("subscriber notified with %+v, want one change to Europe/Berlin", notified)
|
||||
}
|
||||
|
||||
_, body = serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if body["timezone"] != "Europe/Berlin" || body["updated_by"] != testSession.Subject {
|
||||
t.Errorf("GET after PATCH = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
in string // the error message must contain this
|
||||
}{
|
||||
{"bad timezone", `{"timezone":"Mars/Olympus_Mons"}`, "invalid_settings", "timezone"},
|
||||
{"bad time", `{"dinner_time":"25:00"}`, "invalid_settings", "dinner_time"},
|
||||
{"unpadded time", `{"reminder_time":"9:30"}`, "invalid_settings", "reminder_time"},
|
||||
{"bad dow", `{"suggestions_dow":7}`, "invalid_settings", "suggestions_dow"},
|
||||
{"negative dow", `{"chat_autosend_dow":-1}`, "invalid_settings", "chat_autosend_dow"},
|
||||
{"empty object", `{}`, "invalid_settings", "no settings to change"},
|
||||
{"fractional dow", `{"plan_draft_dow":5.5}`, "invalid_request", "plan_draft_dow"},
|
||||
{"string dow", `{"plan_draft_dow":"5"}`, "invalid_request", "plan_draft_dow"},
|
||||
{"numeric time", `{"dinner_time":1730}`, "invalid_request", "dinner_time"},
|
||||
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
||||
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
||||
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
||||
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
||||
{"null body", `null`, "invalid_request", "JSON object"},
|
||||
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
|
||||
{"malformed", `{"dinner_time":`, "invalid_request", "JSON object"},
|
||||
{"too large", `{"timezone":"` + strings.Repeat("a", maxSettingsBody) + `"}`, "invalid_request", "exceeds"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
notified := 0
|
||||
settings.Subscribe(func(household.Settings) { notified++ })
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(tt.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
apiErr, ok := body["error"].(map[string]any)
|
||||
if !ok || len(body) != 1 || len(apiErr) != 3 {
|
||||
t.Fatalf("body = %s, want {\"error\":{code,message,retryable}}", rec.Body)
|
||||
}
|
||||
if apiErr["code"] != tt.code || apiErr["retryable"] != false {
|
||||
t.Errorf("error = %v, want code %q, retryable false", apiErr, tt.code)
|
||||
}
|
||||
if msg, _ := apiErr["message"].(string); !strings.Contains(msg, tt.in) {
|
||||
t.Errorf("message %q does not contain %q", msg, tt.in)
|
||||
}
|
||||
if notified != 0 {
|
||||
t.Errorf("rejected PATCH notified subscribers %d times", notified)
|
||||
}
|
||||
_, after := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if after["timezone"] != "America/New_York" || after["dinner_time"] != "17:30" || after["updated_by"] != nil {
|
||||
t.Errorf("rejected PATCH changed settings: %v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseholdSettingsMethodNotAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPut, settingsPath, strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
if apiErr, _ := body["error"].(map[string]any); apiErr["code"] != "method_not_allowed" {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHouseholdSettingsWithRealSession drives the settings API through the
|
||||
// production router and session manager: a member signs in, reads the CSRF
|
||||
// token from GET /api/v1/me and PATCHes; updated_by comes from the session.
|
||||
// Without a session, or without the CSRF token, the PATCH is refused and
|
||||
// nothing changes.
|
||||
func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions, err := auth.NewSessionManager(st, time.Hour, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sign in as the callback would.
|
||||
login := httptest.NewRecorder()
|
||||
if err := sessions.StartSession(login, httptest.NewRequest(http.MethodGet, "/auth/callback", nil), auth.Claims{
|
||||
Subject: "authentik-sub-42",
|
||||
Email: "cook@example.com",
|
||||
Name: "Home Cook",
|
||||
Groups: []string{"mealquest-admin"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookies := login.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != auth.SessionCookieName {
|
||||
t.Fatalf("StartSession set cookies %v, want the session cookie", cookies)
|
||||
}
|
||||
cookie := cookies[0]
|
||||
withCookie := func(req *http.Request) *http.Request {
|
||||
req.AddCookie(cookie)
|
||||
return req
|
||||
}
|
||||
|
||||
rec, me := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)))
|
||||
token, _ := me["csrfToken"].(string)
|
||||
if rec.Code != http.StatusOK || token == "" {
|
||||
t.Fatalf("GET /api/v1/me = %d %v, want 200 with a CSRF token", rec.Code, me)
|
||||
}
|
||||
|
||||
patch := `{"dinner_time":"18:45"}`
|
||||
refused := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
status int
|
||||
}{
|
||||
{"no session", httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)), http.StatusUnauthorized},
|
||||
{"no CSRF token", withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))), http.StatusForbidden},
|
||||
}
|
||||
for _, tt := range refused {
|
||||
if rec, body := serve(t, router, tt.req); rec.Code != tt.status {
|
||||
t.Errorf("%s: PATCH status = %d, want %d: %v", tt.name, rec.Code, tt.status, body)
|
||||
}
|
||||
}
|
||||
_, body := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, settingsPath, nil)))
|
||||
if body["dinner_time"] != household.SeedDinnerTime || body["updated_by"] != nil {
|
||||
t.Fatalf("refused PATCH changed settings: %v", body)
|
||||
}
|
||||
|
||||
req := withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)))
|
||||
req.Header.Set(CSRFHeader, token)
|
||||
rec, body = serve(t, router, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH status = %d, want 200: %v", rec.Code, body)
|
||||
}
|
||||
if body["dinner_time"] != "18:45" || body["updated_by"] != "authentik-sub-42" {
|
||||
t.Errorf("PATCH = %v, want dinner_time 18:45 updated_by authentik-sub-42", body)
|
||||
}
|
||||
}
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,15 +188,16 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A database migrated by the bootstrap-only build upgrades to the
|
||||
// embedded set, which adds 0001_init.sql alongside the unchanged bootstrap.
|
||||
// embedded set, which adds the later migrations alongside the unchanged
|
||||
// bootstrap.
|
||||
if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -261,3 +262,50 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) {
|
||||
t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in
|
||||
// every 0001 status and checks 0003 carries each row over with its status
|
||||
// mapped and every other column unchanged.
|
||||
func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"}
|
||||
for old := range legacy {
|
||||
if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil {
|
||||
t.Fatalf("insert %s job: %v", old, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for old, want := range legacy {
|
||||
var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string
|
||||
if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old).
|
||||
Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read %s job: %v", old, err)
|
||||
}
|
||||
if status != want {
|
||||
t.Errorf("%s job status = %q, want %q", old, status, want)
|
||||
}
|
||||
got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt}
|
||||
if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) {
|
||||
t.Errorf("%s job columns = %v, want %v", old, got, wantCols)
|
||||
}
|
||||
}
|
||||
if tableExists(t, s, "jobs_new") {
|
||||
t.Error("jobs_new left behind")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any {
|
||||
r["attempts"] = 0
|
||||
case "jobs":
|
||||
r["kind"] = "suggest"
|
||||
r["status"] = "pending"
|
||||
r["status"] = "queued"
|
||||
r["payload"] = "{}"
|
||||
case "schedule_runs":
|
||||
r["schedule"] = "plan_draft"
|
||||
@@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
||||
{"notification_prefs", "chat", 2},
|
||||
{"notification_outbox", "channel", "email"},
|
||||
{"notification_outbox", "status", "queued"},
|
||||
{"jobs", "status", "queued"},
|
||||
{"jobs", "status", "bogus"},
|
||||
{"schedule_runs", "schedule", "hourly"},
|
||||
}
|
||||
s := seededStore(t)
|
||||
|
||||
+16
-1
@@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) {
|
||||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||||
}
|
||||
|
||||
// LocalDateOf returns the calendar date of t in t's own location.
|
||||
func LocalDateOf(t time.Time) LocalDate {
|
||||
y, m, d := t.Date()
|
||||
return LocalDate{y, m, d}
|
||||
}
|
||||
|
||||
// Date returns the year, month and day of d.
|
||||
func (d LocalDate) Date() (year int, month time.Month, day int) {
|
||||
return d.year, d.month, d.day
|
||||
}
|
||||
|
||||
// String returns the YYYY-MM-DD form.
|
||||
func (d LocalDate) String() string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||||
@@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool {
|
||||
return d == LocalDate{}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error.
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error, as is a
|
||||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||||
func (d LocalDate) Value() (driver.Value, error) {
|
||||
if d.IsZero() {
|
||||
return nil, errors.New("store: zero LocalDate")
|
||||
}
|
||||
if d.year < 0 || d.year > 9999 {
|
||||
return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year)
|
||||
}
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateOf(t *testing.T) {
|
||||
auckland, err := time.LoadLocation("Pacific/Auckland")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13).
|
||||
instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC)
|
||||
if got := LocalDateOf(instant).String(); got != "2026-10-03" {
|
||||
t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got)
|
||||
}
|
||||
d := LocalDateOf(instant.In(auckland))
|
||||
if got := d.String(); got != "2026-10-04" {
|
||||
t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got)
|
||||
}
|
||||
if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 {
|
||||
t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
|
||||
@@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
if _, err := (LocalDate{}).Value(); err == nil {
|
||||
t.Error("zero LocalDate Value succeeded")
|
||||
}
|
||||
for _, y := range []int{-1, 10000} {
|
||||
d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
if _, err := d.Value(); err == nil {
|
||||
t.Errorf("LocalDate year %d Value succeeded", y)
|
||||
}
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(nil); err == nil {
|
||||
t.Error("Scan(nil) succeeded")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 0002_household_settings_updated_by: record who last changed the household
|
||||
-- settings, as dinners and suggestion_batches already do. NULL until the
|
||||
-- first change made by a signed-in member.
|
||||
ALTER TABLE household_settings ADD COLUMN updated_by TEXT;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed,
|
||||
-- the states the job runner (internal/jobs) reports. SQLite cannot change a
|
||||
-- CHECK in place, so the table is rebuilt: create the new table, copy every
|
||||
-- row, drop the old one and rename. Nothing references jobs and it has no
|
||||
-- indexes or triggers, so the rebuild touches no other schema object. Rows
|
||||
-- written under the 0001 values are mapped (pending -> queued,
|
||||
-- done -> succeeded) so the copy cannot fail on an existing database.
|
||||
|
||||
CREATE TABLE jobs_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
payload TEXT NOT NULL, -- JSON
|
||||
result TEXT, -- JSON, written progressively
|
||||
error TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
kind,
|
||||
CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END,
|
||||
payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
FROM jobs;
|
||||
|
||||
DROP TABLE jobs;
|
||||
|
||||
ALTER TABLE jobs_new RENAME TO jobs;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?);
|
||||
|
||||
-- name: ClaimNextJob :one
|
||||
-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
-- when no job is queued.
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING *;
|
||||
|
||||
-- name: PublishJobResult :execrows
|
||||
-- Stores a partial result of a running job.
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running';
|
||||
|
||||
-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailRunningJobs :execrows
|
||||
-- Fails every job left running, at boot before any worker starts.
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE status = 'running';
|
||||
|
||||
-- name: GetJob :one
|
||||
SELECT * FROM jobs
|
||||
WHERE id = ?;
|
||||
@@ -10,12 +10,12 @@ INSERT INTO household_settings (
|
||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||
created_at, updated_at
|
||||
updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?
|
||||
?, ?, ?
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
timezone = excluded.timezone,
|
||||
@@ -30,6 +30,7 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
chat_autosend_time = excluded.chat_autosend_time,
|
||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||
updates_seen_at = excluded.updates_seen_at,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
|
||||
Generated
+18
-2
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"lucide-react": "^1.51.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^7.18.4"
|
||||
@@ -736,7 +737,7 @@
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1261,7 +1262,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -1760,6 +1761,21 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz",
|
||||
"integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
+5
-2
@@ -5,13 +5,16 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "npm run lint:colors && vitest run",
|
||||
"lint:colors": "node scripts/lint-colors.mjs",
|
||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"lucide-react": "^1.51.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^7.18.4"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Fails when the production build references Google Fonts: fonts are
|
||||
// self-hosted, so dist must never point at fonts.googleapis.com or
|
||||
// fonts.gstatic.com.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const distDir = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com']
|
||||
|
||||
function* files(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) yield* files(path)
|
||||
else yield path
|
||||
}
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const path of files(distDir)) {
|
||||
const content = readFileSync(path, 'latin1')
|
||||
for (const host of hosts) {
|
||||
if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Production build references Google Fonts; fonts must be self-hosted:')
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('check:dist-fonts: dist makes no Google Fonts requests')
|
||||
@@ -0,0 +1,42 @@
|
||||
// Fails when a hex colour literal appears in any CSS or TypeScript file under
|
||||
// web/ outside the design tokens: every colour must come from a custom
|
||||
// property in tokens.css. Dependencies, build output and hidden directories
|
||||
// (tool caches) are not source and are skipped.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const allowed = new Set(['src/styles/tokens.css'])
|
||||
const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')])
|
||||
const extensions = new Set(['.css', '.ts', '.tsx'])
|
||||
const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
|
||||
|
||||
function* sourceFiles(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path)
|
||||
} else if (extensions.has(extname(entry.name))) yield path
|
||||
}
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const path of sourceFiles(webRoot)) {
|
||||
const name = relative(webRoot, path).split('\\').join('/')
|
||||
if (allowed.has(name)) continue
|
||||
readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
for (const match of line.matchAll(hexColour)) {
|
||||
violations.push(`${name}:${i + 1}: ${match[0]}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Hex colour literals found; use a token from src/styles/tokens.css:')
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('lint:colors: no hex colour literals outside src/styles/tokens.css')
|
||||
+96
-10
@@ -1,15 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import App from './App.tsx'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the MealQuest shell', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, location = fakeLocation(path)) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App api={createApiClient(location)} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return location
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
'/',
|
||||
'/onboarding',
|
||||
'/plan',
|
||||
'/plan/shopping',
|
||||
'/plan/shopping/archive',
|
||||
'/discover',
|
||||
'/quick',
|
||||
'/cook-from-pantry',
|
||||
'/dinner/:id',
|
||||
'/settings',
|
||||
'/settings/notifications',
|
||||
'/settings/ai',
|
||||
'/settings/household',
|
||||
'/settings/regulars',
|
||||
'/settings/calendar',
|
||||
])
|
||||
})
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
renderAt(path)
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
|
||||
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
||||
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the not-in-this-household screen on a 403', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const location = renderAt('/')
|
||||
|
||||
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+46
-13
@@ -1,25 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Placeholder shell only: no product UI, auth or API calls yet.
|
||||
function Shell() {
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
export const routes = [
|
||||
{ path: '/', title: 'Home' },
|
||||
{ path: '/onboarding', title: 'Onboarding' },
|
||||
{ path: '/plan', title: 'Plan' },
|
||||
{ path: '/plan/shopping', title: 'Supply run' },
|
||||
{ path: '/plan/shopping/archive', title: 'Supply archive' },
|
||||
{ path: '/discover', title: 'Discover' },
|
||||
{ path: '/quick', title: 'Quick dinners' },
|
||||
{ path: '/cook-from-pantry', title: 'Cook from what I have' },
|
||||
{ path: '/dinner/:id', title: 'Dinner' },
|
||||
{ path: '/settings', title: 'Settings' },
|
||||
{ path: '/settings/notifications', title: 'Notifications' },
|
||||
{ path: '/settings/ai', title: 'AI assistant' },
|
||||
{ path: '/settings/household', title: 'Household' },
|
||||
{ path: '/settings/regulars', title: 'Regulars' },
|
||||
{ path: '/settings/calendar', title: 'Calendar' },
|
||||
] as const
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<main>
|
||||
<h1>MealQuest</h1>
|
||||
</main>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppProps = {
|
||||
api: ApiClient
|
||||
}
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
export default function App() {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
export default function App({ api }: AppProps) {
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="*" element={<Shell />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ApiError, ME_PATH, useApi } from './api/index.ts'
|
||||
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
|
||||
import { ErrorBanner } from './ui/ErrorBanner.tsx'
|
||||
import { Skeleton } from './ui/Skeleton.tsx'
|
||||
import styles from './pages/FullScreen.module.css'
|
||||
|
||||
export const meQueryKey = [ME_PATH] as const
|
||||
|
||||
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
|
||||
// already navigating to sign-in, so the skeleton stays up; an account outside
|
||||
// the household sees only the "not in this household" screen.
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
|
||||
|
||||
if (forbidden) return <NotInHouseholdPage />
|
||||
if (me.isSuccess) return children
|
||||
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
<Skeleton className={styles.title} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { ApiClient } from './client.ts'
|
||||
|
||||
const ApiContext = createContext<ApiClient | null>(null)
|
||||
|
||||
export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
|
||||
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
||||
}
|
||||
|
||||
// useApi returns the app's API client. It must be rendered inside ApiProvider.
|
||||
export function useApi(): ApiClient {
|
||||
const client = useContext(ApiContext)
|
||||
if (client === null) throw new Error('useApi: no ApiProvider above this component')
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
|
||||
await expect(
|
||||
api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }),
|
||||
).resolves.toEqual({ ok: 2 })
|
||||
|
||||
const get = request(fetchMock, 1)
|
||||
expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' })
|
||||
expect(get.headers.has(CSRF_HEADER)).toBe(false)
|
||||
|
||||
const patch = request(fetchMock, 2)
|
||||
expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' })
|
||||
expect(patch.headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(patch.headers.get('Content-Type')).toBe('application/json')
|
||||
})
|
||||
|
||||
it('refuses a non-GET request before /me has loaded', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example/api/v1/me',
|
||||
'//evil.example/api/v1/me',
|
||||
// The placeholder origin an earlier guard compared against.
|
||||
'//same-origin.invalid/api/v1/me',
|
||||
'https://same-origin.invalid/api/v1/me',
|
||||
'/\\evil.example/api/v1/me',
|
||||
'/auth/logout',
|
||||
'/api/v1/../auth/logout',
|
||||
'/api/v1/%2e%2e/auth/logout',
|
||||
'api/v1/me',
|
||||
])('refuses %s without sending it or the CSRF token', async (path) => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
|
||||
`POST ${path}: not a same-origin /api/v1/ path`,
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects with a typed ApiError from the error body', async () => {
|
||||
stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
|
||||
})
|
||||
|
||||
it('rejects a response without the API error body', async () => {
|
||||
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
|
||||
})
|
||||
|
||||
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
|
||||
const api = createApiClient(location)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
|
||||
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
||||
'/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05',
|
||||
)
|
||||
})
|
||||
|
||||
it('on a csrf 403 refreshes /me once and retries with the new token', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
jsonResponse(201, { id: 'j1' }),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
|
||||
id: 'j1',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' })
|
||||
expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not loop when the retry gets a csrf 403 too', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
errorResponse(403, 'csrf'),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'csrf',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const listener = vi.fn()
|
||||
api.subscribe(listener)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(api.forbidden()).toBe(true)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not take a 403 without the API error body as not in this household', async () => {
|
||||
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMe', () => {
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
||||
|
||||
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
|
||||
export const CSRF_HEADER = 'X-MealQuest-CSRF'
|
||||
|
||||
export const ME_PATH = '/api/v1/me'
|
||||
|
||||
// API_PREFIX is the only path apiFetch sends to.
|
||||
const API_PREFIX = '/api/v1/'
|
||||
|
||||
// apiURL resolves path against the page origin and returns it as the
|
||||
// path-absolute URL to fetch. It refuses any path that would not resolve to a
|
||||
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
|
||||
// else: absolute and protocol-relative URLs on another origin, and dot
|
||||
// segments that climb out of /api/v1/.
|
||||
function apiURL(origin: string, method: string, path: string): string {
|
||||
const url = new URL(path, origin)
|
||||
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
|
||||
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
|
||||
}
|
||||
return url.pathname + url.search
|
||||
}
|
||||
|
||||
// Me is the GET /api/v1/me response: the signed-in member, the household and
|
||||
// the session's CSRF token.
|
||||
export type Me = {
|
||||
name: string
|
||||
email: string
|
||||
household: { label: string }
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
// ApiLocation is the part of window.location the client reads and navigates.
|
||||
// origin is the page origin every request must stay on.
|
||||
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
|
||||
|
||||
export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON.
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export type ApiClient = {
|
||||
// apiFetch sends a same-origin JSON request to path, which must be under
|
||||
// /api/v1/, and resolves with the decoded response body, or rejects with an
|
||||
// ApiError.
|
||||
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||
loadMe(): Promise<Me>
|
||||
// forbidden reports whether a request was refused because the signed-in
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
async function send(method: string, path: string, body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (method !== 'GET') {
|
||||
if (csrfToken === null) {
|
||||
throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`)
|
||||
}
|
||||
headers[CSRF_HEADER] = csrfToken
|
||||
}
|
||||
return fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
// got a csrf 403 at the same time.
|
||||
function refreshMe(): Promise<Me> {
|
||||
refreshing ??= loadMe().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
const method = request.method ?? 'GET'
|
||||
const url = apiURL(location.origin, method, path)
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
// Only a non-GET carries the token, so only it can be refused for it.
|
||||
if (error.code !== 'csrf' || method === 'GET') throw error
|
||||
// The session's token changed: refresh it once and retry once.
|
||||
await refreshMe()
|
||||
response = await send(method, url, request.body)
|
||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||
}
|
||||
return decode<T>(method, path, response)
|
||||
}
|
||||
|
||||
// parseForbidden reads a 403. Any API error other than csrf means the
|
||||
// account is not in this household.
|
||||
async function parseForbidden(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const error = await parseApiError(method, path, response)
|
||||
if (error.code !== 'csrf') markForbidden()
|
||||
return error
|
||||
}
|
||||
|
||||
function markForbidden() {
|
||||
if (forbidden) return
|
||||
forbidden = true
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
|
||||
if (response.status === 401) {
|
||||
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
|
||||
}
|
||||
if (!response.ok) throw await parseApiError(method, path, response)
|
||||
if (response.status === 204) return undefined as T
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function loadMe(): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH)
|
||||
if (
|
||||
!isRecord(me) ||
|
||||
typeof me.name !== 'string' ||
|
||||
typeof me.email !== 'string' ||
|
||||
!isRecord(me.household) ||
|
||||
typeof me.household.label !== 'string' ||
|
||||
typeof me.csrfToken !== 'string' ||
|
||||
me.csrfToken === ''
|
||||
) {
|
||||
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
|
||||
}
|
||||
csrfToken = me.csrfToken
|
||||
return me as Me
|
||||
}
|
||||
|
||||
return {
|
||||
apiFetch,
|
||||
loadMe,
|
||||
forbidden: () => forbidden,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// ApiError is a failed API response carrying the server's
|
||||
// {"error":{"code","message","retryable"}} body.
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
readonly retryable: boolean
|
||||
|
||||
constructor(status: number, code: string, message: string, retryable: boolean) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.retryable = retryable
|
||||
}
|
||||
}
|
||||
|
||||
// parseApiError reads an error response body. A body that is not the API
|
||||
// error shape is itself an error: the server always sends one.
|
||||
export async function parseApiError(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const text = await response.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const error = isRecord(body) ? body.error : undefined
|
||||
if (
|
||||
!isRecord(error) ||
|
||||
typeof error.code !== 'string' ||
|
||||
typeof error.message !== 'string' ||
|
||||
typeof error.retryable !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${method} ${path}: ${response.status} response has no API error body`)
|
||||
}
|
||||
return new ApiError(response.status, error.code, error.message, error.retryable)
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||
export { ApiError } from './errors.ts'
|
||||
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
||||
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError, createQueryClient, shouldRetry } from './index.ts'
|
||||
|
||||
describe('query retries', () => {
|
||||
it('retry only an ApiError the server marked retryable, a bounded number of times', () => {
|
||||
expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true)
|
||||
expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false)
|
||||
expect(shouldRetry(0, new Error('network down'))).toBe(false)
|
||||
})
|
||||
|
||||
it('are the QueryClient default for queries and mutations', () => {
|
||||
const options = createQueryClient().getDefaultOptions()
|
||||
expect(options.queries?.retry).toBe(shouldRetry)
|
||||
expect(options.mutations?.retry).toBe(shouldRetry)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './errors.ts'
|
||||
|
||||
// MAX_RETRIES bounds retries of a failure the server marked retryable.
|
||||
export const MAX_RETRIES = 3
|
||||
|
||||
// shouldRetry retries only an ApiError the server marked retryable — never a
|
||||
// 401, a 403 or a malformed response.
|
||||
export function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: shouldRetry, staleTime: 30_000 },
|
||||
mutations: { retry: shouldRetry },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
||||
|
||||
type Draft = { dinners: string[] }
|
||||
|
||||
function job(status: string, result: Draft | null, error: string | null = null) {
|
||||
return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
|
||||
}
|
||||
|
||||
function renderJob(jobId: string | null) {
|
||||
const api = createApiClient(fakeLocation())
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
// advance runs the timers due in ms and lets the polls they start answer.
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useJob', () => {
|
||||
it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('queued', null),
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
|
||||
await advance(0)
|
||||
expect(result.current.status).toBe('queued')
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
|
||||
|
||||
await advance(499)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
await advance(1)
|
||||
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
|
||||
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
expect(result.current).toEqual({
|
||||
status: 'succeeded',
|
||||
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Terminal: no timer left, no further polls.
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('exposes a failed job as its error and stops', async () => {
|
||||
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(result.current.status).toBe('failed')
|
||||
expect(result.current.error?.message).toBe('model refused')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
errorResponse(503, 'unavailable', true),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
||||
expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// The backoff carries on from the failed poll: the next one is 1 s later.
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stops polling on unmount after a failed poll', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null })
|
||||
expect(result.current.error).toMatchObject({ status: 503 })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops polling the old job when the job id changes', async () => {
|
||||
const fetchMock = stubFetch(job('running', null), job('running', null))
|
||||
const { result, rerender } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
rerender({ id: 'j2' })
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
await advance(0)
|
||||
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('stops polling on unmount', async () => {
|
||||
const fetchMock = stubFetch(job('running', null))
|
||||
const { unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a poll that answers after unmount', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
const fetchMock = stubFetch()
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
unmount()
|
||||
await act(async () => answer(await Promise.resolve(job('running', null))))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(result.current.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('polls nothing without a job id', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const { result } = renderJob(null)
|
||||
await advance(5000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useApi } from './ApiContext.tsx'
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
|
||||
// is running (a partial result); error is set only when it failed.
|
||||
export type Job<R> = {
|
||||
id: string
|
||||
kind: string
|
||||
status: JobStatus
|
||||
result: R | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type JobState<R> = {
|
||||
// undefined until the first poll answers.
|
||||
status: JobStatus | undefined
|
||||
// The latest result, partial while the job runs.
|
||||
result: R | null
|
||||
// The job's failure, or the latest polling request's own failure (cleared
|
||||
// by the next poll that answers).
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
|
||||
export const FIRST_DELAY_MS = 500
|
||||
export const MAX_DELAY_MS = 2000
|
||||
|
||||
const idle = { status: undefined, result: null, error: null }
|
||||
|
||||
function isTerminal(status: JobStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed'
|
||||
}
|
||||
|
||||
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
|
||||
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
|
||||
// polling continues. Polling stops only at a terminal status, when jobId
|
||||
// changes and on unmount. A null jobId polls nothing.
|
||||
export function useJob<R>(jobId: string | null): JobState<R> {
|
||||
const { apiFetch } = useApi()
|
||||
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let delay = FIRST_DELAY_MS
|
||||
|
||||
function next() {
|
||||
timer = setTimeout(poll, delay)
|
||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
|
||||
if (!active) return
|
||||
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
|
||||
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||
if (isTerminal(job.status)) return
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
// A failed poll is not the job's end: keep the last known status and
|
||||
// result, expose the request's error, and poll again.
|
||||
setState((prev) => ({
|
||||
jobId,
|
||||
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
||||
}))
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
void poll()
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [apiFetch, jobId])
|
||||
|
||||
// State left from a previous jobId is not this job's.
|
||||
return state.jobId === jobId ? state.job : idle
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
+6
-1
@@ -1,17 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
throw new Error('MealQuest: #root element missing from index.html')
|
||||
}
|
||||
|
||||
const api = createApiClient(window.location)
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<App api={api} />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* A full-viewport screen shown instead of the app shell. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: min(100%, 480px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
width: 60%;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import styles from './FullScreen.module.css'
|
||||
|
||||
// Shown when the server refuses a signed-in account that is not in this
|
||||
// household. Nothing else renders: the account can do nothing here.
|
||||
export function NotInHouseholdPage() {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<EmptyState title="Not in this household">
|
||||
You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
|
||||
</EmptyState>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type PlaceholderPageProps = {
|
||||
// The route's screen name, shown as the page heading.
|
||||
title: string
|
||||
}
|
||||
|
||||
// Stands in for a screen until its feature is built.
|
||||
export function PlaceholderPage({ title }: PlaceholderPageProps) {
|
||||
return <h1>{title}</h1>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The bottom bar overlays the last 94 px of the screen (plus the safe area),
|
||||
so the scroll area reserves at least 100 px beneath its content. */
|
||||
.mobile .main {
|
||||
padding: 0 20px;
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.tablet .main,
|
||||
.desktop .main {
|
||||
padding: 32px 36px;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import bottomBarStyles from './BottomBar.module.css'
|
||||
import railStyles from './Rail.module.css'
|
||||
import sidebarStyles from './Sidebar.module.css'
|
||||
|
||||
const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings']
|
||||
const MOBILE = 390
|
||||
const TABLET = 1180
|
||||
const DESKTOP = 1440
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return resize
|
||||
}
|
||||
|
||||
function nav() {
|
||||
return screen.getByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function navLinks() {
|
||||
return within(nav()).getAllByRole('link')
|
||||
}
|
||||
|
||||
function current() {
|
||||
return navLinks()
|
||||
.filter((link) => link.getAttribute('aria-current') === 'page')
|
||||
.map((link) => link.textContent)
|
||||
}
|
||||
|
||||
describe('AppShell', () => {
|
||||
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
|
||||
renderAt('/', width)
|
||||
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
||||
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/',
|
||||
'/plan',
|
||||
'/discover',
|
||||
'/quick',
|
||||
'/settings',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows only the mobile bottom bar below 768 px', () => {
|
||||
renderAt('/', 767)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
||||
|
||||
const bar = declaredStyle(nav())
|
||||
expect(bar.position).toBe('absolute')
|
||||
expect(bar.left).toBe('12px')
|
||||
expect(bar.right).toBe('12px')
|
||||
expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))')
|
||||
expect(bar.height).toBe('62px')
|
||||
expect(outerSize(nav(), 'height')).toBe(62)
|
||||
expect(bar.background).toBe('var(--color-text)')
|
||||
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
||||
})
|
||||
|
||||
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
|
||||
renderAt('/discover', MOBILE)
|
||||
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
||||
expect(pills).toHaveLength(1)
|
||||
const pill = declaredStyle(pills[0]!)
|
||||
expect(pill.position).toBe('absolute')
|
||||
expect(pill.top).toBe('10px')
|
||||
expect(pill.height).toBe('42px')
|
||||
expect(pill.background).toBe('var(--color-accent)')
|
||||
expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex))
|
||||
|
||||
const active = screen.getByRole('link', { name: 'Discover' })
|
||||
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
||||
expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!)
|
||||
expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px')
|
||||
|
||||
const inactive = screen.getByRole('link', { name: 'Plan' })
|
||||
expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)')
|
||||
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
||||
})
|
||||
|
||||
it('makes the full 62 px bar height the mobile tap target', () => {
|
||||
renderAt('/', MOBILE)
|
||||
for (const link of navLinks()) {
|
||||
expect(declaredStyle(link).height).toBe('62px')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
})
|
||||
|
||||
it('reserves at least 100 px below the mobile scroll area', () => {
|
||||
renderAt('/', MOBILE)
|
||||
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
|
||||
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
|
||||
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
|
||||
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
|
||||
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
|
||||
renderAt('/plan', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
expect(declaredStyle(nav()).width).toBe('96px')
|
||||
expect(outerSize(nav(), 'width')).toBe(96)
|
||||
expect(declaredStyle(nav()).background).toBe('var(--color-surface)')
|
||||
const mark = within(nav()).getByText('M')
|
||||
expect(declaredStyle(mark).width).toBe('48px')
|
||||
expect(outerSize(mark, 'width')).toBe(48)
|
||||
expect(outerSize(mark, 'height')).toBe(48)
|
||||
expect(declaredStyle(mark).background).toBe('var(--color-accent)')
|
||||
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
||||
|
||||
for (const link of navLinks()) {
|
||||
const pill = link.firstElementChild!
|
||||
expect(declaredStyle(pill).width).toBe('56px')
|
||||
expect(declaredStyle(pill).height).toBe('36px')
|
||||
expect(outerSize(pill, 'width')).toBe(56)
|
||||
expect(outerSize(pill, 'height')).toBe(36)
|
||||
expect(declaredStyle(link).fontSize).toBe('11px')
|
||||
expect(declaredStyle(link).fontWeight).toBe('700')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!)
|
||||
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
|
||||
})
|
||||
|
||||
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
|
||||
renderAt('/quick', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
const sidebar = nav().closest('aside')!
|
||||
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
||||
expect(declaredStyle(sidebar).width).toBe('208px')
|
||||
expect(declaredStyle(sidebar).background).toBe('var(--color-surface)')
|
||||
expect(declaredStyle(sidebar).padding).toBe('28px 16px')
|
||||
// The 16 px side padding sits inside the 208 px, not outside it.
|
||||
expect(outerSize(sidebar, 'width')).toBe(208)
|
||||
|
||||
const wordmark = within(sidebar).getByText('MealQuest')
|
||||
expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)')
|
||||
expect(declaredStyle(wordmark).fontSize).toBe('28px')
|
||||
expect(declaredStyle(wordmark).color).toBe('var(--color-accent)')
|
||||
|
||||
expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument()
|
||||
expect(within(sidebar).getByText('Shared household')).toBeInTheDocument()
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
|
||||
const active = screen.getByRole('link', { name: 'Quick' })
|
||||
expect(active).toHaveClass(sidebarStyles.active!)
|
||||
expect(declaredStyle(active).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
||||
expect(declaredStyle(active).fontSize).toBe('15px')
|
||||
expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!)
|
||||
// Rows stretch across the sidebar, so only their height needs declaring.
|
||||
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
||||
})
|
||||
|
||||
it('switches layout when the viewport crosses a breakpoint', () => {
|
||||
const resize = renderAt('/', MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
resize(TABLET)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
resize(DESKTOP)
|
||||
expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!)
|
||||
resize(MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['mobile', MOBILE],
|
||||
['tablet', TABLET],
|
||||
['desktop', DESKTOP],
|
||||
])('aria-current (%s)', (_layout, width) => {
|
||||
it.each([
|
||||
['/', ['Home']],
|
||||
['/plan', ['Plan']],
|
||||
['/plan/shopping', ['Plan']],
|
||||
['/plan/shopping/archive', ['Plan']],
|
||||
['/discover', ['Discover']],
|
||||
['/quick', ['Quick']],
|
||||
['/settings', ['Settings']],
|
||||
['/settings/notifications', ['Settings']],
|
||||
['/settings/ai', ['Settings']],
|
||||
['/settings/household', ['Settings']],
|
||||
['/settings/regulars', ['Settings']],
|
||||
['/settings/calendar', ['Settings']],
|
||||
['/onboarding', []],
|
||||
['/cook-from-pantry', []],
|
||||
['/dinner/abc123', []],
|
||||
])('%s marks %j current', (path, expected) => {
|
||||
renderAt(path, width)
|
||||
expect(current()).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Outlet } from 'react-router'
|
||||
import { BottomBar } from './BottomBar.tsx'
|
||||
import { Rail } from './Rail.tsx'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { useBreakpoint } from './useBreakpoint.ts'
|
||||
import styles from './AppShell.module.css'
|
||||
|
||||
// The responsive frame around every page: one nav layout per breakpoint and a
|
||||
// scrolling main area for the routed page.
|
||||
export function AppShell() {
|
||||
const breakpoint = useBreakpoint()
|
||||
return (
|
||||
<div className={`${styles.shell} ${styles[breakpoint]}`}>
|
||||
{breakpoint === 'mobile' && <BottomBar />}
|
||||
{breakpoint === 'tablet' && <Rail />}
|
||||
{breakpoint === 'desktop' && <Sidebar />}
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
height: 62px;
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-text);
|
||||
}
|
||||
|
||||
/* The one grape pill, shared by every item: BottomBar sets its left and width
|
||||
to the active item's and slides it between items. */
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
height: 42px;
|
||||
z-index: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The whole bar height is the tap target; the 42 px pill sits behind it.
|
||||
Items stay above the pill. */
|
||||
.item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 62px;
|
||||
min-width: 44px;
|
||||
padding: 0 13px;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-neutral-400);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color .2s;
|
||||
}
|
||||
|
||||
.item[aria-current='page'] {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Inactive items are icon-only on screen but keep their name for assistive tech. */
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
const MOBILE = 390
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
|
||||
// jsdom does no layout, so lay the bar out here: items sit side by side from
|
||||
// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px.
|
||||
function layOut(element: HTMLElement): { left: number; width: number } {
|
||||
const bar = element.parentElement!
|
||||
const items = Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
let left = 6
|
||||
for (const item of items) {
|
||||
const width = item.getAttribute('aria-current') === 'page' ? 110 : 48
|
||||
if (item === element) return { left, width }
|
||||
left += width
|
||||
}
|
||||
return { left: 0, width: 0 }
|
||||
}
|
||||
|
||||
// jsdom paints nothing either, so bounding boxes come from the laid-out
|
||||
// offsets with the bar at the viewport's left edge. A test mid-slide sets
|
||||
// where the pill is drawn and how far each item is still translated.
|
||||
let drawnPill: { left: number; width: number } | null
|
||||
let translated: Map<Element, number>
|
||||
|
||||
function box(left: number, width: number): DOMRect {
|
||||
return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect
|
||||
}
|
||||
|
||||
let animate: Mock<Element['animate']>
|
||||
|
||||
beforeEach(() => {
|
||||
drawnPill = null
|
||||
translated = new Map()
|
||||
// jsdom has no Web Animations API.
|
||||
animate = vi.fn<Element['animate']>(() => ({ cancel: vi.fn() }) as unknown as Animation)
|
||||
Element.prototype.animate = animate
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (!(this instanceof HTMLElement)) return box(0, 0)
|
||||
if (this.classList.contains(styles.pill!)) {
|
||||
return drawnPill
|
||||
? box(drawnPill.left, drawnPill.width)
|
||||
: box(parseFloat(this.style.left), parseFloat(this.style.width))
|
||||
}
|
||||
if (this.classList.contains(styles.item!)) {
|
||||
return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth)
|
||||
}
|
||||
return box(0, 0)
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return layOut(this).left
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return layOut(this).width
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
function nav() {
|
||||
return screen.getByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function pill(): HTMLElement {
|
||||
return nav().querySelector<HTMLElement>(`.${styles.pill}`)!
|
||||
}
|
||||
|
||||
function link(name: string): HTMLElement {
|
||||
return within(nav()).getByRole('link', { name })
|
||||
}
|
||||
|
||||
function runningOn(element: Element): Animation[] {
|
||||
return animate.mock.contexts
|
||||
.map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation }))
|
||||
.filter(({ target }) => target === element)
|
||||
.map(({ animation }) => animation)
|
||||
}
|
||||
|
||||
function animationsOf(element: Element) {
|
||||
return animate.mock.contexts
|
||||
.map((target, i) => ({ target, args: animate.mock.calls[i]! }))
|
||||
.filter(({ target }) => target === element)
|
||||
.map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions }))
|
||||
}
|
||||
|
||||
describe('BottomBar', () => {
|
||||
it('places the pill on the active item without animating on first render', () => {
|
||||
renderAt('/plan')
|
||||
expect(pill().style.left).toBe('54px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
expect(pill()).toBeVisible()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the pill when no item is active', () => {
|
||||
renderAt('/onboarding')
|
||||
expect(pill()).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ left: '6px', width: '110px' },
|
||||
{ left: '150px', width: '110px' },
|
||||
],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(pill().style.left).toBe('150px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
|
||||
// and Quick grows in place; Settings ends where it started.
|
||||
for (const name of ['Plan', 'Discover']) {
|
||||
expect(animationsOf(link(name))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
}
|
||||
expect(animationsOf(link('Quick'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(animationsOf(link('Home'))).toEqual([])
|
||||
expect(animationsOf(link('Settings'))).toEqual([])
|
||||
})
|
||||
|
||||
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Discover'))
|
||||
|
||||
const label = within(link('Discover')).getByText('Discover')
|
||||
expect(label).toHaveClass(styles.label!)
|
||||
expect(animationsOf(label)).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not animate when the active item stays the same', () => {
|
||||
renderAt('/plan')
|
||||
fireEvent.click(link('Plan'))
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('jumps the pill to the new item with no animation under reduced motion', () => {
|
||||
renderAt('/', true)
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
expect(pill().style.left).toBe('198px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('re-places the pill when the viewport resizes', () => {
|
||||
renderAt('/discover')
|
||||
expect(pill().style.left).toBe('102px')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(pill().style.left).toBe('120px')
|
||||
expect(pill().style.width).toBe('96px')
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a second slide from where the first one is on screen, not from its destination', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const [firstPill] = runningOn(pill())
|
||||
const firstPlan = runningOn(link('Plan'))[0]!
|
||||
|
||||
// Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan
|
||||
// still carries 31 of its 62 px FLIP translation.
|
||||
drawnPill = { left: 78, width: 110 }
|
||||
translated.set(link('Plan'), 31)
|
||||
animate.mockClear()
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(firstPill!.cancel).toHaveBeenCalled()
|
||||
expect(firstPlan.cancel).toHaveBeenCalled()
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ left: '78px', width: '110px' },
|
||||
{ left: '198px', width: '110px' },
|
||||
],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
// Plan's layout does not move between Quick and Settings, so it only
|
||||
// finishes the 31 px it had left. Settings moves 62 px left.
|
||||
expect(animationsOf(link('Plan'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(animationsOf(link('Settings'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
|
||||
expect(slide).toHaveLength(4)
|
||||
|
||||
animate.mockClear()
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
fireEvent(window, new Event('resize'))
|
||||
|
||||
for (const animation of slide) expect(animation.cancel).toHaveBeenCalled()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
expect(pill().style.left).toBe('140px')
|
||||
expect(pill().style.width).toBe('96px')
|
||||
})
|
||||
|
||||
it('re-places the pill once the web fonts are ready', async () => {
|
||||
let fontsReady!: () => void
|
||||
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
||||
try {
|
||||
renderAt('/discover')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
||||
fontsReady()
|
||||
await ready
|
||||
expect(pill().style.width).toBe('124px')
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'fonts')
|
||||
}
|
||||
})
|
||||
|
||||
it('crossfades the icon colour with a .2s colour transition', () => {
|
||||
renderAt('/')
|
||||
for (const item of within(nav()).getAllByRole('link')) {
|
||||
expect(declaredStyle(item).transition).toBe('color .2s')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value.
|
||||
const SLIDE_MS = 420
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
const LABEL_MS = 220
|
||||
const LABEL_DELAY_MS = 120
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// The active item and every item's layout offset within the bar when the pill
|
||||
// was last placed (offsets ignore transforms, so a running FLIP does not skew
|
||||
// them).
|
||||
type Geometry = {
|
||||
active: HTMLElement | null
|
||||
itemLefts: number[]
|
||||
}
|
||||
|
||||
// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive
|
||||
// items show only their icon; the active item adds its label. One shared
|
||||
// grape pill sits behind the items and slides to the active one.
|
||||
export function BottomBar() {
|
||||
const barRef = useRef<HTMLElement>(null)
|
||||
const pillRef = useRef<HTMLSpanElement>(null)
|
||||
const placed = useRef<Geometry | null>(null)
|
||||
// The pill slide and item FLIPs currently running.
|
||||
const sliding = useRef<Animation[]>([])
|
||||
const stopSlide = () => {
|
||||
sliding.current.forEach((animation) => animation.cancel())
|
||||
sliding.current = []
|
||||
}
|
||||
const { pathname } = useLocation()
|
||||
|
||||
// After each navigation: if the active item changed, move the pill to it and,
|
||||
// motion allowed, slide the pill, FLIP the items and fade the label in. A
|
||||
// slide still running is measured where it is on screen, then stopped, so
|
||||
// the next one starts from there instead of from its destination.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
const active = activeItem(bar)
|
||||
const before = placed.current
|
||||
if (before && before.active === active) return
|
||||
const from = before && onScreen(bar, pill, before)
|
||||
stopSlide()
|
||||
placed.current = place(bar, pill, active)
|
||||
if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return
|
||||
|
||||
const after = placed.current
|
||||
if (before.active) {
|
||||
sliding.current.push(
|
||||
pill.animate(
|
||||
[
|
||||
{ left: `${from.pill.left}px`, width: `${from.pill.width}px` },
|
||||
{ left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` },
|
||||
],
|
||||
{ duration: SLIDE_MS, easing: SPRING },
|
||||
),
|
||||
)
|
||||
}
|
||||
items(bar).forEach((item, i) => {
|
||||
const dx = from.itemLefts[i]! - after.itemLefts[i]!
|
||||
if (dx === 0) return
|
||||
sliding.current.push(
|
||||
item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], {
|
||||
duration: SLIDE_MS,
|
||||
easing: SPRING,
|
||||
}),
|
||||
)
|
||||
})
|
||||
active.querySelector(`.${styles.label}`)!.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
{ duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' },
|
||||
)
|
||||
}, [pathname])
|
||||
|
||||
// Item widths change with the viewport and once the web fonts arrive, so
|
||||
// stop any running slide and re-place the pill (without animating) on both. A failed font load
|
||||
// rejects fonts.ready and is left to surface as an unhandled rejection.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
let mounted = true
|
||||
const replace = () => {
|
||||
if (!mounted) return
|
||||
stopSlide()
|
||||
placed.current = place(bar, pill, activeItem(bar))
|
||||
}
|
||||
window.addEventListener('resize', replace)
|
||||
// jsdom has no document.fonts; every browser the app targets does.
|
||||
if (document.fonts) void document.fonts.ready.then(replace)
|
||||
return () => {
|
||||
mounted = false
|
||||
window.removeEventListener('resize', replace)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav ref={barRef} aria-label="Primary" className={styles.bar}>
|
||||
<span ref={pillRef} className={styles.pill} aria-hidden="true" />
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function items(bar: HTMLElement): HTMLElement[] {
|
||||
return Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
}
|
||||
|
||||
// NavLink's aria-current is the one record of which item is active.
|
||||
function activeItem(bar: HTMLElement): HTMLElement | null {
|
||||
return bar.querySelector<HTMLElement>(`.${styles.item}[aria-current="page"]`)
|
||||
}
|
||||
|
||||
// Where the pill and items are on screen right now, as offsets within the
|
||||
// bar. Bounding boxes include a running slide: the pill's box is its animated
|
||||
// left and width, and an item's box is its current layout plus whatever FLIP
|
||||
// translation remains, which applies to the layout it had when last placed.
|
||||
function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) {
|
||||
const origin = bar.getBoundingClientRect().left + bar.clientLeft
|
||||
const pillBox = pill.getBoundingClientRect()
|
||||
return {
|
||||
pill: { left: pillBox.left - origin, width: pillBox.width },
|
||||
itemLefts: items(bar).map(
|
||||
(item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Puts the pill on the active item (hidden when no item is active) and
|
||||
// returns the resulting geometry.
|
||||
function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry {
|
||||
pill.hidden = !active
|
||||
pill.style.left = `${active?.offsetLeft ?? 0}px`
|
||||
pill.style.width = `${active?.offsetWidth ?? 0}px`
|
||||
return { active, itemLefts: items(bar).map((item) => item.offsetLeft) }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.rail {
|
||||
width: 96px;
|
||||
padding: 24px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
color: var(--color-neutral-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
width: 56px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './Rail.module.css'
|
||||
|
||||
// Tablet nav: a narrow icon rail with the "M" mark on top.
|
||||
export function Rail() {
|
||||
return (
|
||||
<nav aria-label="Primary" className={styles.rail}>
|
||||
<div className={styles.mark} aria-hidden="true">
|
||||
M
|
||||
</div>
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
</span>
|
||||
{item.label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.sidebar {
|
||||
width: 208px;
|
||||
padding: 28px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0;
|
||||
padding: 0 12px 22px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--color-neutral-800);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.household {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.householdText {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.householdNote {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared
|
||||
// household card at the foot.
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<p className={styles.wordmark}>MealQuest</p>
|
||||
<nav aria-label="Primary" className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? `${styles.item} ${styles.active}` : styles.item)}
|
||||
>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className={styles.household}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
<p className={styles.householdText}>
|
||||
<strong>Our kitchen</strong>
|
||||
<span className={styles.householdNote}>Shared household</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
// Match the path exactly; otherwise nested paths (/plan/shopping,
|
||||
// /settings/ai) also mark the item current.
|
||||
end: boolean
|
||||
}
|
||||
|
||||
// The primary nav, in the same order in every layout.
|
||||
export const navItems: readonly NavItem[] = [
|
||||
{ to: '/', label: 'Home', icon: House, end: true },
|
||||
{ to: '/plan', label: 'Plan', icon: Calendar, end: false },
|
||||
{ to: '/discover', label: 'Discover', icon: Compass, end: false },
|
||||
{ to: '/quick', label: 'Quick', icon: Zap, end: false },
|
||||
{ to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false },
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
|
||||
// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px,
|
||||
// desktop ≥ 1200 px.
|
||||
export const TABLET_QUERY = '(min-width: 768px)'
|
||||
export const DESKTOP_QUERY = '(min-width: 1200px)'
|
||||
|
||||
// The layout the current viewport calls for, updated as the viewport crosses
|
||||
// a breakpoint.
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [lists] = useState(() => ({
|
||||
tablet: window.matchMedia(TABLET_QUERY),
|
||||
desktop: window.matchMedia(DESKTOP_QUERY),
|
||||
}))
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
lists.tablet.addEventListener('change', onChange)
|
||||
lists.desktop.addEventListener('change', onChange)
|
||||
return () => {
|
||||
lists.tablet.removeEventListener('change', onChange)
|
||||
lists.desktop.removeEventListener('change', onChange)
|
||||
}
|
||||
},
|
||||
[lists],
|
||||
)
|
||||
return useSyncExternalStore(subscribe, () =>
|
||||
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* App-wide base styles. Values come only from tokens.css. */
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white".
|
||||
Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */
|
||||
/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside)
|
||||
in place of the handoff's Google Fonts @import, so the app never calls Google.
|
||||
Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets.
|
||||
Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */
|
||||
@font-face {
|
||||
font-family: "Bagel Fat One";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bagel Fat One";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Ground + ink */
|
||||
--color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */
|
||||
--color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */
|
||||
--color-text: #201e1d;
|
||||
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||
--on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */
|
||||
|
||||
/* Primary accent — Grape (hue 300) */
|
||||
--color-accent: #5a358c; /* oklch(0.42 0.14 300) */
|
||||
--color-accent-100: #f8f1ff;
|
||||
--color-accent-200: #eedfff;
|
||||
--color-accent-300: #dac3ff;
|
||||
--color-accent-400: #c1a1f9;
|
||||
--color-accent-500: #a47ee1;
|
||||
--color-accent-600: #865fc0;
|
||||
--color-accent-700: #68449b; /* accent-colored small text on light grounds */
|
||||
--color-accent-800: #4a2d73;
|
||||
--color-accent-900: #2d1a49;
|
||||
|
||||
/* Second accent — Teal blue (hue 215) */
|
||||
--color-accent-2: #29889c; /* oklch(0.58 0.09 215) */
|
||||
--color-accent-2-100: #e0fbff;
|
||||
--color-accent-2-200: #bbf3ff;
|
||||
--color-accent-2-300: #88e0f6;
|
||||
--color-accent-2-400: #3fc7e4;
|
||||
--color-accent-2-500: #00a9ca;
|
||||
--color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */
|
||||
--color-accent-2-700: #006b88;
|
||||
--color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */
|
||||
--color-accent-2-900: #002f3e;
|
||||
|
||||
/* Neutrals (unchanged from Organic) */
|
||||
--color-neutral-100: #f9f4ed;
|
||||
--color-neutral-200: #eee7db;
|
||||
--color-neutral-300: #dcd3c4;
|
||||
--color-neutral-400: #c0b6a5;
|
||||
--color-neutral-500: #a19786;
|
||||
--color-neutral-600: #82796a;
|
||||
--color-neutral-700: #645c50;
|
||||
--color-neutral-800: #474238;
|
||||
--color-neutral-900: #2e2b25;
|
||||
|
||||
/* Type */
|
||||
--font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */
|
||||
--font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */
|
||||
--font-mono: ui-monospace, Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 28px;
|
||||
--radius-card: 32px; /* mobile hero cards */
|
||||
--radius-pill: 999px; /* buttons, inputs, tags, nav */
|
||||
|
||||
/* Spacing (Organic, density 1.1) */
|
||||
--space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
|
||||
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
|
||||
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
|
||||
|
||||
/* Motion */
|
||||
--ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */
|
||||
--ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */
|
||||
--ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --motion-scale: 0; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
|
||||
// (css: true). jsdom's getComputedStyle cannot answer two things these
|
||||
// primitives need checked: it resolves var() colours to transparent, and it
|
||||
// evaluates only "all" and "screen" media queries, so styles inside
|
||||
// @media (prefers-reduced-motion: reduce) never apply.
|
||||
|
||||
function cssRules(): CSSRule[] {
|
||||
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
|
||||
}
|
||||
|
||||
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
|
||||
for (const rule of rules) {
|
||||
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
|
||||
for (const property of Array.from(rule.style)) {
|
||||
style.setProperty(property, rule.style.getPropertyValue(property))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The declarations the stylesheets give the element, as written. Read the
|
||||
// shorthands the CSS uses (background, animation): jsdom does not expand
|
||||
// animation, nor any shorthand holding a var(). Later rules win; specificity
|
||||
// is not considered.
|
||||
export function declaredStyle(element: Element): CSSStyleDeclaration {
|
||||
const style = document.createElement('div').style
|
||||
collect(element, cssRules(), style)
|
||||
return style
|
||||
}
|
||||
|
||||
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
|
||||
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
|
||||
const style = document.createElement('div').style
|
||||
for (const rule of cssRules()) {
|
||||
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
|
||||
collect(element, Array.from(rule.cssRules), style)
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// The @keyframes the element's animation runs. CSS Modules hash keyframe
|
||||
// names, so this follows the element's own animation-name.
|
||||
export function animationKeyframes(element: Element): CSSKeyframesRule {
|
||||
const [name] = declaredStyle(element).animation.split(' ')
|
||||
const keyframes = cssRules().find(
|
||||
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
|
||||
)
|
||||
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
|
||||
return keyframes
|
||||
}
|
||||
|
||||
function px(value: string, what: string): number {
|
||||
if (value === '') return 0
|
||||
const match = /^(\d+(?:\.\d+)?)px$/.exec(value)
|
||||
if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
// The border-box size the element's declared rules give it on one axis:
|
||||
// width or height, plus padding and border unless box-sizing is border-box.
|
||||
// jsdom does no layout (getBoundingClientRect is all zeros), so this is the
|
||||
// way to catch padding that would push a fixed box past its specified size.
|
||||
export function outerSize(element: Element, axis: 'width' | 'height'): number {
|
||||
const style = declaredStyle(element)
|
||||
const size = px(style.getPropertyValue(axis), axis)
|
||||
if (style.boxSizing === 'border-box') return size
|
||||
const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom']
|
||||
return sides.reduce(
|
||||
(total, side) =>
|
||||
total +
|
||||
px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) +
|
||||
px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`),
|
||||
size,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import type { ApiLocation, Me } from '../api/index.ts'
|
||||
|
||||
export const ME: Me = {
|
||||
name: 'Sam',
|
||||
email: 'sam@example.com',
|
||||
household: { label: 'Our kitchen' },
|
||||
csrfToken: 'token-1',
|
||||
}
|
||||
|
||||
export function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
export function errorResponse(status: number, code: string, retryable = false): Response {
|
||||
return jsonResponse(status, { error: { code, message: `${code} message`, retryable } })
|
||||
}
|
||||
|
||||
// stubFetch replaces the global fetch with a mock answered from responses in
|
||||
// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetch(...responses: Response[]): Mock<typeof fetch> {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// request returns the method, path and headers of the fetch mock's nth call.
|
||||
export function request(fetchMock: Mock<typeof fetch>, n: number) {
|
||||
const call = fetchMock.mock.calls[n]
|
||||
if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`)
|
||||
const [input, init] = call
|
||||
return {
|
||||
path: String(input),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
headers: new Headers(init?.headers),
|
||||
body: init?.body,
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLocation stands in for window.location on https://mealquest.test,
|
||||
// recording navigations.
|
||||
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
|
||||
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
// Minimum tap target from the handoff's accessibility rules.
|
||||
export const MIN_HIT_AREA = 44
|
||||
|
||||
function px(value: string): number {
|
||||
return value.endsWith('px') ? Number.parseFloat(value) : 0
|
||||
}
|
||||
|
||||
// The tap target an element's stylesheet guarantees: the larger of its
|
||||
// declared size and declared minimum size, per axis. jsdom does no layout, so
|
||||
// this reads the CSS Modules' computed values (Vitest runs with css: true).
|
||||
// A dimension the CSS leaves unsized counts as 0 and fails the check.
|
||||
export function hitArea(element: Element): { width: number; height: number } {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
width: Math.max(px(style.width), px(style.minWidth)),
|
||||
height: Math.max(px(style.height), px(style.minHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
export function expectMinHitArea(element: Element) {
|
||||
const { width, height } = hitArea(element)
|
||||
expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA)
|
||||
expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { act } from '@testing-library/react'
|
||||
|
||||
type Listener = (event: MediaQueryListEvent) => void
|
||||
|
||||
// Replaces window.matchMedia with one that answers min-width / max-width
|
||||
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
|
||||
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
|
||||
// function that resizes the pretend viewport and notifies every query whose
|
||||
// answer changed.
|
||||
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
|
||||
let width = initialWidth
|
||||
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
|
||||
|
||||
function evaluate(query: string): boolean {
|
||||
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
|
||||
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
|
||||
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
|
||||
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
|
||||
return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1]))
|
||||
}
|
||||
|
||||
window.matchMedia = (query: string) => {
|
||||
const listeners = new Set<Listener>()
|
||||
const list = {
|
||||
media: query,
|
||||
get matches() {
|
||||
return evaluate(query)
|
||||
},
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, listener: Listener) => listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener),
|
||||
addListener: (listener: Listener) => listeners.add(listener),
|
||||
removeListener: (listener: Listener) => listeners.delete(listener),
|
||||
dispatchEvent: () => true,
|
||||
} as unknown as MediaQueryList
|
||||
lists.push({ list, listeners })
|
||||
return list
|
||||
}
|
||||
|
||||
return (next: number) => {
|
||||
const before = lists.map(({ list }) => list.matches)
|
||||
width = next
|
||||
act(() => {
|
||||
lists.forEach(({ list, listeners }, i) => {
|
||||
if (list.matches === before[i]) return
|
||||
const event = { matches: list.matches, media: list.media } as MediaQueryListEvent
|
||||
listeners.forEach((listener) => listener(event))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 44px;
|
||||
min-height: 48px;
|
||||
padding: 0 22px;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled),
|
||||
.primary:active:not(:disabled) {
|
||||
background: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.outline {
|
||||
background: transparent;
|
||||
border-color: var(--color-neutral-300);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled),
|
||||
.outline:active:not(:disabled) {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.dark {
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.dark:hover:not(:disabled),
|
||||
.dark:active:not(:disabled) {
|
||||
background: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Button } from './Button.tsx'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
describe('Button', () => {
|
||||
it('is a button that defaults to type="button" and the primary variant', () => {
|
||||
render(<Button>Save changes</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Save changes' })
|
||||
expect(button).toHaveAttribute('type', 'button')
|
||||
expect(button).toHaveClass(styles.button!, styles.primary!)
|
||||
})
|
||||
|
||||
it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => {
|
||||
render(<Button variant={variant}>Go</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toHaveClass(styles[variant]!)
|
||||
expectMinHitArea(button)
|
||||
})
|
||||
|
||||
it('declares the 100 ms pressed scale transition', () => {
|
||||
render(<Button>Go</Button>)
|
||||
expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms')
|
||||
})
|
||||
|
||||
it('does not fire onClick when disabled', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Go
|
||||
</Button>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toBeDisabled()
|
||||
fireEvent.click(button)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards the ref, rest props and an explicit type', () => {
|
||||
const ref = createRef<HTMLButtonElement>()
|
||||
render(
|
||||
<Button ref={ref} type="submit" aria-describedby="hint">
|
||||
Send
|
||||
</Button>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Send' })
|
||||
expect(ref.current).toBe(button)
|
||||
expect(button).toHaveAttribute('type', 'submit')
|
||||
expect(button).toHaveAttribute('aria-describedby', 'hint')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'outline' | 'dark'
|
||||
|
||||
export type ButtonProps = ComponentPropsWithRef<'button'> & {
|
||||
variant?: ButtonVariant
|
||||
}
|
||||
|
||||
// Pill button. Primary is grape with --on-accent text; outline and dark are the
|
||||
// secondary treatments from the handoff. `type` defaults to "button" so a
|
||||
// Button inside a form never submits by accident.
|
||||
export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
|
||||
const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ')
|
||||
return <button type={type} className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.muted {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Card } from './Card.tsx'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders its children on the surface fill by default', () => {
|
||||
render(<Card aria-label="Tonight">Tacos</Card>)
|
||||
const card = screen.getByLabelText('Tonight')
|
||||
expect(card).toHaveTextContent('Tacos')
|
||||
expect(card).toHaveClass(styles.card!, styles.surface!)
|
||||
})
|
||||
|
||||
it('supports the muted tone', () => {
|
||||
render(<Card data-testid="card" tone="muted" />)
|
||||
expect(screen.getByTestId('card')).toHaveClass(styles.muted!)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
export type CardTone = 'surface' | 'muted'
|
||||
|
||||
export type CardProps = ComponentPropsWithRef<'div'> & {
|
||||
// surface = --color-surface; muted = the secondary neutral-100 fill.
|
||||
tone?: CardTone
|
||||
}
|
||||
|
||||
export function Card({ tone = 'surface', className, ...rest }: CardProps) {
|
||||
const classes = [styles.card, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <div className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The check mark. */
|
||||
.input::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--on-accent);
|
||||
clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 21%, 85% 7%, 39% 69%);
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: transform 160ms var(--ease-spring), opacity 160ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.input:checked {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.input:checked::before {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input:disabled + .label {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.input::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Checkbox } from './Checkbox.tsx'
|
||||
|
||||
describe('Checkbox', () => {
|
||||
it('is a native checkbox named by its label', () => {
|
||||
render(<Checkbox label="Chicken thighs" />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Chicken thighs' })
|
||||
expect(box.tagName).toBe('INPUT')
|
||||
expect(box).toHaveAttribute('type', 'checkbox')
|
||||
expect(box).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('toggles when the input or its label text is clicked', () => {
|
||||
render(<Checkbox label="Limes" />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Limes' })
|
||||
fireEvent.click(box)
|
||||
expect(box).toBeChecked()
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(box).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('reflects a controlled checked state and disabled', () => {
|
||||
render(<Checkbox label="Rice" checked disabled readOnly />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Rice' })
|
||||
expect(box).toBeChecked()
|
||||
expect(box).toBeDisabled()
|
||||
})
|
||||
|
||||
it('has a ≥ 44 px hit area (the wrapping label)', () => {
|
||||
render(<Checkbox label="Rice" />)
|
||||
const hit = screen.getByRole('checkbox', { name: 'Rice' }).closest('label')!
|
||||
expectMinHitArea(hit)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
||||
import styles from './Checkbox.module.css'
|
||||
|
||||
export type CheckboxProps = Omit<ComponentPropsWithRef<'input'>, 'type'> & {
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
// Native checkbox, styled. The wrapping label is the hit area (at least
|
||||
// 44×44), so tapping the text toggles it too.
|
||||
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
|
||||
return (
|
||||
<label className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
<input type="checkbox" className={styles.input} {...rest} />
|
||||
<span className={styles.label}>{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removable {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 44 px hit box around a small cross; negative margins keep the chip 36 px
|
||||
tall and the cross close to the label. */
|
||||
.remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -4px 0 -4px -14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Chip } from './Chip.tsx'
|
||||
|
||||
describe('Chip', () => {
|
||||
it('has no remove button unless onRemove is given', () => {
|
||||
render(<Chip label="Beef tacos" />)
|
||||
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes an accessible "Remove {label}" button that calls onRemove', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<Chip label="Beef tacos" onRemove={onRemove} />)
|
||||
const remove = screen.getByRole('button', { name: 'Remove Beef tacos' })
|
||||
expect(remove).toHaveAttribute('type', 'button')
|
||||
fireEvent.click(remove)
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('gives the remove button a ≥ 44 px hit area', () => {
|
||||
render(<Chip label="Pizza night" onRemove={() => {}} />)
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Chip.module.css'
|
||||
|
||||
export type ChipProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
|
||||
label: string
|
||||
// When set, the chip shows a "Remove {label}" button that calls it.
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
export function Chip({ label, onRemove, className, ...rest }: ChipProps) {
|
||||
const classes = [styles.chip, onRemove && styles.removable, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<span className={classes} {...rest}>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button type="button" className={styles.remove} aria-label={`Remove ${label}`} onClick={onRemove}>
|
||||
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
|
||||
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
max-width: 32ch;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user