Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3e7d0e45 | ||
|
|
0fa9cfe611 | ||
|
|
1dd97b123c | ||
|
|
02f1dd6491 | ||
|
|
496f2e5375 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 | ||
|
|
e050736178 | ||
|
|
3ba78d47bc | ||
|
|
8be5c56f6f |
@@ -44,6 +44,7 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
|||||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||||
# trusts no peer, which is right for local development.
|
# trusts no peer, which is right for local development.
|
||||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||||
|
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,11 +52,16 @@ make dev
|
|||||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||||
every variable above is required except the commented-out session
|
every variable above is required except the commented-out session
|
||||||
lifetimes (when set they must be positive, with MAX at least IDLE) 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
|
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
|
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
|
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
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
|
## Common commands
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
"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/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
@@ -52,6 +53,11 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
|||||||
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
t.Setenv(config.EnvOIDCClientID, "mealquest")
|
||||||
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
|
||||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -202,3 +208,163 @@ func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
|||||||
t.Fatalf("Load error = %v, want a config error naming %s", err, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-1
@@ -17,6 +17,8 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
"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/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"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/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"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/internal/store"
|
||||||
@@ -26,6 +28,9 @@ import (
|
|||||||
// version is set at build time with -ldflags "-X main.version=<version>".
|
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
// jobWorkers is the number of background jobs run at once.
|
||||||
|
const jobWorkers = 2
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,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)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -74,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -136,6 +162,58 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
|||||||
return db, nil
|
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
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const (
|
|||||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||||
|
|
||||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||||
|
|
||||||
|
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
// 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
|
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
|
||||||
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
||||||
TrustedProxy forwarded.Trusted
|
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
|
// 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),
|
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||||
|
|
||||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||||
|
|
||||||
|
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||||
}
|
}
|
||||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, 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
|
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
|
// secretFile reads a secret from the file named by name, which must end in
|
||||||
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||||
func (r *reader) secretFile(name string) Secret {
|
func (r *reader) secretFile(name string) Secret {
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ func TestLoad(t *testing.T) {
|
|||||||
name: "all required vars present",
|
name: "all required vars present",
|
||||||
env: valid(),
|
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",
|
name: "one missing",
|
||||||
env: with(nil, EnvHTTPAddr),
|
env: with(nil, EnvHTTPAddr),
|
||||||
@@ -410,6 +420,8 @@ func TestLoadValues(t *testing.T) {
|
|||||||
EnvOIDCClientID: "mealquest",
|
EnvOIDCClientID: "mealquest",
|
||||||
EnvOIDCClientSecretFile: clientSecretPath,
|
EnvOIDCClientSecretFile: clientSecretPath,
|
||||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||||
|
|
||||||
|
EnvDefaultTZ: "Europe/London",
|
||||||
}
|
}
|
||||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -465,6 +477,9 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if !slices.Equal(cfg.TrustedProxy, want) {
|
if !slices.Equal(cfg.TrustedProxy, want) {
|
||||||
t.Errorf("TrustedProxy = %v, want %v", 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) {
|
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
|
UpdatesSeenAt sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
UpdatedBy sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntegrationsGoogle struct {
|
type IntegrationsGoogle struct {
|
||||||
|
|||||||
@@ -6,17 +6,25 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Querier interface {
|
type Querier interface {
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
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)
|
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||||
|
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(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.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
@@ -26,6 +34,7 @@ type Querier interface {
|
|||||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
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
|
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||||
// unique, so the earliest-created (lowest id) match is returned.
|
// unique, so the earliest-created (lowest id) match is returned.
|
||||||
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error)
|
||||||
@@ -36,8 +45,11 @@ type Querier interface {
|
|||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, 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.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
|
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||||
// Creates the single row (id 1) or replaces every column except id and
|
// 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
|
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
|
WHERE id = 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, e
|
|||||||
&i.UpdatesSeenAt,
|
&i.UpdatesSeenAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.UpdatedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -248,12 +249,12 @@ INSERT INTO household_settings (
|
|||||||
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||||
created_at, updated_at
|
updated_by, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
1, ?, ?, ?, ?, ?,
|
1, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?,
|
?, ?, ?, ?,
|
||||||
?, ?, ?,
|
?, ?, ?,
|
||||||
?, ?
|
?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
timezone = excluded.timezone,
|
timezone = excluded.timezone,
|
||||||
@@ -268,8 +269,9 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
chat_autosend_time = excluded.chat_autosend_time,
|
chat_autosend_time = excluded.chat_autosend_time,
|
||||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||||
updates_seen_at = excluded.updates_seen_at,
|
updates_seen_at = excluded.updates_seen_at,
|
||||||
|
updated_by = excluded.updated_by,
|
||||||
updated_at = excluded.updated_at
|
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 {
|
type UpsertHouseholdSettingsParams struct {
|
||||||
@@ -285,6 +287,7 @@ type UpsertHouseholdSettingsParams struct {
|
|||||||
ChatAutosendTime string
|
ChatAutosendTime string
|
||||||
OnboardingCompletedAt sql.NullString
|
OnboardingCompletedAt sql.NullString
|
||||||
UpdatesSeenAt sql.NullString
|
UpdatesSeenAt sql.NullString
|
||||||
|
UpdatedBy sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
@@ -305,6 +308,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho
|
|||||||
arg.ChatAutosendTime,
|
arg.ChatAutosendTime,
|
||||||
arg.OnboardingCompletedAt,
|
arg.OnboardingCompletedAt,
|
||||||
arg.UpdatesSeenAt,
|
arg.UpdatesSeenAt,
|
||||||
|
arg.UpdatedBy,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -325,6 +329,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho
|
|||||||
&i.UpdatesSeenAt,
|
&i.UpdatesSeenAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.UpdatedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,9 +34,10 @@ func TestCSRFThroughRouter(t *testing.T) {
|
|||||||
{"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON},
|
{"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},
|
{"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
|
// 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},
|
{"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, ""},
|
{"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""},
|
||||||
// Only GET is exempt; HEAD and OPTIONS need the header too.
|
// Only GET is exempt; HEAD and OPTIONS need the header too.
|
||||||
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
{"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON},
|
||||||
|
|||||||
@@ -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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions,
|
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{},
|
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-15
@@ -3,6 +3,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,8 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"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/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
|
// reservedPrefixes are owned by the API router and are never answered with the
|
||||||
@@ -26,9 +29,17 @@ type RouteMounter interface {
|
|||||||
// has its client address and scheme resolved, honouring forwarded headers
|
// has its client address and scheme resolved, honouring forwarded headers
|
||||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
// 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
|
// /media; settings serves /api/v1/settings/household; runner serves
|
||||||
// and the web build are public. Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) {
|
// 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")
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
|
|
||||||
@@ -55,6 +66,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
sub.NotFound(notFound)
|
sub.NotFound(notFound)
|
||||||
}
|
}
|
||||||
apiV1.Get("/me", me(logger))
|
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.Route("/api", func(r chi.Router) {
|
||||||
r.MethodNotAllowed(methodNotAllowed)
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
r.NotFound(notFound)
|
r.NotFound(notFound)
|
||||||
@@ -80,19 +95,8 @@ type apiError struct {
|
|||||||
|
|
||||||
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
// apiErrorHandler responds with status and a fixed, non-retryable API error.
|
||||||
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeError(w, r, logger, status, code, message)
|
||||||
w.WriteHeader(status)
|
|
||||||
if _, err := w.Write(body); err != nil {
|
|
||||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -12,6 +13,11 @@ import (
|
|||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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 (
|
const (
|
||||||
@@ -21,13 +27,18 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newTestRouter(t *testing.T, version string) http.Handler {
|
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()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
assets := fstest.MapFS{
|
assets := fstest.MapFS{
|
||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestHealth(t *testing.T) {
|
||||||
t.Parallel()
|
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 {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
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", got)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A database migrated by the bootstrap-only build upgrades to the
|
// 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 {
|
if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
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", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
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)
|
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
|
r["attempts"] = 0
|
||||||
case "jobs":
|
case "jobs":
|
||||||
r["kind"] = "suggest"
|
r["kind"] = "suggest"
|
||||||
r["status"] = "pending"
|
r["status"] = "queued"
|
||||||
r["payload"] = "{}"
|
r["payload"] = "{}"
|
||||||
case "schedule_runs":
|
case "schedule_runs":
|
||||||
r["schedule"] = "plan_draft"
|
r["schedule"] = "plan_draft"
|
||||||
@@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) {
|
|||||||
{"notification_prefs", "chat", 2},
|
{"notification_prefs", "chat", 2},
|
||||||
{"notification_outbox", "channel", "email"},
|
{"notification_outbox", "channel", "email"},
|
||||||
{"notification_outbox", "status", "queued"},
|
{"notification_outbox", "status", "queued"},
|
||||||
{"jobs", "status", "queued"},
|
{"jobs", "status", "bogus"},
|
||||||
{"schedule_runs", "schedule", "hourly"},
|
{"schedule_runs", "schedule", "hourly"},
|
||||||
}
|
}
|
||||||
s := seededStore(t)
|
s := seededStore(t)
|
||||||
|
|||||||
@@ -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,
|
id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow,
|
||||||
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow,
|
||||||
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
chat_autosend_time, onboarding_completed_at, updates_seen_at,
|
||||||
created_at, updated_at
|
updated_by, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
1, ?, ?, ?, ?, ?,
|
1, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?,
|
?, ?, ?, ?,
|
||||||
?, ?, ?,
|
?, ?, ?,
|
||||||
?, ?
|
?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
timezone = excluded.timezone,
|
timezone = excluded.timezone,
|
||||||
@@ -30,6 +30,7 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
chat_autosend_time = excluded.chat_autosend_time,
|
chat_autosend_time = excluded.chat_autosend_time,
|
||||||
onboarding_completed_at = excluded.onboarding_completed_at,
|
onboarding_completed_at = excluded.onboarding_completed_at,
|
||||||
updates_seen_at = excluded.updates_seen_at,
|
updates_seen_at = excluded.updates_seen_at,
|
||||||
|
updated_by = excluded.updated_by,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -5,10 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.104.1",
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
|||||||
@@ -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')
|
||||||
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.
@@ -1,6 +1,8 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router'
|
import { BrowserRouter } from 'react-router'
|
||||||
|
import './styles/tokens.css'
|
||||||
|
import './styles/base.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementById('root')
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* App-wide base styles. Values come only from tokens.css. */
|
||||||
|
body {
|
||||||
|
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,50 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { EmptyState } from './EmptyState.tsx'
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('shows a Bagel title and body without an action by default', () => {
|
||||||
|
render(<EmptyState title="Quest log is empty">Swipe Try it on a Side quest to save it here.</EmptyState>)
|
||||||
|
const title = screen.getByRole('heading', { name: 'Quest log is empty' })
|
||||||
|
expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)')
|
||||||
|
expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the optional primary action and calls its handler', () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
render(
|
||||||
|
<EmptyState title="No Regulars yet" action={{ label: 'Add a dinner', onClick }}>
|
||||||
|
Add the dinners you make most.
|
||||||
|
</EmptyState>,
|
||||||
|
)
|
||||||
|
const action = screen.getByRole('button', { name: 'Add a dinner' })
|
||||||
|
expectMinHitArea(action)
|
||||||
|
fireEvent.click(action)
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Button } from './Button.tsx'
|
||||||
|
import styles from './EmptyState.module.css'
|
||||||
|
|
||||||
|
export type EmptyStateAction = {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmptyStateProps = {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
// Optional primary action, rendered as a primary Button.
|
||||||
|
action?: EmptyStateAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bagel title, body text and an optional primary action.
|
||||||
|
export function EmptyState({ title, children, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<section className={styles.empty}>
|
||||||
|
<h2 className={styles.title}>{title}</h2>
|
||||||
|
<p className={styles.body}>{children}</p>
|
||||||
|
{action && (
|
||||||
|
<Button className={styles.action} onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { ErrorBanner } from './ErrorBanner.tsx'
|
||||||
|
|
||||||
|
describe('ErrorBanner', () => {
|
||||||
|
it('announces the message and calls onRetry from the Retry button', () => {
|
||||||
|
const onRetry = vi.fn()
|
||||||
|
render(<ErrorBanner onRetry={onRetry}>Couldn't load the Supply run.</ErrorBanner>)
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.")
|
||||||
|
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||||
|
expectMinHitArea(retry)
|
||||||
|
fireEvent.click(retry)
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a caller-supplied retry label', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBanner onRetry={() => {}} retryLabel="Rebuild week">
|
||||||
|
The plan didn't save.
|
||||||
|
</ErrorBanner>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a neutral-100 card with grape-700 text', () => {
|
||||||
|
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
|
||||||
|
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
|
||||||
|
expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Button } from './Button.tsx'
|
||||||
|
import { Card } from './Card.tsx'
|
||||||
|
import styles from './ErrorBanner.module.css'
|
||||||
|
|
||||||
|
export type ErrorBannerProps = {
|
||||||
|
// What went wrong, in the caller's words.
|
||||||
|
children: ReactNode
|
||||||
|
onRetry: () => void
|
||||||
|
retryLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muted neutral-100 card with grape-700 text and a Retry button.
|
||||||
|
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
|
||||||
|
return (
|
||||||
|
<Card tone="muted" role="alert" className={styles.banner}>
|
||||||
|
<p className={styles.message}>{children}</p>
|
||||||
|
<Button onClick={onRetry}>{retryLabel}</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* The button is the hit box; .face is the visible circle. A 40 px face sits in
|
||||||
|
a 44 px hit box whose negative margin keeps the 40 px layout footprint. */
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size40 {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size62 {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
transition: transform 100ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size40 .face {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size62 .face {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active:not(:disabled) .face {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teal {
|
||||||
|
background: var(--color-accent-2-600);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.face {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { IconButton } from './IconButton.tsx'
|
||||||
|
|
||||||
|
describe('IconButton', () => {
|
||||||
|
it('is named by its label, not its icon', () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
render(
|
||||||
|
<IconButton label="Notifications" onClick={onClick}>
|
||||||
|
<svg />
|
||||||
|
</IconButton>,
|
||||||
|
)
|
||||||
|
const button = screen.getByRole('button', { name: 'Notifications' })
|
||||||
|
expect(button).toHaveAttribute('type', 'button')
|
||||||
|
fireEvent.click(button)
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a 44 px hit area around the 40 px circle', () => {
|
||||||
|
render(<IconButton label="Back" size={40} />)
|
||||||
|
const button = screen.getByRole('button', { name: 'Back' })
|
||||||
|
expectMinHitArea(button)
|
||||||
|
const face = button.firstElementChild!
|
||||||
|
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is 62 px at the large size', () => {
|
||||||
|
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||||
|
const button = screen.getByRole('button', { name: 'Try it' })
|
||||||
|
expectMinHitArea(button)
|
||||||
|
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ComponentPropsWithRef } from 'react'
|
||||||
|
import styles from './IconButton.module.css'
|
||||||
|
|
||||||
|
export type IconButtonSize = 40 | 62
|
||||||
|
export type IconButtonTone = 'surface' | 'accent' | 'teal'
|
||||||
|
|
||||||
|
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||||
|
// Accessible name; an icon alone never names a button.
|
||||||
|
label: string
|
||||||
|
size?: IconButtonSize
|
||||||
|
tone?: IconButtonTone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
|
||||||
|
// the 62 px size is the round action button with --shadow-md.
|
||||||
|
export function IconButton({
|
||||||
|
label,
|
||||||
|
size = 40,
|
||||||
|
tone = 'surface',
|
||||||
|
type = 'button',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: IconButtonProps) {
|
||||||
|
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
|
||||||
|
return (
|
||||||
|
<button type={type} aria-label={label} className={classes} {...rest}>
|
||||||
|
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.row {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive:hover {
|
||||||
|
background: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive > .trailing {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading,
|
||||||
|
.trailing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { ListRow } from './ListRow.tsx'
|
||||||
|
import { Toggle } from './Toggle.tsx'
|
||||||
|
|
||||||
|
describe('ListRow', () => {
|
||||||
|
it('renders leading, title, meta and trailing slots without being interactive', () => {
|
||||||
|
render(
|
||||||
|
<ListRow
|
||||||
|
leading={<span>icon</span>}
|
||||||
|
title="Notifications"
|
||||||
|
meta="Push and Google Chat"
|
||||||
|
trailing={<span>chevron</span>}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) {
|
||||||
|
expect(screen.getByText(text)).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a single button with a ≥ 44 px hit area when it has onClick', () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
render(<ListRow title="10 new side quests to swipe" onClick={onClick} />)
|
||||||
|
const row = screen.getByRole('button', { name: '10 new side quests to swipe' })
|
||||||
|
expect(row).toHaveAttribute('type', 'button')
|
||||||
|
expectMinHitArea(row)
|
||||||
|
fireEvent.click(row)
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a trailing toggle outside the row button so each control fires only its own action', () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
const onCheckedChange = vi.fn()
|
||||||
|
render(
|
||||||
|
<ListRow
|
||||||
|
title="Dinner reminders"
|
||||||
|
meta="Push at 4 pm"
|
||||||
|
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
|
||||||
|
onClick={onClick}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const row = screen.getByRole('button', { name: /^Dinner reminders/ })
|
||||||
|
const toggle = screen.getByRole('switch', { name: 'Dinner reminders' })
|
||||||
|
expect(row).not.toContainElement(toggle)
|
||||||
|
expect(row.querySelector('button')).toBeNull()
|
||||||
|
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)
|
||||||
|
expect(onClick).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.click(row)
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
expect(onCheckedChange).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import styles from './ListRow.module.css'
|
||||||
|
|
||||||
|
export type ListRowProps = {
|
||||||
|
title: ReactNode
|
||||||
|
meta?: ReactNode
|
||||||
|
// Leading slot: icon, photo or avatar.
|
||||||
|
leading?: ReactNode
|
||||||
|
// Trailing slot: chevron, toggle, count or action.
|
||||||
|
trailing?: ReactNode
|
||||||
|
// When set, the leading slot, title and meta form one button. The trailing
|
||||||
|
// slot stays a sibling outside it, so a trailing toggle or action is never
|
||||||
|
// nested in the row's button and never triggers the row's action.
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) {
|
||||||
|
const main = (
|
||||||
|
<>
|
||||||
|
{leading && <span className={styles.leading}>{leading}</span>}
|
||||||
|
<span className={styles.body}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
{meta && <span className={styles.meta}>{meta}</span>}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
|
||||||
|
<button type="button" className={styles.main} onClick={onClick}>
|
||||||
|
{main}
|
||||||
|
</button>
|
||||||
|
{trailingSlot}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={[styles.row, className].filter(Boolean).join(' ')}>
|
||||||
|
{main}
|
||||||
|
{trailingSlot}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.track {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.option {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { Segmented } from './Segmented.tsx'
|
||||||
|
import styles from './Segmented.module.css'
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'week', label: 'Week' },
|
||||||
|
{ value: 'month', label: 'Month' },
|
||||||
|
{ value: 'year', label: 'Year' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type Period = (typeof options)[number]['value']
|
||||||
|
|
||||||
|
function Harness({ initial = 'week' }: { initial?: Period }) {
|
||||||
|
const [value, setValue] = useState<Period>(initial)
|
||||||
|
return <Segmented label="Archive period" options={options} value={value} onValueChange={setValue} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function radio(name: string) {
|
||||||
|
return screen.getByRole('radio', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Segmented', () => {
|
||||||
|
it('is a named radiogroup of radios with one checked', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||||
|
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(radio('Month')).toHaveAttribute('aria-checked', 'false')
|
||||||
|
expect(radio('Week')).toHaveClass(styles.selected!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a single tab stop on the selected option', () => {
|
||||||
|
render(<Harness initial="month" />)
|
||||||
|
expect(radio('Month')).toHaveAttribute('tabindex', '0')
|
||||||
|
expect(radio('Week')).toHaveAttribute('tabindex', '-1')
|
||||||
|
expect(radio('Year')).toHaveAttribute('tabindex', '-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves selection and focus with the arrow keys, wrapping at the ends', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
radio('Week').focus()
|
||||||
|
|
||||||
|
fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' })
|
||||||
|
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(radio('Month')).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' })
|
||||||
|
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' })
|
||||||
|
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(radio('Week')).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' })
|
||||||
|
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' })
|
||||||
|
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(radio('Month')).toHaveFocus()
|
||||||
|
expect(radio('Year')).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('jumps to the first and last option with Home and End', () => {
|
||||||
|
render(<Harness initial="month" />)
|
||||||
|
fireEvent.keyDown(radio('Month'), { key: 'End' })
|
||||||
|
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
fireEvent.keyDown(radio('Year'), { key: 'Home' })
|
||||||
|
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects an option on click', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
fireEvent.click(radio('Year'))
|
||||||
|
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
expect(radio('Week')).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives every option a ≥ 44 px hit area', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast when the value is not one of the options', () => {
|
||||||
|
expect(() =>
|
||||||
|
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||||
|
).toThrow('value "decade" is not one of its options')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useRef, type KeyboardEvent } from 'react'
|
||||||
|
import styles from './Segmented.module.css'
|
||||||
|
|
||||||
|
export type SegmentedOption<T extends string> = {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SegmentedProps<T extends string> = {
|
||||||
|
// Accessible name of the radiogroup.
|
||||||
|
label: string
|
||||||
|
options: readonly SegmentedOption<T>[]
|
||||||
|
value: T
|
||||||
|
onValueChange: (value: T) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
|
||||||
|
|
||||||
|
// Segmented control with radiogroup semantics: one tab stop (the selected
|
||||||
|
// option); arrow keys move the selection and focus, wrapping at the ends;
|
||||||
|
// Home and End jump to the first and last option.
|
||||||
|
export function Segmented<T extends string>({ label, options, value, onValueChange, className }: SegmentedProps<T>) {
|
||||||
|
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
const selected = options.findIndex((option) => option.value === value)
|
||||||
|
if (selected === -1) {
|
||||||
|
throw new Error(`Segmented "${label}": value "${value}" is not one of its options`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(index: number) {
|
||||||
|
const option = options[index]
|
||||||
|
if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`)
|
||||||
|
onValueChange(option.value)
|
||||||
|
refs.current[index]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
const delta = step[event.key]
|
||||||
|
let next: number
|
||||||
|
if (delta !== undefined) next = (selected + delta + options.length) % options.length
|
||||||
|
else if (event.key === 'Home') next = 0
|
||||||
|
else if (event.key === 'End') next = options.length - 1
|
||||||
|
else return
|
||||||
|
event.preventDefault()
|
||||||
|
select(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={label}
|
||||||
|
className={[styles.track, className].filter(Boolean).join(' ')}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const checked = index === selected
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
ref={(el) => {
|
||||||
|
refs.current[index] = el
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={checked}
|
||||||
|
tabIndex={checked ? 0 : -1}
|
||||||
|
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||||
|
onClick={() => select(index)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||||
|
animation: fade 320ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
max-height: 90dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(0);
|
||||||
|
animation: rise 320ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scrim,
|
||||||
|
.panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import '../styles/tokens.css'
|
||||||
|
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
|
||||||
|
import { Sheet } from './Sheet.tsx'
|
||||||
|
import styles from './Sheet.module.css'
|
||||||
|
|
||||||
|
function Harness({ withControls = true }: { withControls?: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
|
Open Supply run
|
||||||
|
</button>
|
||||||
|
<Sheet aria-label="Supply run" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<p>Grouped by dinner</p>
|
||||||
|
{withControls && (
|
||||||
|
<>
|
||||||
|
<button type="button">Try it</button>
|
||||||
|
<button type="button">Pass</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSheet() {
|
||||||
|
const opener = screen.getByRole('button', { name: 'Open Supply run' })
|
||||||
|
opener.focus()
|
||||||
|
fireEvent.click(opener)
|
||||||
|
return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrim() {
|
||||||
|
const element = document.querySelector(`.${styles.scrim}`)
|
||||||
|
if (!element) throw new Error('scrim not rendered')
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sheet', () => {
|
||||||
|
it('renders nothing while closed', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a labelled modal dialog', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
const { dialog } = openSheet()
|
||||||
|
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts aria-labelledby as its accessible name', () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<h2 id="sheet-title">Quest log</h2>
|
||||||
|
<Sheet aria-labelledby="sheet-title" open onClose={() => {}}>
|
||||||
|
<p>Nothing yet</p>
|
||||||
|
</Sheet>
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves focus to the first control and restores it to the opener on close', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
const { opener } = openSheet()
|
||||||
|
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
expect(opener).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the dialog itself when it has no focusable control', () => {
|
||||||
|
render(<Harness withControls={false} />)
|
||||||
|
const { dialog } = openSheet()
|
||||||
|
expect(dialog).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips controls that cannot take keyboard focus when moving focus in', () => {
|
||||||
|
render(
|
||||||
|
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||||
|
<button type="button" hidden>
|
||||||
|
Hidden
|
||||||
|
</button>
|
||||||
|
<button type="button" style={{ display: 'none' }}>
|
||||||
|
Not displayed
|
||||||
|
</button>
|
||||||
|
<button type="button" style={{ visibility: 'hidden' }}>
|
||||||
|
Invisible
|
||||||
|
</button>
|
||||||
|
<button type="button" tabIndex={-1}>
|
||||||
|
Not tabbable
|
||||||
|
</button>
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<button type="button">Aria hidden</button>
|
||||||
|
</div>
|
||||||
|
<button type="button">Try it</button>
|
||||||
|
</Sheet>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the dialog itself when every control is hidden', () => {
|
||||||
|
render(
|
||||||
|
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||||
|
<button type="button" hidden>
|
||||||
|
Hidden
|
||||||
|
</button>
|
||||||
|
</Sheet>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not stop the Tab trap on a hidden control', () => {
|
||||||
|
render(
|
||||||
|
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||||
|
<button type="button" hidden>
|
||||||
|
Hidden first
|
||||||
|
</button>
|
||||||
|
<button type="button">Try it</button>
|
||||||
|
<button type="button">Pass</button>
|
||||||
|
<button type="button" hidden>
|
||||||
|
Hidden last
|
||||||
|
</button>
|
||||||
|
</Sheet>,
|
||||||
|
)
|
||||||
|
const first = screen.getByRole('button', { name: 'Try it' })
|
||||||
|
const last = screen.getByRole('button', { name: 'Pass' })
|
||||||
|
|
||||||
|
last.focus()
|
||||||
|
fireEvent.keyDown(last, { key: 'Tab' })
|
||||||
|
expect(first).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||||
|
expect(last).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not stop the Tab trap on a control disabled by its fieldset', () => {
|
||||||
|
render(
|
||||||
|
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||||
|
<fieldset disabled>
|
||||||
|
<button type="button">Disabled first</button>
|
||||||
|
</fieldset>
|
||||||
|
<button type="button">Try it</button>
|
||||||
|
<button type="button">Pass</button>
|
||||||
|
<fieldset disabled>
|
||||||
|
<button type="button">Disabled last</button>
|
||||||
|
</fieldset>
|
||||||
|
</Sheet>,
|
||||||
|
)
|
||||||
|
const first = screen.getByRole('button', { name: 'Try it' })
|
||||||
|
const last = screen.getByRole('button', { name: 'Pass' })
|
||||||
|
expect(first).toHaveFocus()
|
||||||
|
|
||||||
|
last.focus()
|
||||||
|
fireEvent.keyDown(last, { key: 'Tab' })
|
||||||
|
expect(first).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||||
|
expect(last).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps Tab and Shift+Tab inside the sheet', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
openSheet()
|
||||||
|
const first = screen.getByRole('button', { name: 'Try it' })
|
||||||
|
const last = screen.getByRole('button', { name: 'Pass' })
|
||||||
|
|
||||||
|
last.focus()
|
||||||
|
fireEvent.keyDown(last, { key: 'Tab' })
|
||||||
|
expect(first).toHaveFocus()
|
||||||
|
|
||||||
|
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||||
|
expect(last).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps focus on the dialog when Tab has nowhere to go', () => {
|
||||||
|
render(<Harness withControls={false} />)
|
||||||
|
const { dialog } = openSheet()
|
||||||
|
const tab = fireEvent.keyDown(dialog, { key: 'Tab' })
|
||||||
|
expect(tab).toBe(false) // default prevented
|
||||||
|
expect(dialog).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<Sheet aria-label="Supply run" open onClose={onClose}>
|
||||||
|
<button type="button">Try it</button>
|
||||||
|
</Sheet>,
|
||||||
|
)
|
||||||
|
fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' })
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on a scrim click but not on a panel click', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<Sheet aria-label="Supply run" open onClose={onClose}>
|
||||||
|
<p>Grouped by dinner</p>
|
||||||
|
</Sheet>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('dialog'))
|
||||||
|
fireEvent.click(screen.getByText('Grouped by dinner'))
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
fireEvent.click(scrim())
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes the background inert while open and restores it on close', () => {
|
||||||
|
const { container } = render(<Harness />)
|
||||||
|
openSheet()
|
||||||
|
const root = screen.getByRole('dialog').parentElement!
|
||||||
|
expect(root.parentElement).toBe(document.body)
|
||||||
|
expect(container).toHaveAttribute('inert')
|
||||||
|
expect(root).not.toHaveAttribute('inert')
|
||||||
|
|
||||||
|
fireEvent.click(scrim())
|
||||||
|
expect(container).not.toHaveAttribute('inert')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an already-inert background element inert after close', () => {
|
||||||
|
const other = document.createElement('div')
|
||||||
|
other.setAttribute('inert', '')
|
||||||
|
document.body.append(other)
|
||||||
|
try {
|
||||||
|
render(<Harness />)
|
||||||
|
openSheet()
|
||||||
|
fireEvent.click(scrim())
|
||||||
|
expect(other).toHaveAttribute('inert')
|
||||||
|
} finally {
|
||||||
|
other.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rises from 100% to 0 over 320 ms with the soft ease-out', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
const { dialog } = openSheet()
|
||||||
|
expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
|
||||||
|
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft')
|
||||||
|
expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)')
|
||||||
|
const rise = animationKeyframes(dialog)
|
||||||
|
expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)')
|
||||||
|
expect(rise.findRule('to')?.style.transform).toBe('translateY(0)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fades the scrim in to 50% neutral-900', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
openSheet()
|
||||||
|
const style = declaredStyle(scrim())
|
||||||
|
expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)')
|
||||||
|
expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
|
||||||
|
const fade = animationKeyframes(scrim())
|
||||||
|
expect(fade.findRule('from')?.style.opacity).toBe('0')
|
||||||
|
expect(fade.findRule('to')?.style.opacity).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens in place with no rise or fade under reduced motion', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
const { dialog } = openSheet()
|
||||||
|
expect(reducedMotionStyle(dialog).animation).toBe('none')
|
||||||
|
expect(reducedMotionStyle(scrim()).animation).toBe('none')
|
||||||
|
expect(declaredStyle(dialog).transform).toBe('translateY(0)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import styles from './Sheet.module.css'
|
||||||
|
|
||||||
|
// The dialog's accessible name: exactly one of a label or a labelling element.
|
||||||
|
type SheetName =
|
||||||
|
| { 'aria-label': string; 'aria-labelledby'?: never }
|
||||||
|
| { 'aria-labelledby': string; 'aria-label'?: never }
|
||||||
|
|
||||||
|
export type SheetProps = SheetName & {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
'a[href]',
|
||||||
|
'button',
|
||||||
|
'input',
|
||||||
|
'select',
|
||||||
|
'textarea',
|
||||||
|
'[tabindex]',
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
// Whether an element can take keyboard focus: being disabled (by its own
|
||||||
|
// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden
|
||||||
|
// or inert ancestor (or self), display: none on the way up to the panel, or
|
||||||
|
// visibility: hidden all rule it out.
|
||||||
|
function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean {
|
||||||
|
if (element.matches(':disabled')) return false
|
||||||
|
if (element.tabIndex < 0) return false
|
||||||
|
if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false
|
||||||
|
if (getComputedStyle(element).visibility === 'hidden') return false
|
||||||
|
for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) {
|
||||||
|
if (getComputedStyle(node).display === 'none') return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusables(panel: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) =>
|
||||||
|
isKeyboardFocusable(element, panel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focuses the first control that actually takes focus, or the panel if none does.
|
||||||
|
function focusFirst(panel: HTMLElement) {
|
||||||
|
for (const element of focusables(panel)) {
|
||||||
|
element.focus()
|
||||||
|
if (document.activeElement === element) return
|
||||||
|
}
|
||||||
|
panel.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal bottom sheet. Portalled to <body> so everything else there can be made
|
||||||
|
// inert while it is open. Focus moves in on open, Tab is trapped inside, and
|
||||||
|
// focus returns to the opener on close. Escape and a scrim click call onClose.
|
||||||
|
export function Sheet({ open, onClose, children, ...name }: SheetProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current
|
||||||
|
const panel = panelRef.current
|
||||||
|
if (!open || !root || !panel) return
|
||||||
|
|
||||||
|
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
|
const background = Array.from(document.body.children).filter(
|
||||||
|
(element) => element !== root && !element.hasAttribute('inert'),
|
||||||
|
)
|
||||||
|
for (const element of background) element.setAttribute('inert', '')
|
||||||
|
focusFirst(panel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const element of background) element.removeAttribute('inert')
|
||||||
|
opener?.focus()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab' || !panelRef.current) return
|
||||||
|
const items = focusables(panelRef.current)
|
||||||
|
const first = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
if (!first || !last) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const active = document.activeElement
|
||||||
|
if (event.shiftKey && (active === first || active === panelRef.current)) {
|
||||||
|
event.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!event.shiftKey && active === last) {
|
||||||
|
event.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div ref={rootRef} className={styles.root}>
|
||||||
|
<div className={styles.scrim} aria-hidden="true" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={styles.panel}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
{...name}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.skeleton {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
|
||||||
|
import { Skeleton } from './Skeleton.tsx'
|
||||||
|
|
||||||
|
function skeleton(container: HTMLElement) {
|
||||||
|
return container.firstElementChild as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Skeleton', () => {
|
||||||
|
it('is a hidden pill in --color-surface', () => {
|
||||||
|
const { container } = render(<Skeleton />)
|
||||||
|
const element = skeleton(container)
|
||||||
|
expect(element).toHaveAttribute('aria-hidden', 'true')
|
||||||
|
const style = declaredStyle(element)
|
||||||
|
expect(style.borderRadius).toBe('var(--radius-pill)')
|
||||||
|
expect(style.background).toBe('var(--color-surface)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pulses its opacity over 1.2 s', () => {
|
||||||
|
const { container } = render(<Skeleton />)
|
||||||
|
expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite'])
|
||||||
|
expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not pulse under reduced motion', () => {
|
||||||
|
const { container } = render(<Skeleton />)
|
||||||
|
expect(reducedMotionStyle(skeleton(container)).animation).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges a caller className and forwards style', () => {
|
||||||
|
const { container } = render(<Skeleton className="line" style={{ width: '60%' }} />)
|
||||||
|
expect(skeleton(container)).toHaveClass('line')
|
||||||
|
expect(skeleton(container).style.width).toBe('60%')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentPropsWithRef } from 'react'
|
||||||
|
import styles from './Skeleton.module.css'
|
||||||
|
|
||||||
|
export type SkeletonProps = Omit<ComponentPropsWithRef<'div'>, 'children'>
|
||||||
|
|
||||||
|
// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse.
|
||||||
|
// Full width and 16 px tall by default; size it with className or style.
|
||||||
|
// Hidden from assistive technology — announce loading on the container.
|
||||||
|
export function Skeleton({ className, ...rest }: SkeletonProps) {
|
||||||
|
const classes = [styles.skeleton, className].filter(Boolean).join(' ')
|
||||||
|
return <div aria-hidden="true" className={classes} {...rest} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* The input is the 44 px tall hit area; the track and knob are drawn by the
|
||||||
|
vendor pseudo-elements. --slider-fill is set inline from the value. */
|
||||||
|
.slider {
|
||||||
|
appearance: none;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-runnable-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--color-accent) var(--slider-fill),
|
||||||
|
var(--color-neutral-300) var(--slider-fill)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: -8px;
|
||||||
|
border: 3px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-progress {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { Slider } from './Slider.tsx'
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const [value, setValue] = useState(0.7)
|
||||||
|
return (
|
||||||
|
<Slider
|
||||||
|
aria-label="Creativity (temperature)"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Slider', () => {
|
||||||
|
it('is a native slider exposing its range and value', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' })
|
||||||
|
expect(slider).toHaveAttribute('type', 'range')
|
||||||
|
expect(slider).toHaveAttribute('min', '0')
|
||||||
|
expect(slider).toHaveAttribute('max', '1')
|
||||||
|
expect(slider).toHaveValue('0.7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the grape fill with the value', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
const slider = screen.getByRole('slider')
|
||||||
|
expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%')
|
||||||
|
fireEvent.change(slider, { target: { value: '0.2' } })
|
||||||
|
expect(slider).toHaveValue('0.2')
|
||||||
|
expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a ≥ 44 px hit area', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expectMinHitArea(screen.getByRole('slider'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast on an empty range', () => {
|
||||||
|
expect(() => render(<Slider aria-label="Bad" min={1} max={1} value={1} readOnly />)).toThrow(
|
||||||
|
'max (1) must be greater than min (1)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts values at both ends of the range', () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<Slider aria-label="At min" min={0} max={1} value={0} readOnly />
|
||||||
|
<Slider aria-label="At max" min={0} max={1} value={1} readOnly />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%')
|
||||||
|
expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[2, 'value (2) must be within [0, 1]'],
|
||||||
|
[-0.1, 'value (-0.1) must be within [0, 1]'],
|
||||||
|
[Number.NaN, 'value (NaN) must be within [0, 1]'],
|
||||||
|
])('fails fast when the value %s is outside the range', (value, message) => {
|
||||||
|
expect(() => render(<Slider aria-label="Bad" min={0} max={1} value={value} readOnly />)).toThrow(message)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { CSSProperties, ComponentPropsWithRef } from 'react'
|
||||||
|
import styles from './Slider.module.css'
|
||||||
|
|
||||||
|
export type SliderProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'value' | 'min' | 'max'> & {
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native range input, styled: 8 px track, grape fill up to the value, 24 px
|
||||||
|
// knob with a 3 px grape ring. Controlled, because the fill follows `value`.
|
||||||
|
// Name it with aria-label or aria-labelledby.
|
||||||
|
export function Slider({ value, min, max, className, style, ...rest }: SliderProps) {
|
||||||
|
if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`)
|
||||||
|
if (!(value >= min && value <= max)) {
|
||||||
|
throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`)
|
||||||
|
}
|
||||||
|
const fill = `${((value - min) / (max - min)) * 100}%`
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={[styles.slider, className].filter(Boolean).join(' ')}
|
||||||
|
style={{ ...style, '--slider-fill': fill } as CSSProperties}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grape {
|
||||||
|
background: var(--color-accent-100);
|
||||||
|
color: var(--color-accent-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teal {
|
||||||
|
background: var(--color-accent-2-200);
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { Tag } from './Tag.tsx'
|
||||||
|
import styles from './Tag.module.css'
|
||||||
|
|
||||||
|
describe('Tag', () => {
|
||||||
|
it('renders a static, non-interactive label', () => {
|
||||||
|
render(<Tag>Draft</Tag>)
|
||||||
|
const tag = screen.getByText('Draft')
|
||||||
|
expect(tag).toHaveClass(styles.grape!)
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the teal tone', () => {
|
||||||
|
render(<Tag tone="teal">Synced</Tag>)
|
||||||
|
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ComponentPropsWithRef } from 'react'
|
||||||
|
import styles from './Tag.module.css'
|
||||||
|
|
||||||
|
export type TagTone = 'grape' | 'teal'
|
||||||
|
|
||||||
|
export type TagProps = ComponentPropsWithRef<'span'> & {
|
||||||
|
tone?: TagTone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
|
||||||
|
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
||||||
|
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
||||||
|
return <span className={classes} {...rest} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenLabel {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { createRef } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { TextInput } from './TextInput.tsx'
|
||||||
|
|
||||||
|
describe('TextInput', () => {
|
||||||
|
it('is a textbox named by its label', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<TextInput label="Dinner name" placeholder="e.g. Chicken enchiladas" onChange={onChange} />)
|
||||||
|
const input = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||||
|
fireEvent.change(input, { target: { value: 'Chili' } })
|
||||||
|
expect(onChange).toHaveBeenCalledOnce()
|
||||||
|
expect(input).toHaveValue('Chili')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the accessible name when the label is visually hidden', () => {
|
||||||
|
render(<TextInput label="Add a regular" labelHidden />)
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a 48 px pill with a ≥ 44 px hit area', () => {
|
||||||
|
render(<TextInput label="Dinner name" />)
|
||||||
|
const input = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||||
|
expectMinHitArea(input)
|
||||||
|
expect(hitArea(input).height).toBe(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards the ref', () => {
|
||||||
|
const ref = createRef<HTMLInputElement>()
|
||||||
|
render(<TextInput label="Dinner name" ref={ref} />)
|
||||||
|
expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useId, type ComponentPropsWithRef } from 'react'
|
||||||
|
import styles from './TextInput.module.css'
|
||||||
|
|
||||||
|
export type TextInputProps = Omit<ComponentPropsWithRef<'input'>, 'id'> & {
|
||||||
|
label: string
|
||||||
|
// Hide the label visually; it stays the input's accessible name.
|
||||||
|
labelHidden?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 48 px pill input on the surface fill, always associated with its label.
|
||||||
|
export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) {
|
||||||
|
const id = useId()
|
||||||
|
return (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor={id} className={labelHidden ? styles.hiddenLabel : styles.label}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input id={id} type={type} className={[styles.input, className].filter(Boolean).join(' ')} {...rest} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* The button is a 44 px hit box around the 40×24 track; negative margins keep
|
||||||
|
the 40×24 layout footprint. */
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: -10px -2px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-neutral-300);
|
||||||
|
transition: background-color 150ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 180ms var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle[aria-checked='true'] .track {
|
||||||
|
background: var(--color-accent-2-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle[aria-checked='true'] .knob {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.track,
|
||||||
|
.knob {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { Toggle } from './Toggle.tsx'
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const [checked, setChecked] = useState(false)
|
||||||
|
return <Toggle aria-label="Push: New weekly plan ready" checked={checked} onCheckedChange={setChecked} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
return screen.getByRole('switch', { name: 'Push: New weekly plan ready' })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Toggle', () => {
|
||||||
|
it('is a switch reflecting its state in aria-checked', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expect(toggle()).toHaveAttribute('aria-checked', 'false')
|
||||||
|
fireEvent.click(toggle())
|
||||||
|
expect(toggle()).toHaveAttribute('aria-checked', 'true')
|
||||||
|
fireEvent.click(toggle())
|
||||||
|
expect(toggle()).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([' ', 'Enter'])('toggles with the %j key', (key) => {
|
||||||
|
render(<Harness />)
|
||||||
|
fireEvent.keyDown(toggle(), { key })
|
||||||
|
expect(toggle()).toHaveAttribute('aria-checked', 'true')
|
||||||
|
fireEvent.keyDown(toggle(), { key })
|
||||||
|
expect(toggle()).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses native activation for Space and Enter so a key press toggles once', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false)
|
||||||
|
expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false)
|
||||||
|
expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false)
|
||||||
|
expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores other keys', () => {
|
||||||
|
const onCheckedChange = vi.fn()
|
||||||
|
render(<Toggle aria-label="Chat" checked={false} onCheckedChange={onCheckedChange} />)
|
||||||
|
fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' })
|
||||||
|
expect(onCheckedChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is disabled when asked', () => {
|
||||||
|
const onCheckedChange = vi.fn()
|
||||||
|
render(<Toggle aria-label="Chat" checked={false} disabled onCheckedChange={onCheckedChange} />)
|
||||||
|
const sw = screen.getByRole('switch', { name: 'Chat' })
|
||||||
|
expect(sw).toBeDisabled()
|
||||||
|
fireEvent.click(sw)
|
||||||
|
expect(onCheckedChange).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws a 40×24 track inside a ≥ 44 px hit area', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expectMinHitArea(toggle())
|
||||||
|
expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ComponentPropsWithRef, KeyboardEvent } from 'react'
|
||||||
|
import styles from './Toggle.module.css'
|
||||||
|
|
||||||
|
export type ToggleProps = Omit<
|
||||||
|
ComponentPropsWithRef<'button'>,
|
||||||
|
'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children'
|
||||||
|
> & {
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 40×24 pill switch (role="switch"). Name it with aria-label or
|
||||||
|
// aria-labelledby. Space and Enter are handled here and their native button
|
||||||
|
// activation is suppressed, so each key press toggles exactly once.
|
||||||
|
export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) {
|
||||||
|
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||||
|
if (event.key !== ' ' && event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
onCheckedChange(!checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
|
||||||
|
if (event.key === ' ') event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
className={[styles.toggle, className].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => onCheckedChange(!checked)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<span className={styles.track} aria-hidden="true">
|
||||||
|
<span className={styles.knob} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
|
||||||
|
export { Card, type CardProps, type CardTone } from './Card.tsx'
|
||||||
|
export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
|
||||||
|
export { Chip, type ChipProps } from './Chip.tsx'
|
||||||
|
export { EmptyState, type EmptyStateAction, type EmptyStateProps } from './EmptyState.tsx'
|
||||||
|
export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner.tsx'
|
||||||
|
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
|
||||||
|
export { ListRow, type ListRowProps } from './ListRow.tsx'
|
||||||
|
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
|
||||||
|
export { Sheet, type SheetProps } from './Sheet.tsx'
|
||||||
|
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
|
||||||
|
export { Slider, type SliderProps } from './Slider.tsx'
|
||||||
|
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
|
||||||
|
export { TextInput, type TextInputProps } from './TextInput.tsx'
|
||||||
|
export { Toggle, type ToggleProps } from './Toggle.tsx'
|
||||||
@@ -14,6 +14,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
// Inject CSS Modules into jsdom so tests can assert the sizes the
|
||||||
|
// stylesheets actually declare (for example 44 px hit areas).
|
||||||
|
css: true,
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user