diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e62b80d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,106 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: yarn
+ cache-dependency-path: dtrack/ui/yarn.lock
+
+ - name: Install UI dependencies
+ working-directory: dtrack/ui
+ run: yarn install --frozen-lockfile
+
+ - name: Type-check UI
+ working-directory: dtrack/ui
+ run: yarn type-check
+
+ - name: Lint UI (eslint, no fixes)
+ working-directory: dtrack/ui
+ run: npx eslint ./src --ext .jsx,.js,.ts,.tsx --quiet --ignore-path ./.gitignore
+
+ - name: Check UI formatting (prettier, no fixes)
+ working-directory: dtrack/ui
+ run: npx prettier --check "./**/*.{js,jsx,ts,tsx,css,md,json}"
+
+ - name: Run pre-commit hooks
+ uses: pre-commit/action@v3.0.1
+
+ - name: Check test case ids are documented
+ run: ./tests/check-test-case-ids.sh
+
+ stack-tests:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure environment (template values + debug JWT secret)
+ run: |
+ cp .env.tpl .env
+ mkdir -p keys
+ printf 'Dummy5ecr3t4D3bug0n1yN0T4Pr0D123' > keys/jwt-secret
+
+ - name: Bootstrap the database
+ run: ./initdb.sh
+
+ - name: Start the full stack in debug mode
+ run: make debug
+
+ - name: Wait for PostgREST and the UI
+ run: |
+ for url in http://localhost:3000/ http://localhost:5174/; do
+ timeout 120 bash -c "until curl -fsS $url >/dev/null 2>&1; do sleep 2; done"
+ done
+
+ - name: Database tests (pgTAP)
+ run: ./tests/run-db-tests.sh
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: tests/package-lock.json
+
+ - name: Install test dependencies
+ working-directory: tests
+ run: npm ci
+
+ - name: API tests (vitest against PostgREST)
+ run: ./tests/run-api-tests.sh
+
+ - name: End-to-end tests (Playwright against the UI)
+ run: ./tests/run-e2e-tests.sh
+
+ - name: Upload Playwright artifacts
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-artifacts
+ path: |
+ tests/playwright-report/
+ tests/test-results/
+ retention-days: 7
+
+ - name: Dump compose logs
+ if: failure()
+ run: docker compose logs --no-color > compose-logs.txt
+
+ - name: Upload compose logs
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: compose-logs
+ path: compose-logs.txt
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 0bab5a3..253e92d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ keys
*.tar
venv/
backup/
+tests/node_modules/
+tests/test-results/
+tests/playwright-report/
diff --git a/Makefile b/Makefile
index 3bc3b3b..480b9ea 100644
--- a/Makefile
+++ b/Makefile
@@ -12,7 +12,8 @@ tf_env = $(if $(filter $(env),staging prod),$(env),$(if $(filter $(env),null),st
play_env = $(if $(filter $(env),staging prod),$(env),null)
args ?=
-.PHONY: precommit infra infra-destroy play help dev debug initdb down clean
+.PHONY: precommit infra infra-destroy play help dev debug initdb down clean \
+ test test-db test-api test-e2e
precommit:
pre-commit run --all-files
@@ -32,6 +33,19 @@ down:
clean:
$(COMPOSE_DEBUG) down -v --remove-orphans
+# Test targets expect the stack up in debug mode with the database
+# bootstrapped (./initdb.sh && make debug). See docs/testing.md.
+test: test-db test-api test-e2e
+
+test-db:
+ ./tests/run-db-tests.sh
+
+test-api:
+ ./tests/run-api-tests.sh
+
+test-e2e:
+ ./tests/run-e2e-tests.sh
+
infra-init:
$(TERRAFORM_CMD) init -backend-config=backend.tfconf
@@ -55,6 +69,10 @@ help:
@echo " initdb Bootstrap the database (runs ./initdb.sh)."
@echo " down Stop the local stack (data is kept)."
@echo " clean Stop the local stack and delete its data volume."
+ @echo " test Run all test suites (needs the debug stack up + initdb)."
+ @echo " test-db Run the pgTAP database tests."
+ @echo " test-api Run the HTTP tests against PostgREST."
+ @echo " test-e2e Run the Playwright end-to-end tests."
@echo " precommit Manually run pre-commit hooks on all files."
@echo " infra-init Initialize Terraform with backend configuration."
@echo " infra Apply infrastructure configuration using Terraform."
diff --git a/README.md b/README.md
index 8fb9f14..2b9c7bf 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,8 @@ See [docs/development.md](docs/development.md) for the full guide, including dev
| --- | --- |
| [docs/architecture.md](docs/architecture.md) | System design: schemas, roles, RLS, request lifecycle, audit |
| [docs/development.md](docs/development.md) | Running and working on DTrack locally |
+| [docs/testing.md](docs/testing.md) | Test strategy, how to run the suites, testing policies |
+| [docs/test-cases.md](docs/test-cases.md) | Natural-language test case catalog (`TC-…` ids) |
| [docs/deployment.md](docs/deployment.md) | Provisioning infrastructure and deploying |
| [docs/runbooks/backup-restore.md](docs/runbooks/backup-restore.md) | Backup, test-restore and live-restore procedures |
@@ -38,8 +40,9 @@ See [docs/development.md](docs/development.md) for the full guide, including dev
dtrack/db/sql/ Database bootstrap SQL, executed in order by initdb.sh
dtrack/ui/ react-admin single-page app (Vite + TypeScript)
dtrack/dev/queries/ Helper SQL for operations (also used by restore automation)
+tests/ pgTAP, API (Vitest) and E2E (Playwright) suites
config/ Docker images, compose overrides, Ansible playbooks
-.github/ CI/CD workflows and their Ansible playbooks
+.github/ CI and operations workflows, their Ansible playbooks
main.tf Terraform: AWS EC2 + Cloudflare DNS + GitHub deploy keys
```
diff --git a/docs/development.md b/docs/development.md
index af806f6..7c8e8a6 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -85,6 +85,10 @@ docker compose up -d --build react-admin # rebuild just the UI image
Handy inspection queries (activity, audit log, durations) live in
[dtrack/dev/queries/useful.sql](../dtrack/dev/queries/useful.sql).
+With the debug stack up and the database bootstrapped, `make test` runs the
+full test pyramid (or `test-db` / `test-api` / `test-e2e` individually) — see
+[testing.md](testing.md).
+
### Calling the API directly
Any HS256 JWT signed with the debug secret works against PostgREST in debug
diff --git a/docs/test-cases.md b/docs/test-cases.md
new file mode 100644
index 0000000..67bbad5
--- /dev/null
+++ b/docs/test-cases.md
@@ -0,0 +1,191 @@
+# Test case catalog
+
+Every automated test references a test case id from this catalog (enforced in
+CI by [tests/check-test-case-ids.sh](../tests/check-test-case-ids.sh)). The
+catalog is the natural-language source of truth for *what the system promises*;
+the automated suites are its executable expression. It intentionally lives
+in-repo as markdown — reviewable in PRs, greppable, zero extra infrastructure —
+rather than in an external test-management system (see
+[testing.md](testing.md) for that trade-off).
+
+**Id scheme**: `TC--`. Within an area, `0xx` are database-level
+cases (pgTAP), `1xx`+ are HTTP/API-level cases, and `TC-E2E-*` are browser
+journeys. A journey may be implemented as several serial specs
+(`TC-E2E-010a…e`).
+
+**Status values**
+- `automated` — implemented and expected to pass
+- `xfail` — implemented and expected to **fail**: it documents a genuine bug
+ in the application, kept per the testing policy until the bug is fixed
+- `manual` — a documented procedure, not automatable in local/CI environments
+- `planned` — worth automating, not yet implemented
+
+## Schema & roles (database)
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-SCH-001 | All base tables exist in `auth`/`internal` | automated |
+| TC-SCH-002 | All `api` views that PostgREST serves exist | automated |
+| TC-SCH-003 | API entry-point functions exist (`api.onboard`, `pre.request`, `api.timetracking_summary`) | automated |
+| TC-SCH-004 | Row-level security is enabled on `auth.users` and every `internal` table | automated |
+| TC-SCH-005 | Access roles `anon`, `basic`, `pm`, `lead`, `power` exist | automated |
+| TC-SCH-006 | Privilege tiers are cumulative: pm/lead inherit basic, power inherits pm and lead | automated |
+| TC-SCH-007 | `power` bypasses row-level security | automated |
+| TC-SCH-008 | `anon` has no SELECT on data views — API access is opt-in per role | automated |
+
+## Authentication & onboarding
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-AUTH-001 | Membership predicates (`is_project_member`, `is_pm_of`) answer correctly for members and leads | automated |
+| TC-AUTH-101 | Anonymous requests to data endpoints are refused | automated |
+| TC-AUTH-102 | A JWT with an invalid signature is rejected with 401 | automated |
+| TC-AUTH-103 | A valid JWT naming a non-existent user is rejected (role does not exist) | automated |
+| TC-AUTH-104 | An authenticated user retrieves themself via `current_employee`, including roles and `is_power` | automated |
+| TC-AUTH-105 | The OpenAPI description is served at the API root | automated |
+| TC-ONB-101 | `POST /rpc/onboard` is callable anonymously and rejects invalid id tokens | automated |
+| TC-ONB-001 | First login of an unknown Azure user onboards them (role + `auth.users` row) and the SPA proceeds | manual — needs a real Azure id token; verified in staging |
+
+## Employees & user management
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-EMP-001 | `auth.create_user` creates a NOLOGIN role, grants `basic`, inserts the users row, derives the username from the email | automated |
+| TC-EMP-002 | `auth.create_user` upserts: re-creating an existing username updates profile fields without duplicating rows | automated |
+| TC-EMP-003 | First/last name default from the dotted email local part | automated |
+| TC-EMP-004 | `auth.del_user` rejects emails outside the allowed domain | automated |
+| TC-EMP-005 | `auth.del_user` drops the role and deletes the users row for the allowed domain | automated |
+| TC-EMP-101 | A power user can create an employee through `POST /employees` | automated |
+| TC-EMP-102 | A newly created employee can authenticate and see themself | automated |
+| TC-EMP-103 | A basic user cannot create employees | automated |
+| TC-EMP-104 | An unrelated user sees only themself in the employees list | automated |
+| TC-EMP-105 | A power user can update another employee's profile | automated |
+| TC-EMP-106 | An employee can update their own profile fields | **xfail** — `internal.employees_upsert` writes via `INSERT … ON CONFLICT`, and only `power` has INSERT on `auth.users`, so self-updates 403 despite `update_users_policy` intending them |
+
+## Projects
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-PROJ-001 | Creating a project through `api.projects` with a lead creates membership rows and grants the lead `pm` | automated |
+| TC-PROJ-002 | Removing a project's lead revokes `pm` when they lead no other project | automated |
+| TC-PROJ-101 | A project created with lead, member, AoW and activity links reads back with all of them | automated |
+| TC-PROJ-102 | Being made project lead is visible as the `pm` role on `current_employee` | automated |
+| TC-PROJ-103 | The project lead can update their project | automated |
+| TC-PROJ-104 | Unrelated users cannot see the project | automated |
+| TC-PROJ-105 | A non-lead member cannot update the project | **xfail** — `select_projects_policy` lacks `FOR SELECT`, so as a permissive FOR ALL policy it authorizes member updates too (same bug as TC-RLS-012) |
+| TC-PROJ-106 | Members see their project in the list | automated |
+
+## Teams / areas of expertise
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-TEAM-001 | A member cannot belong to two teams within one area of expertise | automated |
+| TC-TEAM-002 | The same member may join teams in different areas of expertise | automated |
+| TC-TEAM-010 | Upserting a team through `api.aoe_teams` creates hierarchy rows and grants its supervisor `lead` | automated |
+| TC-TEAM-011 | Deleting a team as power removes its rows and revokes `lead` | automated |
+
+## Reference data (areas of work, activities)
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-AOW-101 | Power can create an area of work | automated |
+| TC-AOW-102 | Basic users cannot create areas of work | automated |
+| TC-AOW-103 | Areas of work are readable by all authenticated users | automated |
+| TC-ACT-101 | Power can create an activity | automated |
+| TC-ACT-102 | Basic users cannot create activities | automated |
+
+## Time tracking
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-TT-001 | Durations that are multiples of 15 minutes are accepted (including zero) | automated |
+| TC-TT-002 | Durations that are not multiples of 15 minutes are rejected | automated |
+| TC-TT-003 | The UI's `"N.NN hrs"` duration format parses to the intended interval | automated |
+| TC-TT-101 | A member can log their own time through the API in UI duration format | automated |
+| TC-TT-102 | A member cannot log time for someone else | automated |
+| TC-TT-103 | A team lead sees and can log their members' entries | automated |
+| TC-TT-104 | Outsiders see none of these entries | automated |
+| TC-TT-105 | The API rejects durations that are not multiples of 15 minutes | automated |
+
+## Row-level security (cross-cutting)
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-RLS-001 | Members see only their own time trackings | automated |
+| TC-RLS-002 | Members cannot insert time trackings for others | automated |
+| TC-RLS-003 | Writes through `api.time_trackings` respect RLS and parse the UI duration format | automated |
+| TC-RLS-004 | Team leads see their members' time trackings | automated |
+| TC-RLS-005 | Team leads can log time for their members | automated |
+| TC-RLS-006 | Outsiders see no time trackings of others | automated |
+| TC-RLS-010 | Outsiders cannot see a project they don't belong to | automated |
+| TC-RLS-011 | Members see their own project | automated |
+| TC-RLS-012 | Non-lead members cannot update the project | **xfail** — see TC-PROJ-105 |
+| TC-RLS-013 | Project leads can update their project | automated |
+| TC-RLS-014 | Non-power users cannot create brand-new projects | automated |
+| TC-RLS-020 | Basic users cannot create reference data | automated |
+| TC-RLS-021 | Power users can create reference data | automated |
+| TC-RLS-030 | Unrelated users see only themselves in `auth.users` | automated |
+| TC-RLS-031 | Members also see their team lead | automated |
+
+## FAQs
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-FAQ-001 | Only published FAQs are visible to regular users (database level) | automated |
+| TC-FAQ-002 | Regular users cannot create FAQs (database level) | automated |
+| TC-FAQ-101 | Power can create published and draft FAQs | automated |
+| TC-FAQ-102 | Basic users see only published FAQs | automated |
+| TC-FAQ-103 | Basic users cannot create FAQs | automated |
+| TC-FAQ-104 | Power sees drafts too | automated |
+
+## Dashboard plots
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-PLOT-101 | `duration_calendar` aggregates a user's entries | automated |
+| TC-PLOT-102 | The `timetracking_summary` RPC responds for the calling user | automated |
+| TC-PLOT-001 | `timetracking_summary` totals equal the sum of the underlying entries across zoom levels | planned — pgTAP |
+
+## UI ↔ API contract
+
+The PostgREST features `@raphiniert/ra-data-postgrest` and the UI's queries
+rely on; they must keep working across PostgREST upgrades.
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-API-201 | Column aliasing via `select=id,name:username` | automated |
+| TC-API-202 | JSONB containment filters on embedded relations (`areas_of_work=cs.[{"id":N}]`) | automated |
+| TC-API-203 | Exact counts via `Prefer: count=exact` (Content-Range) | automated |
+| TC-API-204 | Ordering by nested JSONB fields (`order=user->name.asc`) | automated |
+| TC-API-205 | `Prefer: return=representation` returns the created row (note: generated ids are currently null in the representation — a quirk of the INSTEAD OF triggers) | automated |
+| TC-API-206 | JSONB arrow filters used by list filters (`user->>id=eq.N`) | automated |
+
+## Security posture
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-SEC-001 | `internal.aoe_teams_grant_or_revoke_privs` is not executable by `basic` | **xfail** — it currently is (SECURITY DEFINER; flagged `TODO: potentially insecure` in the source) |
+
+## End-to-end journeys (browser)
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-E2E-001 | The power user logs in via the debug login, sees the navigation, and can log out | automated |
+| TC-E2E-002 | An unknown user cannot reach the workspace | automated |
+| TC-E2E-010 | Core journey (serial specs a–e): create AoW → activity → project (linked, with manager) → log time through the cascading selects → entry appears in the list | automated |
+| TC-E2E-020 | The dashboard renders for a user with data | automated |
+| TC-E2E-030 | Role-based UI: a plain member does not see other employees' entries or admin-only actions | planned — needs multi-user browser personas |
+| TC-E2E-040 | FAQ lifecycle through the UI: create draft, publish, verify member visibility | planned |
+
+## UI unit level
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-UI-001 | CSV export mapping and id generation in `dtrack/ui/src/utils/utils.tsx` behave as specified | planned — deferred while the UI toolchain (package.json/yarn.lock feeding the production image build) is frozen; see testing.md |
+| TC-UI-002 | Date-range helpers in `utils/Dropdowns.tsx` compute correct default ranges per granularity | planned — same constraint |
+
+## Operational procedures
+
+| Id | Test case | Status |
+| --- | --- | --- |
+| TC-OPS-001 | A production backup restores onto a fresh instance and the app works against it | manual — [runbook](runbooks/backup-restore.md); exercised continuously by the backup workflow's test-restore job |
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 0000000..1285d21
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,135 @@
+# Testing
+
+The current codebase is the MVP and a deliberate stability baseline (git tag
+`mvp-baseline`). The test suite's job is to **pin the behavior of that
+baseline** so consolidation work (docs, CI, tooling, later upgrades) can
+proceed without silent regressions. Tests assert behavior contracts — who can
+see and do what, what the API promises the UI — not incidental details, so
+there is room to grow without rewriting tests.
+
+## Policies
+
+1. **Additions are cheap, changes are not.** Any *edit or deletion* of an
+ existing test must be called out and explained in the PR. A test change is
+ only acceptable when it legitimately improves the system, and is normally
+ accompanied by new tests for the adapted behavior.
+2. **Genuine bugs become expected failures, not fixes.** When a test exposes a
+ real defect in the application, the application is *not* changed (yet); the
+ test is committed and marked expected-fail with the reason:
+ pgTAP `todo()`, vitest `test.fails`. When the defect is later fixed, the
+ harness reports the test as "unexpectedly passing", which forces removing
+ the marker — the test then guards the fix.
+3. **Every automated test cites a `TC-…` id** from
+ [test-cases.md](test-cases.md), the natural-language catalog. CI fails if a
+ test references an undocumented id ([tests/check-test-case-ids.sh](../tests/check-test-case-ids.sh)).
+
+## The layers
+
+Business logic lives in PostgreSQL, so the "unit" level for most of this
+system is the database itself.
+
+| Layer | Tool | Where | What it covers |
+| --- | --- | --- | --- |
+| Database (unit + RLS) | [pgTAP](https://pgtap.org) via `pg_prove` | [tests/db/](../tests/db/) | Schema/role invariants, constraints and triggers, `auth.*` functions, row-level security per persona (`SET ROLE`, exactly what PostgREST does), automatic pm/lead grant/revoke |
+| API (functional) | [Vitest](https://vitest.dev) + [jose](https://github.com/panva/jose) | [tests/api/](../tests/api/) | The PostgREST surface with per-persona HS256 JWTs (debug secret): authentication, CRUD through the `api` views, RLS at HTTP level, error codes, the UI↔API contract (PostgREST query features the react-admin data provider depends on) |
+| End-to-end | [Playwright](https://playwright.dev) | [tests/e2e/](../tests/e2e/) | Real browser against the debug-mode UI: login/logout, the core create-AoW→activity→project→log-time journey through the cascading selects, dashboard render |
+
+### Running locally
+
+```sh
+./initdb.sh && make debug # once: stack up in debug mode, DB bootstrapped
+make test # or test-db / test-api / test-e2e
+```
+
+Notes:
+
+- **DB tests** leave no trace: each file is one transaction that rolls back.
+ pgTAP itself is installed into the *running container* at test time (from
+ the PGDG apt repo already configured in the postgres image), so the
+ production image stays untouched.
+- **API and E2E tests** write real rows. Personas (`api.*@hellodnk8n…`) are
+ created idempotently through the API itself; projects/teams/entries get
+ run-unique names (`api … ` / `E2E … `). On an ephemeral CI
+ database this is invisible; on a long-lived local dev database residue
+ accumulates — `make clean && ./initdb.sh && make debug` resets.
+- Suites run sequentially (one shared database); at the current scale the
+ whole pyramid takes well under a minute locally once images are built.
+
+## CI
+
+[.github/workflows/ci.yml](../.github/workflows/ci.yml) runs on every PR and
+push to main:
+
+- **lint** — UI type-check, eslint and prettier in check mode (no fixes),
+ pre-commit hooks (whitespace, terraform), test-case-id check.
+- **stack-tests** — builds and boots the real compose stack (the same images
+ production runs) with template env values and the debug JWT secret, then
+ runs the pyramid bottom-up: pgTAP → API → E2E. On failure it uploads the
+ Playwright report/traces and full compose logs as artifacts.
+
+## Known defects pinned by expected-fail tests
+
+These were found while writing the baseline suite. They are real application
+issues, deliberately **not fixed yet** (the application is frozen); each has an
+expected-fail test that will flip when it is fixed.
+
+| Test case | Defect | Root cause |
+| --- | --- | --- |
+| TC-RLS-012 / TC-PROJ-105 | Any project member can update the project (name, dates, description) | `select_projects_policy` on `internal.projects` was created without `FOR SELECT`, making it a permissive `FOR ALL` policy whose USING clause also authorizes UPDATE/DELETE ([014.schema.policies.sql](../dtrack/db/sql/002.postgres_user_app_admin.postgres_db_app/014.schema.policies.sql)) |
+| TC-EMP-106 | Employees cannot edit their own profile (403) although `update_users_policy` intends self-updates | `internal.employees_upsert` writes via `INSERT … ON CONFLICT DO UPDATE`, and only `power` has INSERT on `auth.users` ([015.schema.employees.sql](../dtrack/db/sql/002.postgres_user_app_admin.postgres_db_app/015.schema.employees.sql)) |
+| TC-SEC-001 | `basic` can execute `internal.aoe_teams_grant_or_revoke_privs`, a SECURITY DEFINER function that grants/revokes the `lead` role | Explicit `GRANT EXECUTE … TO basic`, flagged `TODO: This is potentially insecure, fix` in [013.project_aoe_functions.sql](../dtrack/db/sql/002.postgres_user_app_admin.postgres_db_app/013.project_aoe_functions.sql) |
+
+Worth knowing, but pinned as current behavior rather than xfail: `Prefer:
+return=representation` returns null generated ids because the INSTEAD OF
+triggers return `NEW` as-is (TC-API-205), and debug-mode onboarding is
+impossible by design — `api.onboard` validates real Azure tokens in-database
+(TC-ONB-101/TC-ONB-001).
+
+## Design decisions & self-review
+
+Decisions made building this, with the trade-offs considered:
+
+1. **pgTAP installed at test time, not baked into the image.** Keeps the
+ production Dockerfile untouched (a hard constraint) at the cost of a
+ ~15 s `apt-get install` on first run per container and a network
+ dependency. *Revisit*: once image changes are allowed, add a test-stage
+ image (or a compose `test` override) so offline runs work.
+2. **Markdown catalog over a test-management system.** Kiwi TCMS/TestLink
+ style tools add a server, accounts and drift risk for a one-team repo;
+ markdown + a CI grep gives ids, review-in-PR and zero infrastructure.
+ *Revisit* if non-engineers need to author cases or the catalog outgrows a
+ single file.
+3. **TypeScript everywhere above SQL.** Vitest for API tests instead of
+ pytest/Hurl keeps one language across UI, API tests and E2E, one lockfile,
+ one `npm ci` in CI. The `tests/` package is fully separate from
+ `dtrack/ui` so the app's frozen `package.json`/`yarn.lock` (which feed the
+ production image build) are never touched.
+4. **E2E authenticates via debug mode, not MSAL.** The debug login signs the
+ same JWT shape PostgREST validates in production; only the signature
+ scheme (HS256 shared secret vs Azure JWKS) differs, and that half is
+ pinned by TC-AUTH-102/103. Real-MSAL login and onboarding remain a manual
+ staging check (TC-ONB-001). Testing MSAL itself would mean automating a
+ third-party login page — brittle and out of scope.
+5. **UI unit tests deferred** (TC-UI-001/002 planned). Almost all logic lives
+ in SQL; the UI's own pure logic is small, and adding a test runner to the
+ frozen UI package risks changing `yarn.lock` and therefore the production
+ image. The E2E journey covers the highest-risk UI behavior (the cascading
+ selects and data provider wiring). *Revisit* when the UI toolchain is
+ unfrozen — then add Vitest + Testing Library inside `dtrack/ui`.
+6. **CI boots the real compose stack** rather than service containers or
+ mocks: the exact images production runs, bootstrapped by the real
+ `initdb.sh`. Slower (image build dominates; ~10 min uncached) but it tests
+ reality, including the bootstrap scripts themselves. *Revisit*: add Docker
+ layer caching (`docker/build-push-action` with GHA cache) when the cycle
+ time starts to hurt.
+7. **Assertions are deliberately loose where the contract is fuzzy** — e.g.
+ "status ≥ 400" where PostgREST's exact code may change across upgrades,
+ exact codes (`42501`, `23514`) where they are the contract. List/count
+ assertions filter by run-scoped names so suites tolerate existing data.
+8. **Sequential execution** (vitest `fileParallelism: false`, Playwright
+ `workers: 1`) because all suites share one database. Parallelizing would
+ need per-worker schemas or databases — not worth it at this size.
+
+Known gaps, on purpose: no load/performance tests, no visual regression, no
+mutation testing, no property-based RLS fuzzing (a future TC-SEC series),
+UI unit level empty. The catalog marks these `planned` where concrete.
diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts
new file mode 100644
index 0000000..d6e9b7d
--- /dev/null
+++ b/tests/api/auth.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from 'vitest';
+
+import { api, asUser, POWER_EMAIL, tokenFor } from './helpers';
+
+describe('authentication and role switching', () => {
+ test('TC-AUTH-101: anonymous requests cannot read data views', async () => {
+ const res = await api('GET', '/employees');
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ expect(res.status).toBeLessThan(500);
+ expect(res.body?.code).toBe('42501');
+ });
+
+ test('TC-AUTH-102: a token with a bad signature is rejected', async () => {
+ const good = await tokenFor(POWER_EMAIL);
+ const tampered = good.slice(0, -4) + 'AAAA';
+ const res = await api('GET', '/current_employee', { token: tampered });
+ expect(res.status).toBe(401);
+ });
+
+ test('TC-AUTH-103: a valid token for a non-existent user is rejected', async () => {
+ const ghost = await asUser('no.such.user@hellodnk8n.onmicrosoft.com');
+ const res = await ghost.get('/current_employee');
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ expect(JSON.stringify(res.body)).toMatch(/does not exist/);
+ });
+
+ test('TC-AUTH-104: the power user sees itself via current_employee', async () => {
+ const power = await asUser(POWER_EMAIL);
+ const res = await power.get('/current_employee');
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveLength(1);
+ expect(res.body[0].email).toBe(POWER_EMAIL);
+ expect(res.body[0].is_power).toBe(true);
+ expect(res.body[0].roles).toContain('power');
+ });
+
+ test('TC-AUTH-105: the OpenAPI description is served at the API root', async () => {
+ const res = await api('GET', '/');
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('paths');
+ });
+
+ test('TC-ONB-101: onboarding endpoint exists and rejects garbage tokens', async () => {
+ // Full onboarding needs a real Azure id token, which local/CI runs don't
+ // have; asserting the anon-callable endpoint rejects junk still pins down
+ // that it is exposed and validating.
+ const res = await api('POST', '/rpc/onboard', { body: { id_token: 'garbage' } });
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ });
+});
diff --git a/tests/api/contract.test.ts b/tests/api/contract.test.ts
new file mode 100644
index 0000000..550f66e
--- /dev/null
+++ b/tests/api/contract.test.ts
@@ -0,0 +1,65 @@
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { ensureProjectWorld, ProjectWorld } from './helpers';
+
+/**
+ * The UI ↔ API contract: PostgREST features the react-admin data provider
+ * (@raphiniert/ra-data-postgrest) and the UI's queries depend on. If one of
+ * these breaks, screens break even though plain CRUD still works.
+ */
+let w: ProjectWorld;
+beforeAll(async () => {
+ w = await ensureProjectWorld('contract');
+});
+
+describe('UI-API contract', () => {
+ test('TC-API-201: column aliasing via select (id,name:username)', async () => {
+ const res = await w.power.get('/employees?select=id,name:username&limit=1');
+ expect(res.status).toBe(200);
+ expect(res.body[0]).toHaveProperty('name');
+ expect(res.body[0]).not.toHaveProperty('username');
+ });
+
+ test('TC-API-202: jsonb containment filter on embedded relations', async () => {
+ // ProjectSelectInput filters projects by area of work this way
+ const filter = encodeURIComponent(`[{"id": ${w.aowId}}]`);
+ const res = await w.alice.get(`/projects?areas_of_work=cs.${filter}&select=id`);
+ expect(res.status).toBe(200);
+ expect(res.body.map((r: any) => r.id)).toContain(w.projectId);
+ });
+
+ test('TC-API-203: exact counts via Prefer: count=exact', async () => {
+ const res = await w.power.get('/employees?select=id&limit=1', {
+ Prefer: 'count=exact',
+ });
+ expect(res.status).toBeLessThan(300);
+ const contentRange = res.headers.get('content-range');
+ expect(contentRange).toBeTruthy();
+ expect(contentRange).toMatch(/\/\d+$/);
+ });
+
+ test('TC-API-204: ordering by nested jsonb field (user->name)', async () => {
+ // TimeTrackingList sorts by user->name when showing the Employee column
+ const res = await w.alice.get('/time_trackings?order=user->name.asc&limit=5');
+ expect(res.status).toBe(200);
+ });
+
+ test('TC-API-205: created rows come back with Prefer: return=representation', async () => {
+ // Note: the INSTEAD OF triggers return NEW as-is, so generated ids are
+ // null in the representation — a quirk the UI currently lives with. This
+ // test pins the shape (a row is returned), not the id.
+ const name = `api contract aow rep ${Date.now()}`;
+ const res = await w.power.post(
+ '/areas_of_work',
+ { name },
+ { Prefer: 'return=representation' },
+ );
+ expect(res.status).toBe(201);
+ expect(res.body?.[0]?.name).toBe(name);
+ });
+
+ test('TC-API-206: jsonb arrow filters used by list filters (user->>id)', async () => {
+ const res = await w.bob.get(`/time_trackings?user->>id=eq.${w.bobId}&select=id`);
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/tests/api/employees.test.ts b/tests/api/employees.test.ts
new file mode 100644
index 0000000..7550eb1
--- /dev/null
+++ b/tests/api/employees.test.ts
@@ -0,0 +1,65 @@
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { ensurePersonas, Fixtures, personaEmail } from './helpers';
+
+let f: Fixtures;
+beforeAll(async () => {
+ f = await ensurePersonas();
+});
+
+describe('employees', () => {
+ test('TC-EMP-101: power can create an employee through the API', async () => {
+ // ensurePersonas already POSTs /employees; assert the result is queryable
+ const res = await f.power.get('/employees?username=eq.api.alice');
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveLength(1);
+ expect(res.body[0].email).toBe(personaEmail('alice'));
+ expect(res.body[0].first_name).toBe('Alice');
+ });
+
+ test('TC-EMP-102: a newly created employee can authenticate', async () => {
+ const res = await f.carol.get('/current_employee?select=id,username,email');
+ expect(res.status).toBe(200);
+ expect(res.body[0].username).toBe('api.carol');
+ });
+
+ test('TC-EMP-103: a basic user cannot create employees', async () => {
+ const res = await f.carol.post('/employees', { username: 'api.mallory' });
+ expect(res.status).toBeGreaterThanOrEqual(400);
+ const check = await f.power.get('/employees?username=eq.api.mallory');
+ expect(check.body).toHaveLength(0);
+ });
+
+ test('TC-EMP-104: an unrelated user does not see other employees', async () => {
+ const res = await f.carol.get('/employees?select=username');
+ expect(res.status).toBe(200);
+ const usernames = res.body.map((r: any) => r.username);
+ expect(usernames).toContain('api.carol');
+ expect(usernames).not.toContain('api.alice');
+ expect(usernames).not.toContain('api.bob');
+ });
+
+ test('TC-EMP-105: power can update another employee profile', async () => {
+ const res = await f.power.patch('/employees?username=eq.api.carol', {
+ position: `Tester ${Date.now()}`,
+ });
+ expect(res.status).toBeLessThan(300);
+ const check = await f.power.get('/employees?username=eq.api.carol&select=position');
+ expect(check.body[0].position).toMatch(/^Tester /);
+ });
+
+ // KNOWN BUG (kept as expected failure, do not "fix" the test):
+ // update_users_policy intends self-updates (WITH CHECK email = current_user)
+ // but internal.employees_upsert writes via INSERT ... ON CONFLICT DO UPDATE,
+ // and only power has INSERT on auth.users — so basic users get 403 when
+ // editing their own profile. See docs/testing.md.
+ test.fails(
+ 'TC-EMP-106: employees can update their own profile fields',
+ async () => {
+ const res = await f.carol.patch('/employees?username=eq.api.carol', {
+ position: 'Self-updated',
+ });
+ expect(res.status).toBeLessThan(300);
+ },
+ );
+});
diff --git a/tests/api/faqs.test.ts b/tests/api/faqs.test.ts
new file mode 100644
index 0000000..6c1f44a
--- /dev/null
+++ b/tests/api/faqs.test.ts
@@ -0,0 +1,49 @@
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { ensurePersonas, Fixtures, runId } from './helpers';
+
+let f: Fixtures;
+beforeAll(async () => {
+ f = await ensurePersonas();
+});
+
+describe('faqs', () => {
+ test('TC-FAQ-101: power can create published and draft FAQs', async () => {
+ for (const [q, published] of [
+ [`api faq published ${runId}`, true],
+ [`api faq draft ${runId}`, false],
+ ] as const) {
+ const res = await f.power.post('/faqs', {
+ question: q,
+ answer: 'because the tests say so',
+ is_published: published,
+ });
+ expect(res.status).toBe(201);
+ }
+ });
+
+ test('TC-FAQ-102: basic users see only published FAQs', async () => {
+ const res = await f.bob.get(`/faqs?question=like.api faq*${runId}&select=question`);
+ expect(res.status).toBe(200);
+ expect(res.body.map((r: any) => r.question)).toEqual([
+ `api faq published ${runId}`,
+ ]);
+ });
+
+ test('TC-FAQ-103: basic users cannot create FAQs', async () => {
+ const res = await f.bob.post('/faqs', {
+ question: `api faq evil ${runId}`,
+ answer: 'nope',
+ is_published: true,
+ });
+ expect(res.status).toBe(403);
+ });
+
+ test('TC-FAQ-104: power sees drafts too', async () => {
+ const res = await f.power.get(
+ `/faqs?question=like.api faq*${runId}&select=question`,
+ );
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveLength(2);
+ });
+});
diff --git a/tests/api/helpers.ts b/tests/api/helpers.ts
new file mode 100644
index 0000000..391cbc2
--- /dev/null
+++ b/tests/api/helpers.ts
@@ -0,0 +1,197 @@
+import { SignJWT } from 'jose';
+
+/**
+ * Shared plumbing for the HTTP tests against PostgREST.
+ *
+ * Tokens are signed with the debug-mode HS256 secret
+ * (config/docker-compose.overrides/debug.yaml), exactly like the UI's debug
+ * login page does, so these tests exercise the same JWT → role-switch path
+ * as production (only the signature scheme differs).
+ */
+
+export const API_URI = process.env.DTRACK_TEST_API_URI ?? 'http://localhost:3000';
+const JWT_SECRET =
+ process.env.DTRACK_TEST_JWT_SECRET ?? 'Dummy5ecr3t4D3bug0n1yN0T4Pr0D123';
+
+/** The initial power user created by initdb.sh ($POSTGRES_USER_APP_POWER). */
+export const POWER_EMAIL =
+ process.env.DTRACK_TEST_POWER_EMAIL ?? 'Dummy.User@example.com';
+
+/** The only domain auth.del_user accepts; test personas live here. */
+export const TEST_DOMAIN = 'hellodnk8n.onmicrosoft.com';
+
+/** Unique per test run, so run-scoped entities never collide with residue. */
+export const runId = Date.now().toString(36);
+
+export const tokenFor = (email: string): Promise =>
+ new SignJWT({ preferred_username: email })
+ .setProtectedHeader({ alg: 'HS256' })
+ .sign(new TextEncoder().encode(JWT_SECRET));
+
+export interface ApiResponse {
+ status: number;
+ headers: Headers;
+ body: any;
+}
+
+export async function api(
+ method: string,
+ path: string,
+ opts: { token?: string; body?: unknown; headers?: Record } = {},
+): Promise {
+ const headers: Record = {
+ Accept: 'application/json',
+ ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+ ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
+ ...opts.headers,
+ };
+ const res = await fetch(`${API_URI}${path}`, {
+ method,
+ headers,
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
+ });
+ const text = await res.text();
+ let body: any = null;
+ try {
+ body = text ? JSON.parse(text) : null;
+ } catch {
+ body = text;
+ }
+ return { status: res.status, headers: res.headers, body };
+}
+
+/** Convenience client bound to one user's JWT. */
+export async function asUser(email: string) {
+ const token = await tokenFor(email);
+ return {
+ email,
+ token,
+ get: (path: string, headers?: Record) =>
+ api('GET', path, { token, headers }),
+ post: (path: string, body?: unknown, headers?: Record) =>
+ api('POST', path, { token, body, headers }),
+ patch: (path: string, body?: unknown, headers?: Record) =>
+ api('PATCH', path, { token, body, headers }),
+ delete: (path: string, headers?: Record) =>
+ api('DELETE', path, { token, headers }),
+ };
+}
+export type UserClient = Awaited>;
+
+/**
+ * Standard personas. Employee creation goes through the API itself
+ * (api.employees INSTEAD OF trigger → auth.create_user), which is idempotent,
+ * so personas are stable across runs while projects/teams are run-scoped.
+ */
+export const personaEmail = (name: string) => `api.${name}@${TEST_DOMAIN}`;
+
+export interface Fixtures {
+ power: UserClient;
+ alice: UserClient; // project lead / AoE team supervisor
+ bob: UserClient; // plain member of alice's project and team
+ carol: UserClient; // unrelated outsider
+ aliceId: number;
+ bobId: number;
+ carolId: number;
+}
+
+async function employeeId(power: UserClient, username: string): Promise {
+ const res = await power.get(`/employees?username=eq.${username}&select=id`);
+ if (res.status !== 200 || !res.body?.[0]) {
+ throw new Error(`fixture employee ${username} missing: ${JSON.stringify(res)}`);
+ }
+ return res.body[0].id;
+}
+
+export async function ensurePersonas(): Promise {
+ const power = await asUser(POWER_EMAIL);
+ for (const [username, first, last] of [
+ ['api.alice', 'Alice', 'Lead'],
+ ['api.bob', 'Bob', 'Member'],
+ ['api.carol', 'Carol', 'Outsider'],
+ ]) {
+ const res = await power.post('/employees', {
+ username,
+ first_name: first,
+ last_name: last,
+ });
+ if (res.status >= 300) {
+ throw new Error(`persona ${username} creation failed: ${JSON.stringify(res)}`);
+ }
+ }
+ return {
+ power,
+ alice: await asUser(personaEmail('alice')),
+ bob: await asUser(personaEmail('bob')),
+ carol: await asUser(personaEmail('carol')),
+ aliceId: await employeeId(power, 'api.alice'),
+ bobId: await employeeId(power, 'api.bob'),
+ carolId: await employeeId(power, 'api.carol'),
+ };
+}
+
+/**
+ * Run-scoped project world: a project led by alice with member bob, linked
+ * to a fresh AoW and activity, plus an AoE team where alice leads bob.
+ */
+export interface ProjectWorld extends Fixtures {
+ projectId: number;
+ projectName: string;
+ aowId: number;
+ activityId: number;
+ aoeId: number;
+}
+
+export async function ensureProjectWorld(tag: string): Promise {
+ const f = await ensurePersonas();
+ const name = (kind: string) => `api ${kind} ${tag} ${runId}`;
+
+ // The INSTEAD OF triggers return NEW as-is, so return=representation gives
+ // back null ids for generated keys; fetch created rows by name instead.
+ const created = async (path: string, body: { name: string; [key: string]: unknown }) => {
+ const res = await f.power.post(path, body);
+ if (res.status >= 300) {
+ throw new Error(`fixture POST ${path} failed: ${JSON.stringify(res)}`);
+ }
+ const row = await f.power.get(
+ `${path}?name=eq.${encodeURIComponent(body.name)}&select=id,name`,
+ );
+ if (!row.body?.[0]) {
+ throw new Error(`fixture ${path} row not found after create`);
+ }
+ return row.body[0];
+ };
+
+ const aow = await created('/areas_of_work', { name: name('aow') });
+ const activity = await created('/activities', { name: name('activity') });
+ await created('/projects', {
+ name: name('project'),
+ description: 'fixture',
+ lead_ids: [f.aliceId],
+ member_ids: [f.bobId],
+ activity_ids: [activity.id],
+ aow_ids: [aow.id],
+ });
+ const project = (
+ await f.power.get(`/projects?name=eq.${encodeURIComponent(name('project'))}`)
+ ).body[0];
+
+ const aoe = await created('/areas_of_expertise', { name: name('aoe') });
+ const team = await f.power.post('/aoe_teams', {
+ aoe: { id: aoe.id },
+ lead: { id: f.aliceId },
+ member_ids: [f.bobId],
+ });
+ if (team.status >= 300) {
+ throw new Error(`fixture aoe_team failed: ${JSON.stringify(team)}`);
+ }
+
+ return {
+ ...f,
+ projectId: project.id,
+ projectName: project.name,
+ aowId: aow.id,
+ activityId: activity.id,
+ aoeId: aoe.id,
+ };
+}
diff --git a/tests/api/projects.test.ts b/tests/api/projects.test.ts
new file mode 100644
index 0000000..ac89a2d
--- /dev/null
+++ b/tests/api/projects.test.ts
@@ -0,0 +1,66 @@
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { ensureProjectWorld, ProjectWorld } from './helpers';
+
+let w: ProjectWorld;
+beforeAll(async () => {
+ w = await ensureProjectWorld('projects');
+});
+
+describe('projects', () => {
+ test('TC-PROJ-101: project created with lead, member and links', async () => {
+ const res = await w.alice.get(`/projects?id=eq.${w.projectId}`);
+ expect(res.status).toBe(200);
+ const project = res.body[0];
+ expect(project.lead_ids).toContain(w.aliceId);
+ expect(project.member_ids).toContain(w.bobId);
+ expect(project.aow_ids).toContain(w.aowId);
+ expect(project.activity_ids).toContain(w.activityId);
+ });
+
+ test('TC-PROJ-102: being made project lead grants the pm role', async () => {
+ const res = await w.alice.get('/current_employee?select=roles');
+ expect(res.status).toBe(200);
+ expect(res.body[0].roles).toContain('pm');
+ });
+
+ test('TC-PROJ-103: the project lead can update their project', async () => {
+ const res = await w.alice.patch(`/projects?id=eq.${w.projectId}`, {
+ description: `updated by lead ${Date.now()}`,
+ });
+ expect(res.status).toBeLessThan(300);
+ const check = await w.alice.get(`/projects?id=eq.${w.projectId}&select=description`);
+ expect(check.body[0].description).toMatch(/^updated by lead /);
+ });
+
+ test('TC-PROJ-104: unrelated users cannot see the project', async () => {
+ const res = await w.carol.get(`/projects?id=eq.${w.projectId}`);
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveLength(0);
+ });
+
+ // KNOWN BUG (kept as expected failure, do not "fix" the test):
+ // select_projects_policy on internal.projects lacks FOR SELECT, so as a
+ // permissive FOR ALL policy it also authorizes UPDATE for any project
+ // member. See TC-RLS-012 in tests/db/03-rls.sql and docs/testing.md.
+ test.fails(
+ 'TC-PROJ-105: a non-lead member cannot update the project',
+ async () => {
+ await w.bob.patch(`/projects?id=eq.${w.projectId}`, {
+ description: 'member should not be able to write this',
+ });
+ const check = await w.alice.get(
+ `/projects?id=eq.${w.projectId}&select=description`,
+ );
+ expect(check.body[0].description).not.toBe(
+ 'member should not be able to write this',
+ );
+ },
+ );
+
+ test('TC-PROJ-106: members see their project in the list', async () => {
+ const res = await w.bob.get(`/projects?id=eq.${w.projectId}&select=name`);
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveLength(1);
+ });
+});
diff --git a/tests/api/refdata.test.ts b/tests/api/refdata.test.ts
new file mode 100644
index 0000000..dcc1f3a
--- /dev/null
+++ b/tests/api/refdata.test.ts
@@ -0,0 +1,40 @@
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { ensurePersonas, Fixtures, runId } from './helpers';
+
+let f: Fixtures;
+beforeAll(async () => {
+ f = await ensurePersonas();
+});
+
+describe('reference data (areas of work, activities)', () => {
+ test('TC-AOW-101: power can create an area of work', async () => {
+ const res = await f.power.post('/areas_of_work', { name: `api refdata aow ${runId}` });
+ expect(res.status).toBe(201);
+ });
+
+ test('TC-AOW-102: basic users cannot create areas of work', async () => {
+ const res = await f.bob.post('/areas_of_work', { name: `api evil aow ${runId}` });
+ expect(res.status).toBe(403);
+ expect(res.body?.code).toBe('42501');
+ });
+
+ test('TC-AOW-103: areas of work are readable by all authenticated users', async () => {
+ const res = await f.carol.get('/areas_of_work?select=id,name');
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body)).toBe(true);
+ });
+
+ test('TC-ACT-101: power can create an activity', async () => {
+ const res = await f.power.post('/activities', {
+ name: `api refdata activity ${runId}`,
+ description: 'created by API tests',
+ });
+ expect(res.status).toBe(201);
+ });
+
+ test('TC-ACT-102: basic users cannot create activities', async () => {
+ const res = await f.bob.post('/activities', { name: `api evil activity ${runId}` });
+ expect(res.status).toBe(403);
+ });
+});
diff --git a/tests/api/timetracking.test.ts b/tests/api/timetracking.test.ts
new file mode 100644
index 0000000..e1f0bae
--- /dev/null
+++ b/tests/api/timetracking.test.ts
@@ -0,0 +1,92 @@
+import { beforeAll, describe, expect, test } from 'vitest';
+
+import { ensureProjectWorld, ProjectWorld, runId } from './helpers';
+
+let w: ProjectWorld;
+beforeAll(async () => {
+ w = await ensureProjectWorld('tt');
+});
+
+const today = new Date().toISOString().slice(0, 10);
+
+const entryFor = (userId: number, description: string) => ({
+ description,
+ date: today,
+ duration: '0.25 hrs',
+ user: { id: userId },
+ project: { id: w.projectId },
+ activity: { id: w.activityId },
+ aow: { id: w.aowId },
+});
+
+describe('time tracking', () => {
+ test('TC-TT-101: a member can log their own time in UI duration format', async () => {
+ const res = await w.bob.post(
+ '/time_trackings',
+ entryFor(w.bobId, `api tt own ${runId}`),
+ { Prefer: 'return=representation' },
+ );
+ expect(res.status).toBe(201);
+
+ const check = await w.bob.get(
+ `/time_trackings?description=eq.${encodeURIComponent(`api tt own ${runId}`)}`,
+ );
+ expect(check.body).toHaveLength(1);
+ expect(check.body[0].duration).toBe('0.25 hrs');
+ expect(check.body[0].user.id).toBe(w.bobId);
+ });
+
+ test('TC-TT-102: a member cannot log time for someone else', async () => {
+ const res = await w.bob.post(
+ '/time_trackings',
+ entryFor(w.aliceId, `api tt forged ${runId}`),
+ );
+ expect(res.status).toBe(403);
+ expect(res.body?.code).toBe('42501');
+ });
+
+ test('TC-TT-103: a team lead sees and can log their members entries', async () => {
+ const seen = await w.alice.get(
+ `/time_trackings?description=eq.${encodeURIComponent(`api tt own ${runId}`)}`,
+ );
+ expect(seen.status).toBe(200);
+ expect(seen.body).toHaveLength(1);
+
+ const logged = await w.alice.post(
+ '/time_trackings',
+ entryFor(w.bobId, `api tt bylead ${runId}`),
+ );
+ expect(logged.status).toBe(201);
+ });
+
+ test('TC-TT-104: outsiders see none of these entries', async () => {
+ const res = await w.carol.get(
+ `/time_trackings?description=like.api tt*${runId}`,
+ );
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveLength(0);
+ });
+
+ test('TC-TT-105: durations must be multiples of 15 minutes', async () => {
+ const res = await w.bob.post('/time_trackings', {
+ ...entryFor(w.bobId, `api tt invalid ${runId}`),
+ duration: '20 minutes',
+ });
+ expect(res.status).toBe(400);
+ expect(res.body?.code).toBe('23514');
+ });
+
+ test('TC-PLOT-101: duration calendar aggregates the user entries', async () => {
+ const res = await w.bob.get(`/duration_calendar?user_id=eq.${w.bobId}`);
+ expect(res.status).toBe(200);
+ expect(res.body.length).toBeGreaterThanOrEqual(1);
+ });
+
+ test('TC-PLOT-102: timetracking_summary RPC responds for the caller', async () => {
+ const res = await w.bob.get(
+ '/rpc/timetracking_summary?axes=%7Buser,daterange%7D&date_part=day&zoom_level=2',
+ );
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body)).toBe(true);
+ });
+});
diff --git a/tests/check-test-case-ids.sh b/tests/check-test-case-ids.sh
new file mode 100755
index 0000000..da6e1ea
--- /dev/null
+++ b/tests/check-test-case-ids.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Every TC-… id referenced by an automated test must be documented in
+# docs/test-cases.md. (One-way check: the catalog may also contain manual and
+# planned cases that have no automation yet.)
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+catalog=docs/test-cases.md
+missing=0
+
+ids=$(grep -rhoE 'TC-[A-Z]+-[0-9]+' tests/db tests/api tests/e2e | sort -u)
+for id in $ids; do
+ if ! grep -q "$id" "$catalog"; then
+ echo "MISSING from $catalog: $id"
+ missing=1
+ fi
+done
+
+if [ "$missing" -ne 0 ]; then
+ exit 1
+fi
+echo "All referenced test case ids are documented in $catalog."
diff --git a/tests/db/00-schema.sql b/tests/db/00-schema.sql
new file mode 100644
index 0000000..d515086
--- /dev/null
+++ b/tests/db/00-schema.sql
@@ -0,0 +1,89 @@
+-- Schema sanity: the objects the API and UI depend on exist and are guarded.
+-- Deliberately loose (existence + security posture), so the schema can grow
+-- without breaking these tests. Test case ids: docs/test-cases.md
+BEGIN;
+
+SELECT plan(38);
+
+-- TC-SCH-001: base tables exist
+SELECT has_table('auth', 'users', 'TC-SCH-001: auth.users exists');
+SELECT has_table('internal', 'activities', 'TC-SCH-001: internal.activities exists');
+SELECT has_table('internal', 'areas_of_work', 'TC-SCH-001: internal.areas_of_work exists');
+SELECT has_table('internal', 'projects', 'TC-SCH-001: internal.projects exists');
+SELECT has_table('internal', 'project_activities', 'TC-SCH-001: internal.project_activities exists');
+SELECT has_table('internal', 'project_areas_of_work', 'TC-SCH-001: internal.project_areas_of_work exists');
+SELECT has_table('internal', 'user_project_roles', 'TC-SCH-001: internal.user_project_roles exists');
+SELECT has_table('internal', 'time_trackings', 'TC-SCH-001: internal.time_trackings exists');
+SELECT has_table('internal', 'areas_of_expertise', 'TC-SCH-001: internal.areas_of_expertise exists');
+SELECT has_table('internal', 'aoe_hierarchies', 'TC-SCH-001: internal.aoe_hierarchies exists');
+SELECT has_table('internal', 'faqs', 'TC-SCH-001: internal.faqs exists');
+
+-- TC-SCH-002: api views exist (the PostgREST surface)
+SELECT has_view('api', 'employees', 'TC-SCH-002: api.employees exists');
+SELECT has_view('api', 'current_employee', 'TC-SCH-002: api.current_employee exists');
+SELECT has_view('api', 'all_employees', 'TC-SCH-002: api.all_employees exists');
+SELECT has_view('api', 'activities', 'TC-SCH-002: api.activities exists');
+SELECT has_view('api', 'areas_of_work', 'TC-SCH-002: api.areas_of_work exists');
+SELECT has_view('api', 'projects', 'TC-SCH-002: api.projects exists');
+SELECT has_view('api', 'areas_of_expertise', 'TC-SCH-002: api.areas_of_expertise exists');
+SELECT has_view('api', 'aoe_teams', 'TC-SCH-002: api.aoe_teams exists');
+SELECT has_view('api', 'time_trackings', 'TC-SCH-002: api.time_trackings exists');
+SELECT has_view('api', 'faqs', 'TC-SCH-002: api.faqs exists');
+SELECT has_view('api', 'duration_calendar', 'TC-SCH-002: api.duration_calendar exists');
+
+-- TC-SCH-003: API entry-point functions exist
+SELECT has_function('api', 'onboard', ARRAY['text'], 'TC-SCH-003: api.onboard exists');
+SELECT has_function('pre', 'request', 'TC-SCH-003: pre.request exists');
+SELECT has_function('api', 'timetracking_summary', 'TC-SCH-003: api.timetracking_summary exists');
+
+-- TC-SCH-004: row-level security is enabled on all guarded tables
+SELECT ok(
+ (SELECT bool_and(relrowsecurity)
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE (n.nspname, c.relname) IN (
+ ('auth', 'users'),
+ ('internal', 'projects'),
+ ('internal', 'user_project_roles'),
+ ('internal', 'project_areas_of_work'),
+ ('internal', 'areas_of_work'),
+ ('internal', 'project_activities'),
+ ('internal', 'activities'),
+ ('internal', 'time_trackings'),
+ ('internal', 'areas_of_expertise'),
+ ('internal', 'aoe_hierarchies'),
+ ('internal', 'faqs')
+ )),
+ 'TC-SCH-004: RLS enabled on auth.users and all internal tables'
+);
+
+-- TC-SCH-005: access roles exist
+SELECT has_role('anon', 'TC-SCH-005: role anon exists');
+SELECT has_role('basic', 'TC-SCH-005: role basic exists');
+SELECT has_role('pm', 'TC-SCH-005: role pm exists');
+SELECT has_role('lead', 'TC-SCH-005: role lead exists');
+SELECT has_role('power', 'TC-SCH-005: role power exists');
+
+-- TC-SCH-006: privilege tiers are cumulative
+SELECT is_member_of('basic', 'pm', 'TC-SCH-006: pm inherits basic');
+SELECT is_member_of('basic', 'lead', 'TC-SCH-006: lead inherits basic');
+SELECT is_member_of('pm', 'power', 'TC-SCH-006: power inherits pm');
+SELECT is_member_of('lead', 'power', 'TC-SCH-006: power inherits lead');
+
+-- TC-SCH-007: power bypasses row-level security
+SELECT ok(
+ (SELECT rolbypassrls FROM pg_roles WHERE rolname = 'power'),
+ 'TC-SCH-007: power has BYPASSRLS'
+);
+
+-- TC-SCH-008: anon has no access to data views (API surface is opt-in)
+SELECT ok(
+ NOT has_table_privilege('anon', 'api.employees', 'SELECT'),
+ 'TC-SCH-008: anon cannot select api.employees'
+);
+SELECT ok(
+ NOT has_table_privilege('anon', 'api.time_trackings', 'SELECT'),
+ 'TC-SCH-008: anon cannot select api.time_trackings'
+);
+
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/tests/db/01-constraints.sql b/tests/db/01-constraints.sql
new file mode 100644
index 0000000..c93f8ad
--- /dev/null
+++ b/tests/db/01-constraints.sql
@@ -0,0 +1,76 @@
+-- Data-integrity constraints and triggers on the internal tables.
+-- Test case ids: docs/test-cases.md
+BEGIN;
+
+SELECT plan(9);
+
+-- Fixture: one user and the reference rows a time tracking needs
+SELECT auth.create_user('fixture.user@hellodnk8n.onmicrosoft.com');
+INSERT INTO internal.activities (name) VALUES ('fixture activity');
+INSERT INTO internal.areas_of_work (name) VALUES ('fixture aow');
+INSERT INTO internal.projects (name) VALUES ('fixture project');
+
+CREATE FUNCTION pg_temp.fixture_tt_insert(dur interval) RETURNS void LANGUAGE sql AS $$
+ INSERT INTO internal.time_trackings (date, duration, user_id, project_id, activity_id, aow_id)
+ VALUES (
+ CURRENT_DATE,
+ dur,
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.user'),
+ (SELECT project_id FROM internal.projects WHERE name = 'fixture project'),
+ (SELECT activity_id FROM internal.activities WHERE name = 'fixture activity'),
+ (SELECT aow_id FROM internal.areas_of_work WHERE name = 'fixture aow')
+ );
+$$;
+
+-- TC-TT-001: durations that are multiples of 15 minutes are accepted
+SELECT lives_ok($$SELECT pg_temp.fixture_tt_insert('15 minutes')$$,
+ 'TC-TT-001: 15 minute duration accepted');
+SELECT lives_ok($$SELECT pg_temp.fixture_tt_insert('7.5 hours')$$,
+ 'TC-TT-001: 7.5 hour duration accepted');
+SELECT lives_ok($$SELECT pg_temp.fixture_tt_insert('0 minutes')$$,
+ 'TC-TT-001: zero duration accepted (in-lieu-of-overtime convention)');
+
+-- TC-TT-002: durations that are not multiples of 15 minutes are rejected
+SELECT throws_ok($$SELECT pg_temp.fixture_tt_insert('20 minutes')$$, '23514',
+ NULL, 'TC-TT-002: 20 minute duration violates duration_check');
+SELECT throws_ok($$SELECT pg_temp.fixture_tt_insert('1 hour 1 minute')$$, '23514',
+ NULL, 'TC-TT-002: 61 minute duration violates duration_check');
+
+-- TC-TT-003: the UI's duration format parses to the intended interval
+SELECT is('0.25 hrs'::interval, '15 minutes'::interval,
+ 'TC-TT-003: "0.25 hrs" parses as 15 minutes');
+SELECT is('7.50 hrs'::interval, '450 minutes'::interval,
+ 'TC-TT-003: "7.50 hrs" parses as 7.5 hours');
+
+-- TC-TEAM-001: a member cannot belong to two teams within one area of expertise
+SELECT auth.create_user('fixture.lead2@hellodnk8n.onmicrosoft.com');
+INSERT INTO internal.areas_of_expertise (name) VALUES ('fixture aoe');
+INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id)
+SELECT aoe_id,
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.lead2'),
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.user')
+FROM internal.areas_of_expertise WHERE name = 'fixture aoe';
+
+SELECT throws_ok($$
+ INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id)
+ SELECT aoe_id,
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.user'),
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.user')
+ FROM internal.areas_of_expertise WHERE name = 'fixture aoe'
+ $$,
+ 'A member cannot belong to multiple teams within the same area of expertise',
+ 'TC-TEAM-001: second team membership in same AoE rejected');
+
+-- TC-TEAM-002: the same member may join teams in different areas of expertise
+INSERT INTO internal.areas_of_expertise (name) VALUES ('fixture aoe 2');
+SELECT lives_ok($$
+ INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id)
+ SELECT aoe_id,
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.lead2'),
+ (SELECT user_id FROM auth.users WHERE username = 'fixture.user')
+ FROM internal.areas_of_expertise WHERE name = 'fixture aoe 2'
+ $$,
+ 'TC-TEAM-002: membership in a second AoE accepted');
+
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/tests/db/02-user-management.sql b/tests/db/02-user-management.sql
new file mode 100644
index 0000000..460a128
--- /dev/null
+++ b/tests/db/02-user-management.sql
@@ -0,0 +1,76 @@
+-- auth.* user and membership management functions.
+-- Test case ids: docs/test-cases.md
+BEGIN;
+
+SELECT plan(12);
+
+-- TC-EMP-001: create_user creates a NOLOGIN role, grants basic, inserts auth.users
+SELECT auth.create_user('test.person@hellodnk8n.onmicrosoft.com', 'Test', 'Person', 'Engineer');
+SELECT has_role('test.person@hellodnk8n.onmicrosoft.com',
+ 'TC-EMP-001: role created for new user');
+SELECT is_member_of('basic', 'test.person@hellodnk8n.onmicrosoft.com',
+ 'TC-EMP-001: new user is granted basic');
+SELECT results_eq(
+ $$SELECT username, first_name, last_name, position FROM auth.users
+ WHERE email = 'test.person@hellodnk8n.onmicrosoft.com'$$,
+ $$VALUES ('test.person'::text, 'Test'::text, 'Person'::text, 'Engineer'::text)$$,
+ 'TC-EMP-001: auth.users row created, username derived from email');
+
+-- TC-EMP-002: create_user is an upsert on username
+SELECT auth.create_user('test.person@hellodnk8n.onmicrosoft.com', 'Renamed', 'Person', 'Manager');
+SELECT results_eq(
+ $$SELECT first_name, position FROM auth.users
+ WHERE email = 'test.person@hellodnk8n.onmicrosoft.com'$$,
+ $$VALUES ('Renamed'::text, 'Manager'::text)$$,
+ 'TC-EMP-002: re-creating an existing user updates profile fields');
+SELECT is(
+ (SELECT count(*)::int FROM auth.users WHERE username = 'test.person'),
+ 1, 'TC-EMP-002: no duplicate row on upsert');
+
+-- TC-EMP-003: create_user derives first/last name from dotted email local part
+SELECT auth.create_user('jane.doe@hellodnk8n.onmicrosoft.com');
+SELECT results_eq(
+ $$SELECT first_name, last_name FROM auth.users WHERE username = 'jane.doe'$$,
+ $$VALUES ('jane'::text, 'doe'::text)$$,
+ 'TC-EMP-003: names default from email local part');
+
+-- TC-EMP-004: del_user refuses emails outside the allowed domain
+SELECT throws_ok(
+ $$SELECT auth.del_user('someone@example.com')$$,
+ 'Invalid email format: someone@example.com',
+ 'TC-EMP-004: del_user rejects foreign domains');
+
+-- TC-EMP-005: del_user drops role and row for the allowed domain
+SELECT auth.del_user('jane.doe@hellodnk8n.onmicrosoft.com');
+SELECT hasnt_role('jane.doe@hellodnk8n.onmicrosoft.com', 'TC-EMP-005: role dropped');
+SELECT is_empty(
+ $$SELECT 1 FROM auth.users WHERE username = 'jane.doe'$$,
+ 'TC-EMP-005: auth.users row deleted');
+
+-- TC-AUTH-001: membership predicates
+SELECT auth.create_user('pred.lead@hellodnk8n.onmicrosoft.com');
+SELECT auth.create_user('pred.member@hellodnk8n.onmicrosoft.com');
+INSERT INTO internal.projects (name) VALUES ('pred project');
+INSERT INTO internal.user_project_roles (user_id, project_id, is_lead)
+VALUES
+ ((SELECT user_id FROM auth.users WHERE username = 'pred.lead'),
+ (SELECT project_id FROM internal.projects WHERE name = 'pred project'), TRUE),
+ ((SELECT user_id FROM auth.users WHERE username = 'pred.member'),
+ (SELECT project_id FROM internal.projects WHERE name = 'pred project'), FALSE);
+
+SELECT ok(auth.is_project_member(
+ (SELECT user_id FROM auth.users WHERE username = 'pred.member'),
+ (SELECT project_id FROM internal.projects WHERE name = 'pred project')),
+ 'TC-AUTH-001: is_project_member true for member');
+SELECT ok(NOT auth.is_project_member(
+ (SELECT user_id FROM auth.users WHERE username = 'pred.member'),
+ (SELECT project_id FROM internal.projects WHERE name = 'pred project'),
+ is_lead => true),
+ 'TC-AUTH-001: is_project_member(is_lead) false for plain member');
+SELECT ok(auth.is_pm_of(
+ (SELECT user_id FROM auth.users WHERE username = 'pred.lead'),
+ (SELECT user_id FROM auth.users WHERE username = 'pred.member')),
+ 'TC-AUTH-001: is_pm_of true for lead over member of same project');
+
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/tests/db/03-rls.sql b/tests/db/03-rls.sql
new file mode 100644
index 0000000..2744f2d
--- /dev/null
+++ b/tests/db/03-rls.sql
@@ -0,0 +1,219 @@
+-- Row-level security: who can see and write what.
+-- Runs as superuser and impersonates personas with SET ROLE, exactly as
+-- PostgREST does after validating a JWT. Test case ids: docs/test-cases.md
+BEGIN;
+
+SELECT plan(19);
+
+-- Fixture personas: a team lead, their team member, an unrelated outsider,
+-- and a power user. Everything rolls back at the end.
+SELECT auth.create_user('rls.lead@hellodnk8n.onmicrosoft.com');
+SELECT auth.create_user('rls.member@hellodnk8n.onmicrosoft.com');
+SELECT auth.create_user('rls.outsider@hellodnk8n.onmicrosoft.com');
+SELECT auth.create_user('rls.power@hellodnk8n.onmicrosoft.com');
+SELECT auth.grant_power('rls.power@hellodnk8n.onmicrosoft.com');
+
+INSERT INTO internal.areas_of_expertise (name) VALUES ('rls aoe');
+INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id)
+SELECT aoe_id,
+ (SELECT user_id FROM auth.users WHERE username = 'rls.lead'),
+ (SELECT user_id FROM auth.users WHERE username = 'rls.member')
+FROM internal.areas_of_expertise WHERE name = 'rls aoe';
+
+INSERT INTO internal.projects (name) VALUES ('rls project');
+INSERT INTO internal.user_project_roles (user_id, project_id, is_lead)
+VALUES
+ ((SELECT user_id FROM auth.users WHERE username = 'rls.lead'),
+ (SELECT project_id FROM internal.projects WHERE name = 'rls project'), TRUE),
+ ((SELECT user_id FROM auth.users WHERE username = 'rls.member'),
+ (SELECT project_id FROM internal.projects WHERE name = 'rls project'), FALSE);
+
+INSERT INTO internal.activities (name) VALUES ('rls activity');
+INSERT INTO internal.areas_of_work (name) VALUES ('rls aow');
+
+INSERT INTO internal.time_trackings (description, date, duration, user_id, project_id, activity_id, aow_id)
+SELECT descr, CURRENT_DATE, '30 minutes',
+ (SELECT user_id FROM auth.users WHERE username = uname),
+ (SELECT project_id FROM internal.projects WHERE name = 'rls project'),
+ (SELECT activity_id FROM internal.activities WHERE name = 'rls activity'),
+ (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow')
+FROM (VALUES ('rls entry member', 'rls.member'), ('rls entry lead', 'rls.lead')) AS f(descr, uname);
+
+INSERT INTO internal.faqs (question, answer, is_published)
+VALUES ('rls published?', 'yes', TRUE), ('rls draft?', 'no', FALSE);
+
+---------------------------------------------------------------
+-- Time trackings
+---------------------------------------------------------------
+SET ROLE "rls.member@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-001: members see only their own time trackings
+SELECT results_eq(
+ $$SELECT description FROM internal.time_trackings WHERE description LIKE 'rls entry%'$$,
+ $$VALUES ('rls entry member'::text)$$,
+ 'TC-RLS-001: member sees only own time trackings');
+
+-- TC-RLS-002: members cannot log time for someone else
+SELECT throws_ok($$
+ INSERT INTO internal.time_trackings (date, duration, user_id, project_id, activity_id, aow_id)
+ SELECT CURRENT_DATE, '15 minutes',
+ (SELECT user_id FROM auth.users WHERE email = 'rls.lead@hellodnk8n.onmicrosoft.com'),
+ (SELECT project_id FROM internal.projects WHERE name = 'rls project'),
+ (SELECT activity_id FROM internal.activities WHERE name = 'rls activity'),
+ (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow')
+ $$, '42501', NULL,
+ 'TC-RLS-002: member cannot insert a time tracking for their lead');
+
+-- TC-RLS-003: writes through the api view respect RLS and parse the UI duration format
+SELECT lives_ok($$
+ INSERT INTO api.time_trackings (description, date, duration, "user", project, activity, aow)
+ SELECT 'rls entry via api', CURRENT_DATE, '0.25 hrs',
+ jsonb_build_object('id', (SELECT user_id FROM auth.users WHERE username = 'rls.member')),
+ jsonb_build_object('id', (SELECT project_id FROM internal.projects WHERE name = 'rls project')),
+ jsonb_build_object('id', (SELECT activity_id FROM internal.activities WHERE name = 'rls activity')),
+ jsonb_build_object('id', (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow'))
+ $$,
+ 'TC-RLS-003: member can log own time through api view');
+SELECT results_eq(
+ $$SELECT duration FROM internal.time_trackings WHERE description = 'rls entry via api'$$,
+ $$VALUES ('15 minutes'::interval)$$,
+ 'TC-RLS-003: "0.25 hrs" stored as 15 minutes');
+
+RESET ROLE;
+SET ROLE "rls.lead@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-004: team leads see their members' time trackings
+SELECT results_eq(
+ $$SELECT count(*)::int FROM internal.time_trackings WHERE description LIKE 'rls entry%'$$,
+ $$VALUES (3)$$,
+ 'TC-RLS-004: lead sees own and member entries');
+
+-- TC-RLS-005: team leads can log time for their members
+SELECT lives_ok($$
+ INSERT INTO internal.time_trackings (description, date, duration, user_id, project_id, activity_id, aow_id)
+ SELECT 'rls entry by lead for member', CURRENT_DATE, '15 minutes',
+ (SELECT user_id FROM auth.users WHERE email = 'rls.member@hellodnk8n.onmicrosoft.com'),
+ (SELECT project_id FROM internal.projects WHERE name = 'rls project'),
+ (SELECT activity_id FROM internal.activities WHERE name = 'rls activity'),
+ (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow')
+ $$,
+ 'TC-RLS-005: lead inserts entry for member');
+
+RESET ROLE;
+SET ROLE "rls.outsider@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-006: outsiders see nothing of others' data
+SELECT is_empty(
+ $$SELECT 1 FROM internal.time_trackings WHERE description LIKE 'rls entry%'$$,
+ 'TC-RLS-006: outsider sees no rls time trackings');
+
+---------------------------------------------------------------
+-- Projects
+---------------------------------------------------------------
+-- TC-RLS-010: outsiders cannot see the project
+SELECT is_empty(
+ $$SELECT 1 FROM internal.projects WHERE name = 'rls project'$$,
+ 'TC-RLS-010: outsider cannot see project');
+
+RESET ROLE;
+SET ROLE "rls.member@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-011: members see their project
+SELECT isnt_empty(
+ $$SELECT 1 FROM internal.projects WHERE name = 'rls project'$$,
+ 'TC-RLS-011: member sees own project');
+
+RESET ROLE;
+SET ROLE "rls.lead@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-013: project leads can update their project
+UPDATE internal.projects SET description = 'updated by lead' WHERE name = 'rls project';
+SELECT results_eq(
+ $$SELECT description FROM internal.projects WHERE name = 'rls project'$$,
+ $$VALUES ('updated by lead'::text)$$,
+ 'TC-RLS-013: lead update persisted');
+
+-- TC-RLS-014: creating a brand-new project is denied to non-power users
+SELECT throws_ok(
+ $$INSERT INTO internal.projects (name) VALUES ('rls new project')$$,
+ '42501', NULL,
+ 'TC-RLS-014: lead cannot create an unrelated new project');
+
+RESET ROLE;
+SET ROLE "rls.member@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-012: non-lead members should not be able to update the project.
+-- KNOWN BUG (expected failure): select_projects_policy is created without
+-- FOR SELECT, making it a permissive FOR ALL policy, so its USING clause
+-- also authorizes UPDATE/DELETE for every project member. Fixing it means
+-- adding FOR SELECT (and re-checking update_leads_projects_policy coverage).
+UPDATE internal.projects SET name = 'rls hacked' WHERE name = 'rls project';
+RESET ROLE;
+SELECT * FROM todo('select_projects_policy lacks FOR SELECT, members can update projects', 1);
+SELECT is_empty(
+ $$SELECT 1 FROM internal.projects WHERE name = 'rls hacked'$$,
+ 'TC-RLS-012: member update affected no rows');
+
+---------------------------------------------------------------
+-- Reference data (activities / areas of work)
+---------------------------------------------------------------
+SET ROLE "rls.member@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-020: reference data is read-only for non-power users
+SELECT throws_ok(
+ $$INSERT INTO internal.areas_of_work (name) VALUES ('rls new aow')$$,
+ '42501', NULL,
+ 'TC-RLS-020: basic cannot create areas of work');
+SELECT throws_ok(
+ $$INSERT INTO internal.activities (name) VALUES ('rls new activity')$$,
+ '42501', NULL,
+ 'TC-RLS-020: basic cannot create activities');
+
+RESET ROLE;
+SET ROLE "rls.power@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-021: power users manage reference data
+SELECT lives_ok(
+ $$INSERT INTO internal.areas_of_work (name) VALUES ('rls power aow')$$,
+ 'TC-RLS-021: power creates area of work');
+
+---------------------------------------------------------------
+-- Users
+---------------------------------------------------------------
+RESET ROLE;
+SET ROLE "rls.outsider@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-030: unrelated users see only themselves in auth.users
+SELECT results_eq(
+ $$SELECT email FROM auth.users WHERE email LIKE 'rls.%'$$,
+ $$VALUES ('rls.outsider@hellodnk8n.onmicrosoft.com'::text)$$,
+ 'TC-RLS-030: outsider sees only self');
+
+RESET ROLE;
+SET ROLE "rls.member@hellodnk8n.onmicrosoft.com";
+
+-- TC-RLS-031: members also see their team lead (and vice versa)
+SELECT results_eq(
+ $$SELECT count(*)::int FROM auth.users WHERE email IN
+ ('rls.member@hellodnk8n.onmicrosoft.com', 'rls.lead@hellodnk8n.onmicrosoft.com')$$,
+ $$VALUES (2)$$,
+ 'TC-RLS-031: member sees self and their lead');
+
+---------------------------------------------------------------
+-- FAQs
+---------------------------------------------------------------
+-- TC-FAQ-001: only published FAQs are visible to regular users
+SELECT results_eq(
+ $$SELECT question FROM internal.faqs WHERE question LIKE 'rls%'$$,
+ $$VALUES ('rls published?'::text)$$,
+ 'TC-FAQ-001: draft FAQ hidden from basic user');
+
+-- TC-FAQ-002: regular users cannot create FAQs
+SELECT throws_ok(
+ $$INSERT INTO internal.faqs (question, answer, is_published) VALUES ('x', 'y', TRUE)$$,
+ '42501', NULL,
+ 'TC-FAQ-002: basic cannot insert FAQ');
+
+RESET ROLE;
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/tests/db/04-role-automation.sql b/tests/db/04-role-automation.sql
new file mode 100644
index 0000000..072270d
--- /dev/null
+++ b/tests/db/04-role-automation.sql
@@ -0,0 +1,78 @@
+-- Automatic privilege management: project leads gain/lose `pm`,
+-- AoE team supervisors gain/lose `lead`. Test case ids: docs/test-cases.md
+BEGIN;
+
+SELECT plan(8);
+
+SELECT auth.create_user('auto.lead@hellodnk8n.onmicrosoft.com');
+SELECT auth.create_user('auto.member@hellodnk8n.onmicrosoft.com');
+SELECT auth.create_user('auto.power@hellodnk8n.onmicrosoft.com');
+SELECT auth.grant_power('auto.power@hellodnk8n.onmicrosoft.com');
+INSERT INTO internal.areas_of_expertise (name) VALUES ('auto aoe');
+
+SET ROLE "auto.power@hellodnk8n.onmicrosoft.com";
+
+---------------------------------------------------------------
+-- Projects: lead_ids drive the pm role
+---------------------------------------------------------------
+-- TC-PROJ-001: creating a project through the api view with a lead grants pm
+INSERT INTO api.projects (name, lead_ids, member_ids, activity_ids, aow_ids)
+VALUES ('auto project',
+ (SELECT jsonb_build_array(user_id) FROM auth.users WHERE username = 'auto.lead'),
+ (SELECT jsonb_build_array(user_id) FROM auth.users WHERE username = 'auto.member'),
+ '[]'::jsonb, '[]'::jsonb);
+
+SELECT isnt_empty(
+ $$SELECT 1 FROM internal.projects WHERE name = 'auto project'$$,
+ 'TC-PROJ-001: project created through api view');
+SELECT results_eq(
+ $$SELECT count(*)::int FROM internal.user_project_roles upr
+ JOIN internal.projects p USING (project_id) WHERE p.name = 'auto project'$$,
+ $$VALUES (2)$$,
+ 'TC-PROJ-001: lead and member membership rows created');
+SELECT is_member_of('pm', 'auto.lead@hellodnk8n.onmicrosoft.com',
+ 'TC-PROJ-001: project lead was granted pm');
+
+-- TC-PROJ-002: removing the lead revokes pm (when they lead nothing else)
+UPDATE api.projects
+SET lead_ids = '[]'::jsonb
+WHERE id = (SELECT project_id FROM internal.projects WHERE name = 'auto project');
+
+SELECT ok(
+ NOT pg_has_role('auto.lead@hellodnk8n.onmicrosoft.com', 'pm', 'MEMBER'),
+ 'TC-PROJ-002: pm revoked after removal as lead');
+
+---------------------------------------------------------------
+-- AoE teams: supervising a team drives the lead role
+---------------------------------------------------------------
+-- TC-TEAM-010: upserting a team through the api view grants lead to its supervisor
+INSERT INTO api.aoe_teams (aoe, lead, member_ids)
+SELECT jsonb_build_object('id', aoe_id),
+ (SELECT jsonb_build_object('id', user_id) FROM auth.users WHERE username = 'auto.lead'),
+ (SELECT jsonb_build_array(user_id) FROM auth.users WHERE username = 'auto.member')
+FROM internal.areas_of_expertise WHERE name = 'auto aoe';
+
+SELECT results_eq(
+ $$SELECT count(*)::int FROM internal.aoe_hierarchies ah
+ JOIN internal.areas_of_expertise aoe USING (aoe_id) WHERE aoe.name = 'auto aoe'$$,
+ $$VALUES (1)$$,
+ 'TC-TEAM-010: hierarchy row created');
+SELECT is_member_of('lead', 'auto.lead@hellodnk8n.onmicrosoft.com',
+ 'TC-TEAM-010: team supervisor was granted lead');
+
+-- TC-TEAM-011: deleting the team (as power) removes it and revokes lead
+DELETE FROM api.aoe_teams
+WHERE (aoe->>'id')::int = (SELECT aoe_id FROM internal.areas_of_expertise WHERE name = 'auto aoe')
+ AND (lead->>'id')::int = (SELECT user_id FROM auth.users WHERE username = 'auto.lead');
+
+SELECT is_empty(
+ $$SELECT 1 FROM internal.aoe_hierarchies ah
+ JOIN internal.areas_of_expertise aoe USING (aoe_id) WHERE aoe.name = 'auto aoe'$$,
+ 'TC-TEAM-011: hierarchy rows removed by power delete');
+SELECT ok(
+ NOT pg_has_role('auto.lead@hellodnk8n.onmicrosoft.com', 'lead', 'MEMBER'),
+ 'TC-TEAM-011: lead revoked after team deletion');
+
+RESET ROLE;
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/tests/db/05-security-observations.sql b/tests/db/05-security-observations.sql
new file mode 100644
index 0000000..97b66d0
--- /dev/null
+++ b/tests/db/05-security-observations.sql
@@ -0,0 +1,20 @@
+-- Security posture tests that currently FAIL and point at genuine issues in
+-- the application. Per the testing policy (docs/testing.md) they are kept
+-- committed but marked TODO (pgTAP's expected-fail), so they don't break the
+-- suite; when the underlying issue is fixed, pg_prove reports them as
+-- "unexpectedly succeeded" and the todo marker must be removed.
+BEGIN;
+
+SELECT plan(1);
+
+-- TC-SEC-001: privilege-syncing function should not be executable by basic.
+-- internal.aoe_teams_grant_or_revoke_privs is SECURITY DEFINER and can grant
+-- or revoke the `lead` role, yet EXECUTE is granted to `basic` (the source
+-- marks this "TODO: This is potentially insecure, fix").
+SELECT * FROM todo('aoe_teams_grant_or_revoke_privs is executable by basic; flagged TODO in 013.project_aoe_functions.sql', 1);
+SELECT ok(
+ NOT has_function_privilege('basic', 'internal.aoe_teams_grant_or_revoke_privs(integer)', 'EXECUTE'),
+ 'TC-SEC-001: basic cannot execute internal.aoe_teams_grant_or_revoke_privs');
+
+SELECT * FROM finish();
+ROLLBACK;
diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts
new file mode 100644
index 0000000..0946237
--- /dev/null
+++ b/tests/e2e/auth.spec.ts
@@ -0,0 +1,32 @@
+import { expect, test } from '@playwright/test';
+
+import { login, POWER_EMAIL } from './helpers';
+
+test('TC-E2E-001: power user can log in and out via the debug login', async ({
+ page,
+}) => {
+ await login(page, POWER_EMAIL);
+
+ // The main navigation is available
+ await expect(page.getByRole('menuitem', { name: 'Projects' })).toBeVisible();
+ await expect(page.getByRole('menuitem', { name: 'Employees' })).toBeVisible();
+
+ // Log out again
+ await page.getByLabel('Profile').click();
+ await page.getByText('Logout').click();
+ await expect(
+ page.getByRole('button', { name: 'Login as this User' }),
+ ).toBeVisible({ timeout: 10_000 });
+});
+
+test('TC-E2E-002: an unknown user cannot reach the workspace', async ({ page }) => {
+ await page.goto('/');
+ await page
+ .getByLabel('Enter any username for DEBUG login')
+ .fill('ghost.user@hellodnk8n.onmicrosoft.com');
+ await page.getByRole('button', { name: 'Login as this User' }).click();
+
+ // Whatever the exact failure UX, the authenticated menu must not appear.
+ await page.waitForTimeout(3000);
+ await expect(page.getByRole('menuitem', { name: 'Time Trackings' })).toHaveCount(0);
+});
diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts
new file mode 100644
index 0000000..c8a203d
--- /dev/null
+++ b/tests/e2e/helpers.ts
@@ -0,0 +1,35 @@
+import { expect, Page } from '@playwright/test';
+
+/** The initial power user created by initdb.sh ($POSTGRES_USER_APP_POWER). */
+export const POWER_EMAIL =
+ process.env.DTRACK_TEST_POWER_EMAIL ?? 'Dummy.User@example.com';
+
+/** Unique per test run so created entities never collide with residue. */
+export const runId = Date.now().toString(36);
+
+/** Log in through the debug login page (VITE_ENVIRONMENT=DEBUG builds). */
+export async function login(page: Page, email: string): Promise {
+ await page.goto('/');
+ await page.getByLabel('Enter any username for DEBUG login').fill(email);
+ await page.getByRole('button', { name: 'Login as this User' }).click();
+ await expect(page.getByRole('menuitem', { name: 'Time Trackings' })).toBeVisible({
+ timeout: 15_000,
+ });
+}
+
+/** Fill a react-admin (MUI) autocomplete input and pick the matching option. */
+export async function pickOption(
+ page: Page,
+ label: string | RegExp,
+ optionText: string,
+): Promise {
+ const input = page.getByRole('combobox', { name: label });
+ await input.click();
+ await input.fill(optionText);
+ await page.getByRole('option', { name: optionText, exact: true }).click();
+}
+
+/** Wait for react-admin's "Element created" snackbar. */
+export async function expectCreated(page: Page): Promise {
+ await expect(page.getByText('Element created')).toBeVisible({ timeout: 10_000 });
+}
diff --git a/tests/e2e/journey.spec.ts b/tests/e2e/journey.spec.ts
new file mode 100644
index 0000000..840f3f7
--- /dev/null
+++ b/tests/e2e/journey.spec.ts
@@ -0,0 +1,80 @@
+import { expect, test } from '@playwright/test';
+
+import { expectCreated, login, pickOption, POWER_EMAIL, runId } from './helpers';
+
+/**
+ * TC-E2E-010: the core user journey, end to end. As the power user, build up
+ * the reference data a time tracking needs (AoW → Activity → Project with
+ * both linked), then log time against it using the cascading selects, and
+ * see the entry in the list.
+ */
+const aowName = `E2E AoW ${runId}`;
+const activityName = `E2E Activity ${runId}`;
+const projectName = `E2E Project ${runId}`;
+const taskDescription = `E2E logged time ${runId}`;
+
+test.describe.configure({ mode: 'serial' });
+
+test('TC-E2E-010a: create an area of work', async ({ page }) => {
+ await login(page, POWER_EMAIL);
+ await page.getByRole('menuitem', { name: 'AoWs' }).click();
+ await page.getByRole('link', { name: 'Create' }).click();
+ await page.getByLabel('Name').fill(aowName);
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expectCreated(page);
+});
+
+test('TC-E2E-010b: create an activity', async ({ page }) => {
+ await login(page, POWER_EMAIL);
+ await page.getByRole('menuitem', { name: 'Activities' }).click();
+ await page.getByRole('link', { name: 'Create' }).click();
+ await page.getByLabel('Name', { exact: true }).fill(activityName);
+ await page.getByLabel('Description').fill('Created by the E2E suite');
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expectCreated(page);
+});
+
+test('TC-E2E-010c: create a project linking AoW, activity and a manager', async ({
+ page,
+}) => {
+ await login(page, POWER_EMAIL);
+ await page.getByRole('menuitem', { name: 'Projects' }).click();
+ await page.getByRole('link', { name: 'Create' }).click();
+ await page.getByLabel('Name', { exact: true }).fill(projectName);
+ await page.getByLabel('Description').fill('Created by the E2E suite');
+ await pickOption(page, 'AoWs', aowName);
+ await pickOption(page, 'Activities', activityName);
+ await pickOption(page, 'Managers', 'Dummy.User');
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expectCreated(page);
+});
+
+test('TC-E2E-010d: log time through the cascading selects', async ({ page }) => {
+ await login(page, POWER_EMAIL);
+ await page.getByRole('menuitem', { name: 'Time Trackings' }).click();
+ await page.getByRole('link', { name: 'Create' }).click();
+
+ // Employee Name defaults to the logged-in identity; drive the cascade
+ await pickOption(page, 'Area of Work', aowName);
+ await pickOption(page, 'Project', projectName);
+ await pickOption(page, 'Activity', activityName);
+ await page.getByLabel('Task Description').fill(taskDescription);
+ await pickOption(page, 'Duration', '0.25 hrs');
+ await page.getByRole('button', { name: 'Save' }).click();
+ await expectCreated(page);
+});
+
+test('TC-E2E-010e: the logged entry appears in the list', async ({ page }) => {
+ await login(page, POWER_EMAIL);
+ await page.getByRole('menuitem', { name: 'Time Trackings' }).click();
+ await expect(page.getByText(taskDescription)).toBeVisible({ timeout: 15_000 });
+});
+
+test('TC-E2E-020: the dashboard renders for a user with data', async ({ page }) => {
+ await login(page, POWER_EMAIL);
+ // Dashboard is the default route
+ await page.goto('/');
+ await expect(page.getByRole('tab', { name: 'Chart' })).toBeVisible({
+ timeout: 15_000,
+ });
+});
diff --git a/tests/package-lock.json b/tests/package-lock.json
new file mode 100644
index 0000000..3b9da41
--- /dev/null
+++ b/tests/package-lock.json
@@ -0,0 +1,1730 @@
+{
+ "name": "dtrack-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dtrack-tests",
+ "devDependencies": {
+ "@playwright/test": "^1.53.0",
+ "@types/node": "^24.0.0",
+ "jose": "^6.0.11",
+ "typescript": "^5.8.3",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/tests/package.json b/tests/package.json
new file mode 100644
index 0000000..198c39d
--- /dev/null
+++ b/tests/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "dtrack-tests",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test:api": "vitest run api",
+ "test:e2e": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.53.0",
+ "@types/node": "^24.0.0",
+ "jose": "^6.0.11",
+ "typescript": "^5.8.3",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts
new file mode 100644
index 0000000..932d416
--- /dev/null
+++ b/tests/playwright.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 60_000,
+ retries: process.env.CI ? 1 : 0,
+ // The suites drive one shared UI/database; keep them sequential.
+ workers: 1,
+ use: {
+ baseURL: process.env.DTRACK_TEST_APP_URI ?? 'http://localhost:5174',
+ trace: 'retain-on-failure',
+ },
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
+ projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
+});
diff --git a/tests/run-api-tests.sh b/tests/run-api-tests.sh
new file mode 100755
index 0000000..572e341
--- /dev/null
+++ b/tests/run-api-tests.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Run the HTTP tests against a running PostgREST (see docs/testing.md).
+# Prerequisites: stack up in debug mode and database bootstrapped
+# (./initdb.sh && make debug).
+set -euo pipefail
+cd "$(dirname "$0")"
+[ -d node_modules ] || npm ci
+npx vitest run api
diff --git a/tests/run-db-tests.sh b/tests/run-db-tests.sh
new file mode 100755
index 0000000..ba2084c
--- /dev/null
+++ b/tests/run-db-tests.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Run the pgTAP suite against the running dev/CI postgres container.
+#
+# pgTAP is installed into the *running container* at test time (packages from
+# the PGDG apt repo already configured in the postgres image), so the
+# production image stays untouched. The extension lives in the database until
+# the volume is recreated; every test file is a single transaction that rolls
+# back, so no test data persists.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+echo "--> Ensuring pgTAP and pg_prove are installed in the postgres container"
+docker compose exec -T -u root postgres bash -c '
+ command -v pg_prove >/dev/null && dpkg -s postgresql-15-pgtap >/dev/null 2>&1 || {
+ apt-get update -qq &&
+ apt-get install -y -qq postgresql-15-pgtap libtap-parser-sourcehandler-pgtap-perl
+ }' >/dev/null
+
+docker compose exec -T postgres psql -q -U postgres -d dtrack \
+ -c 'CREATE EXTENSION IF NOT EXISTS pgtap;'
+
+echo "--> Copying tests into the container and running pg_prove"
+docker compose exec -T postgres rm -rf /tmp/dtrack-db-tests
+docker compose cp tests/db postgres:/tmp/dtrack-db-tests
+docker compose exec -T postgres \
+ bash -c 'pg_prove -U postgres -d dtrack /tmp/dtrack-db-tests/*.sql'
diff --git a/tests/run-e2e-tests.sh b/tests/run-e2e-tests.sh
new file mode 100755
index 0000000..c04d66d
--- /dev/null
+++ b/tests/run-e2e-tests.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Run the Playwright end-to-end tests against the running debug-mode UI
+# (see docs/testing.md). Prerequisites: stack up in debug mode and database
+# bootstrapped (./initdb.sh && make debug).
+set -euo pipefail
+cd "$(dirname "$0")"
+[ -d node_modules ] || npm ci
+npx playwright install ${CI:+--with-deps} chromium
+npx playwright test
diff --git a/tests/tsconfig.json b/tests/tsconfig.json
new file mode 100644
index 0000000..4dc1336
--- /dev/null
+++ b/tests/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "types": ["node"],
+ "noEmit": true
+ },
+ "include": ["api", "e2e", "*.ts"]
+}
diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts
new file mode 100644
index 0000000..218a959
--- /dev/null
+++ b/tests/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['api/**/*.test.ts'],
+ // API tests hit a shared database; run files sequentially so fixtures
+ // written by one file can't race another file's assertions.
+ fileParallelism: false,
+ testTimeout: 30_000,
+ hookTimeout: 60_000,
+ },
+});