mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:11:10 +00:00
[AZ-486] F7 endpoint builders + STC-ARCH-02 (cycle 1 close)
Single source of truth for every /api/<service>/... URL the UI talks to: src/api/endpoints.ts (25 typed builders) re-exported via the F4 barrel. Migrates 13 production callsites in admin / annotations / flights / settings / dataset / auth / api-client / FlightContext / DetectionClasses to endpoints.* . Adds the STC-ARCH-02 static gate (--mode=api-literals in scripts/check-arch-imports.mjs, wired into scripts/run-tests.sh) that fails any new hardcoded /api/<service>/ literal in src/ outside endpoints.ts and *.test.tsx? files. Tests: +36 contract assertions in src/api/endpoints.test.ts (every builder, character-identical), +6 STC-ARCH-02 architecture cases in tests/architecture_imports.test.ts (single / double / template literal fail paths, *.test.* exemption, line-comment skip, migrated codebase pass). Fast profile 167 -> 209 PASS / 13 SKIP / 0 FAIL, +42 new, 0 regressions. Static profile 31 / 31 PASS. Closes architecture baseline finding F7. Cycle 1 of Phase B closed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,8 +50,8 @@
|
||||
|
||||
- **Epic**: TBD
|
||||
- **Directory**: `src/api/`
|
||||
- **Public API** (via `src/api/index.ts` barrel): `api`, `setToken`, `getToken`, `getApiBase`, `setNavigateToLogin`, `createSSE`.
|
||||
- **Internal**: none (both files are externally consumed)
|
||||
- **Public API** (via `src/api/index.ts` barrel): `api`, `setToken`, `getToken`, `getApiBase`, `setNavigateToLogin`, `createSSE`, `endpoints` (the typed URL-builder object that is the single source of truth for every `/api/<service>/...` path the UI talks to today — AZ-486 / F7; `STC-ARCH-02` enforces it).
|
||||
- **Internal**: none (every file is externally consumed; the colocated `endpoints.test.ts` IS the wire-contract documentation per `module-layout.md`'s "code-derived documentation" pattern).
|
||||
- **Owns**: `src/api/**`
|
||||
- **Imports from**: `00_foundation` (types)
|
||||
- **Consumed by**: `02_auth`, `03_shared-ui`, every feature page (04, 05, 06, 07, 08, 09)
|
||||
@@ -227,6 +227,8 @@ The following inferences could not be made cleanly from code alone. They are sur
|
||||
|
||||
3. ~~No barrel exports anywhere~~ — **resolved by AZ-485 (F4)**. Every component now exposes a `src/<component>/index.ts` barrel; cross-component imports go through it; `STC-ARCH-01` enforces it. One F3-pending exemption (`classColors`) remains documented in Layout Rule #3 above and in `architecture_compliance_baseline.md`.
|
||||
|
||||
3a. ~~Hardcoded `/api/<service>/` URLs scattered across callsites~~ — **resolved by AZ-486 (F7)**. The single source of truth is `src/api/endpoints.ts` (re-exported via the `01_api-transport` barrel from rule #3). Every production callsite of `api.*` and `createSSE()` uses an `endpoints.*` builder; the colocated `src/api/endpoints.test.ts` pins every URL string and serves as the wire-contract documentation. The `STC-ARCH-02` static gate (`scripts/check-arch-imports.mjs --mode=api-literals`, wired into `scripts/run-tests.sh --static-only`) fails the build on any new hardcoded `/api/<service>/` literal under `src/`. Exemptions: `src/api/endpoints.ts` (the contract owner) and any `*.test.ts` / `*.test.tsx` under `src/` (test files are exempt because tests legitimately assert URL strings — MSW handlers, contract tests, etc.).
|
||||
|
||||
4. **`mission-planner/` is owned by `05_flights` but lives at the repo root** (not under `src/`). Layout rule #1 says one component owns one or more top-level directories — this satisfies the rule (it owns two: `src/features/flights/` AND `mission-planner/`). Implement-skill consumers must include `mission-planner/**` in `05_flights`'s OWNED glob. **Decision needed**: confirm the implement skill should treat `mission-planner/**` as OWNED by 05_flights (otherwise it's FORBIDDEN by default).
|
||||
|
||||
5. **`05_flights` cycle inside the port-source**. `mission-planner/src/flightPlanning/MapView.tsx ↔ MiniMap.tsx` form a circular import (named-handle, see `00_discovery.md` §7 footnote). They were analyzed together in batch MP-B6. The cycle is internal to the component and does not cross component boundaries; flagged here for completeness.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 10 (Phase B cycle 1, batch 2 of 2 — cycle close)
|
||||
**Tasks**: AZ-486 (Endpoint builders + STC-ARCH-02)
|
||||
**Date**: 2026-05-11
|
||||
**Cycle**: Phase B feature cycle, Step 10 — Implement
|
||||
**Total complexity**: 5 pts
|
||||
**Epic**: AZ-447 (`01-testability-refactoring`)
|
||||
**Closes**: architecture baseline finding **F7** (`_docs/02_document/architecture_compliance_baseline.md`)
|
||||
**Depends on**: AZ-485 (F4 barrels) — committed in `23746ec`, AC-6 verified barrel re-export
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified / Added | Tests | AC Coverage | Issues |
|
||||
|------|--------|------------------------|-------|-------------|--------|
|
||||
| AZ-486_refactor_endpoint_builders | Done | **2 new files** (`src/api/endpoints.ts` — 25 builders; `src/api/endpoints.test.ts` — 36 contract assertions); **1 barrel update** (`src/api/index.ts` re-exports `endpoints`); **13 production files migrated** (`src/api/client.ts`, `src/auth/AuthContext.tsx`, `src/components/{FlightContext,DetectionClasses}.tsx`, `src/features/{admin/AdminPage,annotations/{AnnotationsPage,AnnotationsSidebar,CanvasEditor,MediaList,VideoPlayer},dataset/DatasetPage,flights/FlightsPage,settings/SettingsPage}.tsx`); **1 static-check script extended** (`scripts/check-arch-imports.mjs` — added `--mode=api-literals` for STC-ARCH-02 alongside existing `--mode=arch-imports` for STC-ARCH-01); **1 runner wired** (`scripts/run-tests.sh` — STC-ARCH-02 row added; STC-ARCH-01 invocation made explicit with `--mode=arch-imports`); **1 test file extended** (`tests/architecture_imports.test.ts` — 6 new STC-ARCH-02 cases covering single-/double-/template-literal fail paths, *.test.* exemption, line-comment skip, and migrated-codebase pass); **1 doc updated** (`_docs/02_document/module-layout.md` — `01_api-transport` Public API now lists `endpoints`; Verification Needed item #3a records F7 resolution + STC-ARCH-02 inventory + exemptions) | 36 new contract tests in `src/api/endpoints.test.ts` + 6 new architecture tests in `tests/architecture_imports.test.ts` = **+42 fast tests**; fast profile re-baselined from 167 → 209 passes (0 regressions); 31/31 static checks PASS including new `STC-ARCH-02` | 7 / 7 ACs covered | None |
|
||||
|
||||
## AC Test Coverage: All 7 ACs covered
|
||||
|
||||
| AC | Where | Profile | Status |
|
||||
|----|-------|---------|--------|
|
||||
| AC-1 — Every current path has a builder; URL strings character-identical | `src/api/endpoints.test.ts` (36 `expect(...).toBe('...')` cases — one per builder × every realistic input shape) | fast | PASS — every URL literal that lived in source before this refactor is reproduced exactly. Parameter interpolation (id strings, query strings, two-segment composites like `flightWaypoint(flightId, waypointId)`) covered explicitly. The test file IS the wire contract per `module-layout.md`'s "code-derived documentation" pattern. |
|
||||
| AC-2 — No `/api/<service>/` literals remain in production | `node scripts/check-arch-imports.mjs --mode=api-literals` (exit 0) over `src/` excluding `endpoints.ts` and `*.test.tsx?`; cross-checked with workspace-wide grep showing only `endpoints.ts` retains the literals | static (`STC-ARCH-02`) | PASS — 0 hits outside the documented exemptions |
|
||||
| AC-3 — Static gate fails on a newly-introduced literal | `tests/architecture_imports.test.ts` — 3 fail-on-synthetic-fixture cases (single-quoted, double-quoted, template-literal) all assert `status != 0` and `stderr` mentions `STC-ARCH-02` + fixture filename | fast | PASS — every quote style trips the gate. Single quote and template literal cases shown in the run log; double-quote case implicitly verified (same regex branch) |
|
||||
| AC-4 — Static gate passes on the migrated codebase | `tests/architecture_imports.test.ts` `AC-4: passes on the migrated codebase` + `STC-ARCH-02` row in the static profile | fast + static | PASS — exit code 0, stderr empty |
|
||||
| AC-5 — Fast profile remains green | `bash scripts/run-tests.sh` (static + fast); fast went 167 / 13 / 0 → 209 / 13 / 0 (+36 endpoint contract + 6 architecture STC-ARCH-02 = +42 new passes) | static + fast | PASS — 0 regressions; only adds new tests |
|
||||
| AC-6 — `endpoints` is re-exported from `src/api/index.ts` (the F4 barrel) | `src/api/endpoints.test.ts` `AC-6: barrel re-export` (`endpoints === endpointsViaBarrel`); 13 production consumers import `endpoints` via `'../api'` or `'../../api'` — verified by STC-ARCH-01 still PASS | fast + static | PASS — same object identity; no deep imports reintroduced |
|
||||
| AC-7 — MSW handlers and e2e stubs continue to match | Pre-existing MSW handlers across the fast suite still intercept correctly (no NEW "intercepted unhandled" errors introduced by the refactor); URL strings character-identical per AC-1; e2e profile not run in this batch (per project's batch-level testing strategy — handed off to Step 11 / Step 16 full run) | fast | PASS — observed MSW unhandled-warning lines under `ConfirmDialog.test.tsx` are pre-existing noise (AuthProvider boot triggers `/api/admin/auth/refresh` which that test file deliberately leaves unhandled; the auth-refresh URL is character-identical to pre-refactor); no new failure modes |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Single shared static-check script with `--mode` flag, not a second `check-api-literals.mjs`.** Mirrors AZ-485's "single source of truth" decision (batch 9 / Design Decision #1). Both gates walk the same codebase, use the same `IGNORED_DIRS` / `SOURCE_EXT` / `walkSourceFiles` machinery, and skip the same single-line comments. Forking the script would have duplicated the walker and the comment-skip rule in two places, which is exactly the kind of drift STC-ARCH-* gates exist to prevent. The `--mode` flag is a one-line dispatch in `main()`.
|
||||
|
||||
2. **STC-ARCH-02 regex matches all three quote styles** (`'`, `"`, `` ` ``), not just single quotes as the task spec's illustrative `ripgrep "'/api/[a-z-]+/"` suggested. Quote style is not a meaningful difference for "no hardcoded URLs in production" — a developer could regress the gate by switching quote styles otherwise. The regex `[\`'"]/api/[a-z][a-z-]*/` requires the path to be preceded by a string-opener, which avoids false positives on comment text that mentions `/api/<service>/` as documentation. Three fail-on-synthetic test cases (one per quote style) lock this behavior in.
|
||||
|
||||
3. **`*.test.{ts,tsx}` files under `src/` are exempted from STC-ARCH-02.** Tests legitimately assert URL strings — MSW handlers, the `endpoints.test.ts` contract itself, and existing colocated tests (`src/api/client.test.ts`, `src/auth/{AuthContext,ProtectedRoute}.test.tsx`, `src/components/Header.test.tsx`) all reference `/api/...` literals. The exemption is documented in five places: the static-check script's `API_LITERAL_EXEMPT_FILES_RE` comment, the bash wrapper in `run-tests.sh`, `module-layout.md` Verification Needed item #3a, the architecture test (`AC-3: still PASSES when an offending literal lives in a *.test.ts file under src/`), and the in-code header comment at the top of `endpoints.ts`. Same exemption discipline as the AZ-485 F3-pending exemption (5 places).
|
||||
|
||||
4. **Function-form builders everywhere (not constants).** Pinned by the task spec's "Why function form" comment in `endpoints.ts`. Allows parameter interpolation without callsites re-introducing template literals (and re-introducing STC-ARCH-02 violations), keeps tree-shaking per-builder under Vite's production rollup, and lets builders that take a query string own the `?` boundary so the wire contract stays identical (e.g., `endpoints.annotations.dataset(queryString)` returns `` `/api/annotations/dataset?${queryString}` `` — caller passes `params.toString()`, not a literal `?`-prefixed string).
|
||||
|
||||
5. **`endpoints.flights.collection(queryString?)` accepts an optional query string.** Today's callsites are split: `FlightContext` reads `'/api/flights?pageSize=1000'` (GET with query), `FlightsPage` writes `'/api/flights'` (POST without query). One builder with an optional arg keeps the wire-contract surface coherent; passing `undefined` returns the bare path. Validated by two test cases (`flights.collection() without query` and `flights.collection(queryString) appends ?queryString`). Same shape used for `annotations.dataset(queryString)` and `annotations.media(queryString)`.
|
||||
|
||||
6. **`endpoints.annotations.annotationsByMedia(mediaId, pageSize?=1000)` exposes the page-size constant.** Every current callsite uses `pageSize=1000`; the optional arg lets future tuning be a single-file change (per task spec NFR / Maintainability). Two test cases pin both the default and the override path.
|
||||
|
||||
7. **`endpoints.admin.class(id: string | number)` widens the ID type.** `DetectionClass.id` is `number` in the type system today (per `AdminPage` line 51 cast), but the rest of the admin builders take `string` because user/aircraft IDs are GUIDs. Widening to `string | number` at the builder accepts today's number-typed call from `AdminPage.handleDeleteClass` without an explicit cast and stays forward-compatible if the backend later switches `DetectionClass.id` to UUID. Two test cases (`'cls-7'` and `42`) pin both arms.
|
||||
|
||||
8. **`endpoints.detect.media(mediaId)` is the only entry under a non-annotations namespace.** The `/api/detect/<mediaId>` path is a single-segment service path (no further segments today) consumed only by `AnnotationsSidebar`. Keeping it under its own `detect` namespace mirrors the URL's first segment and leaves room for future detect-service endpoints without renaming.
|
||||
|
||||
9. **`src/api/endpoints.ts` lives under `01_api-transport` — F6 explicitly out of scope.** Per the AZ-486 spec's `Excluded` section, the future `src/shared/` move (F6) is deferred. The barrel-re-export pattern means consumers import `{ endpoints } from '../api'` — when F6 lands and moves the file, only `src/api/index.ts` needs to flip the re-export source; consumers do not change. This is exactly the protection F4 / AZ-485 was built to provide.
|
||||
|
||||
## Code Review Verdict: PASS
|
||||
|
||||
Self-review (implement skill Step 9 / 10) applied to the 2 new + 13 modified + 1 script-extended + 1 runner-wired + 1 doc-updated + 1 test-extended files:
|
||||
|
||||
- **0 Critical, 0 High, 0 Medium, 0 Low findings.**
|
||||
- **Scope discipline**: every modified file is one of (contract author, deep-literal consumer, static-check author, runner wirer, contract documentor, gate test author). The spec's listed files are all migrated; two additional files outside the spec's explicit list (`CanvasEditor.tsx`, `VideoPlayer.tsx`) were also migrated because they contain `/api/<service>/` literals as `<img src>` / `<video src>` URLs — including them is required for AC-2 / STC-ARCH-02 to pass, and the spec's "every component that calls `api.*` or `createSSE`" intent reads "every UI callsite of a wire-contract URL", which these are.
|
||||
- **No silent error suppression**: `check-arch-imports.mjs` writes the full hit list to stderr (with `STC-ARCH-02` named in the header) before exiting non-zero; the bash delegate (`static_check_no_api_literals`) propagates the exit code; `run-tests.sh` records the result into the static CSV. No new `try { } catch { }` blocks in production code; no new `2>/dev/null` redirects.
|
||||
- **Single-responsibility**: `endpoints.ts` exports one shape (the typed URL-builder object) with one job (produce wire-contract URLs). `endpoints.test.ts` has one job (pin every URL string). `check-arch-imports.mjs` now has two modes but each scanner function (`scanArchImports`, `scanApiLiterals`) has one job. The `main()` dispatch is a 12-line config-and-call.
|
||||
- **No new dependencies**: `endpoints.ts` is plain TypeScript with `as const`. `endpoints.test.ts` uses Vitest + the barrel import. The script extension uses Node stdlib (`fs`, `path`, `url`) only — same as before.
|
||||
- **Architecture compliance (Phase 7)**: STC-ARCH-01 still PASS (no new cross-component deep imports); the new `endpoints` import in `client.ts` is intra-component (`./endpoints`). The 12 modified consumer files all import `endpoints` via the `01_api-transport` barrel. Layer direction unchanged.
|
||||
- **Cross-task consistency (Phase 6)**: builds on AZ-485 cleanly — uses the same barrel pattern (`module-layout.md` Rule #3) and the same static-check delivery mechanism (`scripts/check-arch-imports.mjs`). The shared script now has symmetric STC-ARCH-01 + STC-ARCH-02 modes. AZ-447 epic now has both F4 and F7 closed.
|
||||
- **Performance**: `STC-PERF01` (initial JS bundle ≤ 2 MB gzipped) still PASS after the refactor. The `endpoints` object is small and tree-shakeable per builder per the Vite production rollup; observed bundle size unchanged within measurement noise.
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
|
||||
The session resumed an in-progress AZ-486 batch (the user-recommended "option A" path from `/autodev` re-entry). No auto-fix loop was needed — the missing pieces (DatasetPage + DetectionClasses migrations, STC-ARCH-02 wiring, architecture test extension, doc update) were straightforward additions to a coherent partial implementation. The first `bash scripts/run-tests.sh` run went green: 31/31 static + 209/13/0 fast.
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
No multi-pass investigations. The resume was a continuation, not a debug loop.
|
||||
|
||||
## Test Run Summary
|
||||
|
||||
- `bash scripts/run-tests.sh` (static + fast) — exit 0
|
||||
- **Static profile**: 31 / 31 PASS, including `STC-ARCH-01` (166 ms) and the new `STC-ARCH-02` (179 ms). `STC-T1` (tsc) 3.8 s, `STC-B1` (vite build) 7.4 s.
|
||||
- **Fast profile**: `bun run test:fast` — 28 files / **209 passed** / 13 skipped / 22.58 s wall. +42 vs end-of-batch-9 (167 + 36 endpoint contract + 6 architecture STC-ARCH-02 = 209). 0 regressions.
|
||||
- `node scripts/check-arch-imports.mjs --mode=api-literals` (direct invocation) — exit 0, stderr empty.
|
||||
- `node scripts/check-arch-imports.mjs --mode=arch-imports` (direct invocation) — exit 0, stderr empty.
|
||||
- `ReadLints` — clean on all modified files.
|
||||
- Pre-existing MSW "intercepted unhandled" stderr lines under `ConfirmDialog.test.tsx` are NOT new and NOT caused by this batch: the failing URL `/api/admin/auth/refresh` is character-identical pre- and post-refactor (AC-1 verifies); the warning has been latent in the suite and is not a blocker.
|
||||
|
||||
## Documented Drifts (cumulative across batch)
|
||||
|
||||
None new. The F3-pending exemption (`classColors`) carried forward from batch 9 is unchanged. STC-ARCH-02 has no F3 interaction.
|
||||
|
||||
## Phase B Cycle 1 Status
|
||||
|
||||
This is **batch 2 of 2** in Phase B cycle 1 (the cycle covered baseline findings **F4** + **F7** under epic AZ-447). Both batches are now complete:
|
||||
|
||||
- Batch 9 / AZ-485 — F4 (Public API barrels + STC-ARCH-01) — committed `23746ec`
|
||||
- Batch 10 / AZ-486 — F7 (Endpoint builders + STC-ARCH-02) — this batch, uncommitted pending user approval
|
||||
|
||||
**Cycle 1 complete** once batch 10 is committed. Per the autodev existing-code flow, Step 10 (Implement) then auto-chains to Step 11 (Run Tests) → Step 12 (Test-Spec Sync) → Step 13 (Update Docs) → Step 14 (Security Audit, optional) → Step 15 (Performance Test, optional) → Step 16 (Deploy) → Step 17 (Retrospective) → loop back to Step 9 with `cycle: 2` incremented.
|
||||
|
||||
## Cumulative Code Review Trigger
|
||||
|
||||
Per the implement skill Step 14.5, cumulative code review fires every `K=3` batches. Phase B cycle 1 had only 2 batches (AZ-485, AZ-486); no cumulative review is triggered at this cycle close. The existing `cumulative_review_batches_07-08_cycle1_report.md` was the Phase A wrap. The next cumulative review will be after batches 11 + 12 + 13 of cycle 2 (or whenever the next 3-batch window closes).
|
||||
|
||||
## Next Batch
|
||||
|
||||
**All Phase B cycle 1 tasks complete.** Final implementation report for cycle 1 will be `_docs/03_implementation/implementation_report_phase_b_cycle1.md` (written at the close of Step 10 once user approves commit of batch 10). The autodev orchestrator will auto-chain to Step 11 (Run Tests, full suite, owned by `test-run/SKILL.md`) after commit.
|
||||
+30
-13
@@ -7,8 +7,8 @@ name: Implement
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 7
|
||||
name: batch-9-cycle1-az485-complete
|
||||
detail: "AZ-485 (F4 barrels) implemented + reviewed; batch_09_report saved; archive done; AZ-486 is the next batch in cycle 1"
|
||||
name: batch-10-cycle1-az486-complete
|
||||
detail: "AZ-486 (F7 endpoint builders + STC-ARCH-02) implemented + reviewed; batch_10_report saved; archive done; awaiting user approval to commit and then auto-chain to Step 11"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
@@ -23,19 +23,36 @@ step_3_ac_gap_handling: rollback-to-6c (option A)
|
||||
`glossary.md`, plus `_docs/01_solution/solution.md` and
|
||||
`_docs/00_problem/{problem,acceptance_criteria,restrictions,security_approach}.md`.
|
||||
- Implement-skill batch reports at
|
||||
`_docs/03_implementation/batch_0{1..9}_report.md` (batch 09 = AZ-485 cycle-1 batch-1).
|
||||
`_docs/03_implementation/batch_0{1..9}_report.md` + `batch_10_report.md`
|
||||
(batch 09 = AZ-485 cycle-1 batch-1; batch 10 = AZ-486 cycle-1 batch-2).
|
||||
- Cumulative reviews PASS_WITH_WARNINGS at
|
||||
`_docs/03_implementation/cumulative_review_batches_01-03_report.md`,
|
||||
`_docs/03_implementation/cumulative_review_batches_04-06_cycle1_report.md`,
|
||||
`_docs/03_implementation/cumulative_review_batches_07-08_cycle1_report.md`
|
||||
(cycle close — Phase A wrap, no batch 9).
|
||||
- Phase B started. Step 9 (New Task) cycle 1 closed:
|
||||
- AZ-485 (F4 — Public API barrels + STC-ARCH-01, 5 pts, no deps)
|
||||
- AZ-486 (F7 — Endpoint builders + STC-ARCH-02, 5 pts, blocked by AZ-485)
|
||||
- F1 (mission-planner convergence) deliberately not created — needs `/decompose` for 7+ port-group cycles in its own Epic.
|
||||
- Step 10 (Implement) batch 9 (AZ-485) done:
|
||||
- 11 new barrels, ~40 production deep imports migrated, ~22 test deep imports migrated.
|
||||
- `scripts/check-arch-imports.mjs` + `STC-ARCH-01` static gate added (mirrors the `check-banned-deps.mjs` pattern).
|
||||
- `tests/architecture_imports.test.ts` covers AC-4 / AC-5 + 2 exemption cases (4 new fast tests; total 167 PASS / 13 SKIP / 0 FAIL).
|
||||
- One F3-pending exemption: `src/features/annotations/classColors` is imported directly (circular-barrel avoidance), documented in 5 places.
|
||||
- Next: AZ-486 (endpoint builders) — depends on AZ-485 commits landing first.
|
||||
- Phase B cycle 1 closed (2 batches, both AC + static + fast green):
|
||||
- AZ-485 (F4 — Public API barrels + STC-ARCH-01, 5 pts) — committed 23746ec
|
||||
- AZ-486 (F7 — Endpoint builders + STC-ARCH-02, 5 pts) — batch 10 done,
|
||||
uncommitted, awaiting user approval.
|
||||
- Step 10 (Implement) batch 10 (AZ-486) done:
|
||||
- 2 new files (`src/api/endpoints.ts` 25 builders, `src/api/endpoints.test.ts` 36 cases).
|
||||
- 1 barrel update (`src/api/index.ts` re-exports `endpoints`).
|
||||
- 13 production files migrated to `endpoints.*` (admin, annotations,
|
||||
flights, settings, dataset, auth, client, FlightContext,
|
||||
DetectionClasses, CanvasEditor, VideoPlayer, MediaList,
|
||||
AnnotationsSidebar, AnnotationsPage).
|
||||
- `scripts/check-arch-imports.mjs` extended with `--mode=api-literals`
|
||||
(STC-ARCH-02) alongside `--mode=arch-imports` (STC-ARCH-01);
|
||||
`scripts/run-tests.sh` wires both modes.
|
||||
- `tests/architecture_imports.test.ts` extended with 6 STC-ARCH-02 cases
|
||||
(single/double/template-literal fail paths, *.test.* exemption,
|
||||
line-comment skip, migrated-codebase pass).
|
||||
- `_docs/02_document/module-layout.md` `01_api-transport` Public API now
|
||||
lists `endpoints`; Verification Needed item #3a records F7 resolution.
|
||||
- Test counts: 167 → 209 PASS / 13 SKIP / 0 FAIL (+42).
|
||||
- Static: 31 / 31 PASS including new STC-ARCH-02.
|
||||
- Cumulative code review (K=3): no trigger — Phase B cycle 1 had only 2
|
||||
batches (9, 10).
|
||||
- Next on commit of batch 10: auto-chain to Step 11 (Run Tests) via
|
||||
`test-run/SKILL.md`. Final cycle-1 implementation report (`implementation_report_phase_b_cycle1.md`) is written at that point per
|
||||
implement skill Step 16 handoff rule.
|
||||
|
||||
+122
-27
@@ -1,21 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// AZ-485 / F4 — STC-ARCH-01: no cross-component deep imports.
|
||||
// AZ-485 / F4 (STC-ARCH-01) + AZ-486 / F7 (STC-ARCH-02).
|
||||
//
|
||||
// STC-ARCH-01 (mode=arch-imports, default) — no cross-component deep imports.
|
||||
// Every component under src/<component>/ exposes its Public API via a barrel
|
||||
// (src/<component>/index.ts). Cross-component imports MUST go through the
|
||||
// barrel; reaching into another component's internal files is a layering
|
||||
// violation.
|
||||
//
|
||||
// STC-ARCH-02 (mode=api-literals) — no hardcoded `/api/<service>/...` literals
|
||||
// in production source. The single source of truth for those URLs is
|
||||
// src/api/endpoints.ts (AZ-486 / F7). Any production callsite of api.* or
|
||||
// createSSE() that needs an API path must use an `endpoints.*` builder.
|
||||
//
|
||||
// Single source of truth — scripts/run-tests.sh delegates here, and the
|
||||
// architecture unit test calls this script with a synthetic fixture to verify
|
||||
// the check fails on a deep import (AC-4) and passes on the migrated codebase
|
||||
// (AC-5). Mirrors the scripts/check-banned-deps.mjs pattern (AZ-482).
|
||||
// architecture unit test (tests/architecture_imports.test.ts) calls this
|
||||
// script with synthetic fixtures to verify each gate fails on a regression
|
||||
// and passes on the migrated codebase. Mirrors the scripts/check-banned-deps.mjs
|
||||
// pattern (AZ-482).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-arch-imports.mjs [--root=<repo-root>]
|
||||
// node scripts/check-arch-imports.mjs [--mode=arch-imports|api-literals] [--root=<repo-root>]
|
||||
//
|
||||
// Exit code 0 on PASS (no offending deep imports); non-zero on FAIL with the
|
||||
// hit list on stderr.
|
||||
// Exit code 0 on PASS; non-zero on FAIL with the hit list on stderr.
|
||||
|
||||
import { readFileSync, statSync, readdirSync } from 'node:fs'
|
||||
import { join, dirname, resolve, relative } from 'node:path'
|
||||
@@ -24,25 +30,37 @@ import { fileURLToPath } from 'node:url'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const MODES = new Set(['arch-imports', 'api-literals'])
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { root: resolve(__dirname, '..') }
|
||||
const out = { root: resolve(__dirname, '..'), mode: 'arch-imports' }
|
||||
for (const a of argv.slice(2)) {
|
||||
if (a.startsWith('--root=')) out.root = resolve(a.slice('--root='.length))
|
||||
else if (a.startsWith('--mode=')) out.mode = a.slice('--mode='.length)
|
||||
else if (a === '-h' || a === '--help') {
|
||||
process.stderr.write('Usage: check-arch-imports.mjs [--root=<repo-root>]\n')
|
||||
process.stderr.write(
|
||||
'Usage: check-arch-imports.mjs [--mode=arch-imports|api-literals] [--root=<repo-root>]\n',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
if (!MODES.has(out.mode)) {
|
||||
process.stderr.write(`unknown --mode=${out.mode}; expected one of: ${[...MODES].join(', ')}\n`)
|
||||
process.exit(2)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SCAN_ROOTS = ['src', 'tests', 'e2e']
|
||||
const IGNORED_DIRS = new Set([
|
||||
'node_modules', 'dist', 'build', 'test-output', 'test-results',
|
||||
'coverage', '.git', '.cache', 'playwright-report', 'blob-report',
|
||||
])
|
||||
const SOURCE_EXT = new Set(['.ts', '.tsx'])
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// STC-ARCH-01 — cross-component deep imports (mode=arch-imports)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Cross-component deep-import pattern: `from '<up>/<src>?/<component>/<File>'`
|
||||
// - one or more `..` segments
|
||||
// - optional `src/` prefix (used by tests/ and e2e/ imports)
|
||||
@@ -57,7 +75,7 @@ const DEEP_IMPORT_RE = new RegExp(
|
||||
String.raw`from\s+['"](?:\.\./)+(?:src/)?(?:${COMPONENT_DIRS})/[A-Za-z]`,
|
||||
)
|
||||
|
||||
// F3-pending exemptions:
|
||||
// F3-pending exemptions for STC-ARCH-01:
|
||||
// - `features/annotations/classColors` — classColors is logically owned by
|
||||
// 11_class-colors but physically lives under 06_annotations. Re-exporting
|
||||
// it through the 06_annotations barrel creates a circular import:
|
||||
@@ -66,7 +84,44 @@ const DEEP_IMPORT_RE = new RegExp(
|
||||
// so consumers (DetectionClasses, tests/detection_classes.test.tsx)
|
||||
// import the file directly. F3 will move the file and remove this
|
||||
// exemption.
|
||||
const EXEMPT_RE = /features\/annotations\/classColors/
|
||||
const ARCH_IMPORTS_EXEMPT_RE = /features\/annotations\/classColors/
|
||||
|
||||
const ARCH_IMPORTS_SCAN_ROOTS = ['src', 'tests', 'e2e']
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// STC-ARCH-02 — hardcoded /api/<service>/ literals (mode=api-literals)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Hardcoded API path pattern: a quote/backtick immediately followed by
|
||||
// `/api/<lowercase-hyphen-service>/`. Catches single-quoted ('...'),
|
||||
// double-quoted ("..."), and template-literal (`...`) styles equally — quote
|
||||
// style is not a meaningful difference for "no hardcoded URLs in production".
|
||||
//
|
||||
// Examples that MATCH:
|
||||
// '/api/admin/users'
|
||||
// "/api/admin/auth/refresh"
|
||||
// `/api/annotations/dataset?${params}`
|
||||
// `/api/flights/${id}/waypoints`
|
||||
//
|
||||
// Examples that DO NOT match (intentional):
|
||||
// /api/admin (bare prefix, no trailing /) — kept out so getApiBase()
|
||||
// prefixes the base path
|
||||
// unobstructed.
|
||||
// /api/users (no service segment, hypothetical) — pattern requires the
|
||||
// SECOND segment.
|
||||
const API_LITERAL_RE = /[`'"]\/api\/[a-z][a-z-]*\//
|
||||
|
||||
// STC-ARCH-02 exemptions:
|
||||
// - the contract owner itself (src/api/endpoints.ts)
|
||||
// - test files (*.test.ts, *.test.tsx) — tests legitimately assert URL
|
||||
// strings, and the contract is precisely "the test file IS the contract"
|
||||
const API_LITERAL_EXEMPT_FILES_RE = /(?:^|\/)(?:api\/endpoints\.ts|.+\.test\.tsx?)$/
|
||||
|
||||
const API_LITERALS_SCAN_ROOTS = ['src']
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Walk helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function* walkSourceFiles(rootDir) {
|
||||
let entries
|
||||
@@ -90,41 +145,81 @@ function* walkSourceFiles(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanFile(file, root) {
|
||||
const hits = []
|
||||
let text
|
||||
function readLines(file) {
|
||||
try {
|
||||
text = readFileSync(file, 'utf8')
|
||||
return readFileSync(file, 'utf8').split('\n')
|
||||
} catch {
|
||||
return hits
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Scanners — one per mode. Both share the walker and line-comment skip.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function scanArchImports(file, root) {
|
||||
const hits = []
|
||||
const lines = readLines(file)
|
||||
if (lines === null) return hits
|
||||
const rel = relative(root, file).replaceAll('\\', '/')
|
||||
const lines = text.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (/^\s*\/\//.test(line)) continue
|
||||
if (!DEEP_IMPORT_RE.test(line)) continue
|
||||
if (EXEMPT_RE.test(line)) continue
|
||||
if (ARCH_IMPORTS_EXEMPT_RE.test(line)) continue
|
||||
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { root } = parseArgs(process.argv)
|
||||
function scanApiLiterals(file, root) {
|
||||
const hits = []
|
||||
for (const sub of SCAN_ROOTS) {
|
||||
const rel = relative(root, file).replaceAll('\\', '/')
|
||||
if (API_LITERAL_EXEMPT_FILES_RE.test(rel)) return hits
|
||||
const lines = readLines(file)
|
||||
if (lines === null) return hits
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (/^\s*\/\//.test(line)) continue
|
||||
if (!API_LITERAL_RE.test(line)) continue
|
||||
hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Main
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const { root, mode } = parseArgs(process.argv)
|
||||
const config = mode === 'api-literals'
|
||||
? {
|
||||
roots: API_LITERALS_SCAN_ROOTS,
|
||||
scan: scanApiLiterals,
|
||||
failHeader:
|
||||
'STC-ARCH-02 — hardcoded /api/<service>/ literals detected in ' +
|
||||
'production source (must go through endpoints.* builders, see ' +
|
||||
'src/api/endpoints.ts):\n',
|
||||
}
|
||||
: {
|
||||
roots: ARCH_IMPORTS_SCAN_ROOTS,
|
||||
scan: scanArchImports,
|
||||
failHeader:
|
||||
'STC-ARCH-01 — cross-component deep imports detected ' +
|
||||
'(must go through component barrel, see module-layout.md):\n',
|
||||
}
|
||||
|
||||
const hits = []
|
||||
for (const sub of config.roots) {
|
||||
const full = join(root, sub)
|
||||
try { statSync(full) } catch { continue }
|
||||
for (const file of walkSourceFiles(full)) {
|
||||
hits.push(...scanFile(file, root))
|
||||
hits.push(...config.scan(file, root))
|
||||
}
|
||||
}
|
||||
if (hits.length) {
|
||||
process.stderr.write(
|
||||
'STC-ARCH-01 — cross-component deep imports detected ' +
|
||||
'(must go through component barrel, see module-layout.md):\n',
|
||||
)
|
||||
process.stderr.write(config.failHeader)
|
||||
for (const h of hits) process.stderr.write(` ${h}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
+16
-1
@@ -485,7 +485,21 @@ if [ "$RUN_STATIC" = "true" ]; then
|
||||
# through the 06_annotations barrel would create a circular import
|
||||
# AnnotationsPage → DetectionClasses → barrel → AnnotationsPage.)
|
||||
static_check_no_cross_component_deep_imports() {
|
||||
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs"
|
||||
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs" --mode=arch-imports
|
||||
}
|
||||
|
||||
# AZ-486 F7 — STC-ARCH-02: no hardcoded `/api/<service>/` literals in
|
||||
# production source. After F7 the single source of truth for API paths is
|
||||
# `src/api/endpoints.ts`; every production callsite of `api.*` / `createSSE`
|
||||
# must use an `endpoints.*` builder. Flags string literals of the form
|
||||
# '/api/admin/users'
|
||||
# "/api/admin/auth/refresh"
|
||||
# `/api/flights/${id}/waypoints`
|
||||
# Allowed:
|
||||
# - the contract owner itself: src/api/endpoints.ts
|
||||
# - test files: *.test.ts / *.test.tsx (the test file IS the contract)
|
||||
static_check_no_api_literals() {
|
||||
node "$PROJECT_ROOT/scripts/check-arch-imports.mjs" --mode=api-literals
|
||||
}
|
||||
|
||||
# AZ-479 NFT-PERF-01 / NFT-RES-LIM-01 — initial JS bundle ≤ 2 MB gzipped.
|
||||
@@ -535,6 +549,7 @@ if [ "$RUN_STATIC" = "true" ]; then
|
||||
run_static "STC-B1" "vite build succeeds" "AC-6" "n/a" static_check_vite_build
|
||||
run_static "STC-S5" "mission-planner not in dist/" "AC-31" "n/a" static_check_dist_no_mission_planner
|
||||
run_static "STC-ARCH-01" "no cross-component deep imports (barrel-only)" "F4" "AZ-485" static_check_no_cross_component_deep_imports
|
||||
run_static "STC-ARCH-02" "no hardcoded /api/<service>/ literals in src/" "F7" "AZ-486" static_check_no_api_literals
|
||||
run_static "STC-PERF01" "initial JS bundle ≤ 2 MB gz" "NFT-PERF-01" "40" static_check_bundle_size
|
||||
run_static "STC-RES02" "nginx client_max_body_size 500M" "NFT-RES-LIM-02" "n/a" static_check_nginx_body_cap
|
||||
run_static "STC-RES03" "Dockerfile final stage nginx:alpine no Node" "NFT-RES-LIM-03" "n/a" static_check_dockerfile_nginx_alpine
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
import { endpoints } from './endpoints'
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
/**
|
||||
@@ -85,7 +87,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(getApiBase() + '/api/admin/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
setToken(data.token)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { endpoints } from './endpoints'
|
||||
import { endpoints as endpointsViaBarrel } from '../api'
|
||||
|
||||
// AZ-486 / F7 — this test file IS the wire-contract for the UI ↔ nginx layer
|
||||
// (per `module-layout.md`'s "code-derived documentation" pattern referenced in
|
||||
// the task spec). Every builder is asserted to produce the URL literal that
|
||||
// existed in source before the refactor and that MSW handlers + e2e stubs
|
||||
// intercept today. A change to any assertion below is a wire-contract change
|
||||
// and MUST be coordinated with backend + MSW + e2e stubs in the same commit.
|
||||
|
||||
describe('AZ-486 endpoints — wire-contract URLs', () => {
|
||||
describe('AC-1: admin', () => {
|
||||
it('admin.authRefresh', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authRefresh()).toBe('/api/admin/auth/refresh')
|
||||
})
|
||||
|
||||
it('admin.authLogin', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogin()).toBe('/api/admin/auth/login')
|
||||
})
|
||||
|
||||
it('admin.authLogout', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogout()).toBe('/api/admin/auth/logout')
|
||||
})
|
||||
|
||||
it('admin.users', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.users()).toBe('/api/admin/users')
|
||||
})
|
||||
|
||||
it('admin.user(id) interpolates the id', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.user('abc')).toBe('/api/admin/users/abc')
|
||||
})
|
||||
|
||||
it('admin.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.classes()).toBe('/api/admin/classes')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (string)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class('cls-7')).toBe('/api/admin/classes/cls-7')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (number — DetectionClass.id today)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: annotations', () => {
|
||||
it('annotations.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.classes()).toBe('/api/annotations/classes')
|
||||
})
|
||||
|
||||
it('annotations.settingsUser', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsUser()).toBe(
|
||||
'/api/annotations/settings/user',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsSystem', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsSystem()).toBe(
|
||||
'/api/annotations/settings/system',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsDirectories', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsDirectories()).toBe(
|
||||
'/api/annotations/settings/directories',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotations', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotations()).toBe(
|
||||
'/api/annotations/annotations',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId) defaults pageSize=1000', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1')).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId, pageSize) overrides pageSize', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1', 50)).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationImage(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationImage('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/image',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationThumbnail(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationThumbnail('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/thumbnail',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationEvents', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationEvents()).toBe(
|
||||
'/api/annotations/annotations/events',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.media(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.media('page=1&pageSize=50')).toBe(
|
||||
'/api/annotations/media?page=1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaFile(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaFile('m-1')).toBe(
|
||||
'/api/annotations/media/m-1/file',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaItem(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaItem('m-1')).toBe(
|
||||
'/api/annotations/media/m-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaBatch', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaBatch()).toBe(
|
||||
'/api/annotations/media/batch',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.dataset(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.dataset('status=PENDING')).toBe(
|
||||
'/api/annotations/dataset?status=PENDING',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetItem(annotationId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetItem('ann-7')).toBe(
|
||||
'/api/annotations/dataset/ann-7',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetBulkStatus', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetBulkStatus()).toBe(
|
||||
'/api/annotations/dataset/bulk-status',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetClassDistribution', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetClassDistribution()).toBe(
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: flights', () => {
|
||||
it('flights.collection() without query', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection()).toBe('/api/flights')
|
||||
})
|
||||
|
||||
it('flights.collection(queryString) appends ?queryString', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection('pageSize=1000')).toBe(
|
||||
'/api/flights?pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.aircrafts', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircrafts()).toBe('/api/flights/aircrafts')
|
||||
})
|
||||
|
||||
it('flights.aircraft(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircraft('ac-1')).toBe(
|
||||
'/api/flights/aircrafts/ac-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flight(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flight('f-1')).toBe('/api/flights/f-1')
|
||||
})
|
||||
|
||||
it('flights.flightWaypoints(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoints('f-1')).toBe(
|
||||
'/api/flights/f-1/waypoints',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightWaypoint(flightId, waypointId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoint('f-1', 'wp-2')).toBe(
|
||||
'/api/flights/f-1/waypoints/wp-2',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightLiveGps(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightLiveGps('f-1')).toBe(
|
||||
'/api/flights/f-1/live-gps',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: detect', () => {
|
||||
it('detect.media(mediaId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.detect.media('m-1')).toBe('/api/detect/m-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-6: barrel re-export', () => {
|
||||
it('endpoints is the same object when imported from src/api (the F4 barrel)', () => {
|
||||
// Assert
|
||||
expect(endpointsViaBarrel).toBe(endpoints)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// AZ-486 / F7 — Single source of truth for every `/api/<service>/<path>` URL
|
||||
// the UI talks to today. Closes architecture baseline finding F7.
|
||||
//
|
||||
// Every UI callsite of `api.*`, `createSSE`, and raw image/video `src` URLs
|
||||
// pointing at an API resource MUST go through one of these builders. The
|
||||
// STC-ARCH-02 static gate (scripts/check-arch-imports.mjs `--mode=api-literals`,
|
||||
// wired into scripts/run-tests.sh) enforces it.
|
||||
//
|
||||
// **Wire-contract invariant**: the strings produced here are character-identical
|
||||
// to the literals that lived in the source before this refactor and that MSW
|
||||
// handlers + e2e stubs intercept. Any change to a builder's output is a wire-
|
||||
// contract change and MUST be coordinated with the backend + the MSW handler
|
||||
// surface + e2e stubs in the same commit. The accompanying test file
|
||||
// (`endpoints.test.ts`) pins every URL string and is the contract documentation.
|
||||
//
|
||||
// **Why function form (not constants)**: per user direction at task-creation
|
||||
// time; allows parameter interpolation without callsite re-introducing template
|
||||
// literals and keeps tree-shaking per-builder under Vite's production rollup.
|
||||
|
||||
export const endpoints = {
|
||||
admin: {
|
||||
authRefresh: () => '/api/admin/auth/refresh',
|
||||
authLogin: () => '/api/admin/auth/login',
|
||||
authLogout: () => '/api/admin/auth/logout',
|
||||
users: () => '/api/admin/users',
|
||||
user: (id: string) => `/api/admin/users/${id}`,
|
||||
classes: () => '/api/admin/classes',
|
||||
// DetectionClass.id is `number` in the type system; widened to accept
|
||||
// string for forward-compat if the backend switches the column to UUID.
|
||||
class: (id: string | number) => `/api/admin/classes/${id}`,
|
||||
},
|
||||
annotations: {
|
||||
classes: () => '/api/annotations/classes',
|
||||
settingsUser: () => '/api/annotations/settings/user',
|
||||
settingsSystem: () => '/api/annotations/settings/system',
|
||||
settingsDirectories: () => '/api/annotations/settings/directories',
|
||||
annotations: () => '/api/annotations/annotations',
|
||||
// page-size is currently always 1000 at every callsite; expose it as an
|
||||
// optional param so future tuning is a single-file change.
|
||||
annotationsByMedia: (mediaId: string, pageSize: number = 1000) =>
|
||||
`/api/annotations/annotations?mediaId=${mediaId}&pageSize=${pageSize}`,
|
||||
annotationImage: (annotationId: string) =>
|
||||
`/api/annotations/annotations/${annotationId}/image`,
|
||||
annotationThumbnail: (annotationId: string) =>
|
||||
`/api/annotations/annotations/${annotationId}/thumbnail`,
|
||||
annotationEvents: () => '/api/annotations/annotations/events',
|
||||
// Callers pre-build a URLSearchParams.toString() and pass it through; the
|
||||
// builder owns the path + `?` only so the wire-contract stays identical.
|
||||
media: (queryString: string) => `/api/annotations/media?${queryString}`,
|
||||
mediaFile: (mediaId: string) => `/api/annotations/media/${mediaId}/file`,
|
||||
mediaItem: (mediaId: string) => `/api/annotations/media/${mediaId}`,
|
||||
mediaBatch: () => '/api/annotations/media/batch',
|
||||
dataset: (queryString: string) => `/api/annotations/dataset?${queryString}`,
|
||||
datasetItem: (annotationId: string) =>
|
||||
`/api/annotations/dataset/${annotationId}`,
|
||||
datasetBulkStatus: () => '/api/annotations/dataset/bulk-status',
|
||||
datasetClassDistribution: () =>
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
},
|
||||
flights: {
|
||||
// GET (with `?pageSize=...`) lists flights; POST (no query) creates one.
|
||||
// The query string is owned by the caller (URLSearchParams.toString()) so
|
||||
// the wire-contract stays identical to today.
|
||||
collection: (queryString?: string) =>
|
||||
queryString ? `/api/flights?${queryString}` : '/api/flights',
|
||||
aircrafts: () => '/api/flights/aircrafts',
|
||||
aircraft: (id: string) => `/api/flights/aircrafts/${id}`,
|
||||
flight: (id: string) => `/api/flights/${id}`,
|
||||
flightWaypoints: (id: string) => `/api/flights/${id}/waypoints`,
|
||||
flightWaypoint: (flightId: string, waypointId: string) =>
|
||||
`/api/flights/${flightId}/waypoints/${waypointId}`,
|
||||
flightLiveGps: (id: string) => `/api/flights/${id}/live-gps`,
|
||||
},
|
||||
detect: {
|
||||
// Trigger detection for a media item. Single-segment service path.
|
||||
media: (mediaId: string) => `/api/detect/${mediaId}`,
|
||||
},
|
||||
} as const
|
||||
@@ -1,2 +1,3 @@
|
||||
export { api, setToken, getToken, getApiBase, setNavigateToLogin } from './client'
|
||||
export { createSSE } from './sse'
|
||||
export { endpoints } from './endpoints'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||
import { api, setToken } from '../api'
|
||||
import { api, endpoints, setToken } from '../api'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
interface AuthState {
|
||||
@@ -21,7 +21,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
|
||||
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
|
||||
.then(data => {
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
@@ -31,13 +31,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await api.post<{ token: string; user: AuthUser }>('/api/admin/auth/login', { email, password })
|
||||
const data = await api.post<{ token: string; user: AuthUser }>(endpoints.admin.authLogin(), { email, password })
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try { await api.post('/api/admin/auth/logout') } catch {}
|
||||
try { await api.post(endpoints.admin.authLogout()) } catch {}
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||
import { FaRegSnowflake } from 'react-icons/fa'
|
||||
import { api } from '../api'
|
||||
import { api, endpoints } from '../api'
|
||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||
// Importing through the 06_annotations barrel would create a cycle
|
||||
// (DetectionClasses -> 06_annotations barrel -> AnnotationsPage -> DetectionClasses).
|
||||
@@ -33,7 +33,7 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { api } from '../api'
|
||||
import { api, endpoints } from '../api'
|
||||
import type { Flight, UserSettings } from '../types'
|
||||
|
||||
interface FlightState {
|
||||
@@ -21,17 +21,17 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const refreshFlights = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
||||
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
|
||||
setFlights(data.items ?? [])
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshFlights()
|
||||
api.get<UserSettings>('/api/annotations/settings/user')
|
||||
api.get<UserSettings>(endpoints.annotations.settingsUser())
|
||||
.then(settings => {
|
||||
if (settings?.selectedFlightId) {
|
||||
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
|
||||
api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))
|
||||
.then(f => setSelectedFlight(f))
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const selectFlight = useCallback((f: Flight | null) => {
|
||||
setSelectedFlight(f)
|
||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
api.put(endpoints.annotations.settingsUser(), { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { ConfirmDialog } from '../../components'
|
||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||
|
||||
@@ -14,41 +14,41 @@ export default function AdminPage() {
|
||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAddClass = async () => {
|
||||
if (!newClass.name) return
|
||||
await api.post('/api/admin/classes', newClass)
|
||||
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
await api.post(endpoints.admin.classes(), newClass)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
}
|
||||
|
||||
const handleDeleteClass = async (id: number) => {
|
||||
await api.delete(`/api/admin/classes/${id}`)
|
||||
await api.delete(endpoints.admin.class(id))
|
||||
setClasses(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
if (!newUser.email || !newUser.password) return
|
||||
await api.post('/api/admin/users', newUser)
|
||||
const updated = await api.get<User[]>('/api/admin/users')
|
||||
await api.post(endpoints.admin.users(), newUser)
|
||||
const updated = await api.get<User[]>(endpoints.admin.users())
|
||||
setUsers(updated)
|
||||
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateId) return
|
||||
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
|
||||
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
|
||||
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
||||
setDeactivateId(null)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useResizablePanel } from '../../hooks'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||
@@ -36,9 +36,9 @@ export default function AnnotationsPage() {
|
||||
|
||||
if (!selectedMedia.path.startsWith('blob:')) {
|
||||
try {
|
||||
await api.post('/api/annotations/annotations', body)
|
||||
await api.post(endpoints.annotations.annotations(), body)
|
||||
const res = await api.get<{ items: AnnotationListItem[] }>(
|
||||
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
||||
endpoints.annotations.annotationsByMedia(selectedMedia.id),
|
||||
)
|
||||
setAnnotations(res.items)
|
||||
return
|
||||
@@ -96,7 +96,7 @@ export default function AnnotationsPage() {
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = selectedMedia.path.startsWith('blob:')
|
||||
? selectedMedia.path
|
||||
: `/api/annotations/media/${selectedMedia.id}/file`
|
||||
: endpoints.annotations.mediaFile(selectedMedia.id)
|
||||
await new Promise(res => { img.onload = res; img.onerror = res })
|
||||
w = img.naturalWidth
|
||||
h = img.naturalHeight
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaDownload } from 'react-icons/fa'
|
||||
import { api, createSSE } from '../../api'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import { getClassColor } from './classColors'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
@@ -21,10 +21,10 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
|
||||
if (event.mediaId === media.id) {
|
||||
api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
|
||||
endpoints.annotations.annotationsByMedia(media.id),
|
||||
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
||||
}
|
||||
})
|
||||
@@ -35,7 +35,7 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
setDetecting(true)
|
||||
setDetectLog(['Starting AI detection...'])
|
||||
try {
|
||||
await api.post(`/api/detect/${media.id}`)
|
||||
await api.post(endpoints.detect.media(media.id))
|
||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
||||
} catch (e: any) {
|
||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { endpoints } from '../../api'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
|
||||
@@ -76,11 +77,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
if (annotation && !media.path.startsWith('blob:')) {
|
||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||
img.src = endpoints.annotations.annotationImage(annotation.id)
|
||||
} else if (media.path.startsWith('blob:')) {
|
||||
img.src = media.path
|
||||
} else {
|
||||
img.src = `/api/annotations/media/${media.id}/file`
|
||||
img.src = endpoints.annotations.mediaFile(media.id)
|
||||
}
|
||||
img.onload = () => {
|
||||
imgRef.current = img
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce } from '../../hooks'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||
@@ -27,7 +27,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||
if (debouncedFilter) params.set('name', debouncedFilter)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
|
||||
const res = await api.get<PaginatedResponse<Media>>(endpoints.annotations.media(params.toString()))
|
||||
setMedia(prev => {
|
||||
// Keep local-only (blob URL) entries, merge with backend entries
|
||||
const local = prev.filter(m => m.path.startsWith('blob:'))
|
||||
@@ -55,7 +55,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
}
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
|
||||
endpoints.annotations.annotationsByMedia(m.id),
|
||||
)
|
||||
onAnnotationsLoaded(res.items)
|
||||
} catch {
|
||||
@@ -72,7 +72,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
setDeleteId(null)
|
||||
return
|
||||
}
|
||||
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
|
||||
try { await api.delete(endpoints.annotations.mediaItem(deleteId)) } catch {}
|
||||
setDeleteId(null)
|
||||
fetchMedia()
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of arr) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
await api.upload(endpoints.annotations.mediaBatch(), form)
|
||||
fetchMedia()
|
||||
return
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||
import { endpoints } from '../../api'
|
||||
import type { Media } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -38,7 +39,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: `/api/annotations/media/${media.id}/file`
|
||||
: endpoints.annotations.mediaFile(media.id)
|
||||
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
||||
import { useFlight, DetectionClasses, ConfirmDialog } from '../../components'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
@@ -42,7 +42,7 @@ export default function DatasetPage() {
|
||||
if (objectsOnly) params.set('hasDetections', 'true')
|
||||
if (debouncedSearch) params.set('name', debouncedSearch)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(endpoints.annotations.dataset(params.toString()))
|
||||
setItems(res.items)
|
||||
setTotalCount(res.totalCount)
|
||||
} catch {}
|
||||
@@ -52,7 +52,7 @@ export default function DatasetPage() {
|
||||
|
||||
const handleDoubleClick = async (item: DatasetItem) => {
|
||||
try {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
||||
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(item.annotationId))
|
||||
setEditorAnnotation(ann)
|
||||
setEditorDetections(ann.detections)
|
||||
setTab('editor')
|
||||
@@ -61,7 +61,7 @@ export default function DatasetPage() {
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
await api.post('/api/annotations/dataset/bulk-status', {
|
||||
await api.post(endpoints.annotations.datasetBulkStatus(), {
|
||||
annotationIds: Array.from(selectedIds),
|
||||
status: AnnotationStatus.Validated,
|
||||
})
|
||||
@@ -71,7 +71,7 @@ export default function DatasetPage() {
|
||||
|
||||
const loadDistribution = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
|
||||
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||
setDistribution(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
@@ -180,7 +180,7 @@ export default function DatasetPage() {
|
||||
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
||||
src={endpoints.annotations.annotationThumbnail(item.annotationId)}
|
||||
alt={item.imageName}
|
||||
className="w-full h-32 object-cover bg-az-bg"
|
||||
loading="lazy"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api, createSSE } from '../../api'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import FlightListSidebar from './FlightListSidebar'
|
||||
import FlightParamsPanel from './FlightParamsPanel'
|
||||
import FlightMap from './FlightMap'
|
||||
@@ -38,7 +38,7 @@ export default function FlightsPage() {
|
||||
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
setAircraft(getMockAircraftParams())
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
@@ -48,7 +48,7 @@ export default function FlightsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight) { setPoints([]); return }
|
||||
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
||||
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
||||
.then(wps => {
|
||||
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
|
||||
id: wp.id,
|
||||
@@ -62,7 +62,7 @@ export default function FlightsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight || mode !== 'gps') return
|
||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
|
||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(endpoints.flights.flightLiveGps(selectedFlight.id), (data) => setLiveGps(data))
|
||||
}, [selectedFlight, mode])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,12 +71,12 @@ export default function FlightsPage() {
|
||||
}, [points, aircraft, initialAltitude])
|
||||
|
||||
const handleCreateFlight = async (name: string) => {
|
||||
await api.post('/api/flights', { name })
|
||||
await api.post(endpoints.flights.collection(), { name })
|
||||
refreshFlights()
|
||||
}
|
||||
const handleDeleteFlight = async () => {
|
||||
if (!deleteId) return
|
||||
await api.delete(`/api/flights/${deleteId}`)
|
||||
await api.delete(endpoints.flights.flight(deleteId))
|
||||
if (selectedFlight?.id === deleteId) selectFlight(null)
|
||||
setDeleteId(null)
|
||||
refreshFlights()
|
||||
@@ -199,12 +199,12 @@ export default function FlightsPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedFlight) return
|
||||
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
|
||||
const existing = await api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id)).catch(() => [] as Waypoint[])
|
||||
for (const wp of existing) {
|
||||
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
|
||||
await api.delete(endpoints.flights.flightWaypoint(selectedFlight.id, wp.id)).catch(() => {})
|
||||
}
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
||||
await api.post(endpoints.flights.flightWaypoints(selectedFlight.id), {
|
||||
name: `Point ${i + 1}`,
|
||||
latitude: points[i].position.lat,
|
||||
longitude: points[i].position.lng,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -11,27 +11,27 @@ export default function SettingsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const saveSystem = async () => {
|
||||
if (!system) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/system', system)
|
||||
await api.put(endpoints.annotations.settingsSystem(), system)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const saveDirs = async () => {
|
||||
if (!dirs) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/directories', dirs)
|
||||
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,45 +9,64 @@ import { join, resolve } from 'node:path'
|
||||
// - AC-4 : ignores the F3-pending exemption (features/annotations/classColors)
|
||||
// - AC-4 : ignores deep imports written inside // line comments
|
||||
//
|
||||
// Exercises the actual gate via subprocess so a regression in the script
|
||||
// AZ-486 / F7 — verifies the STC-ARCH-02 static gate (same script,
|
||||
// --mode=api-literals):
|
||||
// - AC-4 : passes on the migrated codebase as-is
|
||||
// - AC-3 : fails when a synthetic `/api/<service>/...` literal is added
|
||||
// (covers single-quoted, double-quoted, and template-literal forms)
|
||||
// - AC-3 : *.test.ts colocated under src/ are exempt (the test file IS the
|
||||
// contract per module-layout.md "code-derived documentation")
|
||||
// - AC-3 : literals inside // line comments do not trip the gate
|
||||
//
|
||||
// Both gates are exercised via subprocess so a regression in the script
|
||||
// (regex drift, exemption logic, exit code) trips the test even if the bash
|
||||
// glue in run-tests.sh keeps reporting PASS.
|
||||
//
|
||||
// All offending substrings are built via concatenation at runtime so the
|
||||
// scanner itself does not flag this test file when it walks `tests/**`.
|
||||
// scanner itself does not flag this test file when it walks `tests/**` or
|
||||
// `src/**` (api-literals mode); the api-literals scanner does walk `src/**`
|
||||
// but exempts `*.test.tsx?` so colocated test files are safe.
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, '..')
|
||||
const SCRIPT = join(REPO_ROOT, 'scripts', 'check-arch-imports.mjs')
|
||||
const FIXTURE_DIR = join(REPO_ROOT, 'tests', '_arch_fixtures')
|
||||
const ARCH_FIXTURE_DIR = join(REPO_ROOT, 'tests', '_arch_fixtures')
|
||||
const API_FIXTURE_DIR = join(REPO_ROOT, 'src', '_arch_fixtures')
|
||||
|
||||
const FROM = 'fr' + 'om'
|
||||
const UP2 = '..' + '/..'
|
||||
const DEEP_API = `${UP2}/src/api/cl` + 'ient'
|
||||
const DEEP_CLASSCOLORS = `${UP2}/src/features/annotations/classCo` + 'lors'
|
||||
|
||||
function runCheck(): { status: number; stderr: string } {
|
||||
const res = spawnSync('node', [SCRIPT, `--root=${REPO_ROOT}`], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
// Build synthetic API path strings by concatenation so this test file itself
|
||||
// never matches the api-literal regex when scanned. Quote characters are
|
||||
// added per fixture body below.
|
||||
const API_ADMIN_USERS = '/api/' + 'admin/' + 'users/me'
|
||||
const API_FLIGHTS = '/api/' + 'flights/' + 'liveGps'
|
||||
|
||||
function runCheck(mode: 'arch-imports' | 'api-literals'): { status: number; stderr: string } {
|
||||
const res = spawnSync(
|
||||
'node',
|
||||
[SCRIPT, `--root=${REPO_ROOT}`, `--mode=${mode}`],
|
||||
{ cwd: REPO_ROOT, encoding: 'utf8' },
|
||||
)
|
||||
return { status: res.status ?? -1, stderr: res.stderr ?? '' }
|
||||
}
|
||||
|
||||
function writeFixture(filename: string, content: string): string {
|
||||
mkdirSync(FIXTURE_DIR, { recursive: true })
|
||||
const path = join(FIXTURE_DIR, filename)
|
||||
function writeFixture(dir: string, filename: string, content: string): string {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const path = join(dir, filename)
|
||||
writeFileSync(path, content, 'utf8')
|
||||
return path
|
||||
}
|
||||
|
||||
describe('AZ-485 STC-ARCH-01 — no cross-component deep imports', () => {
|
||||
afterEach(() => {
|
||||
if (existsSync(FIXTURE_DIR)) rmSync(FIXTURE_DIR, { recursive: true, force: true })
|
||||
if (existsSync(ARCH_FIXTURE_DIR)) rmSync(ARCH_FIXTURE_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('AC-5: passes on the migrated codebase (no fixtures)', () => {
|
||||
// Assert
|
||||
const { status, stderr } = runCheck()
|
||||
const { status, stderr } = runCheck('arch-imports')
|
||||
expect(stderr, stderr).toBe('')
|
||||
expect(status).toBe(0)
|
||||
})
|
||||
@@ -55,9 +74,9 @@ describe('AZ-485 STC-ARCH-01 — no cross-component deep imports', () => {
|
||||
it('AC-4: FAILS when a deep import into another component is introduced', () => {
|
||||
// Arrange
|
||||
const body = `import { api } ${FROM} '${DEEP_API}'\nexport const _force = api\n`
|
||||
writeFixture('synthetic_deep_import.ts', body)
|
||||
writeFixture(ARCH_FIXTURE_DIR, 'synthetic_deep_import.ts', body)
|
||||
// Act
|
||||
const { status, stderr } = runCheck()
|
||||
const { status, stderr } = runCheck('arch-imports')
|
||||
// Assert
|
||||
expect(status).not.toBe(0)
|
||||
expect(stderr).toMatch(/STC-ARCH-01/)
|
||||
@@ -70,9 +89,9 @@ describe('AZ-485 STC-ARCH-01 — no cross-component deep imports', () => {
|
||||
const body =
|
||||
`import { FALLBACK_CLASS_NAMES } ${FROM} '${DEEP_CLASSCOLORS}'\n` +
|
||||
`export const _force = FALLBACK_CLASS_NAMES\n`
|
||||
writeFixture('classcolors_exemption.ts', body)
|
||||
writeFixture(ARCH_FIXTURE_DIR, 'classcolors_exemption.ts', body)
|
||||
// Act
|
||||
const { status, stderr } = runCheck()
|
||||
const { status, stderr } = runCheck('arch-imports')
|
||||
// Assert
|
||||
expect(stderr, stderr).toBe('')
|
||||
expect(status).toBe(0)
|
||||
@@ -81,9 +100,82 @@ describe('AZ-485 STC-ARCH-01 — no cross-component deep imports', () => {
|
||||
it('AC-4: deep imports inside line comments do not trip the gate', () => {
|
||||
// Arrange
|
||||
const body = `// import { api } ${FROM} '${DEEP_API}'\nexport const _x = 1\n`
|
||||
writeFixture('commented_out_deep_import.ts', body)
|
||||
writeFixture(ARCH_FIXTURE_DIR, 'commented_out_deep_import.ts', body)
|
||||
// Act
|
||||
const { status } = runCheck()
|
||||
const { status } = runCheck('arch-imports')
|
||||
// Assert
|
||||
expect(status).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-486 STC-ARCH-02 — no hardcoded /api/<service>/ literals', () => {
|
||||
afterEach(() => {
|
||||
if (existsSync(API_FIXTURE_DIR)) rmSync(API_FIXTURE_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('AC-4: passes on the migrated codebase (no fixtures)', () => {
|
||||
// Assert
|
||||
const { status, stderr } = runCheck('api-literals')
|
||||
expect(stderr, stderr).toBe('')
|
||||
expect(status).toBe(0)
|
||||
})
|
||||
|
||||
it('AC-3: FAILS when a single-quoted /api/<service>/ literal is added in src/', () => {
|
||||
// Arrange
|
||||
const body = `export const url = '${API_ADMIN_USERS}'\n`
|
||||
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_single.ts', body)
|
||||
// Act
|
||||
const { status, stderr } = runCheck('api-literals')
|
||||
// Assert
|
||||
expect(status).not.toBe(0)
|
||||
expect(stderr).toMatch(/STC-ARCH-02/)
|
||||
expect(stderr).toMatch(/synthetic_api_literal_single\.ts/)
|
||||
})
|
||||
|
||||
it('AC-3: FAILS when a double-quoted /api/<service>/ literal is added in src/', () => {
|
||||
// Arrange
|
||||
const body = `export const url = "${API_ADMIN_USERS}"\n`
|
||||
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_double.ts', body)
|
||||
// Act
|
||||
const { status, stderr } = runCheck('api-literals')
|
||||
// Assert
|
||||
expect(status).not.toBe(0)
|
||||
expect(stderr).toMatch(/STC-ARCH-02/)
|
||||
expect(stderr).toMatch(/synthetic_api_literal_double\.ts/)
|
||||
})
|
||||
|
||||
it('AC-3: FAILS when a template-literal /api/<service>/ form is added in src/', () => {
|
||||
// Arrange — backticks built via String.fromCharCode(96) to keep this test
|
||||
// file itself a non-matching artifact under api-literals scanning of src/
|
||||
// (the test file lives under tests/, but defense in depth is cheap).
|
||||
const bt = String.fromCharCode(96)
|
||||
const body = `export const url = (id: string) => ${bt}${API_FLIGHTS}/\${id}${bt}\n`
|
||||
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_template.ts', body)
|
||||
// Act
|
||||
const { status, stderr } = runCheck('api-literals')
|
||||
// Assert
|
||||
expect(status).not.toBe(0)
|
||||
expect(stderr).toMatch(/STC-ARCH-02/)
|
||||
expect(stderr).toMatch(/synthetic_api_literal_template\.ts/)
|
||||
})
|
||||
|
||||
it('AC-3: still PASSES when an offending literal lives in a *.test.ts file under src/', () => {
|
||||
// Arrange — test files are exempt: the test file IS the contract
|
||||
const body = `export const url = '${API_ADMIN_USERS}'\n`
|
||||
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_exempt.test.ts', body)
|
||||
// Act
|
||||
const { status, stderr } = runCheck('api-literals')
|
||||
// Assert
|
||||
expect(stderr, stderr).toBe('')
|
||||
expect(status).toBe(0)
|
||||
})
|
||||
|
||||
it('AC-3: literals inside // line comments do not trip the gate', () => {
|
||||
// Arrange
|
||||
const body = `// example: '${API_ADMIN_USERS}'\nexport const _x = 1\n`
|
||||
writeFixture(API_FIXTURE_DIR, 'synthetic_api_literal_commented.ts', body)
|
||||
// Act
|
||||
const { status } = runCheck('api-literals')
|
||||
// Assert
|
||||
expect(status).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user