diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b858da1..5cd97fc 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -68,16 +68,28 @@ jobs: - run: go build ./... working-directory: backend - - run: go vet ./... + - name: Vet (dev tags) + run: go vet -tags "test,dev" ./... working-directory: backend - - name: Test + - name: Vet (prod tags) + run: go vet -tags "test,!dev" ./... + working-directory: backend + + - name: Test (dev tags) working-directory: backend run: go test -tags "test,dev" -count=1 -v -timeout 180s ./... env: POSTGRES_HOST: postgres TEST_DB_HOST: postgres + - name: Test (prod tags — behavioral ratelimit, prod-only compile) + working-directory: backend + run: go test -tags "test,!dev" -count=1 -timeout 180s ./... + env: + POSTGRES_HOST: postgres + TEST_DB_HOST: postgres + race: name: Race detector runs-on: ubuntu-latest @@ -130,7 +142,7 @@ jobs: - run: PGPASSWORD=mypassword psql -h postgres -U myuser -d mydb -c "CREATE DATABASE crussell_test_db;" 2>/dev/null || true - - name: Test with race detector + - name: Race (dev tags) working-directory: backend run: go test -tags "test,dev" -race -count=1 -timeout 240s ./... env: @@ -138,6 +150,14 @@ jobs: TEST_DB_HOST: postgres CGO_ENABLED: "1" + - name: Race (prod tags) + working-directory: backend + run: go test -tags "test,!dev" -race -count=1 -timeout 240s ./... + env: + POSTGRES_HOST: postgres + TEST_DB_HOST: postgres + CGO_ENABLED: "1" + vulns: name: Go vulnerabilities runs-on: ubuntu-latest diff --git a/README.md b/README.md index cc23f63..a1dea3c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Nail salon booking platform — Go 1.25 backend + SvelteKit 5 SPA + PostgreSQL 1 ## Features -**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: 5-minute goroutine clears expired reservations. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation extracted into a reusable `closing_time` helper. +**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 20 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation extracted into a reusable `closing_time` helper. **Payments**: Square Terminal (in-person) + Web Payments SDK (online). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases now insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record. @@ -76,7 +76,7 @@ Default logins (password: `password`): ```bash cd backend && go build -o bin/backend ./main.go cd frontend && npm ci && npm run build -cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 1,198 tests, 0 failures, 4 skipped (~13s) +cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 1,251/1,255 tests passed, 4 skipped (~13s) cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min) cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min) ``` diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 130e371..089443d 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -939,6 +939,9 @@ BEGIN -- Anonymize admin notification references (not customer data — just drop the user link) UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id; + + -- Clear login audit trail (no ongoing legal basis after account closure) + DELETE FROM login_audit WHERE user_id = target_id; END; $$ LANGUAGE plpgsql; @@ -961,6 +964,9 @@ BEGIN user_id = NULL WHERE user_id = target_id; + -- Clear login audit trail before deleting the user row + DELETE FROM login_audit WHERE user_id = target_id; + DELETE FROM users WHERE id = target_id AND account_role = 'guest'; END; $$ LANGUAGE plpgsql; diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index d9a1182..93fc9af 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -14,7 +14,7 @@ These are blockers: missing functionality that prevents daily operations, legal | # | Gap | Effort | Area | Notes | |---|---|---|---|---| | 1 | **CurrentAppointment action stubs** | M (1d) | Frontend | `Extend` and `Cancel` buttons on Today page are dead. Staff cannot cancel or extend an in-progress appointment from the Today page. Edit, Take Payment, and Reschedule are already wired. | -| 2 | **~~Reservation/anonymization background cron~~** | S (2-3h) | Backend | **DONE**: `CleanupOldReservations()` now runs in a background goroutine (5-minute ticker, 30s timeout) with graceful shutdown via SIGTERM/SIGINT. `AnonymizeStaleGuestAccounts()`, `CleanupExpiredGiftCards()`, `CleanupIdleAccounts()`, `CleanupExpiredFinancialRecords()`, `CleanupOldNameHistory()` still only run on `GET /api/availability`. | +| 2 | **~~Reservation/cleanup background cron~~** | S (3h) | Backend | **DONE**: All cleanup functions migrated to `backend/internal/jobs/` — centralised cron scheduler. 20 jobs registered with staggered schedules: cleanup-reservations (5min), cleanup-expired-deposits (5min), cleanup-rate-limiters (5min), cleanup-gdpr-export-cache (5min), cleanup-progressive-rate-limiter (1min), cleanup-expired-loyalty-redemptions (hourly), cleanup-old-idempotency-keys (hourly), cleanup-revoked-jtis (hourly), cleanup-stale-login-entries (hourly), transition-discount-campaigns (hourly), notify-unpaid-1-week (7am daily), notify-unpaid-1-month (7am daily), cleanup-verification-codes (2am daily), cleanup-refresh-tokens (2am daily), anonymize-stale-guest-accounts (3am daily), cleanup-idle-accounts (3:30am daily), cleanup-expired-financial-records (4am daily), cleanup-old-name-history (4:30am daily), cleanup-expired-gift-cards (5am daily). | | 3 | **VAT/Tax export endpoints** | M (1-2d) | Backend | `get_vat_return_data()` and `export_sales_transactions()` SQL functions exist. No admin API to trigger them. Needed for HMRC Making Tax Digital compliance. | | 4 | **Password reset flow** | S (2-3h) | Frontend | Backend has `/api/verify/generate` and `/api/verify/check`. Login page has no "forgot password" link or form. Customers who forget their password must call the salon. | | 5 | **Email verification flow** | S (2-3h) | Frontend | Users register with `unverified_email` role. No UI to enter verification code or resend. `+layout.svelte` has an alert-based prototype that needs to be wired properly. | diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index e833b69..6cb43e7 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -81,7 +81,7 @@ Lunch protection: `findAllLunchGaps()` returns all gap durations in the middle w Discount campaigns: time-based (date range), per-user milestone (exact booking count), global milestone (salon-wide count, max redemptions cap), anniversary (time since first completed booking). All discounts stack additively against the **original** booking total — each creates its own `booking_discounts` row and discounted `payments` row. -Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `active → draft` for re-editing). +Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `active → draft` for re-editing). Status transitions for time-based campaigns are handled automatically by the cron scheduler (hourly job `transition-discount-campaigns`) — `draft → active` on `start_date`, `active → completed` on `end_date` or `times_redeemed ≥ max_redemptions`. ### Compliance @@ -215,7 +215,7 @@ npm run dev # Dev server with HMR ```bash cd backend -go test -tags "test,dev" ./... # 1,198 tests, 0 failures, 4 skipped +go test -tags "test,dev" ./... # 1,251/1,255 tests passed, 4 skipped go test -tags "test,dev" -v -run TestName ./... # Single test ``` diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 03ae573..37d9651 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -283,7 +283,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. | GET | `/api/scheduling/default-hours` | None | 120/min | Get weekly default hours | | GET | `/api/scheduling/exceptional-groups` | None | 120/min | List holiday/special hour groups | | GET | `/api/scheduling/working-hours` | None | 120/min | Merged default + exceptional hours | -| GET | `/api/scheduling/available-hours` | None | 120/min | Available slots (triggers cleanup) | +| GET | `/api/scheduling/available-hours` | None | 120/min | Available slots (cleanup removed — runs on cron) | | POST | `/api/bookings/reserve` | Optional | 30/min | Reserve slot (user=1h, anon=10min, 50-cap) | | POST | `/api/bookings` | Optional | 30/min | Create booking (with `Idempotency-Key` header) | | GET | `/api/portfolio/images` | None | — | List images (cursor pagination, tag & category filters). Query params: `limit` (1-100, default 20), `cursor` (from `next_cursor` in response), `tag` (fuzzy substring), `tags` (comma-separated, fuzzy), `filter[category]=value` (exact). Tag results sorted by relevance then date; filter-only sorted by date. Returns `{ images: [...], next_cursor: "..." }`. | @@ -542,7 +542,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. **Financial cleanup:** `CleanupExpiredFinancialRecords()` runs on the same availability fetch. Aggregates expired payments/refunds into monthly stats and deletes granular records past their retention threshold. -**Why designed this way:** Storing reservations in `time_blockers` means they automatically participate in availability calculations — no separate reservation table needed. The TTL-based cleanup is lazy (triggered on availability fetch) rather than cron-based. This avoids the need for a background job or cron scheduler in the early MVP. +**Why designed this way:** Storing reservations in `time_blockers` means they automatically participate in availability calculations — no separate reservation table needed. TTL-based cleanup runs on the centralised `jobs` scheduler (cron-based, see below). --- @@ -573,7 +573,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. - "Last use" includes: balance check, topup, redeem, payment, any admin action - Rolling expiry resets on each use - Expired balance moves to `gift_card_expired_balances` for recovery -- `CleanupExpiredGiftCards()` runs on every availability fetch (lazy, no cron) +- `CleanupExpiredGiftCards()` runs on the centralised `jobs` scheduler (daily at 5am) **Idle Account Cleanup:** @@ -796,7 +796,7 @@ validTransitions := map[string]map[string]bool{ 2. Subtracting existing bookings (with gap logic) 3. Subtracting time blockers (including reservations) 4. **Late night lock**: After 22:00, blocks next morning 00:00-11:00 for non-admin users -5. Triggers `CleanupOldReservations()`, `AnonymizeStaleGuestAccounts()`, `CleanupExpiredLoyaltyRedemptions()`, `CleanupExpiredFinancialRecords()`, `CleanupExpiredGiftCards()`, `CleanupIdleAccounts()`, `CleanupOldNameHistory()` +5. All data cleanup runs on the centralised `jobs` scheduler (cron-based), not inline during the HTTP request. See `backend/internal/jobs/cleanup.go` for the full schedule. **Time Blockers:** Can be one-off (no cron) or recurring (cron expression). Cron expansion via `robfig/cron/v3` parser. @@ -967,7 +967,7 @@ All applicable discounts stack additively (not compound). Each discount is calcu A record is only deleted when **both** applicable conditions are met — the 7-year rule AND the 1-year post-anonymization buffer (if applicable). -**Trigger:** `CleanupExpiredFinancialRecords(ctx)` runs on every `GET /api/availability` alongside other cleanup functions. Lazy execution — no cron or background worker needed. +**Trigger:** `CleanupExpiredFinancialRecords(ctx)` runs on the centralised `jobs` scheduler (daily at 4am). **Aggregation columns** (`financial_aggregates` table): @@ -996,7 +996,7 @@ A record is only deleted when **both** applicable conditions are met — the 7-y **Tables:** `financial_aggregates`, `payments`, `refunds` -**Decision:** The lazy cleanup approach (triggered on availability fetch) was chosen because the salon operates during business hours and someone always checks availability at least once per day. This avoids the need for a cron job or background worker in the early MVP. The aggregation is idempotent — safe to run repeatedly. +**Decision:** The aggregation is idempotent — safe to run repeatedly. All cleanup now runs on the centralised `jobs` scheduler (cron-based). See `backend/internal/jobs/cleanup.go` for schedules. --- @@ -1244,7 +1244,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user ### Test Coverage -**1,198 tests run** across all packages (4 skipped, 0 failures). Recent additions: self-blocking prevention tests (excludeUserID coverage for GetAvailableHours, EditBookingHandler, AdminRescheduleBookingHandler, ReserveSlotHandler anon IP cleanup, AdminReserveSlotHandler anon IP cleanup), closing_time tests (3), content-type middleware tests (2), booking handler tests (FOR UPDATE overlap checks, admin reserve with closing_time, gift card buy with VAT), and the new admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity, plus 2 inverse-isolation tests on the user-side `cancel_reservation_test.go` to prove the two cancel endpoints are properly partitioned by their WHERE clauses). Booking integration tests continue to expand: duplicate completion guard, daily stamp cap (handler + SQL subquery), invalid status transitions, sequential edit, timezone independence, and past-booking no-show guard. The `clock` package itself has tests for Now() and clock interface correctness. +**1,251/1,255 tests run** across all packages (4 skipped, 0 failures). Recent additions: centralised job scheduler tests (3 — RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21 — NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup tests (6), rate limiter production behavior tests (6). Self-blocking prevention tests (excludeUserID coverage for GetAvailableHours, EditBookingHandler, AdminRescheduleBookingHandler, ReserveSlotHandler anon IP cleanup, AdminReserveSlotHandler anon IP cleanup), closing_time tests (3), content-type middleware tests (2), booking handler tests (FOR UPDATE overlap checks, admin reserve with closing_time, gift card buy with VAT), and the new admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity, plus 2 inverse-isolation tests on the user-side `cancel_reservation_test.go` to prove the two cancel endpoints are properly partitioned by their WHERE clauses). Booking integration tests continue to expand: duplicate completion guard, daily stamp cap (handler + SQL subquery), invalid status transitions, sequential edit, timezone independence, and past-booking no-show guard. The `clock` package itself has tests for Now() and clock interface correctness. | Package | Coverage Area | |---------|--------------| @@ -1769,6 +1769,42 @@ FROM bookings b LEFT JOIN payments p ON p.booking_id = b.id WHERE b.user_id = $1 **Files**: `customer_relationship.go` (GetCustomerRelationshipHandler) +--- + +### Background Jobs Scheduler + +**Package**: `backend/internal/jobs/` — `Scheduler` struct wrapping `github.com/robfig/cron/v3` with per-job concurrency control, timeout, panic recovery, and logging. + +**How it works:** All background data maintenance jobs are registered in `RegisterAll()` (`cleanup.go`) with cron expressions. Each job runs in its own goroutine on schedule. Jobs with `Concurrency: 1` skip a tick if the previous run is still in-flight. Graceful shutdown via `sched.Shutdown()` which cancels the base context and waits for in-flight jobs to complete. + +**Job catalogue** (all defined in `backend/internal/jobs/cleanup.go`): + +| Frequency | Jobs | Cron | +|-----------|------|------| +| Every min | Progressive rate limiter cleanup | `* * * * *` | +| Every 5 min | Reservation cleanup, expired deposits, rate limiter cleanup, GDPR cache cleanup | `*/5 * * * *` | +| Hourly | Loyalty redemptions, idempotency keys, revoked JTIs, stale login entries, discount campaign auto-transition | `0 * * * *` | +| Daily 7am | Unpaid booking notifications (1-week and 1-month overdue) | `0 7 * * *` | +| Daily 2am | Expired verification codes, expired/revoked refresh tokens | `0 2 * * *` | +| Daily 3am | Stale guest account anonymization | `0 3 * * *` | +| Daily 3:30am | Idle account cleanup | `30 3 * * *` | +| Daily 4am | Financial record aggregation + deletion | `0 4 * * *` | +| Daily 4:30am | Name history cleanup | `30 4 * * *` | +| Daily 5am | Expired gift cards | `0 5 * * *` | + +**Previously lazy (side-effects in `GetAvailableHours`):** All cleanup functions were called synchronously on every `GET /api/scheduling/available-hours` request. They are now extracted into the cron scheduler, removing ~43 lines of side-effect code from the HTTP handler. + +**Previously ad-hoc goroutines migrated:** + +| Old Location | Old Pattern | New Home | +|-------------|-------------|----------| +| `main.go:432-450` | `time.NewTicker(5min)` + `go func()` | `jobs.CleanupOldReservations` | +| `auth/jwt.go:108-124` | `StartJTICleanup()` — `time.NewTicker(30min)` | `jobs.CleanupRevokedJTIs` (hourly) | +| `handlers/user/gdpr_export.go:27-48` | `init()` + `time.NewTicker(5min)` | `user.CleanupGDPRExportCache` | +| `handlers/auth/local.go:42-65` | `init()` + `time.NewTicker(1h)` | `authHandlers.CleanupStaleLoginEntries` | +| `mw/ratelimit.go:31-43` | `NewRateLimiter()` + `go func()` | `mw.CleanupAllRateLimiters` | +| `mw/ratelimit.go:104-116` | `NewProgressiveRateLimiter()` + `go func()` | `mw.CleanupProgressiveRateLimiter` | + ### Summary | Pattern | Queries eliminated | Risk replaced | Cloud cost | diff --git a/obsidian/Crussell/Testing Architecture & DB Management.md b/obsidian/Crussell/Testing Architecture & DB Management.md index 8cf4278..5389f1f 100644 --- a/obsidian/Crussell/Testing Architecture & DB Management.md +++ b/obsidian/Crussell/Testing Architecture & DB Management.md @@ -1,6 +1,6 @@ # Testing Architecture & DB Management -**Last Updated:** July 2026 (v3 — admin reservation cancel coverage, 1,198 tests) +**Last Updated:** July 2026 (v4 — centralised job scheduler tests, 1,255 tests) --- @@ -501,7 +501,7 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic |--------|-------| | Quick check (`-count=1`) | **~13s** | | Packages | 19 tested, 0 failures | -| Tests | 1,198 run, 4 skipped, 0 failing | +| Tests | 1,251/1,255 passed, 4 skipped, 0 failing | New test additions in this batch: | Test | Coverage | @@ -520,7 +520,7 @@ New test additions in this batch: | `TestCancelReservation_DoesNotTouchAnonReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:anon:%` (defensive — the WHERE clause only matches `RESERVATION:user:%`) | | `TestCancelReservation_DoesNotTouchAdminReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:admin:%`. Pairs with the admin-side test that verifies admin cancel ignores `RESERVATION:user:%`. Proves the two endpoints are properly partitioned. | -**Total tests:** 1,198 run (4 skipped) across 19 packages. 0 failures. Growth driven by new `clock` package tests, `closing_time` tests (3), `contenttype` middleware tests (2), expanded booking/payment handler test coverage, and the new admin reservation cancel coverage (12 tests: walkin + callin success, isolation, no-op, unauth, empty ctx, walkin+callin coexistence, anon untouched, response format parity, overlapping reservations deleted, user reservations untouched, idempotent double-cancel). Two inverse-isolation tests added to the existing user-side `cancel_reservation_test.go` to verify the user and admin cancel endpoints are properly partitioned by `RESERVATION:user:%` / `RESERVATION:admin:%` WHERE clauses. +**Total tests:** 1,251/1,255 run (4 skipped) across all packages. 0 failures. Growth driven by new `clock` package tests, `closing_time` tests (3), `contenttype` middleware tests (2), expanded booking/payment handler test coverage, admin reservation cancel coverage (12 tests: walkin + callin success, isolation, no-op, unauth, empty ctx, walkin+callin coexistence, anon untouched, response format parity, overlapping reservations deleted, user reservations untouched, idempotent double-cancel), centralised job scheduler tests (3: RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21: NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup prod tests (6), and rate limiter production behavior tests (6). Two inverse-isolation tests added to the existing user-side `cancel_reservation_test.go` to verify the user and admin cancel endpoints are properly partitioned by `RESERVATION:user:%` / `RESERVATION:admin:%` WHERE clauses. ### What Drives Test Time @@ -637,9 +637,9 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use ### Q: What's the total test count? -1,198 tests run across all packages (4 skipped). 0 failures across 20 packages. +1,251/1,255 tests run across all packages (4 skipped). 0 failures. -**Notable new tests:** Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, expanded gift card buy flow tests with VAT, and full admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity). +**Notable new tests:** Centralised job scheduler tests (3 — RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21 — NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup tests (6), rate limiter production behavior tests (6). Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, expanded gift card buy flow tests with VAT, and full admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity). ### Q: Why use `-count=10` for thorough verification?