From 67d5b0062a0d598fb9ee2aead61fed855df0ba8a Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Wed, 8 Jul 2026 16:24:28 +0545 Subject: [PATCH 1/2] fix(server): make behavior and config consistent, add test suite - readyz always reports ready; servable state is exposed as `ready` on /status - imports fully replace cached strings in one transaction - normalize lang query param to lowercase - fail fast on invalid or missing configuration - enable WAL; set pragmas via Exec so both sqlite drivers behave the same - add ETag/If-None-Match caching on /strings; expose ETag via CORS - inject build version via ldflags into openapi and /status - record last_pull on unchanged pulls too - drop BaseContext so shutdown drains in-flight requests - derive healthcheck URL safely from LISTEN_ADDR - reject non-positive durations in config - align INITIAL_PULL_DEADLINE default with docs (45s) - document 304 response in openapi schema; 500 where applicable - fix log-before-error in pull; remove dead code and nil-guards - add table-driven tests for parser, db, handlers, config, sync - add go test step to CI; pass VERSION build-arg to docker --- .github/workflows/ci.yml | 21 ++ Dockerfile | 4 +- README.md | 39 +++- config.go | 80 ++++--- config_test.go | 275 ++++++++++++++++++++++++ db.go | 134 ++++++------ db_test.go | 410 ++++++++++++++++++++++++++++++++++++ handlers.go | 70 +++++-- handlers_test.go | 391 ++++++++++++++++++++++++++++++++++ main.go | 70 +++++-- main_test.go | 36 ++++ openapi.go | 18 +- openapi.json | 66 +++--- sync_pull.go | 95 +++------ sync_pull_test.go | 443 +++++++++++++++++++++++++++++++++++++++ xlsx_test.go | 259 +++++++++++++++++++++++ 16 files changed, 2171 insertions(+), 240 deletions(-) create mode 100644 config_test.go create mode 100644 db_test.go create mode 100644 handlers_test.go create mode 100644 main_test.go create mode 100644 sync_pull_test.go create mode 100644 xlsx_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a69235..668eceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Vet run: go vet ./... + - name: Test + run: go test ./... + - name: Build run: go build -trimpath -ldflags="-s -w" -o cacheppuccino . @@ -84,6 +87,22 @@ jobs: type=sha,format=short,prefix=0.1.0-c type=ref,event=tag + - name: Determine build version + id: ver + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + # Tag format: v0.1.0 -> 0.1.0 + VERSION="${GITHUB_REF_NAME#v}" + else + SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)" + VERSION="0.1.0-c${SHORT_SHA}" + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + - name: Build and push docker image uses: docker/build-push-action@v6 with: @@ -91,6 +110,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.ver.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max helm: diff --git a/Dockerfile b/Dockerfile index ee6252e..7a9a7a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ # -------- build stage -------- FROM golang:1.23 AS build +ARG VERSION=dev + ENV CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 @@ -12,7 +14,7 @@ RUN go mod download COPY . . -RUN go build -trimpath -ldflags="-s -w" -o /out/cacheppuccino . +RUN go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/cacheppuccino . # -------- runtime stage -------- diff --git a/README.md b/README.md index bbe70ba..32c51d2 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ It periodically downloads an XLSX export from a translation service, stores the ## Features - XLSX import from external translation service -- Periodic background sync +- Periodic background sync (full replace per import; the XLSX is the source of truth) - SQLite-backed cache - Fetch translations by page(s) + language +- ETag / If-None-Match support on `/strings` - Consistent JSON response envelope - OpenAPI 3 schema generation (via kin-openapi) - Health + readiness endpoints @@ -81,11 +82,24 @@ curl "http://localhost:8080/strings?pages=home,about&lang=en" On startup: -1. Performs an initial XLSX pull. -2. If successful → service becomes ready. +1. If the SQLite cache already holds data from a previous run, `/status` reports `ready: true` immediately. +2. Performs an initial XLSX pull. A successful pull also sets `ready: true`. 3. Periodically refreshes based on `PULL_INTERVAL`. -The service avoids re-importing unchanged XLSX files by hashing the downloaded content. +Each import fully replaces the cached strings inside a single transaction, so rows removed +from the XLSX disappear from the cache. The service avoids re-importing unchanged XLSX +files by hashing the downloaded content. + +### XLSX format + +The export must contain a sheet named `Translations` (falls back to the first sheet), with +a header row of exactly `page | key | | | ...` (case-insensitive). Files with +other header names (e.g. `Namespace`) are rejected. + +### Response caching + +`/strings` responses carry an `ETag` derived from the last import hash and +`Cache-Control: public, max-age=60`. Requests with a matching `If-None-Match` get `304 Not Modified`. ## Environment Variables @@ -94,7 +108,7 @@ The service avoids re-importing unchanged XLSX files by hashing the downloaded c |----------|----------|------------| | `TRANSLATION_BASE_URL` | Yes | Base URL of translation service | | `TRANSLATION_APPLICATION_ID` | Yes | Translation application ID | -| `TRANSLATION_API_KEY` | No | Sent as `X-API-KEY` header | +| `TRANSLATION_API_KEY` | Yes | Sent as `X-API-KEY` header | | `SQLITE_PATH` | No | Default: `/data/cacheppuccino.db` | | `PULL_INTERVAL` | No | Default: `10m` | | `HTTP_TIMEOUT` | No | Default: `30s` | @@ -135,7 +149,10 @@ docker compose down -v ## Health Checks - `GET /healthz` → service running -- `GET /readyz` → initial sync completed +- `GET /readyz` → always `200` while the process is up. Deliberately not gated on data: + the deploy tooling restarts pods that stay unready, and with a single replica there is + no alternative pod to route to. Whether the cache actually holds servable data is + reported as `ready` on `GET /status`. - Docker healthcheck uses internal `--healthcheck` flag @@ -159,8 +176,8 @@ go run . --schema ## Database - SQLite -- WAL mode enabled -- Uses `mode=rwc` +- WAL mode with `synchronous=NORMAL` +- Single connection (`MaxOpenConns=1`) - Indexed by `(page, lang)` - Metadata table stores: - `last_pull_rfc3339` @@ -175,6 +192,12 @@ go mod tidy go run . ``` +Run tests: + +```bash +go test ./... +``` + Build binary: ```bash diff --git a/config.go b/config.go index 8bb791d..b149366 100644 --- a/config.go +++ b/config.go @@ -1,8 +1,10 @@ package main import ( - "log" + "errors" + "fmt" "os" + "slices" "time" ) @@ -18,44 +20,72 @@ type Config struct { LogLevel string } -func LoadConfig() Config { - return Config{ +var validLogLevels = []string{"debug", "info", "warn", "error"} + +// LoadConfig reads configuration from the environment. +// It collects every problem instead of stopping at the first one, +// so a misconfigured deployment reports all mistakes at once. +func LoadConfig() (Config, error) { + var errs []error + + requireEnv := func(k string) string { + v := os.Getenv(k) + if v == "" { + errs = append(errs, fmt.Errorf("missing required env: %s", k)) + } + return v + } + + envDuration := func(k string, def time.Duration) time.Duration { + v := os.Getenv(k) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + errs = append(errs, fmt.Errorf("invalid duration for %s: %q", k, v)) + return def + } + return d + } + + cfg := Config{ ListenAddr: env("LISTEN_ADDR", ":8080"), SQLitePath: env("SQLITE_PATH", "/data/cacheppuccino.db"), - TranslationBaseURL: mustEnv("TRANSLATION_BASE_URL"), - TranslationApplicationID: mustEnv("TRANSLATION_APPLICATION_ID"), - TranslationAPIKey: mustEnv("TRANSLATION_API_KEY"), + TranslationBaseURL: requireEnv("TRANSLATION_BASE_URL"), + TranslationApplicationID: requireEnv("TRANSLATION_APPLICATION_ID"), + TranslationAPIKey: requireEnv("TRANSLATION_API_KEY"), HTTPTimeout: envDuration("HTTP_TIMEOUT", 30*time.Second), PullInterval: envDuration("PULL_INTERVAL", 10*time.Minute), - InitialPullDeadline: envDuration("INITIAL_PULL_DEADLINE", 60*time.Second), + InitialPullDeadline: envDuration("INITIAL_PULL_DEADLINE", 45*time.Second), LogLevel: env("LOG_LEVEL", "info"), } -} -func env(k, def string) string { - v := os.Getenv(k) - if v == "" { - return def + if cfg.HTTPTimeout <= 0 { + errs = append(errs, fmt.Errorf("HTTP_TIMEOUT must be positive, got %s", cfg.HTTPTimeout)) + } + if cfg.PullInterval <= 0 { + errs = append(errs, fmt.Errorf("PULL_INTERVAL must be positive, got %s", cfg.PullInterval)) + } + // Zero means "no deadline" for the initial pull; only negatives are invalid. + if cfg.InitialPullDeadline < 0 { + errs = append(errs, fmt.Errorf("INITIAL_PULL_DEADLINE must not be negative, got %s", cfg.InitialPullDeadline)) } - return v -} -func mustEnv(k string) string { - v := os.Getenv(k) - if v == "" { - log.Fatalf("missing required env: %s", k) + if !slices.Contains(validLogLevels, cfg.LogLevel) { + errs = append(errs, fmt.Errorf("invalid LOG_LEVEL: %q (valid: debug, info, warn, error)", cfg.LogLevel)) } - return v + + if len(errs) > 0 { + return Config{}, errors.Join(errs...) + } + return cfg, nil } -func envDuration(k string, def time.Duration) time.Duration { +func env(k, def string) string { v := os.Getenv(k) if v == "" { return def } - d, err := time.ParseDuration(v) - if err != nil { - return def - } - return d + return v } diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..7e76bf1 --- /dev/null +++ b/config_test.go @@ -0,0 +1,275 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// ctAllConfigEnvVars is every env var LoadConfig reads. Each test sets all of +// them explicitly ("" simulates unset) to shield tests from the host env. +var ctAllConfigEnvVars = []string{ + "TRANSLATION_BASE_URL", + "TRANSLATION_APPLICATION_ID", + "TRANSLATION_API_KEY", + "LISTEN_ADDR", + "SQLITE_PATH", + "HTTP_TIMEOUT", + "PULL_INTERVAL", + "INITIAL_PULL_DEADLINE", + "LOG_LEVEL", +} + +// ctSetConfigEnv sets every config env var, using values from overrides and +// "" for anything not listed there. +func ctSetConfigEnv(t *testing.T, overrides map[string]string) { + t.Helper() + for _, k := range ctAllConfigEnvVars { + t.Setenv(k, overrides[k]) + } +} + +// ctRequiredEnv sets only the three required vars to placeholder values. +func ctRequiredEnv() map[string]string { + return map[string]string{ + "TRANSLATION_BASE_URL": "https://translate.example.com", + "TRANSLATION_APPLICATION_ID": "app-id", + "TRANSLATION_API_KEY": "api-key", + } +} + +func TestLoadConfigDefaults(t *testing.T) { + ctSetConfigEnv(t, ctRequiredEnv()) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v, want nil", err) + } + + if got, want := cfg.TranslationBaseURL, "https://translate.example.com"; got != want { + t.Errorf("TranslationBaseURL = %q, want %q", got, want) + } + if got, want := cfg.TranslationApplicationID, "app-id"; got != want { + t.Errorf("TranslationApplicationID = %q, want %q", got, want) + } + if got, want := cfg.TranslationAPIKey, "api-key"; got != want { + t.Errorf("TranslationAPIKey = %q, want %q", got, want) + } + if got, want := cfg.ListenAddr, ":8080"; got != want { + t.Errorf("ListenAddr = %q, want %q", got, want) + } + if got, want := cfg.SQLitePath, "/data/cacheppuccino.db"; got != want { + t.Errorf("SQLitePath = %q, want %q", got, want) + } + if got, want := cfg.HTTPTimeout, 30*time.Second; got != want { + t.Errorf("HTTPTimeout = %v, want %v", got, want) + } + if got, want := cfg.PullInterval, 10*time.Minute; got != want { + t.Errorf("PullInterval = %v, want %v", got, want) + } + if got, want := cfg.InitialPullDeadline, 45*time.Second; got != want { + t.Errorf("InitialPullDeadline = %v, want %v", got, want) + } + if got, want := cfg.LogLevel, "info"; got != want { + t.Errorf("LogLevel = %q, want %q", got, want) + } +} + +func TestLoadConfigMissingRequired(t *testing.T) { + required := []string{ + "TRANSLATION_BASE_URL", + "TRANSLATION_APPLICATION_ID", + "TRANSLATION_API_KEY", + } + + for _, missing := range required { + t.Run(missing, func(t *testing.T) { + env := ctRequiredEnv() + env[missing] = "" + ctSetConfigEnv(t, env) + + _, err := LoadConfig() + if err == nil { + t.Fatalf("LoadConfig() error = nil, want error mentioning %s", missing) + } + if !strings.Contains(err.Error(), missing) { + t.Errorf("error %q does not mention %s", err.Error(), missing) + } + }) + } + + t.Run("all missing", func(t *testing.T) { + ctSetConfigEnv(t, nil) + + _, err := LoadConfig() + if err == nil { + t.Fatal("LoadConfig() error = nil, want error mentioning all required vars") + } + for _, k := range required { + if !strings.Contains(err.Error(), k) { + t.Errorf("error %q does not mention %s", err.Error(), k) + } + } + }) +} + +func TestLoadConfigInvalidValues(t *testing.T) { + tests := []struct { + name string + overrides map[string]string + wantInError []string + }{ + { + name: "invalid PULL_INTERVAL", + overrides: map[string]string{"PULL_INTERVAL": "abc"}, + wantInError: []string{"PULL_INTERVAL", `"abc"`}, + }, + { + name: "invalid HTTP_TIMEOUT", + overrides: map[string]string{"HTTP_TIMEOUT": "thirty"}, + wantInError: []string{"HTTP_TIMEOUT", `"thirty"`}, + }, + { + name: "invalid INITIAL_PULL_DEADLINE", + overrides: map[string]string{"INITIAL_PULL_DEADLINE": "45"}, + wantInError: []string{"INITIAL_PULL_DEADLINE", `"45"`}, + }, + { + name: "invalid LOG_LEVEL", + overrides: map[string]string{"LOG_LEVEL": "verbose"}, + wantInError: []string{"LOG_LEVEL", `"verbose"`}, + }, + { + name: "zero PULL_INTERVAL", + overrides: map[string]string{"PULL_INTERVAL": "0s"}, + wantInError: []string{"PULL_INTERVAL", "positive"}, + }, + { + name: "negative PULL_INTERVAL", + overrides: map[string]string{"PULL_INTERVAL": "-10m"}, + wantInError: []string{"PULL_INTERVAL", "positive"}, + }, + { + name: "zero HTTP_TIMEOUT", + overrides: map[string]string{"HTTP_TIMEOUT": "0"}, + wantInError: []string{"HTTP_TIMEOUT", "positive"}, + }, + { + name: "negative INITIAL_PULL_DEADLINE", + overrides: map[string]string{"INITIAL_PULL_DEADLINE": "-1s"}, + wantInError: []string{"INITIAL_PULL_DEADLINE", "negative"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := ctRequiredEnv() + for k, v := range tt.overrides { + env[k] = v + } + ctSetConfigEnv(t, env) + + _, err := LoadConfig() + if err == nil { + t.Fatalf("LoadConfig() error = nil, want error mentioning %v", tt.wantInError) + } + for _, want := range tt.wantInError { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err.Error(), want) + } + } + }) + } +} + +func TestLoadConfigMultipleProblems(t *testing.T) { + // Missing one required var plus a bad duration plus a bad log level: + // all must be reported in the single joined error. + env := ctRequiredEnv() + env["TRANSLATION_API_KEY"] = "" + env["PULL_INTERVAL"] = "bogus" + env["LOG_LEVEL"] = "loud" + ctSetConfigEnv(t, env) + + _, err := LoadConfig() + if err == nil { + t.Fatal("LoadConfig() error = nil, want joined error with all problems") + } + for _, want := range []string{ + "TRANSLATION_API_KEY", + "PULL_INTERVAL", `"bogus"`, + "LOG_LEVEL", `"loud"`, + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err.Error(), want) + } + } +} + +func TestLoadConfigZeroInitialPullDeadline(t *testing.T) { + // Zero means "no deadline" and must be accepted. + env := ctRequiredEnv() + env["INITIAL_PULL_DEADLINE"] = "0s" + ctSetConfigEnv(t, env) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v, want nil", err) + } + if cfg.InitialPullDeadline != 0 { + t.Errorf("InitialPullDeadline = %v, want 0", cfg.InitialPullDeadline) + } +} + +func TestLoadConfigValidOverrides(t *testing.T) { + env := ctRequiredEnv() + env["LISTEN_ADDR"] = "127.0.0.1:9999" + env["SQLITE_PATH"] = "/tmp/other.db" + env["HTTP_TIMEOUT"] = "5s" + env["PULL_INTERVAL"] = "1h30m" + env["INITIAL_PULL_DEADLINE"] = "250ms" + env["LOG_LEVEL"] = "debug" + ctSetConfigEnv(t, env) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v, want nil", err) + } + + if got, want := cfg.ListenAddr, "127.0.0.1:9999"; got != want { + t.Errorf("ListenAddr = %q, want %q", got, want) + } + if got, want := cfg.SQLitePath, "/tmp/other.db"; got != want { + t.Errorf("SQLitePath = %q, want %q", got, want) + } + if got, want := cfg.HTTPTimeout, 5*time.Second; got != want { + t.Errorf("HTTPTimeout = %v, want %v", got, want) + } + if got, want := cfg.PullInterval, 90*time.Minute; got != want { + t.Errorf("PullInterval = %v, want %v", got, want) + } + if got, want := cfg.InitialPullDeadline, 250*time.Millisecond; got != want { + t.Errorf("InitialPullDeadline = %v, want %v", got, want) + } + if got, want := cfg.LogLevel, "debug"; got != want { + t.Errorf("LogLevel = %q, want %q", got, want) + } +} + +func TestLoadConfigValidLogLevels(t *testing.T) { + for _, level := range []string{"debug", "info", "warn", "error"} { + t.Run(level, func(t *testing.T) { + env := ctRequiredEnv() + env["LOG_LEVEL"] = level + ctSetConfigEnv(t, env) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v, want nil", err) + } + if cfg.LogLevel != level { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, level) + } + }) + } +} diff --git a/db.go b/db.go index dde5e6a..849ffc7 100644 --- a/db.go +++ b/db.go @@ -3,6 +3,7 @@ package main import ( "context" "database/sql" + "errors" "fmt" "os" "path/filepath" @@ -14,6 +15,11 @@ import ( "github.com/uptrace/bun/driver/sqliteshim" ) +const ( + metaKeyLastPull = "last_pull_rfc3339" + metaKeyLastHash = "last_xlsx_sha256" +) + type DB struct { sql *sql.DB bun *bun.DB @@ -25,21 +31,31 @@ func OpenDB(path string) (*DB, error) { return nil, err } - sqldb, err := sql.Open( - sqliteshim.ShimName, - "file:"+path+"?mode=rwc"+ - "&_pragma=busy_timeout(5000)"+ - "&_pragma=temp_store=2"+ - "&_pragma=busy_timeout(5000)", - ) - + sqldb, err := sql.Open(sqliteshim.ShimName, "file:"+path+"?mode=rwc") if err != nil { return nil, err } + // Single connection: pragmas below stick for the process lifetime, + // and SQLite sees one writer, so no busy contention between our own queries. sqldb.SetMaxOpenConns(1) sqldb.SetConnMaxLifetime(0) + // Set via Exec (not DSN params) so behavior is identical across the + // cgo (mattn) and pure-Go (modernc) drivers sqliteshim may pick. + pragmas := []string{ + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA temp_store = MEMORY", + } + for _, p := range pragmas { + if _, err := sqldb.Exec(p); err != nil { + _ = sqldb.Close() + return nil, fmt.Errorf("%s: %w", p, err) + } + } + bdb := bun.NewDB(sqldb, sqlitedialect.New()) db := &DB{sql: sqldb, bun: bdb} @@ -82,56 +98,54 @@ func (db *DB) migrate(ctx context.Context) error { return err } -func (db *DB) UpsertString(ctx context.Context, page, key, lang, value string, updatedAt time.Time) error { - m := &StringModel{ - Page: page, - Key: key, - Lang: lang, - Value: value, - UpdatedAt: updatedAt.UTC(), - } - - _, err := db.bun.NewInsert(). - Model(m). - On("CONFLICT (page, key, lang) DO UPDATE"). - Set("value = EXCLUDED.value"). - Set("updated_at = EXCLUDED.updated_at"). - Exec(ctx) - - return err +// HasStrings reports whether the cache holds any servable data. +func (db *DB) HasStrings(ctx context.Context) (bool, error) { + return db.bun.NewSelect(). + Model((*StringModel)(nil)). + Exists(ctx) } -// UpsertStringsBatch upserts many rows efficiently. -// Uses a transaction + chunked multi-row INSERT to avoid SQLite variable limits. -func (db *DB) UpsertStringsBatch(ctx context.Context, rows []StringModel) error { - if len(rows) == 0 { - return nil - } - +// ReplaceImport atomically replaces the entire strings table with the given +// rows and records the import metadata. The XLSX is the complete source of +// truth, so rows absent from it must not survive an import. +func (db *DB) ReplaceImport(ctx context.Context, rows []StringRow, hash string, pulledAt time.Time) error { // SQLite default max variables is often 999. // We insert 5 columns per row: page, key, lang, value, updated_at. const ( sqliteMaxVars = 999 colsPerRow = 5 - safetyHeadroom = 50 // leave some room + safetyHeadroom = 50 ) - maxRowsPerStmt := (sqliteMaxVars - safetyHeadroom) / colsPerRow - if maxRowsPerStmt < 1 { - return fmt.Errorf("invalid maxRowsPerStmt=%d", maxRowsPerStmt) + const maxRowsPerStmt = (sqliteMaxVars - safetyHeadroom) / colsPerRow + + models := make([]StringModel, 0, len(rows)) + for _, r := range rows { + models = append(models, StringModel{ + Page: r.Page, + Key: r.Key, + Lang: r.Lang, + Value: r.Value, + UpdatedAt: r.UpdatedAt.UTC(), + }) } - // Transaction: makes the whole import much faster and consistent. tx, err := db.bun.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() - for i := 0; i < len(rows); i += maxRowsPerStmt { - end := min(i+maxRowsPerStmt, len(rows)) + if _, err := tx.NewDelete().Model((*StringModel)(nil)).Where("1 = 1").Exec(ctx); err != nil { + return err + } + + for i := 0; i < len(models); i += maxRowsPerStmt { + end := min(i+maxRowsPerStmt, len(models)) - chunk := rows[i:end] + chunk := models[i:end] + // ON CONFLICT keeps last-one-wins semantics for duplicate + // (page, key, lang) rows within a single XLSX. _, err := tx.NewInsert(). Model(&chunk). On("CONFLICT (page, key, lang) DO UPDATE"). @@ -143,30 +157,26 @@ func (db *DB) UpsertStringsBatch(ctx context.Context, rows []StringModel) error } } - return tx.Commit() -} - -// UpsertStringsFromRows is a convenience wrapper if you want to pass parsed rows directly. -func (db *DB) UpsertStringsFromRows( - ctx context.Context, - rows []StringRow, // rename ParsedRow to your actual ParseXLSX row type -) error { - if len(rows) == 0 { - return nil + meta := []MetaModel{ + {K: metaKeyLastHash, V: hash}, + {K: metaKeyLastPull, V: pulledAt.UTC().Format(time.RFC3339)}, } - - models := make([]StringModel, 0, len(rows)) - for _, r := range rows { - models = append(models, StringModel{ - Page: r.Page, - Key: r.Key, - Lang: r.Lang, - Value: r.Value, - UpdatedAt: r.UpdatedAt.UTC(), - }) + for _, m := range meta { + if err := upsertMetaTx(ctx, tx, m); err != nil { + return err + } } - return db.UpsertStringsBatch(ctx, models) + return tx.Commit() +} + +func upsertMetaTx(ctx context.Context, tx bun.Tx, m MetaModel) error { + _, err := tx.NewInsert(). + Model(&m). + On("CONFLICT (k) DO UPDATE"). + Set("v = EXCLUDED.v"). + Exec(ctx) + return err } func (db *DB) GetStringsByPagesLang(ctx context.Context, pages []string, lang string) (map[string]map[string]string, []string, error) { @@ -234,7 +244,7 @@ func (db *DB) GetMeta(ctx context.Context, k string) (string, bool, error) { Scan(ctx) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return "", false, nil } return "", false, err diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..2c07d6e --- /dev/null +++ b/db_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "reflect" + "slices" + "testing" + "time" +) + +var dbtUpdatedAt = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + +// dbtOpen opens a fresh DB under t.TempDir() and closes it on cleanup. +func dbtOpen(t *testing.T) *DB { + t.Helper() + db, err := OpenDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func dbtRow(page, key, lang, value string) StringRow { + return StringRow{Page: page, Key: key, Lang: lang, Value: value, UpdatedAt: dbtUpdatedAt} +} + +func dbtMustImport(t *testing.T, db *DB, rows []StringRow, hash string, pulledAt time.Time) { + t.Helper() + if err := db.ReplaceImport(context.Background(), rows, hash, pulledAt); err != nil { + t.Fatalf("ReplaceImport: %v", err) + } +} + +func dbtCountStrings(t *testing.T, db *DB) int { + t.Helper() + var n int + if err := db.sql.QueryRow("SELECT COUNT(*) FROM strings").Scan(&n); err != nil { + t.Fatalf("count strings: %v", err) + } + return n +} + +// dbtMeta fetches a meta key and fails the test if the key is absent. +func dbtMeta(t *testing.T, db *DB, key string) string { + t.Helper() + v, ok, err := db.GetMeta(context.Background(), key) + if err != nil { + t.Fatalf("GetMeta(%q): %v", key, err) + } + if !ok { + t.Fatalf("GetMeta(%q): key missing", key) + } + return v +} + +// dbtPullTime parses the stored last-pull meta value as RFC3339. +func dbtPullTime(t *testing.T, db *DB) time.Time { + t.Helper() + raw := dbtMeta(t, db, metaKeyLastPull) + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + t.Fatalf("meta %q = %q is not RFC3339: %v", metaKeyLastPull, raw, err) + } + return parsed +} + +func TestOpenDBCreatesParentDirs(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "deeper", "test.db") + db, err := OpenDB(path) + if err != nil { + t.Fatalf("OpenDB with non-existent parent dirs: %v", err) + } + defer func() { _ = db.Close() }() + + if _, err := db.HasStrings(context.Background()); err != nil { + t.Fatalf("HasStrings on freshly created DB: %v", err) + } +} + +func TestOpenDBEnablesWAL(t *testing.T) { + db := dbtOpen(t) + + var mode string + if err := db.sql.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil { + t.Fatalf("PRAGMA journal_mode: %v", err) + } + if mode != "wal" { + t.Fatalf("journal_mode = %q, want %q", mode, "wal") + } +} + +func TestHasStrings(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + has, err := db.HasStrings(ctx) + if err != nil { + t.Fatalf("HasStrings on fresh DB: %v", err) + } + if has { + t.Fatal("HasStrings on fresh DB = true, want false") + } + + dbtMustImport(t, db, []StringRow{dbtRow("home", "title", "en", "Home")}, "h1", time.Now()) + + has, err = db.HasStrings(ctx) + if err != nil { + t.Fatalf("HasStrings after import: %v", err) + } + if !has { + t.Fatal("HasStrings after import = false, want true") + } +} + +func TestReplaceImportBasics(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + hash := "deadbeefcafe" + pulledAt := time.Date(2026, 7, 8, 10, 30, 0, 0, time.FixedZone("NPT", 5*3600+45*60)) + + rows := []StringRow{ + dbtRow("home", "title", "en", "Home"), + dbtRow("home", "subtitle", "en", "Welcome"), + dbtRow("about", "title", "en", "About"), + } + dbtMustImport(t, db, rows, hash, pulledAt) + + got, cleaned, err := db.GetStringsByPagesLang(ctx, []string{"home", "about"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + want := map[string]map[string]string{ + "home": {"title": "Home", "subtitle": "Welcome"}, + "about": {"title": "About"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("GetStringsByPagesLang map = %v, want %v", got, want) + } + if wantCleaned := []string{"home", "about"}; !slices.Equal(cleaned, wantCleaned) { + t.Errorf("cleaned = %v, want %v", cleaned, wantCleaned) + } + + if gotHash := dbtMeta(t, db, metaKeyLastHash); gotHash != hash { + t.Errorf("meta %q = %q, want %q", metaKeyLastHash, gotHash, hash) + } + if gotPull := dbtPullTime(t, db); !gotPull.Equal(pulledAt) { + t.Errorf("meta %q = %v, want time equal to %v", metaKeyLastPull, gotPull, pulledAt) + } +} + +func TestReplaceImportFullReplace(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + first := []StringRow{ + dbtRow("old", "title", "en", "Old title"), + dbtRow("old", "body", "en", "Old body"), + } + dbtMustImport(t, db, first, "hash-1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + + second := []StringRow{ + dbtRow("new", "title", "en", "New title"), + } + pulledAt2 := time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC) + dbtMustImport(t, db, second, "hash-2", pulledAt2) + + if n := dbtCountStrings(t, db); n != 1 { + t.Errorf("row count after second import = %d, want 1", n) + } + + got, _, err := db.GetStringsByPagesLang(ctx, []string{"old", "new"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + if len(got["old"]) != 0 { + t.Errorf("stale rows survived import: got[\"old\"] = %v, want empty", got["old"]) + } + if !reflect.DeepEqual(got["new"], map[string]string{"title": "New title"}) { + t.Errorf("got[\"new\"] = %v, want map with only new title", got["new"]) + } + + if gotHash := dbtMeta(t, db, metaKeyLastHash); gotHash != "hash-2" { + t.Errorf("meta %q = %q, want %q", metaKeyLastHash, gotHash, "hash-2") + } + if gotPull := dbtPullTime(t, db); !gotPull.Equal(pulledAt2) { + t.Errorf("meta %q = %v, want time equal to %v", metaKeyLastPull, gotPull, pulledAt2) + } +} + +func TestReplaceImportDuplicateRowsLastWins(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + rows := []StringRow{ + dbtRow("home", "title", "en", "first"), + dbtRow("home", "title", "en", "second"), + dbtRow("home", "title", "en", "third"), + } + if err := db.ReplaceImport(ctx, rows, "h", time.Now()); err != nil { + t.Fatalf("ReplaceImport with duplicate (page,key,lang) rows: %v", err) + } + + if n := dbtCountStrings(t, db); n != 1 { + t.Errorf("row count = %d, want 1", n) + } + + got, _, err := db.GetStringsByPagesLang(ctx, []string{"home"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + if v := got["home"]["title"]; v != "third" { + t.Errorf("value = %q, want %q (last duplicate wins)", v, "third") + } +} + +func TestReplaceImportChunking(t *testing.T) { + db := dbtOpen(t) + + // > 400 rows spans several ~189-row insert chunks. + const total = 450 + rows := make([]StringRow, 0, total) + for i := range total { + rows = append(rows, dbtRow("bulk", fmt.Sprintf("key-%03d", i), "en", fmt.Sprintf("value-%03d", i))) + } + dbtMustImport(t, db, rows, "bulk-hash", time.Now()) + + if n := dbtCountStrings(t, db); n != total { + t.Fatalf("row count = %d, want %d", n, total) + } + + got, _, err := db.GetStringsByPagesLang(context.Background(), []string{"bulk"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + if len(got["bulk"]) != total { + t.Errorf("len(got[\"bulk\"]) = %d, want %d", len(got["bulk"]), total) + } + if v := got["bulk"]["key-449"]; v != "value-449" { + t.Errorf("got[\"bulk\"][\"key-449\"] = %q, want %q", v, "value-449") + } +} + +func TestReplaceImportEmptyWipesTableAndSetsMeta(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + dbtMustImport(t, db, []StringRow{dbtRow("home", "title", "en", "Home")}, "hash-1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + + pulledAt2 := time.Date(2026, 3, 3, 6, 7, 8, 0, time.UTC) + if err := db.ReplaceImport(ctx, nil, "hash-empty", pulledAt2); err != nil { + t.Fatalf("ReplaceImport with nil rows: %v", err) + } + + has, err := db.HasStrings(ctx) + if err != nil { + t.Fatalf("HasStrings: %v", err) + } + if has { + t.Error("HasStrings after empty import = true, want false") + } + + if gotHash := dbtMeta(t, db, metaKeyLastHash); gotHash != "hash-empty" { + t.Errorf("meta %q = %q, want %q", metaKeyLastHash, gotHash, "hash-empty") + } + if gotPull := dbtPullTime(t, db); !gotPull.Equal(pulledAt2) { + t.Errorf("meta %q = %v, want time equal to %v", metaKeyLastPull, gotPull, pulledAt2) + } +} + +func TestGetStringsByPagesLangPageCleaning(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + dbtMustImport(t, db, []StringRow{ + dbtRow("home", "title", "en", "Home"), + dbtRow("about", "title", "en", "About"), + }, "h", time.Now()) + + cases := []struct { + name string + pages []string + wantCleaned []string + }{ + {"trim and dedupe", []string{" home ", "home", "\tabout\n", "home"}, []string{"home", "about"}}, + {"dedupe applies after trim", []string{"home", " home"}, []string{"home"}}, + {"unknown page kept", []string{"home", "no-such-page"}, []string{"home", "no-such-page"}}, + {"only empty and whitespace", []string{"", " ", "\t\n"}, []string{}}, + {"nil pages", nil, []string{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, cleaned, err := db.GetStringsByPagesLang(ctx, tc.pages, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang(%v): %v", tc.pages, err) + } + if !slices.Equal(cleaned, tc.wantCleaned) { + t.Errorf("cleaned = %v, want %v", cleaned, tc.wantCleaned) + } + if got == nil { + t.Fatal("result map is nil, want non-nil") + } + if len(got) != len(tc.wantCleaned) { + t.Errorf("len(map) = %d, want %d (one entry per cleaned page)", len(got), len(tc.wantCleaned)) + } + for _, p := range tc.wantCleaned { + m, ok := got[p] + if !ok { + t.Errorf("map missing requested page %q", p) + continue + } + if m == nil { + t.Errorf("map for page %q is nil, want non-nil", p) + } + } + }) + } +} + +func TestGetStringsByPagesLangUnknownPageEmptyMap(t *testing.T) { + db := dbtOpen(t) + + dbtMustImport(t, db, []StringRow{dbtRow("home", "title", "en", "Home")}, "h", time.Now()) + + got, _, err := db.GetStringsByPagesLang(context.Background(), []string{"missing"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + m, ok := got["missing"] + if !ok { + t.Fatal("unknown page absent from map, want present with empty map") + } + if m == nil { + t.Fatal("map for unknown page is nil, want empty non-nil map") + } + if len(m) != 0 { + t.Fatalf("map for unknown page = %v, want empty", m) + } +} + +func TestGetStringsByPagesLangFiltersByLang(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + dbtMustImport(t, db, []StringRow{ + dbtRow("home", "title", "en", "Home"), + dbtRow("home", "title", "fr", "Accueil"), + dbtRow("home", "greeting", "fr", "Bonjour"), + }, "h", time.Now()) + + gotFR, _, err := db.GetStringsByPagesLang(ctx, []string{"home"}, "fr") + if err != nil { + t.Fatalf("GetStringsByPagesLang(fr): %v", err) + } + wantFR := map[string]string{"title": "Accueil", "greeting": "Bonjour"} + if !reflect.DeepEqual(gotFR["home"], wantFR) { + t.Errorf("fr map = %v, want %v", gotFR["home"], wantFR) + } + + gotEN, _, err := db.GetStringsByPagesLang(ctx, []string{"home"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang(en): %v", err) + } + wantEN := map[string]string{"title": "Home"} + if !reflect.DeepEqual(gotEN["home"], wantEN) { + t.Errorf("en map = %v, want %v", gotEN["home"], wantEN) + } +} + +func TestGetMetaMissingKey(t *testing.T) { + db := dbtOpen(t) + + v, ok, err := db.GetMeta(context.Background(), "no-such-key") + if err != nil { + t.Fatalf("GetMeta on missing key: %v", err) + } + if ok { + t.Error("ok = true, want false") + } + if v != "" { + t.Errorf("value = %q, want empty string", v) + } +} + +func TestSetMetaOverwrites(t *testing.T) { + db := dbtOpen(t) + ctx := context.Background() + + if err := db.SetMeta(ctx, "k1", "v1"); err != nil { + t.Fatalf("SetMeta initial: %v", err) + } + if err := db.SetMeta(ctx, "k1", "v2"); err != nil { + t.Fatalf("SetMeta overwrite: %v", err) + } + + v, ok, err := db.GetMeta(ctx, "k1") + if err != nil { + t.Fatalf("GetMeta: %v", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + if v != "v2" { + t.Errorf("value = %q, want %q", v, "v2") + } +} diff --git a/handlers.go b/handlers.go index f710383..990cbac 100644 --- a/handlers.go +++ b/handlers.go @@ -32,6 +32,7 @@ type StatusResponse struct { LastPull string `json:"last_pull"` LastHash string `json:"last_hash"` Ready bool `json:"ready"` + Version string `json:"version"` } func (s *Server) routes() http.Handler { @@ -42,8 +43,7 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("GET /status", s.handleStatus) mux.HandleFunc("GET /openapi.json", s.handleOpenAPI) - h := http.Handler(mux) - h = withRequestLogging(mux, s.logger) + h := withRequestLogging(mux, s.logger) h = withRecovery(h, s.logger) c := cors.New(cors.Options{ @@ -55,9 +55,11 @@ func (s *Server) routes() http.Handler { "Accept", "Authorization", "Content-Type", + "If-None-Match", "X-Requested-With", }, - MaxAge: 300, + ExposedHeaders: []string{"ETag"}, + MaxAge: 300, }) return c.Handler(h) @@ -67,33 +69,39 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { writeOK(w, http.StatusOK, HealthResponse{Status: "ok"}) } +// handleReadyz always reports ready while the process is up: the alpha +// deploy tooling restarts pods that stay unready past a limit, and with a +// single replica gating traffic gains nothing (there is no healthy +// alternative pod). The real "can serve" signal is `ready` on /status. func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { - // FIXME: we should return actual ready state - // if s.ready != nil && s.ready.IsReady() { - // writeOK(w, http.StatusOK, ReadyResponse{Status: "ready"}) - // return - // } - // writeErr(w, http.StatusServiceUnavailable, "not_ready", "not ready", nil) - - // FIXME: we're temporarily always ready writeOK(w, http.StatusOK, ReadyResponse{Status: "ready"}) } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - lastPull, _, _ := s.db.GetMeta(r.Context(), "last_pull_rfc3339") - lastHash, _, _ := s.db.GetMeta(r.Context(), "last_xlsx_sha256") + lastPull, _, err := s.db.GetMeta(r.Context(), metaKeyLastPull) + if err != nil { + writeErr(w, http.StatusInternalServerError, "internal_error", "failed to read status", nil) + return + } + lastHash, _, err := s.db.GetMeta(r.Context(), metaKeyLastHash) + if err != nil { + writeErr(w, http.StatusInternalServerError, "internal_error", "failed to read status", nil) + return + } writeOK(w, http.StatusOK, StatusResponse{ LastPull: lastPull, LastHash: lastHash, - Ready: s.ready != nil && s.ready.IsReady(), + Ready: s.ready.IsReady(), + Version: version, }) } func (s *Server) handleGetStrings(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - lang := strings.TrimSpace(q.Get("lang")) + // Language codes are case-insensitive (BCP 47); import stores lowercase. + lang := strings.ToLower(strings.TrimSpace(q.Get("lang"))) if lang == "" { writeErr(w, http.StatusBadRequest, "bad_request", "missing required query param: lang", map[string]string{ "lang": "required", @@ -132,15 +140,47 @@ func (s *Server) handleGetStrings(w http.ResponseWriter, r *http.Request) { return } + // Any given URL's content is fully determined by the last import, + // so the import hash is a valid ETag for every /strings URL. + // Only 200/304 responses carry the caching headers. + var etag string + if hash, ok, err := s.db.GetMeta(r.Context(), metaKeyLastHash); err == nil && ok { + etag = `"` + hash + `"` + if etagMatches(r.Header.Get("If-None-Match"), etag) { + setCacheHeaders(w, etag) + w.WriteHeader(http.StatusNotModified) + return + } + } + stringsByPage, cleanedPages, err := s.db.GetStringsByPagesLang(r.Context(), pages, lang) if err != nil { writeErr(w, http.StatusInternalServerError, "internal_error", "failed to fetch strings", nil) return } + if etag != "" { + setCacheHeaders(w, etag) + } writeOK(w, http.StatusOK, StringsResponse{ Lang: lang, Pages: cleanedPages, Strings: stringsByPage, }) } + +func setCacheHeaders(w http.ResponseWriter, etag string) { + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "public, max-age=60") +} + +func etagMatches(ifNoneMatch, etag string) bool { + for _, candidate := range strings.Split(ifNoneMatch, ",") { + candidate = strings.TrimSpace(candidate) + candidate = strings.TrimPrefix(candidate, "W/") + if candidate == "*" || candidate == etag { + return true + } + } + return false +} diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..8855f81 --- /dev/null +++ b/handlers_test.go @@ -0,0 +1,391 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +const htTestHash = "51b23fc6f6c1de1c69a9b0f0f8a06f21c2e4a89f3a2e2b9d7c6a5e4d3c2b1a09" + +type htAPIError struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]string `json:"details"` +} + +type htEnvelope struct { + Ok bool `json:"ok"` + Data json.RawMessage `json:"data"` + Error *htAPIError `json:"error"` +} + +type htStringsData struct { + Lang string `json:"lang"` + Pages []string `json:"pages"` + Strings map[string]map[string]string `json:"strings"` +} + +func htNewServer(t *testing.T) *Server { + t.Helper() + + db, err := OpenDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + return &Server{ + db: db, + ready: &ReadyState{}, + logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + } +} + +// htSeedFrench imports a small fixture of French strings and returns the +// import hash used as the ETag source. +func htSeedFrench(t *testing.T, srv *Server) string { + t.Helper() + + rows := []StringRow{ + {Page: "a", Key: "hello", Lang: "fr", Value: "bonjour", UpdatedAt: time.Now()}, + {Page: "a", Key: "welcome", Lang: "fr", Value: "bienvenue", UpdatedAt: time.Now()}, + {Page: "b", Key: "bye", Lang: "fr", Value: "au revoir", UpdatedAt: time.Now()}, + } + if err := srv.db.ReplaceImport(context.Background(), rows, htTestHash, time.Now()); err != nil { + t.Fatalf("ReplaceImport: %v", err) + } + return htTestHash +} + +func htGet(t *testing.T, h http.Handler, target string, header map[string]string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, target, nil) + for k, v := range header { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func htDecodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) htEnvelope { + t.Helper() + + var env htEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v (body: %q)", err, rec.Body.String()) + } + return env +} + +func htDecodeData(t *testing.T, env htEnvelope, out any) { + t.Helper() + + if env.Data == nil { + t.Fatalf("envelope has no data field") + } + if err := json.Unmarshal(env.Data, out); err != nil { + t.Fatalf("decode data: %v (data: %q)", err, string(env.Data)) + } +} + +func TestHealthz(t *testing.T) { + srv := htNewServer(t) + rec := htGet(t, srv.routes(), "/healthz", nil) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if got, want := strings.TrimSpace(rec.Body.String()), `{"ok":true,"data":{"status":"ok"}}`; got != want { + t.Errorf("body = %s, want %s", got, want) + } +} + +func TestReadyz(t *testing.T) { + srv := htNewServer(t) + h := srv.routes() + + // Always 200 while the process is up, even before any servable data + // exists; the servable-data signal is `ready` on /status. + for _, ready := range []bool{false, true} { + srv.ready.SetReady(ready) + + rec := htGet(t, h, "/readyz", nil) + if rec.Code != http.StatusOK { + t.Fatalf("ready=%v: status = %d, want %d", ready, rec.Code, http.StatusOK) + } + env := htDecodeEnvelope(t, rec) + if !env.Ok { + t.Errorf("ready=%v: ok = false, want true", ready) + } + var data struct { + Status string `json:"status"` + } + htDecodeData(t, env, &data) + if data.Status != "ready" { + t.Errorf("ready=%v: status = %q, want %q", ready, data.Status, "ready") + } + } +} + +func TestStatus(t *testing.T) { + srv := htNewServer(t) + h := srv.routes() + + rec := htGet(t, h, "/status", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + env := htDecodeEnvelope(t, rec) + if !env.Ok { + t.Fatalf("ok = false, want true") + } + + var fields map[string]any + htDecodeData(t, env, &fields) + for _, k := range []string{"last_pull", "last_hash", "ready", "version"} { + if _, present := fields[k]; !present { + t.Errorf("data missing field %q", k) + } + } + + var data StatusResponse + htDecodeData(t, env, &data) + if data.Version != "dev" { + t.Errorf("version = %q, want %q", data.Version, "dev") + } + if data.Ready { + t.Errorf("ready = true, want false before SetReady") + } + if data.LastHash != "" { + t.Errorf("last_hash = %q, want empty before import", data.LastHash) + } + + hash := htSeedFrench(t, srv) + srv.ready.SetReady(true) + + rec = htGet(t, h, "/status", nil) + if rec.Code != http.StatusOK { + t.Fatalf("after import: status = %d, want %d", rec.Code, http.StatusOK) + } + htDecodeData(t, htDecodeEnvelope(t, rec), &data) + if data.LastHash != hash { + t.Errorf("last_hash = %q, want %q", data.LastHash, hash) + } + if data.LastPull == "" { + t.Errorf("last_pull is empty after import") + } + if !data.Ready { + t.Errorf("ready = false, want true after SetReady") + } +} + +func TestGetStringsValidation(t *testing.T) { + srv := htNewServer(t) + h := srv.routes() + + tests := []struct { + name string + target string + wantDetails map[string]string + }{ + { + name: "missing lang", + target: "/strings", + wantDetails: map[string]string{"lang": "required"}, + }, + { + name: "missing pages", + target: "/strings?lang=fr", + wantDetails: map[string]string{"page": "repeatable", "pages": "csv"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := htGet(t, h, tc.target, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + env := htDecodeEnvelope(t, rec) + if env.Ok { + t.Errorf("ok = true, want false") + } + if env.Error == nil { + t.Fatalf("error is nil") + } + if env.Error.Code != "bad_request" { + t.Errorf("error.code = %q, want %q", env.Error.Code, "bad_request") + } + if !reflect.DeepEqual(env.Error.Details, tc.wantDetails) { + t.Errorf("error.details = %v, want %v", env.Error.Details, tc.wantDetails) + } + }) + } +} + +func TestGetStringsLangNormalization(t *testing.T) { + srv := htNewServer(t) + htSeedFrench(t, srv) + + rec := htGet(t, srv.routes(), "/strings?lang=FR&page=a", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var data htStringsData + htDecodeData(t, htDecodeEnvelope(t, rec), &data) + if data.Lang != "fr" { + t.Errorf("lang = %q, want %q", data.Lang, "fr") + } + if got := data.Strings["a"]["hello"]; got != "bonjour" { + t.Errorf("strings.a.hello = %q, want %q", got, "bonjour") + } +} + +func TestGetStringsPagesParsing(t *testing.T) { + srv := htNewServer(t) + htSeedFrench(t, srv) + h := srv.routes() + + tests := []struct { + name string + query string + wantPages []string + }{ + {"repeated page params", "page=a&page=b", []string{"a", "b"}}, + {"csv pages", "pages=a,b", []string{"a", "b"}}, + {"page wins over pages", "page=a&pages=b", []string{"a"}}, + {"duplicate page params deduped", "page=a&page=a&page=b", []string{"a", "b"}}, + {"csv duplicates deduped", "pages=a,a,b", []string{"a", "b"}}, + {"csv whitespace trimmed", "pages=%20a%20,%20b%20", []string{"a", "b"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := htGet(t, h, "/strings?lang=fr&"+tc.query, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rec.Code, http.StatusOK, rec.Body.String()) + } + + var data htStringsData + htDecodeData(t, htDecodeEnvelope(t, rec), &data) + if !reflect.DeepEqual(data.Pages, tc.wantPages) { + t.Errorf("pages = %v, want %v", data.Pages, tc.wantPages) + } + if len(data.Strings) != len(tc.wantPages) { + t.Errorf("strings has %d pages, want %d", len(data.Strings), len(tc.wantPages)) + } + for _, p := range tc.wantPages { + if _, present := data.Strings[p]; !present { + t.Errorf("strings missing page %q", p) + } + } + }) + } +} + +func TestGetStringsUnknownPage(t *testing.T) { + srv := htNewServer(t) + htSeedFrench(t, srv) + + rec := htGet(t, srv.routes(), "/strings?lang=fr&page=a&page=nope", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var data htStringsData + htDecodeData(t, htDecodeEnvelope(t, rec), &data) + unknown, present := data.Strings["nope"] + if !present { + t.Fatalf("strings missing unknown page %q", "nope") + } + if len(unknown) != 0 { + t.Errorf("strings.nope = %v, want empty object", unknown) + } + if got := data.Strings["a"]["hello"]; got != "bonjour" { + t.Errorf("strings.a.hello = %q, want %q", got, "bonjour") + } +} + +func TestGetStringsNoETagBeforeImport(t *testing.T) { + srv := htNewServer(t) + + rec := htGet(t, srv.routes(), "/strings?lang=fr&page=a", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if etag := rec.Header().Get("ETag"); etag != "" { + t.Errorf("ETag = %q, want no ETag on fresh db", etag) + } + if cc := rec.Header().Get("Cache-Control"); cc != "" { + t.Errorf("Cache-Control = %q, want none on fresh db", cc) + } +} + +func TestGetStringsETag(t *testing.T) { + srv := htNewServer(t) + hash := htSeedFrench(t, srv) + h := srv.routes() + etag := `"` + hash + `"` + + tests := []struct { + name string + ifNoneMatch string + wantStatus int + }{ + {"no conditional header", "", http.StatusOK}, + {"matching etag", etag, http.StatusNotModified}, + {"star", "*", http.StatusNotModified}, + {"stale etag", `"stale"`, http.StatusOK}, + {"multiple candidates with match", `"stale", ` + etag, http.StatusNotModified}, + {"weak matching etag", "W/" + etag, http.StatusNotModified}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + header := map[string]string{} + if tc.ifNoneMatch != "" { + header["If-None-Match"] = tc.ifNoneMatch + } + rec := htGet(t, h, "/strings?lang=fr&page=a", header) + + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus) + } + // Both 200 and 304 must carry the caching headers. + if got := rec.Header().Get("ETag"); got != etag { + t.Errorf("ETag = %q, want %q", got, etag) + } + if got, want := rec.Header().Get("Cache-Control"), "public, max-age=60"; got != want { + t.Errorf("Cache-Control = %q, want %q", got, want) + } + + if tc.wantStatus == http.StatusNotModified { + if rec.Body.Len() != 0 { + t.Errorf("304 body = %q, want empty", rec.Body.String()) + } + return + } + + var data htStringsData + htDecodeData(t, htDecodeEnvelope(t, rec), &data) + if got := data.Strings["a"]["hello"]; got != "bonjour" { + t.Errorf("strings.a.hello = %q, want %q", got, "bonjour") + } + }) + } +} diff --git a/main.go b/main.go index 4a1c4cd..9b68a3c 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,9 @@ import ( "time" ) +// version is injected at build time via -ldflags "-X main.version=...". +var version = "dev" + func main() { healthcheck := flag.Bool("healthcheck", false, "run a quick health probe and exit") schema := flag.Bool("schema", false, "generate a openapi.json file and exit") @@ -21,32 +24,25 @@ func main() { flag.Parse() if *schema { - openapiSchema, err := buildOpenAPISpec("/") - if err != nil { + if err := writeSchemaFile("openapi.json"); err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } - - // Convert to JSON bytes - fileData, jsonErr := json.MarshalIndent(openapiSchema, "", " ") - if jsonErr != nil { - fmt.Fprintln(os.Stderr, jsonErr.Error()) - os.Exit(1) - } - - fileErr := os.WriteFile("openapi.json", fileData, 0644) - if fileErr != nil { - fmt.Fprintln(os.Stderr, fileErr.Error()) - os.Exit(1) - } os.Exit(0) } - cfg := LoadConfig() + cfg, err := LoadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } logger := newLogger(cfg.LogLevel) if *healthcheck { - err := doHealthcheck("http://127.0.0.1" + cfg.ListenAddr + "/healthz") + url, err := healthcheckURL(cfg.ListenAddr) + if err == nil { + err = doHealthcheck(url) + } if err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) @@ -66,18 +62,29 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // Ready = can serve: data from a previous run counts, even if the + // upstream is currently unreachable. A pull success also sets it. ready := &ReadyState{} - ready.SetReady(true) + hasData, err := db.HasStrings(ctx) + if err != nil { + logger.Error("readiness check failed", slog.String("err", err.Error())) + os.Exit(1) + } + ready.SetReady(hasData) srv := &Server{db: db, ready: ready, logger: logger} logger.Info("cacheppuccino starting", + slog.String("version", version), slog.String("addr", cfg.ListenAddr), + slog.Bool("has_data", hasData), slog.String("pull_interval", cfg.PullInterval.String()), slog.String("http_timeout", cfg.HTTPTimeout.String()), slog.String("initial_pull_deadline", cfg.InitialPullDeadline.String()), ) + // No BaseContext: request contexts must outlive the shutdown signal + // so Shutdown can drain in-flight requests instead of aborting them. httpServer := &http.Server{ Addr: cfg.ListenAddr, Handler: srv.routes(), @@ -86,9 +93,6 @@ func main() { WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20, - BaseContext: func(l net.Listener) context.Context { - return ctx - }, } go func() { @@ -124,6 +128,20 @@ func main() { } } +func writeSchemaFile(path string) error { + spec, err := buildOpenAPISpec("/") + if err != nil { + return err + } + + data, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) +} + func newLogger(level string) *slog.Logger { var lvl slog.Level switch level { @@ -141,6 +159,16 @@ func newLogger(level string) *slog.Logger { return slog.New(h) } +// healthcheckURL derives the local probe URL from LISTEN_ADDR, +// which may be ":8080", "0.0.0.0:8080", or "host:8080". +func healthcheckURL(listenAddr string) (string, error) { + _, port, err := net.SplitHostPort(listenAddr) + if err != nil { + return "", fmt.Errorf("invalid LISTEN_ADDR %q: %w", listenAddr, err) + } + return "http://127.0.0.1:" + port + "/healthz", nil +} + func doHealthcheck(url string) error { c := &http.Client{Timeout: 2 * time.Second} resp, err := c.Get(url) diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e3407ad --- /dev/null +++ b/main_test.go @@ -0,0 +1,36 @@ +package main + +import "testing" + +func TestHealthcheckURL(t *testing.T) { + tests := []struct { + listenAddr string + want string + wantErr bool + }{ + {listenAddr: ":8080", want: "http://127.0.0.1:8080/healthz"}, + {listenAddr: "0.0.0.0:8080", want: "http://127.0.0.1:8080/healthz"}, + {listenAddr: "127.0.0.1:9999", want: "http://127.0.0.1:9999/healthz"}, + {listenAddr: "somehost:8081", want: "http://127.0.0.1:8081/healthz"}, + {listenAddr: "8080", wantErr: true}, + {listenAddr: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.listenAddr, func(t *testing.T) { + got, err := healthcheckURL(tt.listenAddr) + if tt.wantErr { + if err == nil { + t.Fatalf("healthcheckURL(%q) = %q, want error", tt.listenAddr, got) + } + return + } + if err != nil { + t.Fatalf("healthcheckURL(%q): %v", tt.listenAddr, err) + } + if got != tt.want { + t.Errorf("healthcheckURL(%q) = %q, want %q", tt.listenAddr, got, tt.want) + } + }) + } +} diff --git a/openapi.go b/openapi.go index 7e03896..f53924f 100644 --- a/openapi.go +++ b/openapi.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "fmt" "net/http" "github.com/getkin/kin-openapi/openapi3" @@ -41,7 +40,7 @@ func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { spec, err := buildOpenAPISpec(baseURL) if err != nil { - fmt.Println(err.Error()) + s.logger.Error("openapi spec build failed", "err", err) writeErr(w, http.StatusInternalServerError, "internal_error", "failed to build openapi spec", nil) return } @@ -102,7 +101,7 @@ func buildOpenAPISpec(baseURL string) (*openapi3.T, error) { OpenAPI: "3.0.3", Info: &openapi3.Info{ Title: "cacheppuccino", - Version: "0.1.0", + Version: version, }, Servers: openapi3.Servers{ {URL: baseURL}, @@ -139,17 +138,13 @@ func buildOpenAPISpec(baseURL string) (*openapi3.T, error) { } spec.Paths.Set("/readyz", &openapi3.PathItem{ Get: &openapi3.Operation{ - Summary: "Readiness endpoint", + Summary: "Readiness endpoint (always ready while the process is up; see /status for the servable-data signal)", OperationID: "getReadyz", Responses: newResponses(map[string]*openapi3.ResponseRef{ "200": {Value: &openapi3.Response{ Description: ptrString("Ready"), Content: ready200, }}, - "503": {Value: &openapi3.Response{ - Description: ptrString("Not ready"), - Content: errResp, - }}, }), }, }) @@ -168,6 +163,10 @@ func buildOpenAPISpec(baseURL string) (*openapi3.T, error) { Description: ptrString("OK"), Content: status200, }}, + "500": {Value: &openapi3.Response{ + Description: ptrString("Internal error"), + Content: errResp, + }}, }), }, }) @@ -225,6 +224,9 @@ func buildOpenAPISpec(baseURL string) (*openapi3.T, error) { Description: ptrString("OK"), Content: strings200, }}, + "304": {Value: &openapi3.Response{ + Description: ptrString("Not modified (If-None-Match matched the current ETag)"), + }}, "400": {Value: &openapi3.Response{ Description: ptrString("Bad request"), Content: errResp, diff --git a/openapi.json b/openapi.json index dd65d1b..fe809b8 100644 --- a/openapi.json +++ b/openapi.json @@ -1,7 +1,7 @@ { "info": { "title": "cacheppuccino", - "version": "0.1.0" + "version": "dev" }, "openapi": "3.0.3", "paths": { @@ -89,24 +89,35 @@ }, "description": "Ready" }, - "503": { + "default": { + "description": "" + } + }, + "summary": "Readiness endpoint (always ready while the process is up; see /status for the servable-data signal)" + } + }, + "/status": { + "get": { + "operationId": "getStatus", + "responses": { + "200": { "content": { "application/json": { "schema": { "properties": { - "error": { + "data": { "nullable": true, "properties": { - "code": { + "last_hash": { "type": "string" }, - "details": { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "last_pull": { + "type": "string" }, - "message": { + "ready": { + "type": "boolean" + }, + "version": { "type": "string" } }, @@ -120,35 +131,27 @@ } } }, - "description": "Not ready" + "description": "OK" }, - "default": { - "description": "" - } - }, - "summary": "Readiness endpoint" - } - }, - "/status": { - "get": { - "operationId": "getStatus", - "responses": { - "200": { + "500": { "content": { "application/json": { "schema": { "properties": { - "data": { + "error": { "nullable": true, "properties": { - "last_hash": { + "code": { "type": "string" }, - "last_pull": { - "type": "string" + "details": { + "additionalProperties": { + "type": "string" + }, + "type": "object" }, - "ready": { - "type": "boolean" + "message": { + "type": "string" } }, "type": "object" @@ -161,7 +164,7 @@ } } }, - "description": "OK" + "description": "Internal error" }, "default": { "description": "" @@ -245,6 +248,9 @@ }, "description": "OK" }, + "304": { + "description": "Not modified (If-None-Match matched the current ETag)" + }, "400": { "content": { "application/json": { diff --git a/sync_pull.go b/sync_pull.go index e583e63..676c39a 100644 --- a/sync_pull.go +++ b/sync_pull.go @@ -30,18 +30,8 @@ func (c *TranslationClient) DownloadXLSX( applicationID string, logger *slog.Logger, ) ([]byte, error) { - if logger == nil { - logger = slog.Default() - } - if c == nil { - return nil, fmt.Errorf("translation client is nil") - } - if c.baseURL == "" { - return nil, fmt.Errorf("translation base URL is empty") - } - url := fmt.Sprintf("%s/api/Application/%s/Translation/export", c.baseURL, applicationID) - logger.Info("Requesting export", slog.String("url", url)) + logger.Info("pull: requesting export", slog.String("url", url)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -52,25 +42,12 @@ func (c *TranslationClient) DownloadXLSX( req.Header.Set("X-API-KEY", c.apiKey) } - hc := c.http - if hc == nil { - hc = http.DefaultClient - } - - resp, err := hc.Do(req) + resp, err := c.http.Do(req) if err != nil { - // resp can be nil on network errors; don't touch it return nil, fmt.Errorf("export request failed: %w", err) } defer resp.Body.Close() - logger.Info( - "Export request complete", - slog.Int("status", resp.StatusCode), - slog.String("cl", resp.Header.Get("Content-Length")), - slog.String("ct", resp.Header.Get("Content-Type")), - ) - if resp.StatusCode/100 != 2 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) return nil, fmt.Errorf("download failed: %s: %s", resp.Status, string(b)) @@ -94,47 +71,39 @@ func PullOnce( ) (PullResult, error) { t0 := time.Now() - logger.Info("pull: download start") xlsx, err := client.DownloadXLSX(ctx, applicationID, logger) - logger.Info("pull: download done", slog.Duration("dur", time.Since(t0)), slog.Int("bytes", len(xlsx))) - if err != nil { return PullResult{}, err } + logger.Info("pull: download done", slog.Duration("dur", time.Since(t0)), slog.Int("bytes", len(xlsx))) hash := HashBytes(xlsx) - prev, ok, err := db.GetMeta(ctx, "last_xlsx_sha256") + prev, ok, err := db.GetMeta(ctx, metaKeyLastHash) if err != nil { return PullResult{}, err } if ok && prev == hash { + // Content unchanged; still record that a pull succeeded. + if err := db.SetMeta(ctx, metaKeyLastPull, time.Now().UTC().Format(time.RFC3339)); err != nil { + return PullResult{}, err + } return PullResult{Skipped: true, Hash: hash, Rows: 0}, nil } t1 := time.Now() rows, err := ParseXLSX(xlsx) - logger.Info("pull: parse done", slog.Duration("dur", time.Since(t1)), slog.Int("rows", len(rows))) if err != nil { return PullResult{}, err } + logger.Info("pull: parse done", slog.Duration("dur", time.Since(t1)), slog.Int("rows", len(rows))) t2 := time.Now() - if err := db.UpsertStringsFromRows(ctx, rows); err != nil { - return PullResult{}, err - } - logger.Info("pull: upsert done", slog.Duration("dur", time.Since(t2))) - - err = db.SetMeta(ctx, "last_xlsx_sha256", hash) - if err != nil { - return PullResult{}, err - } - - err = db.SetMeta(ctx, "last_pull_rfc3339", time.Now().UTC().Format(time.RFC3339)) - if err != nil { + if err := db.ReplaceImport(ctx, rows, hash, time.Now()); err != nil { return PullResult{}, err } + logger.Info("pull: import done", slog.Duration("dur", time.Since(t2))) return PullResult{Skipped: false, Hash: hash, Rows: len(rows)}, nil } @@ -152,6 +121,14 @@ func StartPeriodicPuller( jitterMax := time.Duration(float64(interval) * 0.10) ticker := time.NewTicker(interval) + logPull := func(kind string, res PullResult) { + if res.Skipped { + logger.Info(kind+" pull unchanged", slog.String("hash", res.Hash)) + } else { + logger.Info(kind+" pull imported", slog.Int("rows", res.Rows), slog.String("hash", res.Hash)) + } + } + go func() { defer ticker.Stop() @@ -168,23 +145,12 @@ func StartPeriodicPuller( res, err := PullOnce(pullCtx, db, client, applicationID, logger) if err != nil { - if logger != nil { - logger.Warn("initial pull failed; service remains unready until a pull succeeds", "err", err) - } + logger.Warn("initial pull failed; service stays unready unless it already has data", "err", err) return } - if ready != nil { - ready.SetReady(true) - } - - if logger != nil { - if res.Skipped { - logger.Info("initial pull unchanged", slog.String("hash", res.Hash)) - } else { - logger.Info("initial pull imported", slog.Int("rows", res.Rows), slog.String("hash", res.Hash)) - } - } + ready.SetReady(true) + logPull("initial", res) }() for { @@ -205,23 +171,12 @@ func StartPeriodicPuller( res, err := PullOnce(ctx, db, client, applicationID, logger) if err != nil { - if logger != nil { - logger.Warn("periodic pull failed", "err", err) - } + logger.Warn("periodic pull failed", "err", err) continue } - if ready != nil { - ready.SetReady(true) - } - - if logger != nil { - if res.Skipped { - logger.Info("periodic pull unchanged", slog.String("hash", res.Hash)) - } else { - logger.Info("periodic pull imported", slog.Int("rows", res.Rows), slog.String("hash", res.Hash)) - } - } + ready.SetReady(true) + logPull("periodic", res) } } }() diff --git a/sync_pull_test.go b/sync_pull_test.go new file mode 100644 index 0000000..618a5c8 --- /dev/null +++ b/sync_pull_test.go @@ -0,0 +1,443 @@ +package main + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/xuri/excelize/v2" +) + +const stSheetName = "Translations" + +func stLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) +} + +func stOpenDB(t *testing.T) *DB { + t.Helper() + db, err := OpenDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func stClient(baseURL, apiKey string) *TranslationClient { + return NewTranslationClient(Config{ + TranslationBaseURL: baseURL, + TranslationAPIKey: apiKey, + HTTPTimeout: 5 * time.Second, + }) +} + +// stXLSX builds an in-memory workbook whose "Translations" sheet holds the +// given rows; rows[0] is the header. +func stXLSX(t *testing.T, rows [][]string) []byte { + t.Helper() + + f := excelize.NewFile() + defer func() { _ = f.Close() }() + + if err := f.SetSheetName("Sheet1", stSheetName); err != nil { + t.Fatalf("SetSheetName: %v", err) + } + for i := range rows { + cell, err := excelize.CoordinatesToCellName(1, i+1) + if err != nil { + t.Fatalf("CoordinatesToCellName: %v", err) + } + if err := f.SetSheetRow(stSheetName, cell, &rows[i]); err != nil { + t.Fatalf("SetSheetRow: %v", err) + } + } + + buf, err := f.WriteToBuffer() + if err != nil { + t.Fatalf("WriteToBuffer: %v", err) + } + return buf.Bytes() +} + +// stSeedPayload has 3 data rows x 2 languages = 6 imported StringRows. +func stSeedPayload(t *testing.T) []byte { + t.Helper() + return stXLSX(t, [][]string{ + {"page", "key", "en", "fr"}, + {"home", "greeting", "Hello", "Bonjour"}, + {"home", "farewell", "Goodbye", "Au revoir"}, + {"about", "title", "About us", "A propos"}, + }) +} + +func stSeedWantEN() map[string]map[string]string { + return map[string]map[string]string{ + "home": {"greeting": "Hello", "farewell": "Goodbye"}, + "about": {"title": "About us"}, + } +} + +func TestTranslationClientDownloadXLSXRequestShape(t *testing.T) { + cases := []struct { + name string + apiKey string + wantHeader bool + }{ + {name: "api key set", apiKey: "secret", wantHeader: true}, + {name: "api key empty", apiKey: "", wantHeader: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + mu sync.Mutex + gotMethod string + gotPath string + gotKey string + headerSeen bool + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotMethod = r.Method + gotPath = r.URL.Path + gotKey = r.Header.Get("X-API-KEY") + _, headerSeen = r.Header[http.CanonicalHeaderKey("X-API-KEY")] + mu.Unlock() + _, _ = w.Write([]byte("body-bytes")) + })) + defer srv.Close() + + client := stClient(srv.URL, tc.apiKey) + body, err := client.DownloadXLSX(context.Background(), "app-1", stLogger()) + if err != nil { + t.Fatalf("DownloadXLSX: %v", err) + } + if string(body) != "body-bytes" { + t.Errorf("body = %q, want %q", body, "body-bytes") + } + + mu.Lock() + defer mu.Unlock() + if gotMethod != http.MethodGet { + t.Errorf("method = %q, want GET", gotMethod) + } + if want := "/api/Application/app-1/Translation/export"; gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) + } + if tc.wantHeader { + if !headerSeen || gotKey != tc.apiKey { + t.Errorf("X-API-KEY = %q (present=%v), want %q", gotKey, headerSeen, tc.apiKey) + } + } else if headerSeen { + t.Errorf("X-API-KEY header present (%q), want absent", gotKey) + } + }) + } +} + +func TestPullOnceFirstThenSkipThenReplace(t *testing.T) { + ctx := context.Background() + db := stOpenDB(t) + logger := stLogger() + + payload1 := stSeedPayload(t) + // v2: "home"/"farewell" removed, "contact"/"email" added. + payload2 := stXLSX(t, [][]string{ + {"page", "key", "en", "fr"}, + {"home", "greeting", "Hello", "Bonjour"}, + {"about", "title", "About us", "A propos"}, + {"contact", "email", "Email", "Courriel"}, + }) + + var mu sync.Mutex + payload := payload1 + setPayload := func(p []byte) { + mu.Lock() + payload = p + mu.Unlock() + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + p := payload + mu.Unlock() + _, _ = w.Write(p) + })) + defer srv.Close() + + client := stClient(srv.URL, "secret") + + // First pull: full import. + res1, err := PullOnce(ctx, db, client, "app-1", logger) + if err != nil { + t.Fatalf("first PullOnce: %v", err) + } + if res1.Skipped { + t.Error("first pull: Skipped = true, want false") + } + if res1.Rows != 6 { + t.Errorf("first pull: Rows = %d, want 6", res1.Rows) + } + if want := HashBytes(payload1); res1.Hash != want { + t.Errorf("first pull: Hash = %q, want %q", res1.Hash, want) + } + + gotEN, _, err := db.GetStringsByPagesLang(ctx, []string{"home", "about"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang(en): %v", err) + } + if want := stSeedWantEN(); !reflect.DeepEqual(gotEN, want) { + t.Errorf("en strings after first pull = %v, want %v", gotEN, want) + } + gotFR, _, err := db.GetStringsByPagesLang(ctx, []string{"home"}, "fr") + if err != nil { + t.Fatalf("GetStringsByPagesLang(fr): %v", err) + } + wantFR := map[string]map[string]string{ + "home": {"greeting": "Bonjour", "farewell": "Au revoir"}, + } + if !reflect.DeepEqual(gotFR, wantFR) { + t.Errorf("fr strings after first pull = %v, want %v", gotFR, wantFR) + } + + hashMeta, ok, err := db.GetMeta(ctx, metaKeyLastHash) + if err != nil { + t.Fatalf("GetMeta(%s): %v", metaKeyLastHash, err) + } + if !ok || hashMeta != res1.Hash { + t.Errorf("meta %s = %q (ok=%v), want %q", metaKeyLastHash, hashMeta, ok, res1.Hash) + } + pullMeta, ok, err := db.GetMeta(ctx, metaKeyLastPull) + if err != nil { + t.Fatalf("GetMeta(%s): %v", metaKeyLastPull, err) + } + if !ok { + t.Fatalf("meta %s not set after first pull", metaKeyLastPull) + } + if _, err := time.Parse(time.RFC3339, pullMeta); err != nil { + t.Errorf("meta %s = %q is not RFC3339: %v", metaKeyLastPull, pullMeta, err) + } + + // Second pull with identical bytes: skipped, but last pull refreshed. + const stale = "2000-01-01T00:00:00Z" + if err := db.SetMeta(ctx, metaKeyLastPull, stale); err != nil { + t.Fatalf("SetMeta: %v", err) + } + + res2, err := PullOnce(ctx, db, client, "app-1", logger) + if err != nil { + t.Fatalf("second PullOnce: %v", err) + } + if !res2.Skipped { + t.Error("second pull: Skipped = false, want true") + } + if res2.Rows != 0 { + t.Errorf("second pull: Rows = %d, want 0", res2.Rows) + } + if res2.Hash != res1.Hash { + t.Errorf("second pull: Hash = %q, want %q", res2.Hash, res1.Hash) + } + + pullMeta2, ok, err := db.GetMeta(ctx, metaKeyLastPull) + if err != nil { + t.Fatalf("GetMeta(%s): %v", metaKeyLastPull, err) + } + if !ok { + t.Fatalf("meta %s missing after skipped pull", metaKeyLastPull) + } + if pullMeta2 == stale { + t.Errorf("meta %s = %q, want refreshed even on skipped pull", metaKeyLastPull, pullMeta2) + } + ts, err := time.Parse(time.RFC3339, pullMeta2) + if err != nil { + t.Fatalf("meta %s = %q is not RFC3339: %v", metaKeyLastPull, pullMeta2, err) + } + if !ts.After(time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("meta %s = %q, want a recent timestamp", metaKeyLastPull, pullMeta2) + } + + // Third pull with changed payload: full replacement, removed row gone. + setPayload(payload2) + + res3, err := PullOnce(ctx, db, client, "app-1", logger) + if err != nil { + t.Fatalf("third PullOnce: %v", err) + } + if res3.Skipped { + t.Error("third pull: Skipped = true, want false") + } + if res3.Rows != 6 { + t.Errorf("third pull: Rows = %d, want 6", res3.Rows) + } + if want := HashBytes(payload2); res3.Hash != want { + t.Errorf("third pull: Hash = %q, want %q", res3.Hash, want) + } + if res3.Hash == res1.Hash { + t.Error("third pull: hash unchanged despite different payload") + } + + gotEN3, _, err := db.GetStringsByPagesLang(ctx, []string{"home", "about", "contact"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang(en): %v", err) + } + wantEN3 := map[string]map[string]string{ + "home": {"greeting": "Hello"}, + "about": {"title": "About us"}, + "contact": {"email": "Email"}, + } + if !reflect.DeepEqual(gotEN3, wantEN3) { + t.Errorf("en strings after third pull = %v, want %v (removed row must be gone)", gotEN3, wantEN3) + } + + hashMeta3, ok, err := db.GetMeta(ctx, metaKeyLastHash) + if err != nil { + t.Fatalf("GetMeta(%s): %v", metaKeyLastHash, err) + } + if !ok || hashMeta3 != res3.Hash { + t.Errorf("meta %s = %q (ok=%v), want %q", metaKeyLastHash, hashMeta3, ok, res3.Hash) + } +} + +func TestPullOnceUpstreamHTTPError(t *testing.T) { + ctx := context.Background() + db := stOpenDB(t) + logger := stLogger() + payload := stSeedPayload(t) + + var mu sync.Mutex + fail := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + f := fail + mu.Unlock() + if f { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("upstream exploded")) + return + } + _, _ = w.Write(payload) + })) + defer srv.Close() + + client := stClient(srv.URL, "secret") + + res, err := PullOnce(ctx, db, client, "app-1", logger) + if err != nil { + t.Fatalf("seed PullOnce: %v", err) + } + + mu.Lock() + fail = true + mu.Unlock() + + _, err = PullOnce(ctx, db, client, "app-1", logger) + if err == nil { + t.Fatal("PullOnce on upstream 500: want error, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error = %q, want it to mention status 500", err) + } + + hashMeta, ok, err := db.GetMeta(ctx, metaKeyLastHash) + if err != nil { + t.Fatalf("GetMeta(%s): %v", metaKeyLastHash, err) + } + if !ok || hashMeta != res.Hash { + t.Errorf("meta %s = %q (ok=%v), want unchanged %q", metaKeyLastHash, hashMeta, ok, res.Hash) + } + gotEN, _, err := db.GetStringsByPagesLang(ctx, []string{"home", "about"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + if want := stSeedWantEN(); !reflect.DeepEqual(gotEN, want) { + t.Errorf("en strings after failed pull = %v, want unchanged %v", gotEN, want) + } +} + +func TestPullOnceInvalidXLSXBody(t *testing.T) { + ctx := context.Background() + db := stOpenDB(t) + logger := stLogger() + payload := stSeedPayload(t) + + var mu sync.Mutex + junk := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + j := junk + mu.Unlock() + if j { + _, _ = w.Write([]byte("definitely not a zip archive")) + return + } + _, _ = w.Write(payload) + })) + defer srv.Close() + + client := stClient(srv.URL, "secret") + + res, err := PullOnce(ctx, db, client, "app-1", logger) + if err != nil { + t.Fatalf("seed PullOnce: %v", err) + } + + mu.Lock() + junk = true + mu.Unlock() + + _, err = PullOnce(ctx, db, client, "app-1", logger) + if err == nil { + t.Fatal("PullOnce on junk body: want parse error, got nil") + } + if strings.Contains(err.Error(), "download failed") { + t.Errorf("error = %q, want a parse error, not a download error", err) + } + + hashMeta, ok, err := db.GetMeta(ctx, metaKeyLastHash) + if err != nil { + t.Fatalf("GetMeta(%s): %v", metaKeyLastHash, err) + } + if !ok || hashMeta != res.Hash { + t.Errorf("meta %s = %q (ok=%v), want unchanged %q", metaKeyLastHash, hashMeta, ok, res.Hash) + } + gotEN, _, err := db.GetStringsByPagesLang(ctx, []string{"home", "about"}, "en") + if err != nil { + t.Fatalf("GetStringsByPagesLang: %v", err) + } + if want := stSeedWantEN(); !reflect.DeepEqual(gotEN, want) { + t.Errorf("en strings after failed pull = %v, want unchanged %v", gotEN, want) + } +} + +func TestPullOnceContextCancelled(t *testing.T) { + db := stOpenDB(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + _, _ = w.Write([]byte("never used")) + })) + defer srv.Close() + + client := stClient(srv.URL, "secret") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := PullOnce(ctx, db, client, "app-1", stLogger()) + if err == nil { + t.Fatal("PullOnce with cancelled context: want error, got nil") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error = %v, want errors.Is(err, context.Canceled)", err) + } +} diff --git a/xlsx_test.go b/xlsx_test.go new file mode 100644 index 0000000..b4a1780 --- /dev/null +++ b/xlsx_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "regexp" + "testing" + "time" + + "github.com/xuri/excelize/v2" +) + +type xtSheet struct { + name string + rows [][]any +} + +// xtBuildXLSX builds an in-memory workbook. The first sheet in sheets +// replaces the default "Sheet1"; subsequent sheets are appended. +func xtBuildXLSX(t *testing.T, sheets []xtSheet) []byte { + t.Helper() + + f := excelize.NewFile() + defer func() { _ = f.Close() }() + + for i, s := range sheets { + if i == 0 { + if s.name != "Sheet1" { + if err := f.SetSheetName("Sheet1", s.name); err != nil { + t.Fatalf("SetSheetName: %v", err) + } + } + } else { + if _, err := f.NewSheet(s.name); err != nil { + t.Fatalf("NewSheet: %v", err) + } + } + for r, row := range s.rows { + cell, err := excelize.CoordinatesToCellName(1, r+1) + if err != nil { + t.Fatalf("CoordinatesToCellName: %v", err) + } + if err := f.SetSheetRow(s.name, cell, &row); err != nil { + t.Fatalf("SetSheetRow: %v", err) + } + } + } + + buf, err := f.WriteToBuffer() + if err != nil { + t.Fatalf("WriteToBuffer: %v", err) + } + return buf.Bytes() +} + +// xtRowEq compares everything except UpdatedAt. +func xtRowEq(a, b StringRow) bool { + return a.Page == b.Page && a.Key == b.Key && a.Lang == b.Lang && a.Value == b.Value +} + +func xtAssertRows(t *testing.T, got, want []StringRow) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d rows, want %d\ngot: %+v", len(got), len(want), got) + } + for i := range want { + if !xtRowEq(got[i], want[i]) { + t.Errorf("row %d: got %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestParseXLSXHappyPath(t *testing.T) { + // A decoy first sheet with an invalid header ensures the + // "Translations" sheet is preferred over the first sheet. + data := xtBuildXLSX(t, []xtSheet{ + { + name: "Decoy", + rows: [][]any{{"Namespace", "Key", "en"}, {"x", "y", "z"}}, + }, + { + name: "Translations", + rows: [][]any{ + {"page", "key", "EN", "fr", "Es"}, + {"home", "title", "Hello", "Bonjour", "Hola"}, + {"home", "subtitle", "World", "", "Mundo"}, + }, + }, + }) + + before := time.Now().UTC() + got, err := ParseXLSX(data) + if err != nil { + t.Fatalf("ParseXLSX: %v", err) + } + + want := []StringRow{ + {Page: "home", Key: "title", Lang: "en", Value: "Hello"}, + {Page: "home", Key: "title", Lang: "fr", Value: "Bonjour"}, + {Page: "home", Key: "title", Lang: "es", Value: "Hola"}, + {Page: "home", Key: "subtitle", Lang: "en", Value: "World"}, + {Page: "home", Key: "subtitle", Lang: "es", Value: "Mundo"}, + } + xtAssertRows(t, got, want) + + for i, r := range got { + if r.UpdatedAt.IsZero() { + t.Errorf("row %d: UpdatedAt is zero", i) + } + if r.UpdatedAt.Location() != time.UTC { + t.Errorf("row %d: UpdatedAt location = %v, want UTC", i, r.UpdatedAt.Location()) + } + if r.UpdatedAt.Before(before) || r.UpdatedAt.After(time.Now().UTC()) { + t.Errorf("row %d: UpdatedAt %v outside expected window", i, r.UpdatedAt) + } + if !r.UpdatedAt.Equal(got[0].UpdatedAt) { + t.Errorf("row %d: UpdatedAt differs from first row", i) + } + } +} + +func TestParseXLSXSheetFallback(t *testing.T) { + // No "Translations" sheet: the first sheet is parsed instead. + data := xtBuildXLSX(t, []xtSheet{ + { + name: "Sheet1", + rows: [][]any{ + {"page", "key", "en"}, + {"about", "heading", "About us"}, + }, + }, + }) + + got, err := ParseXLSX(data) + if err != nil { + t.Fatalf("ParseXLSX: %v", err) + } + xtAssertRows(t, got, []StringRow{ + {Page: "about", Key: "heading", Lang: "en", Value: "About us"}, + }) +} + +func TestParseXLSXHeaderValidation(t *testing.T) { + tests := []struct { + name string + header []any + wantErr bool + }{ + {"namespace instead of page", []any{"Namespace", "Key", "en"}, true}, + {"swapped page and key", []any{"key", "page", "en"}, true}, + {"fewer than 3 columns", []any{"page", "key"}, true}, + {"case-insensitive with whitespace", []any{" Page ", " KEY ", " en"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := xtBuildXLSX(t, []xtSheet{ + { + name: "Translations", + rows: [][]any{tt.header, {"p1", "k1", "v1"}}, + }, + }) + got, err := ParseXLSX(data) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseXLSX: expected error, got rows %+v", got) + } + return + } + if err != nil { + t.Fatalf("ParseXLSX: %v", err) + } + xtAssertRows(t, got, []StringRow{ + {Page: "p1", Key: "k1", Lang: "en", Value: "v1"}, + }) + }) + } +} + +func TestParseXLSXRowFiltering(t *testing.T) { + data := xtBuildXLSX(t, []xtSheet{ + { + name: "Translations", + rows: [][]any{ + {"page", "key", "en", "", "fr"}, // empty lang header: column skipped + {" ", "k1", "skipped"}, // whitespace page: row skipped + {"p1", " ", "skipped"}, // whitespace key: row skipped + {"onlypage"}, // shorter than 2 cells: row skipped + {"p2", "k2"}, // no value cells: nothing emitted + {"p3", "k3", "", "x", " salut "}, // empty en cell + skipped empty-header col + {"p4", "k4", " hello "}, // value gets trimmed + }, + }, + }) + + got, err := ParseXLSX(data) + if err != nil { + t.Fatalf("ParseXLSX: %v", err) + } + xtAssertRows(t, got, []StringRow{ + {Page: "p3", Key: "k3", Lang: "fr", Value: "salut"}, + {Page: "p4", Key: "k4", Lang: "en", Value: "hello"}, + }) +} + +func TestParseXLSXEmptySheet(t *testing.T) { + data := xtBuildXLSX(t, []xtSheet{{name: "Translations"}}) + + got, err := ParseXLSX(data) + if err != nil { + t.Fatalf("ParseXLSX: %v", err) + } + if got != nil { + t.Fatalf("expected nil rows for empty sheet, got %+v", got) + } +} + +func TestParseXLSXInvalidInput(t *testing.T) { + if _, err := ParseXLSX([]byte("junk")); err == nil { + t.Fatal("expected error for non-xlsx input") + } +} + +var xtHexRe = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func TestHashBytes(t *testing.T) { + tests := []struct { + name string + input []byte + want string + }{ + { + "empty input", + []byte{}, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + "abc", + []byte("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HashBytes(tt.input) + if !xtHexRe.MatchString(got) { + t.Errorf("HashBytes(%q) = %q, not 64 lowercase hex chars", tt.input, got) + } + if got != tt.want { + t.Errorf("HashBytes(%q) = %q, want %q", tt.input, got, tt.want) + } + if again := HashBytes(tt.input); again != got { + t.Errorf("HashBytes not deterministic: %q then %q", got, again) + } + }) + } + + if HashBytes([]byte("a")) == HashBytes([]byte("b")) { + t.Error("HashBytes returned same hash for different inputs") + } +} From a5de1654ecb2acf2354c922cc181f856d4b9f9c9 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Wed, 8 Jul 2026 18:00:30 +0545 Subject: [PATCH 2/2] fix(healthcheck): probe the configured bind host, not always loopback --- main.go | 15 +++++++++++---- main_test.go | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 9b68a3c..416286e 100644 --- a/main.go +++ b/main.go @@ -159,14 +159,21 @@ func newLogger(level string) *slog.Logger { return slog.New(h) } -// healthcheckURL derives the local probe URL from LISTEN_ADDR, -// which may be ":8080", "0.0.0.0:8080", or "host:8080". +// healthcheckURL derives the probe URL from LISTEN_ADDR. The probe runs +// inside the same container as the server (the distroless image has no +// curl, so the binary probes itself): wildcard binds (":8080", +// "0.0.0.0:8080", "[::]:8080") are reachable via loopback, while an +// explicit bind host must be probed directly. func healthcheckURL(listenAddr string) (string, error) { - _, port, err := net.SplitHostPort(listenAddr) + host, port, err := net.SplitHostPort(listenAddr) if err != nil { return "", fmt.Errorf("invalid LISTEN_ADDR %q: %w", listenAddr, err) } - return "http://127.0.0.1:" + port + "/healthz", nil + switch host { + case "", "0.0.0.0", "::": + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port) + "/healthz", nil } func doHealthcheck(url string) error { diff --git a/main_test.go b/main_test.go index e3407ad..3b3c125 100644 --- a/main_test.go +++ b/main_test.go @@ -8,10 +8,13 @@ func TestHealthcheckURL(t *testing.T) { want string wantErr bool }{ + // Wildcard binds are probed via loopback. {listenAddr: ":8080", want: "http://127.0.0.1:8080/healthz"}, {listenAddr: "0.0.0.0:8080", want: "http://127.0.0.1:8080/healthz"}, + {listenAddr: "[::]:8080", want: "http://127.0.0.1:8080/healthz"}, + // Explicit bind hosts are probed directly. {listenAddr: "127.0.0.1:9999", want: "http://127.0.0.1:9999/healthz"}, - {listenAddr: "somehost:8081", want: "http://127.0.0.1:8081/healthz"}, + {listenAddr: "somehost:8081", want: "http://somehost:8081/healthz"}, {listenAddr: "8080", wantErr: true}, {listenAddr: "", wantErr: true}, }