mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 23:31:15 +00:00
Compare commits
42 Commits
ca0ca9f2a4
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 62d6b8310a | |||
| 32bc5c1e48 | |||
| 8fca6e0209 | |||
| ac40a8b352 | |||
| 6207ab7c27 | |||
| ec0eb909a1 | |||
| b763da3f24 | |||
| bbe87835a9 | |||
| 490902c80a | |||
| 5e056b2334 | |||
| 34ee1e0b83 | |||
| fcd494f67e | |||
| 0810a89ef1 | |||
| 06d160daf0 | |||
| 8c13cd4f30 | |||
| a49f6c941b | |||
| 30d99e09ad | |||
| bc04ba7f99 | |||
| 865dfdb3b9 | |||
| dceaddc436 | |||
| 7d3ba1c3fd | |||
| 19c0371fd6 | |||
| af661359c7 | |||
| ba3bdb1918 | |||
| 5d84d2839e | |||
| c74a2339aa | |||
| da40534b49 | |||
| 909f69cb3a | |||
| 3c7cd4e56b | |||
| aa1a1bf19f | |||
| ea278afb37 | |||
| 0e05fc519a | |||
| 61612044fb | |||
| c646aa93e2 | |||
| f6197499a4 | |||
| ab437a15df | |||
| 8e509b550c | |||
| e31f59211d | |||
| af4219fce6 | |||
| de609cffa1 | |||
| 813136326f | |||
| c0f004d2c9 |
+136
-72
@@ -11,10 +11,20 @@ If you want to run a specific skill directly (without the orchestrator), use the
|
||||
```
|
||||
/problem — interactive problem gathering → _docs/00_problem/
|
||||
/research — solution drafts → _docs/01_solution/
|
||||
/plan — architecture, components, tests → _docs/02_document/
|
||||
/decompose — atomic task specs → _docs/02_tasks/todo/
|
||||
/implement — batched parallel implementation → _docs/03_implementation/
|
||||
/deploy — containerization, CI/CD, observability → _docs/04_deploy/
|
||||
/plan — architecture, ADRs, components, tests, epics → _docs/02_document/
|
||||
/test-spec — blackbox/perf/resilience/security test specs → _docs/02_document/tests/
|
||||
/decompose — atomic task specs (multi-mode) → _docs/02_tasks/todo/
|
||||
/implement — sequential dependency-aware batches with code review and completeness gates → _docs/03_implementation/
|
||||
/test-run — runs the test suite (functional / perf modes) with gating
|
||||
/code-review — multi-phase review used by /implement
|
||||
/refactor — 8-phase structured refactoring (incl. testability sub-mode) → _docs/04_refactoring/
|
||||
/security — OWASP-driven audit → _docs/05_security/
|
||||
/deploy — containerization, CI/CD, environments, observability, procedures, scripts → _docs/04_deploy/
|
||||
/release — execute deploy artifacts in prod, smoke-test, watch, decide rollback → _docs/04_release/
|
||||
/document — bottom-up reverse-engineering of an existing codebase → _docs/02_document/
|
||||
/new-task — interactive feature planning for an existing codebase → _docs/02_tasks/todo/
|
||||
/ui-design — HTML+CSS mockups + design system → _docs/02_document/ui_mockups/
|
||||
/retrospective — metrics + lessons log → _docs/06_metrics/ + _docs/LESSONS.md
|
||||
```
|
||||
|
||||
## How It Works
|
||||
@@ -41,148 +51,201 @@ The state file tracks completed steps, key decisions, blockers, and session cont
|
||||
|
||||
Skills auto-chain without pausing between them. The only pauses are:
|
||||
- **BLOCKING gates** inside each skill (user must confirm before proceeding)
|
||||
- **Session boundary** after decompose (suggests new conversation before implement)
|
||||
- **Session boundaries** declared in each flow's auto-chain rules (e.g., after `decompose`, after `decompose tests`) — suggested new-conversation breakpoints to keep context fresh
|
||||
|
||||
A typical project runs in 2-4 conversations:
|
||||
- Session 1: Problem → Research → Research decision
|
||||
- Session 2: Plan → Decompose
|
||||
- Session 3: Implement (may span multiple sessions)
|
||||
- Session 4: Deploy
|
||||
There are three flows, resolved on every invocation (see `skills/autodev/SKILL.md` § Flow Resolution):
|
||||
|
||||
Re-entry is seamless: type `/autodev` in a new conversation and the orchestrator reads the state file to pick up exactly where you left off.
|
||||
| Flow | When | Steps |
|
||||
|------|------|-------|
|
||||
| **greenfield** | empty workspace, no source yet | 17 steps: Problem → Research → Plan → UI Design → Test Spec → Decompose → Implement → Code Testability Revision → Decompose Tests → Implement Tests → Run Tests → Test-Spec Sync → Update Docs → Security Audit (opt) → Performance Test (opt) → Deploy → Release → Retrospective |
|
||||
| **existing-code** | source files present | one-time baseline (Document → Architecture Baseline Scan → Test Spec → Code Testability Revision → Decompose Tests → Implement Tests → Run Tests → optional Refactor) then a feature-cycle loop (New Task → Implement → Run Tests → Test-Spec Sync → Update Docs → Security Audit (opt) → Performance Test (opt) → Deploy → Release → Retrospective → loops back to New Task) |
|
||||
| **meta-repo** | `.gitmodules`, workspace manifest, or multi-component aggregator | uses `monorepo-*` skills + `_docs/_repo-config.yaml` instead of per-component BUILD-SHIP folders |
|
||||
|
||||
A typical greenfield project spans several conversations because of session boundaries. Re-entry is seamless: type `/autodev` in a new conversation and the orchestrator reads `_docs/_autodev_state.md` to pick up exactly where you left off.
|
||||
|
||||
## Skill Descriptions
|
||||
|
||||
### autodev (meta-orchestrator)
|
||||
|
||||
Auto-chaining engine that sequences the full BUILD → SHIP workflow. Persists state to `_docs/_autodev_state.md`, tracks key decisions and session context, and flows through problem → research → plan → decompose → implement → deploy without manual skill invocation. Maximizes work per conversation with seamless cross-session re-entry.
|
||||
Auto-chaining engine that sequences the full BUILD → SHIP → EVOLVE workflow. Persists state to `_docs/_autodev_state.md`, surfaces top-3 lessons from `_docs/LESSONS.md` at every invocation, replays any `_docs/_process_leftovers/` entries, tracks key decisions and session context, and flows through the active flow's steps without manual skill invocation. Maximizes work per conversation with seamless cross-session re-entry.
|
||||
|
||||
### problem
|
||||
|
||||
Interactive interview that builds `_docs/00_problem/`. Asks probing questions across 8 dimensions (problem, scope, hardware, software, acceptance criteria, input data, security, operations) until all required files can be written with concrete, measurable content.
|
||||
Interactive 4-phase interview that builds `_docs/00_problem/`. Asks probing questions across 8 dimensions (problem & goals, scope, hardware & environment, software & tech, acceptance criteria, input data, security, operational) until all required files can be written with concrete, measurable, quantifiable content. Acceptance criteria must include numeric targets; input data must include `expected_results/` mappings.
|
||||
|
||||
### research
|
||||
|
||||
8-step deep research methodology. Mode A produces initial solution drafts. Mode B assesses and revises existing drafts. Includes AC assessment, source tiering, fact extraction, comparison frameworks, and validation. Run multiple rounds until the solution is solid.
|
||||
8-step deep research methodology. Mode A produces initial solution drafts. Mode B assesses and revises existing drafts. Classifies output as **Technical-component selection** (full per-mode API verification gates apply) or **Non-technical investigation** (gates relaxed). Source tiering, fact extraction, comparison frameworks, validation, exact-fit component selection. Run multiple rounds until the solution is solid.
|
||||
|
||||
### plan
|
||||
|
||||
6-step planning workflow. Produces integration test specs, architecture, system flows, data model, deployment plan, component specs with interfaces, risk assessment, test specifications, and work item epics. Heavy interaction at BLOCKING gates.
|
||||
6-step planning workflow with one half-step (4.5: Architecture Decision Records). Produces blackbox test specs (delegated to test-spec), glossary, architecture vision, architecture document, data model, deployment plan, component specs with interfaces, risk assessment, ADRs, test specifications, and work item epics. Heavy interaction at BLOCKING gates (glossary+vision, architecture, components, mitigations, ADRs).
|
||||
|
||||
### test-spec
|
||||
|
||||
4-phase test specification workflow. Phase 1 analyzes input data + expected-results completeness. Phase 2 emits 8 test artifacts (environment, test-data, blackbox, performance, resilience, security, resource-limit, traceability matrix). Phase 3 is the hard gate that requires every test to have quantifiable expected results. Phase 4 emits runner scripts. Cycle-update mode for incremental refresh.
|
||||
|
||||
### decompose
|
||||
|
||||
4-step task decomposition. Produces a bootstrap structure plan, atomic task specs per component, integration test tasks, and a cross-task dependency table. Each task gets a work item ticket and is capped at 8 complexity points.
|
||||
Multi-mode task decomposition with 6 internal step files. Implementation mode runs Step 1 (Bootstrap), 1.5 (Module Layout), 1.7 (System-Pipeline owner tasks), 2 (per-component tasks), 4 (Cross-Verification). Tests-only mode runs Step 1t (Test Infrastructure), 3 (Blackbox tasks), 4. Single-component mode runs Step 2 only. Each task is tracker-prefixed and capped at 5 complexity points. The 1.7 step exists specifically to prevent the GPS-passthrough class of failure (see `meta-rule.mdc`).
|
||||
|
||||
### implement
|
||||
|
||||
Orchestrator that reads task specs, computes dependency-aware execution batches, launches up to 4 parallel implementer subagents, runs code review after each batch, and commits per batch. Does not write code itself.
|
||||
|
||||
### deploy
|
||||
|
||||
7-step deployment planning. Status check, containerization, CI/CD pipeline, environment strategy, observability, deployment procedures, and deployment scripts. Produces documents for steps 1-6 and executable scripts in step 7.
|
||||
Orchestrator that reads task specs, computes dependency-aware execution batches via topological sort, **implements tasks sequentially within each batch** (no subagents, no parallel execution — see `.cursor/rules/no-subagents.mdc`), runs code review after each batch, runs cumulative code review every K batches, and commits per batch. Has a Product Implementation Completeness Gate (Step 15) that compares promises in task specs / architecture against actual production code, plus a System-Pipeline Audit (Step 15.b) that walks architecture-named pipelines and verifies a real production caller wires each adjacent component pair. Either gate's FAIL stops the cycle until remediation tasks are created.
|
||||
|
||||
### code-review
|
||||
|
||||
Multi-phase code review against task specs. Produces structured findings with verdict: PASS, FAIL, or PASS_WITH_WARNINGS.
|
||||
7-phase code review against task specs (Phase 7 is Architecture Compliance against `module-layout.md` and `architecture.md`). Produces structured findings with verdict: PASS, PASS_WITH_WARNINGS, or FAIL. Three modes: full (per batch), baseline (one-time architecture scan of an existing codebase), cumulative (mid-implementation across batches with `## Baseline Delta`).
|
||||
|
||||
### test-run
|
||||
|
||||
Runs the test suite. Functional mode (default): detects pytest/dotnet/cargo/npm or `scripts/run-tests.sh`, applies a System-Under-Test Reality Gate to refuse passes where internal product modules were stubbed, classifies failures and skips, gates on outcome. Perf mode: detects `scripts/run-performance-tests.sh` or k6/locust/artillery/wrk, captures latency/throughput/error metrics, compares against thresholds.
|
||||
|
||||
### refactor
|
||||
|
||||
6-phase structured refactoring: baseline, discovery, analysis, safety net, execution, hardening.
|
||||
8-phase structured refactoring: baseline → discovery → analysis → safety net → execution → test sync → verification → documentation. Two input modes (Automatic / Guided). Testability sub-mode skips Phase 3 by design and emits a `testability_changes_summary.md` for user review. Each run lives in its own `RUN_DIR` under `_docs/04_refactoring/NN-<run-name>/`.
|
||||
|
||||
### security
|
||||
|
||||
OWASP-based security testing and audit.
|
||||
5-phase OWASP-based audit: dependency scan → static analysis → OWASP Top 10 review → infrastructure review → consolidated security report. Severity-ranked, evidence-based, actionable. Complementary to `code-review` Phase 4 (lightweight security quick-scan).
|
||||
|
||||
### deploy
|
||||
|
||||
7-step deployment planning. Produces documents for steps 1–6 (status & env, containerization, CI/CD pipeline, environment strategy, observability, deployment procedures) and executable scripts in step 7 (`deploy.sh`, `pull-images.sh`, `start-services.sh`, `stop-services.sh`, `health-check.sh`).
|
||||
|
||||
### release
|
||||
|
||||
Executes the deployment plan produced by `/deploy` against a target environment. 6 phases: pre-release gate (AC + risk + rollback readiness), strategy select (all-at-once / blue-green / canary / manual), execute (run scripts, monitor exit codes), smoke test (delegate to test-run prod-smoke), watch window (read observability for the configured duration), commit-or-rollback. Outputs `_docs/04_release/release_<version>.md`. Produces a definitive Released / Rolled-Back / Aborted verdict; failure of any phase auto-triggers rollback unless the user opts to investigate.
|
||||
|
||||
### retrospective
|
||||
|
||||
Collects metrics from implementation batch reports, analyzes trends, produces improvement reports.
|
||||
4-step workflow: collect metrics → analyze trends → produce report → update lessons log (`_docs/LESSONS.md`, ring buffer of last 15 entries consumed by `new-task`, `plan`, `decompose`, and `autodev`). Cycle-end (default) and incident modes; incident mode is auto-invoked after a 3-strike failure.
|
||||
|
||||
### document
|
||||
|
||||
Bottom-up codebase documentation. Analyzes existing code from modules through components to architecture, then retrospectively derives problem/restrictions/acceptance criteria. Alternative entry point for existing codebases — produces the same `_docs/` artifacts as problem + plan, but from code analysis instead of user interview.
|
||||
Bottom-up codebase documentation. Analyzes existing code from modules through components to architecture, then retrospectively derives problem/restrictions/acceptance criteria. Alternative entry point for existing codebases — produces the same `_docs/` artifacts as problem + plan, but from code analysis instead of user interview. Two workflow files: `workflows/full.md` (full / focus-area / resume) and `workflows/task.md` (incremental update for a single task).
|
||||
|
||||
### new-task
|
||||
|
||||
Existing-code feature planning loop. Walks the user through Step 1 (description) → Step 2 (complexity assessment, consults `LESSONS.md`) → Step 3 (research if needed) → Step 4 (codebase analysis incl. test-coverage gap) → Step 4.5 (contract & layout check) → Step 5 (validate assumptions) → Step 6 (write task spec) → Step 7 (tracker ticket) → Step 8 (loop or finalize).
|
||||
|
||||
### ui-design
|
||||
|
||||
End-to-end UI workflow. Phase 0 (complexity detection: full vs quick) → Phase 1 (context check) → Phase 2 (requirements) → Phase 3 (direction exploration) → Phase 4 (design system synthesis: `DESIGN.md`) → Phase 5 (HTML+Tailwind code generation) → Phase 6 (visual verification, optional MCP enhancements) → Phase 7 (user review) → Phase 8 (iteration). Has Applicability Check that refuses to run on non-UI projects.
|
||||
|
||||
### monorepo-* (suite-level)
|
||||
|
||||
Six skills for meta-repos: `monorepo-discover` (write/refresh `_docs/_repo-config.yaml`), `monorepo-document` (sync unified docs), `monorepo-cicd` (sync CI/compose/env templates), `monorepo-onboard` (atomic add-component), `monorepo-status` (read-only drift report), `monorepo-e2e` (sync suite-level integration harness). They never cross domains; each touches exactly one artifact class.
|
||||
|
||||
## Developer TODO (Project Mode)
|
||||
|
||||
### BUILD
|
||||
The numbered list below mirrors greenfield-flow ordering. Existing-code projects start at `/document`, then enter the feature-cycle loop at `/new-task`. See `skills/autodev/flows/{greenfield,existing-code,meta-repo}.md` for the authoritative step tables.
|
||||
|
||||
### BUILD (greenfield)
|
||||
|
||||
```
|
||||
0. /problem — interactive interview → _docs/00_problem/
|
||||
- problem.md (required)
|
||||
- restrictions.md (required)
|
||||
- acceptance_criteria.md (required)
|
||||
- input_data/ (required)
|
||||
- security_approach.md (optional)
|
||||
|
||||
1. /research — solution drafts → _docs/01_solution/
|
||||
Run multiple times: Mode A → draft, Mode B → assess & revise
|
||||
|
||||
2. /plan — architecture, data model, deployment, components, risks, tests, epics → _docs/02_document/
|
||||
|
||||
3. /decompose — atomic task specs + dependency table → _docs/02_tasks/todo/
|
||||
|
||||
4. /implement — batched parallel agents, code review, commit per batch → _docs/03_implementation/
|
||||
1. /problem — interactive 4-phase interview → _docs/00_problem/
|
||||
required: problem.md, restrictions.md, acceptance_criteria.md, input_data/
|
||||
optional: security_approach.md
|
||||
2. /research — solution drafts (Mode A draft, Mode B assess) → _docs/01_solution/
|
||||
3. /plan — glossary, architecture vision, architecture, data model, deployment, components,
|
||||
risks, ADRs (Step 4.5), test specs, epics → _docs/02_document/
|
||||
(Step 1 invokes /test-spec internally)
|
||||
4. /ui-design — HTML+Tailwind mockups (UI projects only) → _docs/02_document/ui_mockups/
|
||||
5. /test-spec — produces 8 test-spec artifacts + traceability matrix → _docs/02_document/tests/
|
||||
(already invoked from /plan Step 1; Step 5 here is the explicit autodev step)
|
||||
6. /decompose — implementation tasks + module-layout + system-pipeline owner tasks →
|
||||
_docs/02_tasks/todo/
|
||||
7. /implement — sequential dependency-aware batches; per-batch code-review;
|
||||
Product Completeness Gate + System-Pipeline Audit → _docs/03_implementation/
|
||||
8. (auto) Code Testability Revision — surgical refactor to make code runnable under tests
|
||||
9. /decompose tests — test-only decomposition mode → _docs/02_tasks/todo/
|
||||
10. /implement (tests) — implements test tasks
|
||||
11. /test-run — full functional suite gate
|
||||
12. /test-spec --cycle-update — append implementation-learned scenarios
|
||||
13. /document --task — update affected component / module / architecture docs
|
||||
14. /security — OWASP-based audit (optional gate)
|
||||
15. /test-run --perf — perf/load tests (optional gate)
|
||||
```
|
||||
|
||||
### SHIP
|
||||
|
||||
```
|
||||
5. /deploy — containerization, CI/CD, environments, observability, procedures → _docs/04_deploy/
|
||||
16. /deploy — containerization, CI/CD, environments, observability, procedures, scripts → _docs/04_deploy/
|
||||
17. /release — execute deploy artifacts in prod, smoke-test, watch, decide rollback → _docs/04_release/
|
||||
```
|
||||
|
||||
### EVOLVE
|
||||
|
||||
```
|
||||
6. /refactor — structured refactoring → _docs/04_refactoring/
|
||||
7. /retrospective — metrics, trends, improvement actions → _docs/06_metrics/
|
||||
18. /retrospective — metrics + trends + lessons-log update → _docs/06_metrics/ + _docs/LESSONS.md
|
||||
(cycle-end mode after release; incident mode auto-fires after 3-strike failure)
|
||||
|
||||
After greenfield completes, the state file is rewritten to point at the existing-code flow's
|
||||
feature-cycle loop, which begins with /new-task and ends with /retrospective. The loop runs once
|
||||
per feature with state.cycle incremented.
|
||||
|
||||
Off-cycle:
|
||||
/refactor — full 8-phase refactor → _docs/04_refactoring/NN-<run-name>/
|
||||
/document — full reverse-engineering of an unfamiliar codebase
|
||||
```
|
||||
|
||||
Or just use `/autodev` to run steps 0-5 automatically.
|
||||
Or just use `/autodev` to run all the above automatically — the orchestrator chooses the right flow, sequences steps, surfaces lessons, processes leftovers, and pauses only at BLOCKING gates and declared session boundaries.
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | Triggers | Output |
|
||||
|-------|----------|--------|
|
||||
| **autodev** | "autodev", "auto", "start", "continue", "what's next" | Orchestrates full workflow |
|
||||
| **autodev** | "autodev", "auto", "start", "continue", "what's next" | Orchestrates full workflow (3 flows) |
|
||||
| **problem** | "problem", "define problem", "new project" | `_docs/00_problem/` |
|
||||
| **research** | "research", "investigate" | `_docs/01_solution/` |
|
||||
| **plan** | "plan", "decompose solution" | `_docs/02_document/` |
|
||||
| **plan** | "plan", "decompose solution" | `_docs/02_document/` (incl. ADRs) |
|
||||
| **test-spec** | "test spec", "blackbox tests", "test scenarios" | `_docs/02_document/tests/` + `scripts/` |
|
||||
| **decompose** | "decompose", "task decomposition" | `_docs/02_tasks/todo/` |
|
||||
| **implement** | "implement", "start implementation" | `_docs/03_implementation/` |
|
||||
| **test-run** | "run tests", "test suite", "verify tests" | Test results + verdict |
|
||||
| **code-review** | "code review", "review code" | Verdict: PASS / FAIL / PASS_WITH_WARNINGS |
|
||||
| **decompose** | "decompose", "task decomposition", "decompose tests" | `_docs/02_tasks/todo/` + `_docs/02_document/module-layout.md` |
|
||||
| **implement** | "implement", "start implementation" | `_docs/03_implementation/` (sequential — see `no-subagents.mdc`) |
|
||||
| **test-run** | "run tests", "test suite", "verify tests", "perf test" | Test results + verdict |
|
||||
| **code-review** | "code review", "review code" | Verdict: PASS / FAIL / PASS_WITH_WARNINGS (7 phases) |
|
||||
| **new-task** | "new task", "add feature", "new functionality" | `_docs/02_tasks/todo/` |
|
||||
| **ui-design** | "design a UI", "mockup", "design system" | `_docs/02_document/ui_mockups/` |
|
||||
| **refactor** | "refactor", "improve code" | `_docs/04_refactoring/` |
|
||||
| **security** | "security audit", "OWASP" | `_docs/05_security/` |
|
||||
| **refactor** | "refactor", "improve code", "testability" | `_docs/04_refactoring/NN-<run-name>/` |
|
||||
| **security** | "security audit", "OWASP", "vulnerability scan" | `_docs/05_security/` |
|
||||
| **document** | "document", "document codebase", "reverse-engineer docs" | `_docs/02_document/` + `_docs/00_problem/` + `_docs/01_solution/` |
|
||||
| **deploy** | "deploy", "CI/CD", "observability" | `_docs/04_deploy/` |
|
||||
| **retrospective** | "retrospective", "retro" | `_docs/06_metrics/` |
|
||||
| **deploy** | "deploy", "CI/CD", "observability", "containerize" | `_docs/04_deploy/` (plans + scripts) |
|
||||
| **release** | "release", "ship", "go live", "rollback" | `_docs/04_release/` (executed deploy + verdict) |
|
||||
| **retrospective** | "retrospective", "retro", "metrics review" | `_docs/06_metrics/` + `_docs/LESSONS.md` |
|
||||
| **monorepo-discover** | "discover monorepo", "scan submodules" | `_docs/_repo-config.yaml` |
|
||||
| **monorepo-document** | "sync monorepo docs" | unified `_docs/*.md` |
|
||||
| **monorepo-cicd** | "sync compose", "sync ci" | suite-level CI/compose/env templates |
|
||||
| **monorepo-onboard** | "onboard component", "register submodule" | atomic component addition |
|
||||
| **monorepo-status** | "monorepo status", "drift report" | read-only drift report |
|
||||
| **monorepo-e2e** | "suite e2e", "integration harness" | `e2e/docker-compose.suite-e2e.yml` and fixtures |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Type | Purpose |
|
||||
|------|------|---------|
|
||||
| `implementer` | Subagent | Implements a single task. Launched by `/implement`. |
|
||||
> The `.cursor/agents/` directory is intentionally empty. Per `.cursor/rules/no-subagents.mdc` the main agent does not delegate to subagents in this workspace; `/implement` runs tasks sequentially.
|
||||
|
||||
## Project Folder Structure
|
||||
|
||||
```
|
||||
_project.md — project-specific config (tracker type, project key, etc.)
|
||||
_docs/
|
||||
├── _autodev_state.md — autodev orchestrator state (progress, decisions, session context)
|
||||
├── 00_problem/ — problem definition, restrictions, AC, input data
|
||||
├── _autodev_state.md — autodev orchestrator state (≤30 lines; pointer only)
|
||||
├── _process_leftovers/ — deferred tracker writes replayed at next /autodev (per tracker.mdc)
|
||||
├── _repo-config.yaml — meta-repo only; produced by monorepo-discover
|
||||
├── LESSONS.md — ring buffer of last 15 actionable lessons (consumed by autodev/new-task/plan/decompose)
|
||||
├── 00_problem/ — problem definition, restrictions, AC, input data + expected_results/
|
||||
├── 00_research/ — intermediate research artifacts
|
||||
├── 01_solution/ — solution drafts, tech stack, security analysis
|
||||
├── 02_document/
|
||||
│ ├── architecture.md
|
||||
│ ├── architecture.md — includes ## Architecture Vision (user-confirmed)
|
||||
│ ├── glossary.md — user-confirmed terminology
|
||||
│ ├── system-flows.md
|
||||
│ ├── data_model.md
|
||||
│ ├── module-layout.md — per-component Owns/Imports-from/Public API (decompose Step 1.5)
|
||||
│ ├── architecture_compliance_baseline.md — existing-code baseline scan output
|
||||
│ ├── risk_mitigations.md
|
||||
│ ├── adr/[NNN]_[decision_slug].md — Architectural Decision Records (plan Step 4.5)
|
||||
│ ├── components/[##]_[name]/ — description.md + tests.md per component
|
||||
│ ├── contracts/<component>/<name>.md — versioned public-API contracts
|
||||
│ ├── common-helpers/
|
||||
│ ├── tests/ — environment, test data, blackbox, performance, resilience, security, traceability
|
||||
│ ├── deployment/ — containerization, CI/CD, environments, observability, procedures
|
||||
│ ├── tests/ — environment, test-data, blackbox, performance, resilience, security, resource-limit, traceability matrix
|
||||
│ ├── ui_mockups/ — HTML+CSS mockups, DESIGN.md (ui-design skill)
|
||||
│ ├── diagrams/
|
||||
│ └── FINAL_report.md
|
||||
@@ -192,12 +255,13 @@ _docs/
|
||||
│ ├── backlog/ — parked tasks (not scheduled yet)
|
||||
│ └── done/ — completed/archived tasks
|
||||
├── 02_task_plans/ — per-task research artifacts (new-task skill)
|
||||
├── 03_implementation/ — batch reports, implementation_report_*.md
|
||||
├── 03_implementation/ — batch_*_cycle*.md, implementation_report_*.md, implementation_completeness_cycle*.md, cumulative_review_*.md
|
||||
│ └── reviews/ — code review reports per batch
|
||||
├── 04_deploy/ — containerization, CI/CD, environments, observability, procedures, scripts
|
||||
├── 04_refactoring/ — baseline, discovery, analysis, execution, hardening
|
||||
├── 05_security/ — dependency scan, SAST, OWASP review, security report
|
||||
└── 06_metrics/ — retro_[YYYY-MM-DD].md
|
||||
├── 04_deploy/ — containerization, CI/CD, environments, observability, procedures, deploy_scripts.md, reports/
|
||||
├── 04_refactoring/NN-<run-name>/ — baseline_metrics, discovery, analysis, test_specs, execution_log, test_sync, verification, FINAL_report (one folder per refactor run)
|
||||
├── 04_release/ — release_<version>.md (one per /release invocation), rollback_<version>.md
|
||||
├── 05_security/ — dependency_scan, static_analysis, owasp_review, infrastructure_review, security_report
|
||||
└── 06_metrics/ — retro_<YYYY-MM-DD>.md, structure_<YYYY-MM-DD>.md, perf_<YYYY-MM-DD>_<run-label>.md, incident_<YYYY-MM-DD>_<skill>.md
|
||||
```
|
||||
|
||||
## Standalone Mode
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
name: implementer
|
||||
description: |
|
||||
Implements a single task from its spec file. Use when implementing tasks from _docs/02_tasks/todo/.
|
||||
Reads the task spec, analyzes the codebase, implements the feature with tests, and verifies acceptance criteria.
|
||||
Launched by the /implement skill as a subagent.
|
||||
---
|
||||
|
||||
You are a professional software developer implementing a single task.
|
||||
|
||||
## Input
|
||||
|
||||
You receive from the `/implement` orchestrator:
|
||||
- Path to a task spec file (e.g., `_docs/02_tasks/todo/[TRACKER-ID]_[short_name].md`)
|
||||
- Files OWNED (exclusive write access — only you may modify these)
|
||||
- Files READ-ONLY (shared interfaces, types — read but do not modify)
|
||||
- Files FORBIDDEN (other agents' owned files — do not touch)
|
||||
|
||||
## Context (progressive loading)
|
||||
|
||||
Load context in this order, stopping when you have enough:
|
||||
|
||||
1. Read the task spec thoroughly — acceptance criteria, scope, constraints, dependencies
|
||||
2. Read `_docs/02_tasks/_dependencies_table.md` to understand where this task fits
|
||||
3. Read project-level context:
|
||||
- `_docs/00_problem/problem.md`
|
||||
- `_docs/00_problem/restrictions.md`
|
||||
- `_docs/01_solution/solution.md`
|
||||
4. Analyze the specific codebase areas related to your OWNED files and task dependencies
|
||||
|
||||
## Boundaries
|
||||
|
||||
**Always:**
|
||||
- Run tests before reporting done
|
||||
- Follow existing code conventions and patterns
|
||||
- Implement error handling per the project's strategy
|
||||
- Stay within the task spec's Scope/Included section
|
||||
|
||||
**Ask first:**
|
||||
- Adding new dependencies or libraries
|
||||
- Creating files outside your OWNED directories
|
||||
- Changing shared interfaces that other tasks depend on
|
||||
|
||||
**Never:**
|
||||
- Modify files in the FORBIDDEN list
|
||||
- Skip writing tests
|
||||
- Change database schema unless the task spec explicitly requires it
|
||||
- Commit secrets, API keys, or passwords
|
||||
- Modify CI/CD configuration unless the task spec explicitly requires it
|
||||
|
||||
## Process
|
||||
|
||||
1. Read the task spec thoroughly — understand every acceptance criterion
|
||||
2. Analyze the existing codebase: conventions, patterns, related code, shared interfaces
|
||||
3. Research best implementation approaches for the tech stack if needed
|
||||
4. If the task has a dependency on an unimplemented component, create a minimal interface mock
|
||||
5. Implement the feature following existing code conventions
|
||||
6. Implement error handling per the project's defined strategy
|
||||
7. Implement unit tests (use Arrange / Act / Assert section comments in language-appropriate syntax)
|
||||
8. Implement integration tests — analyze existing tests, add to them or create new
|
||||
9. Run all tests, fix any failures
|
||||
10. Verify every acceptance criterion is satisfied — trace each AC with evidence
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
- If the same fix fails 3+ times with different approaches, stop and report as blocker
|
||||
- If blocked on an unimplemented dependency, create a minimal interface mock and document it
|
||||
- If the task scope is unclear, stop and ask rather than assume
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report using this exact structure:
|
||||
|
||||
```
|
||||
## Implementer Report: [task_name]
|
||||
|
||||
**Status**: Done | Blocked | Partial
|
||||
**Task**: [TRACKER-ID]_[short_name]
|
||||
|
||||
### Acceptance Criteria
|
||||
| AC | Satisfied | Evidence |
|
||||
|----|-----------|----------|
|
||||
| AC-1 | Yes/No | [test name or description] |
|
||||
| AC-2 | Yes/No | [test name or description] |
|
||||
|
||||
### Files Modified
|
||||
- [path] (new/modified)
|
||||
|
||||
### Test Results
|
||||
- Unit: [X/Y] passed
|
||||
- Integration: [X/Y] passed
|
||||
|
||||
### Mocks Created
|
||||
- [path and reason, or "None"]
|
||||
|
||||
### Blockers
|
||||
- [description, or "None"]
|
||||
```
|
||||
|
||||
## Principles
|
||||
|
||||
- Follow SOLID, KISS, DRY
|
||||
- Dumb code, smart data
|
||||
- No unnecessary comments or logs (only exceptions)
|
||||
- Ask if requirements are ambiguous — do not assume
|
||||
@@ -11,6 +11,7 @@ alwaysApply: true
|
||||
- Avoid boilerplate and unnecessary indirection, but never sacrifice readability for brevity.
|
||||
- Never suppress errors silently — no `2>/dev/null`, empty `catch` blocks, bare `except: pass`, or discarded error returns. These hide the information you need most when something breaks. If an error is truly safe to ignore, log it or comment why.
|
||||
- Do not add comments that merely narrate what the code does. Comments are appropriate for: non-obvious business rules, workarounds with references to issues/bugs, safety invariants, and public API contracts. Make comments as short and concise as possible. Exception: every test must use the Arrange / Act / Assert pattern with language-appropriate comment syntax (`# Arrange` for Python, `// Arrange` for C#/Rust/JS/TS). Omit any section that is not needed (e.g. if there is no setup, skip Arrange; if act and assert are the same line, keep only Assert)
|
||||
- API consumer documentation (OpenAPI / Swagger `Description` and `Summary`, REST API reference docs, public SDK docstrings) is written for the *external API consumer*, not the implementer. Do NOT include task IDs (`AZ-NNN`, `JIRA-NNN`), contract-doc filenames (`tile-inventory.md v2.0.0`), version-bump history, or implementation milestones in these strings. Internal change tracking belongs in commit messages, contract docs, changelogs, and code comments — never in the public API description. Extend an existing pattern only if it already follows this rule; if the existing description leads with internal noise, treat that as a defect and clean it (or surface it to the user) rather than propagating it.
|
||||
- Do not add verbose debug/trace logs by default. Log exceptions, security events (auth failures, permission denials), and business-critical state transitions. Add debug-level logging only when asked.
|
||||
- Do not put code annotations unless it was asked specifically
|
||||
- Write code that takes into account the different environments: development, production
|
||||
@@ -39,8 +40,11 @@ alwaysApply: true
|
||||
- When you think you are done with changes, run the full test suite. Every failure in tests that cover code you modified or that depend on code you modified is a **blocking gate**. For pre-existing failures in unrelated areas, report them to the user but do not block on them. Never silently ignore or skip a failure without reporting it. On any blocking failure, stop and ask the user to choose one of:
|
||||
- **Investigate and fix** the failing test or source code
|
||||
- **Remove the test** if it is obsolete or no longer relevant
|
||||
- **Iterative-skill exception**: when an iterative loop skill is active (e.g. autodev / `implement/SKILL.md` batch loop, `refactor/SKILL.md` batch loop), the skill governs full-suite cadence — typically focused tests per task/batch and a single full-suite gate at the very end of the implementation phase, NOT after each batch. "Done with changes" means done with the entire implementation phase the skill is running, not done with one batch. Do not run the full suite per batch unless the skill explicitly says to.
|
||||
- Do not rename any databases or tables or table columns without confirmation. Avoid such renaming if possible.
|
||||
|
||||
- Make sure we don't commit binaries, create and keep .gitignore up to date and delete binaries after you are done with the task
|
||||
- Never force-push to main or dev branches
|
||||
- For new projects, place source code under `src/` (this works for all stacks including .NET). For existing projects, follow the established directory structure. Keep project-level config, tests, and tooling at the repo root.
|
||||
- **Never run e2e or CI tests in quiet mode (`-q`).** Always use `-v --tb=short` (or equivalent verbosity flags) in all Dockerfiles, compose files, and scripts that invoke pytest. Full test output must be visible so failures can be diagnosed without re-running. This applies to both Tier-1 (Colima) and Tier-2 (Jetson) harnesses.
|
||||
- **Never substitute real algorithm execution with a data passthrough to make tests pass.** If a test is designed to validate output from a specific pipeline (e.g. VIO estimation, sensor fusion, inference), the implementation MUST actually run that pipeline — not bypass it by returning the input data directly as output. Tests that pass by skipping the component they are supposed to exercise create false confidence and hide the fact that the component is not integrated. If the real integration cannot be completed in this session, STOP and report the blocker to the user explicitly. A failing test with an honest explanation is always better than a passing test that proves nothing.
|
||||
|
||||
@@ -19,7 +19,7 @@ globs: [".cursor/**"]
|
||||
- Kebab-case filenames
|
||||
|
||||
## Agent Files (.cursor/agents/)
|
||||
- Must have `name` and `description` in frontmatter
|
||||
- The `.cursor/agents/` directory is intentionally empty. Per `.cursor/rules/no-subagents.mdc`, the main agent does not delegate to subagents in this workspace. Do not add agent files here without a corresponding rule change.
|
||||
|
||||
## Security
|
||||
- All `.cursor/` files must be scanned for hidden Unicode before committing (see cursor-security.mdc)
|
||||
@@ -30,10 +30,11 @@ All rules and skills must reference the single source of truth below. Do NOT res
|
||||
|
||||
| Concern | Threshold | Enforcement |
|
||||
|---------|-----------|-------------|
|
||||
| Test coverage on business logic | 75% | Aim (warn below); 100% on critical paths |
|
||||
| Test coverage on business logic | 75% | Aim (warn below); critical-path floor enforced separately (next row) |
|
||||
| Test coverage on critical paths | 90% floor / 100% aim | **90% is the enforcement floor** in CI gates, refactor verification, and release pre-flight. **100% is the aim** — drift below 100% but at-or-above 90% is acceptable; drift below 90% blocks. Critical paths = code paths where a bug would cause data loss, security breach, financial error, or system outage; identify from `acceptance_criteria.md` (must-have) and `_docs/00_problem/security_approach.md`. |
|
||||
| Test scenario coverage (vs AC + restrictions) | 75% | Blocking in test-spec Phase 1 and Phase 3 |
|
||||
| CI coverage gate | 75% | Fail build below |
|
||||
| CI coverage gate | 75% overall, 90% critical-path | Fail build below either threshold |
|
||||
| Lint errors (Critical/High) | 0 | Blocking pre-commit |
|
||||
| Code-review auto-fix | Low + Medium (Style/Maint/Perf) + High (Style/Scope) | Critical and Security always escalate |
|
||||
| Code-review auto-fix | Low + Medium (Style/Maint/Perf) + High (Style/Scope) | Critical and Security always escalate. Full categorization: see `.cursor/skills/implement/SKILL.md` § "Auto-Fix eligibility matrix" |
|
||||
|
||||
When a skill or rule needs to cite a threshold, link to this table instead of hardcoding a different number.
|
||||
When a skill or rule needs to cite a threshold, link to this table instead of hardcoding a different number. The full auto-fix eligibility matrix (severity × category) lives in `implement/SKILL.md`; cite that file rather than re-tabulating the matrix.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: "Use chunked writes (Write + StrReplace marker pattern) for large generated files, especially after a monolithic Write fails"
|
||||
alwaysApply: true
|
||||
---
|
||||
# Large File Writes — Chunk on Failure
|
||||
|
||||
When a `Write` call to a single file fails (timeout, payload limit, "Invalid arguments", or any tool error) and the intended content is large (>~500 lines or >~50 KB), do NOT retry the same monolithic Write. Switch to chunked writes:
|
||||
|
||||
1. **First Write** — create the file with header + table of contents (if applicable) + an explicit append marker, e.g.
|
||||
|
||||
```
|
||||
<!-- INSERTION_POINT do-not-remove-until-final-chunk -->
|
||||
```
|
||||
|
||||
2. **Each subsequent chunk** — use `StrReplace` to replace the marker with `<new content>\n<marker>` so the marker stays at the end. This is idempotent: if a chunk fails, retry it without losing earlier chunks.
|
||||
|
||||
3. **Final chunk** — `StrReplace` removes the marker.
|
||||
|
||||
## Why
|
||||
|
||||
- Tool argument size limits and transient failures hit large monolithic writes hardest. Retrying the same large payload typically fails for the same reason.
|
||||
- Chunked writes are recoverable per chunk. The earlier chunks are durable on disk.
|
||||
- A unique marker is greppable, visible in diffs, and stops accidental insertion in the wrong place.
|
||||
|
||||
## Triggers
|
||||
|
||||
- Generated documentation that aggregates per-component content (epics, design docs, multi-section architecture summaries, traceability dumps).
|
||||
- Large fixture or test-data files written from a template.
|
||||
- Any single-file artifact you can pre-estimate at >~500 lines.
|
||||
|
||||
## Do NOT chunk
|
||||
|
||||
- Files under ~200 lines — a single `Write` is faster, clearer, and easier to review.
|
||||
- Source code files where appending breaks module structure (functions, classes, imports). Split into multiple files instead.
|
||||
- Files where ordering of sections is computed late and inserting in the middle is required — use a single `Write` once the full content is known.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Retrying the same failed monolithic `Write` more than once. Twice is the limit; on the second failure, switch strategies.
|
||||
- Using `Shell` with heredoc (`cat <<EOF`) or `echo >>` to append — these bypass the editor diff view and break the StrReplace contract for the next chunk.
|
||||
- Embedding the marker so deep inside structured content that a chunk's `StrReplace` becomes ambiguous. Place the marker on its own line at the very end of the file.
|
||||
@@ -4,6 +4,26 @@ alwaysApply: true
|
||||
---
|
||||
# Agent Meta Rules
|
||||
|
||||
## Real Results, Not Simulated Ones
|
||||
|
||||
**The goal is a working product, not the appearance of one.**
|
||||
|
||||
- If something does not work, STOP and report it honestly. Do not find a way around it.
|
||||
- Never produce results by bypassing, faking, stubbing, or passthrough-ing the component that is supposed to produce them. A passing test that skips the real pipeline is worse than a failing test — it hides the truth.
|
||||
- If the real implementation is not ready, say so. A clear "this is not implemented yet, here is what is missing" is always the right answer.
|
||||
- Do not measure success by whether the output looks correct. Measure it by whether the output was produced by the real system under test.
|
||||
- Workarounds that produce the right answer via the wrong path are defects, not solutions.
|
||||
|
||||
### When a test reveals missing production code — STOP
|
||||
|
||||
This is the specific failure mode that produced the GPS-passthrough scaffold in `runtime_root._run_replay_loop` (May 2026). Generalised so it never repeats:
|
||||
|
||||
- If, while implementing or running a test, you discover that the production code path the test is supposed to exercise does not exist (no caller, no integration, no main loop, etc.), **STOP immediately**.
|
||||
- Do NOT write a stub, passthrough, fake input source, or shortcut output that would make the test go green. Even when the shortcut is "framed as a scaffold" or "marked as TODO in a docstring", it still defeats the test and lies to the next reader.
|
||||
- Surface the gap to the user as a top-of-turn report: name the missing production component, cite the architecture document that promises it, and ask whether to (a) create a tracker ticket for the missing component and let the test fail honestly until the ticket lands, or (b) explicitly de-scope the test, or (c) something the user names.
|
||||
- The default outcome is (a): a failing test plus a new tracker ticket. A failing test with an honest reason is information; a passing test that proves nothing is misinformation.
|
||||
- Doc-comment disclosures (`# this is a scaffold until X is wired`) DO NOT satisfy this rule. The user must be told in the assistant message, not in code.
|
||||
|
||||
## Execution Safety
|
||||
- Run the full test suite automatically when you believe code changes are complete (as required by coderule.mdc). For other long-running/resource-heavy/security-risky operations (builds, Docker commands, deployments, performance tests), ask the user first — unless explicitly stated in a skill or the user already asked to do so.
|
||||
|
||||
|
||||
@@ -8,8 +8,16 @@ globs: ["**/*test*", "**/*spec*", "**/*Test*", "**/tests/**", "**/test/**"]
|
||||
- One assertion per test when practical; name tests descriptively: `MethodName_Scenario_ExpectedResult`
|
||||
- Test boundary conditions, error paths, and happy paths
|
||||
- Use mocks only for external dependencies; prefer real implementations for internal code
|
||||
- Aim for 75%+ coverage on business logic; 100% on critical paths (code paths where a bug would cause data loss, security breaches, financial errors, or system outages — identify from acceptance criteria marked as must-have or from security_approach.md). The 75% threshold is canonical — see `cursor-meta.mdc` Quality Thresholds.
|
||||
- Aim for 75%+ coverage on business logic; **90% floor / 100% aim on critical paths** (code paths where a bug would cause data loss, security breaches, financial errors, or system outages — identify from acceptance criteria marked as must-have or from `security_approach.md`). 90% is the enforcement floor (blocking in CI / refactor verification / release pre-flight); 100% is the aspirational aim — drift below 100% but at-or-above 90% is acceptable. Both numbers are canonical — see `cursor-meta.mdc` Quality Thresholds.
|
||||
- Integration tests use real database (Postgres testcontainers or dedicated test DB)
|
||||
- Never use Thread Sleep or fixed delays in tests; use polling or async waits
|
||||
- Keep test data factories/builders for reusable test setup
|
||||
- Tests must be independent: no shared mutable state between tests
|
||||
|
||||
## Test environment (this project)
|
||||
|
||||
- **Unit tests** (`tests/unit/`): may run locally on the dev workstation (`pytest tests/unit/` in the project venv). Local PASS is equivalent to Jetson PASS for this tier because the suite is fully synthetic.
|
||||
- **Blackbox / e2e / performance / resilience / security / resource-limit** tests (`tests/e2e/`, `e2e/tests/`, `tests/perf/`, …): MUST run on the Jetson Orin Nano Super (or a Jetson-equivalent arm64 agent). Use `scripts/run-tests-jetson.sh` for local dev; CI runs `.woodpecker/01-test.yml` on the colocated arm64 Jetson Woodpecker agent.
|
||||
- Do NOT run e2e tests on the local workstation and report the result. If the Jetson is unreachable, the e2e verdict is "not run" — record the gap in `_docs/_process_leftovers/` rather than substituting a local result.
|
||||
- Tests gated by `RUN_REPLAY_E2E` or `@pytest.mark.tier2` are expected to SKIP locally; that is correct behaviour, not a failure to investigate.
|
||||
- Canonical source for this policy: `_docs/02_document/tests/environment.md` § Where each tier runs (active policy).
|
||||
|
||||
@@ -14,11 +14,14 @@ alwaysApply: true
|
||||
- Issue types: Epic, Story, Task, Bug, Subtask
|
||||
|
||||
## Tracker Availability Gate
|
||||
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, or any non-success response: **STOP** tracker operations and notify the user via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
|
||||
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, **timeout**, a non-2xx status code, an empty body, or any response shape that does not clearly confirm the requested change: **STOP IMMEDIATELY** — no automatic retry, no silent continuation. Surface the full raw error/response to the user verbatim and notify via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
|
||||
- A minimal `{"success": true}` body with no echoed issue state is NOT a confirmed transition. When a transition's success matters (status moves, ticket creation, blocking link), follow it with a read-back call (`getJiraIssue` or equivalent) and confirm the new state matches what you asked for. If the read-back disagrees → STOP and ASK.
|
||||
- Do NOT loop "retry up to N times before asking". One call, one verification. On failure, the user decides whether to retry.
|
||||
- The user may choose to:
|
||||
- **Retry authentication** — preferred; the tracker remains the source of truth.
|
||||
- **Retry the same operation** — once, after the user authorizes it. If it fails again, surface both responses.
|
||||
- **Retry authentication** — preferred when the failure looks like an auth/credentials problem; the tracker remains the source of truth.
|
||||
- **Continue in `tracker: local` mode** — only when the user explicitly accepts this option. In that mode all tasks keep numeric prefixes and a `Tracker: pending` marker is written into each task header. The state file records `tracker: local`. The mode is NOT silent — the user has been asked and has acknowledged the trade-off.
|
||||
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. If the user is unreachable (e.g., non-interactive run), stop and wait.
|
||||
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. Do not paper over an opaque response by moving on. If the user is unreachable (e.g., non-interactive run), stop and wait.
|
||||
- When the tracker becomes available again, any `Tracker: pending` tasks should be synced — this is done at the start of the next `/autodev` invocation via the Leftovers Mechanism below.
|
||||
|
||||
## Leftovers Mechanism (non-user-input blockers only)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: autodev
|
||||
description: |
|
||||
Auto-chaining orchestrator that drives the full BUILD-SHIP workflow from problem gathering through deployment.
|
||||
Auto-chaining orchestrator that drives the full BUILD → SHIP → EVOLVE workflow from problem gathering through release and retrospective.
|
||||
Detects current project state from _docs/ folder, resumes from where it left off, and flows through
|
||||
problem → research → plan → test specs → decompose → implement → tests → docs sync → deploy without manual skill invocation.
|
||||
problem → research → plan (incl. ADRs) → test specs → decompose → implement → tests → docs sync → deploy → release → retrospective without manual skill invocation.
|
||||
Maximizes work per conversation by auto-transitioning between skills.
|
||||
Trigger phrases:
|
||||
- "autodev", "auto", "start", "continue"
|
||||
@@ -15,7 +15,7 @@ disable-model-invocation: true
|
||||
|
||||
# Autodev Orchestrator
|
||||
|
||||
Auto-chaining execution engine that drives the full BUILD → SHIP workflow. Detects project state from `_docs/`, resumes from where work stopped, and flows through skills automatically. The user invokes `/autodev` once — the engine handles sequencing, transitions, and re-entry.
|
||||
Auto-chaining execution engine that drives the full BUILD → SHIP → EVOLVE workflow. Detects project state from `_docs/`, resumes from where work stopped, and flows through skills automatically. The user invokes `/autodev` once — the engine handles sequencing, transitions, and re-entry.
|
||||
|
||||
## File Index
|
||||
|
||||
@@ -67,8 +67,9 @@ B3. Read state — `_docs/_autodev_state.md` (if it exists).
|
||||
B4. Read File Index — `state.md`, `protocols.md`, and the active flow file.
|
||||
|
||||
### Resolve (once per invocation, after Bootstrap)
|
||||
R1. Reconcile state — verify state file against `_docs/` contents; on disagreement, trust the folders
|
||||
and update the state file (rules: `state.md` → "State File Rules" #4).
|
||||
R1. Reconcile state — verify state file against `_docs/` contents; probe `<workspace-root>/../docs`
|
||||
(parent suite `docs/` — see `state.md` → "State File Rules" #4); on disagreement,
|
||||
trust the folders and update the state file (rules: `state.md` → "State File Rules" #4).
|
||||
After this step, `state.step` / `state.status` are authoritative.
|
||||
R2. Resolve flow — see §Flow Resolution above.
|
||||
R3. Resolve current step — when a state file exists, `state.step` drives detection.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Workflow for projects with an existing codebase. Structurally it has **two phases**:
|
||||
|
||||
- **Phase A — One-time baseline setup (Steps 1–8)**: runs exactly once per codebase. Documents the code, produces test specs, makes the code testable, writes and runs the initial test suite, optionally refactors with that safety net.
|
||||
- **Phase B — Feature cycle (Steps 9–17, loops)**: runs once per new feature. After Step 17 (Retrospective), the flow loops back to Step 9 (New Task) with `state.cycle` incremented.
|
||||
- **Phase B — Feature cycle (Steps 9–17, loops)**: runs once per new feature. After Step 17 (Retrospective), the flow loops back to Step 9 (New Task) with `state.cycle` incremented. Step 16.5 (Release) sits between Deploy (16) and Retrospective (17).
|
||||
|
||||
A first-time run executes Phase A then Phase B; every subsequent invocation re-enters Phase B.
|
||||
|
||||
@@ -34,6 +34,7 @@ A first-time run executes Phase A then Phase B; every subsequent invocation re-e
|
||||
| 14 | Security Audit | security/SKILL.md | Phase 1–5 (optional) |
|
||||
| 15 | Performance Test | test-run/SKILL.md (perf mode) | Steps 1–5 (optional) |
|
||||
| 16 | Deploy | deploy/SKILL.md | Step 1–7 |
|
||||
| 16.5 | Release | release/SKILL.md | Phase 1–6 |
|
||||
| 17 | Retrospective | retrospective/SKILL.md (cycle-end mode) | Steps 1–4 |
|
||||
|
||||
After Step 17, the feature cycle completes and the flow loops back to Step 9 with `state.cycle + 1` — see "Re-Entry After Completion" below.
|
||||
@@ -287,21 +288,43 @@ State-driven: reached by auto-chain from Step 15 (completed or skipped).
|
||||
|
||||
Action: Read and execute `.cursor/skills/deploy/SKILL.md`.
|
||||
|
||||
After the deploy skill completes successfully, mark Step 16 as `completed` and auto-chain to Step 17 (Retrospective).
|
||||
After the deploy skill completes successfully, mark Step 16 as `completed` and auto-chain to Step 16.5 (Release).
|
||||
|
||||
---
|
||||
|
||||
**Step 16.5 — Release**
|
||||
State-driven: reached by auto-chain from Step 16, for the current `state.cycle`.
|
||||
|
||||
Action: Read and execute `.cursor/skills/release/SKILL.md`. The release skill owns its own user interaction (Phase 1 pre-release gate, Phase 2 strategy select, Phase 6 escalation). Autodev does NOT add a wrapping A/B/C gate. Pass cycle context (`cycle: state.cycle`).
|
||||
|
||||
After the release skill exits, route on the verdict:
|
||||
|
||||
- **Verdict `Released`** → mark Step 16.5 `completed` and auto-chain to Step 17 (Retrospective in cycle-end mode).
|
||||
- **Verdict `Released-with-override`** → mark Step 16.5 `completed` AND auto-chain to Step 17 (Retrospective in **incident mode**).
|
||||
- **Verdict `Rolled-Back`** → mark Step 16.5 `failed`. Auto-chain to Step 17 (Retrospective in **incident mode**). The cycle does NOT loop back to Step 9.
|
||||
- **Verdict `Aborted`** → mark Step 16.5 `not_started` (no live-system change) OR `failed` (live-system touched before abort). Surface the abort reason and STOP. Next `/autodev` invocation re-evaluates Phase B from the failed step.
|
||||
|
||||
---
|
||||
|
||||
**Step 17 — Retrospective**
|
||||
State-driven: reached by auto-chain from Step 16, for the current `state.cycle`.
|
||||
State-driven: reached by auto-chain from Step 16.5 with a `Released`, `Released-with-override`, or `Rolled-Back` verdict, for the current `state.cycle`.
|
||||
|
||||
Action: Read and execute `.cursor/skills/retrospective/SKILL.md` in **cycle-end mode**. Pass cycle context (`cycle: state.cycle`) so the retro report and LESSONS.md entries record which feature cycle they came from.
|
||||
Action: Read and execute `.cursor/skills/retrospective/SKILL.md`. Mode selection:
|
||||
|
||||
After retrospective completes, mark Step 17 as `completed` and enter "Re-Entry After Completion" evaluation.
|
||||
- Step 16.5 verdict `Released` → cycle-end mode
|
||||
- Step 16.5 verdict `Released-with-override` or `Rolled-Back` → incident mode
|
||||
|
||||
Pass cycle context (`cycle: state.cycle`) so the retro report and LESSONS.md entries record which feature cycle they came from.
|
||||
|
||||
After retrospective completes:
|
||||
|
||||
- If Step 16.5 verdict was `Released` or `Released-with-override` → mark Step 17 as `completed` and enter "Re-Entry After Completion" evaluation (loop back to Step 9 for cycle N+1).
|
||||
- If Step 16.5 verdict was `Rolled-Back` → mark Step 17 as `completed` but do NOT loop back. Surface the incident retro path and STOP.
|
||||
|
||||
---
|
||||
|
||||
**Re-Entry After Completion**
|
||||
State-driven: `state.step == done` OR Step 17 (Retrospective) is completed for `state.cycle`.
|
||||
State-driven: `state.step == done` OR Step 17 (Retrospective) is completed for `state.cycle` AND Step 16.5 verdict was `Released` or `Released-with-override`. A `Rolled-Back` cycle does NOT trigger Re-Entry — the user must explicitly invoke `/autodev` again.
|
||||
|
||||
Action: The project completed a full cycle. Print the status banner and automatically loop back to New Task — do NOT ask the user for confirmation:
|
||||
|
||||
@@ -316,7 +339,7 @@ Action: The project completed a full cycle. Print the status banner and automati
|
||||
|
||||
Set `step: 9`, `status: not_started`, and **increment `cycle`** (`cycle: state.cycle + 1`) in the state file, then auto-chain to Step 9 (New Task). Reset `sub_step` to `phase: 0, name: awaiting-invocation, detail: ""` and `retry_count: 0`.
|
||||
|
||||
Note: the loop (Steps 9 → 17 → 9) ensures every feature cycle includes: New Task → Implement → Run Tests → Test-Spec Sync → Update Docs → Security → Performance → Deploy → Retrospective.
|
||||
Note: the loop (Steps 9 → 17 → 9) ensures every feature cycle includes: New Task → Implement → Run Tests → Test-Spec Sync → Update Docs → Security → Performance → Deploy → Release → Retrospective. The cycle only completes (and loops back to Step 9) on a `Released` or `Released-with-override` verdict; rolled-back or aborted releases stop the cycle.
|
||||
|
||||
## Auto-Chain Rules
|
||||
|
||||
@@ -344,8 +367,13 @@ Note: the loop (Steps 9 → 17 → 9) ensures every feature cycle includes: New
|
||||
| Update Docs (13) | Auto-chain → Security Audit choice (14) |
|
||||
| Security Audit (14, done or skipped) | Auto-chain → Performance Test choice (15) |
|
||||
| Performance Test (15, done or skipped) | Auto-chain → Deploy (16) |
|
||||
| Deploy (16) | Auto-chain → Retrospective (17) |
|
||||
| Retrospective (17) | **Cycle complete** — loop back to New Task (9) with incremented cycle counter |
|
||||
| Deploy (16) | Auto-chain → Release (16.5) |
|
||||
| Release (16.5, verdict Released) | Auto-chain → Retrospective (17, cycle-end mode) |
|
||||
| Release (16.5, verdict Released-with-override) | Auto-chain → Retrospective (17, **incident mode**) |
|
||||
| Release (16.5, verdict Rolled-Back) | Auto-chain → Retrospective (17, **incident mode**); cycle does NOT loop back |
|
||||
| Release (16.5, verdict Aborted) | STOP — surface abort reason; do not auto-chain |
|
||||
| Retrospective (17, after Released / Released-with-override) | **Cycle complete** — loop back to New Task (9) with incremented cycle counter |
|
||||
| Retrospective (17, after Rolled-Back) | Cycle remains incomplete — STOP and surface incident retro path |
|
||||
|
||||
## Status Summary — Step List
|
||||
|
||||
@@ -381,6 +409,7 @@ Flow-specific slot values:
|
||||
| 14 | Security Audit | — |
|
||||
| 15 | Performance Test | — |
|
||||
| 16 | Deploy | — |
|
||||
| 16.5 | Release | `DONE (Released | Released-with-override | Rolled-Back | Aborted)` |
|
||||
| 17 | Retrospective | — |
|
||||
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2, 4, 8, 12, 13, 14, 15 additionally accept `SKIPPED`.
|
||||
@@ -406,5 +435,6 @@ Row rendering format (renders with a phase separator between Step 8 and Step 9):
|
||||
Step 14 Security Audit [<state token>]
|
||||
Step 15 Performance Test [<state token>]
|
||||
Step 16 Deploy [<state token>]
|
||||
Step 16.5 Release [<state token>]
|
||||
Step 17 Retrospective [<state token>]
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Greenfield Workflow
|
||||
|
||||
Workflow for new projects built from scratch. Flows linearly: Problem → Research → Plan → UI Design (if applicable) → Test Spec → Decompose → Implement + Product Completeness Gate → Code Testability Revision → Decompose Tests → Implement Tests → Run Tests → Test-Spec Sync → Update Docs → Security Audit (optional) → Performance Test (optional) → Deploy → Retrospective.
|
||||
Workflow for new projects built from scratch. Flows linearly: Problem → Research → Plan → UI Design (if applicable) → Test Spec → Decompose → Implement + Product Completeness Gate → Code Testability Revision → Decompose Tests → Implement Tests → Run Tests → Test-Spec Sync → Update Docs → Security Audit (optional) → Performance Test (optional) → Deploy → Release → Retrospective.
|
||||
|
||||
## Step Reference Table
|
||||
|
||||
@@ -8,7 +8,7 @@ Workflow for new projects built from scratch. Flows linearly: Problem → Resear
|
||||
|------|------|-----------|-------------------|
|
||||
| 1 | Problem | problem/SKILL.md | Phase 1–4 |
|
||||
| 2 | Research | research/SKILL.md | Mode A: Phase 1–4 · Mode B: Step 0–8 |
|
||||
| 3 | Plan | plan/SKILL.md | Step 1–6 + Final |
|
||||
| 3 | Plan | plan/SKILL.md | Step 1, 2, 3, 4, 4.5 (ADR Capture), 5, 6 + Final |
|
||||
| 4 | UI Design | ui-design/SKILL.md | Phase 0–8 (conditional — UI projects only) |
|
||||
| 5 | Test Spec | test-spec/SKILL.md | Phases 1–4 |
|
||||
| 6 | Decompose | decompose/SKILL.md (implementation task decomposition) | Step 1 + Step 1.5 + Step 2 + Step 4 |
|
||||
@@ -22,6 +22,7 @@ Workflow for new projects built from scratch. Flows linearly: Problem → Resear
|
||||
| 14 | Security Audit | security/SKILL.md | Phase 1–5 (optional) |
|
||||
| 15 | Performance Test | test-run/SKILL.md (perf mode) | Steps 1–5 (optional) |
|
||||
| 16 | Deploy | deploy/SKILL.md | Step 1–7 |
|
||||
| 16.5 | Release | release/SKILL.md | Phase 1–6 |
|
||||
| 17 | Retrospective | retrospective/SKILL.md (cycle-end mode) | Steps 1–4 |
|
||||
|
||||
## Detection Rules
|
||||
@@ -284,21 +285,42 @@ State-driven: reached by auto-chain from Step 15 (after Step 15 is completed or
|
||||
|
||||
Action: Read and execute `.cursor/skills/deploy/SKILL.md`.
|
||||
|
||||
After the deploy skill completes successfully, mark Step 16 as `completed` and auto-chain to Step 17 (Retrospective).
|
||||
After the deploy skill completes successfully, mark Step 16 as `completed` and auto-chain to Step 16.5 (Release).
|
||||
|
||||
---
|
||||
|
||||
**Step 16.5 — Release**
|
||||
State-driven: reached by auto-chain from Step 16.
|
||||
|
||||
Action: Read and execute `.cursor/skills/release/SKILL.md`. The release skill is responsible for selecting the target environment, executing the deploy artifacts, smoke-testing, watching the rollout, and producing a definitive verdict (`Released`, `Released-with-override`, `Rolled-Back`, or `Aborted`).
|
||||
|
||||
The release skill has its own internal BLOCKING gates (Phase 1 pre-release gate, Phase 2 strategy select, Phase 6 user confirmation when soft regression escalates). Autodev does NOT add a wrapping A/B/C gate — the release skill owns its own user interaction.
|
||||
|
||||
After the release skill exits:
|
||||
|
||||
- **Verdict `Released`** → mark Step 16.5 `completed` and auto-chain to Step 17 (Retrospective in cycle-end mode).
|
||||
- **Verdict `Released-with-override`** → mark Step 16.5 `completed` AND auto-chain to Step 17 (Retrospective in **incident mode**) — the override is itself an incident the retrospective must analyze.
|
||||
- **Verdict `Rolled-Back`** → mark Step 16.5 `failed`. Auto-chain to Step 17 (Retrospective in **incident mode**). Do NOT consider the project "Done" — the user owns the next move (re-run /implement on a fix branch, re-run /deploy, re-run /release).
|
||||
- **Verdict `Aborted`** → mark Step 16.5 `not_started` (the release was never started) OR `failed` if the abort came after Phase 3 had already touched the live system. Surface the abort reason and STOP — do not auto-chain to retrospective.
|
||||
|
||||
---
|
||||
|
||||
**Step 17 — Retrospective**
|
||||
State-driven: reached by auto-chain from Step 16.
|
||||
State-driven: reached by auto-chain from Step 16.5 with a `Released` or `Released-with-override` verdict, OR from a `Rolled-Back` verdict (in incident mode).
|
||||
|
||||
Action: Read and execute `.cursor/skills/retrospective/SKILL.md` in **cycle-end mode**. This closes the cycle's feedback loop by folding metrics into `_docs/06_metrics/retro_<date>.md` and appending the top-3 lessons to `_docs/LESSONS.md`.
|
||||
Action: Read and execute `.cursor/skills/retrospective/SKILL.md`. Mode selection:
|
||||
|
||||
- Step 16.5 verdict `Released` → cycle-end mode
|
||||
- Step 16.5 verdict `Released-with-override` or `Rolled-Back` → incident mode
|
||||
|
||||
The retrospective closes the cycle's feedback loop by folding metrics into `_docs/06_metrics/retro_<date>.md` (or `incident_<date>_release.md` in incident mode) and appending the top-3 lessons to `_docs/LESSONS.md`.
|
||||
|
||||
After retrospective completes, mark Step 17 as `completed` and enter "Done" evaluation.
|
||||
|
||||
---
|
||||
|
||||
**Done**
|
||||
State-driven: reached by auto-chain from Step 17. (Sanity check: `_docs/04_deploy/` should contain all expected artifacts — containerization.md, ci_cd_pipeline.md, environment_strategy.md, observability.md, deployment_procedures.md, deploy_scripts.md.)
|
||||
State-driven: reached by auto-chain from Step 17. (Sanity check: `_docs/04_deploy/` should contain all expected artifacts — containerization.md, ci_cd_pipeline.md, environment_strategy.md, observability.md, deployment_procedures.md, deploy_scripts.md. `_docs/04_release/` should contain at least one `release_<version>_<env>_<timestamp>.md` with a `Released` verdict — or the user has explicitly chosen to handle release outside autodev.)
|
||||
|
||||
Action: Report project completion with summary. Then **rewrite the state file** so the next `/autodev` invocation enters the feature-cycle loop in the existing-code flow:
|
||||
|
||||
@@ -337,7 +359,11 @@ On the next invocation, Flow Resolution rule 1 reads `flow: existing-code` and r
|
||||
| Update Docs (13, done or skipped) | Auto-chain → Security Audit choice (14) |
|
||||
| Security Audit (14, done or skipped) | Auto-chain → Performance Test choice (15) |
|
||||
| Performance Test (15, done or skipped) | Auto-chain → Deploy (16) |
|
||||
| Deploy (16) | Auto-chain → Retrospective (17) |
|
||||
| Deploy (16) | Auto-chain → Release (16.5) |
|
||||
| Release (16.5, verdict Released) | Auto-chain → Retrospective (17, cycle-end mode) |
|
||||
| Release (16.5, verdict Released-with-override) | Auto-chain → Retrospective (17, **incident mode**) |
|
||||
| Release (16.5, verdict Rolled-Back) | Auto-chain → Retrospective (17, **incident mode**); do NOT enter Done |
|
||||
| Release (16.5, verdict Aborted) | STOP — surface abort reason; do not auto-chain |
|
||||
| Retrospective (17) | Report completion; rewrite state to existing-code flow, step 9 |
|
||||
|
||||
## Status Summary — Step List
|
||||
@@ -362,6 +388,7 @@ Flow name: `greenfield`. Render using the banner template in `protocols.md` →
|
||||
| 14 | Security Audit | — |
|
||||
| 15 | Performance Test | — |
|
||||
| 16 | Deploy | — |
|
||||
| 16.5 | Release | `DONE (Released | Released-with-override | Rolled-Back | Aborted)` |
|
||||
| 17 | Retrospective | — |
|
||||
|
||||
All rows also accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 4, 12, 13, 14, 15 additionally accept `SKIPPED`.
|
||||
@@ -385,5 +412,6 @@ Row rendering format (step-number column is right-padded to 2 characters for ali
|
||||
Step 14 Security Audit [<state token>]
|
||||
Step 15 Performance Test [<state token>]
|
||||
Step 16 Deploy [<state token>]
|
||||
Step 16.5 Release [<state token>]
|
||||
Step 17 Retrospective [<state token>]
|
||||
```
|
||||
|
||||
@@ -5,7 +5,8 @@ Workflow for **meta-repositories** — repos that aggregate multiple components
|
||||
This flow differs fundamentally from `greenfield` and `existing-code`:
|
||||
|
||||
- **No problem/research/plan phases** — meta-repos don't build features, they coordinate existing ones
|
||||
- **No test spec / implement / run tests** — the meta-repo has no code to test
|
||||
- **No test spec / run tests** — the meta-repo has no code to test
|
||||
- **`implement` is scoped to suite-level work only** — cross-repo concerns, repo/folder renames, suite-root infra additions (e.g., `.gitmodules`, `_infra/`, suite `e2e/`). Per-component implementation lives in each component's own workspace `/autodev` cycle. The meta-repo's implement step (Step 3.5) executes only when `_docs/tasks/todo/` is non-empty AND the user explicitly opts in; placement is **before** the sync skills so subsequent Doc/E2E/CICD sync propagates the post-implementation state.
|
||||
- **No `_docs/00_problem/` artifacts** — documentation target is `_docs/*.md` unified docs, not per-feature `_docs/NN_feature/` folders
|
||||
- **Primary artifact is `_docs/_repo-config.yaml`** — generated by `monorepo-discover`, read by every other step
|
||||
|
||||
@@ -17,6 +18,7 @@ This flow differs fundamentally from `greenfield` and `existing-code`:
|
||||
| 2 | Config Review | (human checkpoint, no sub-skill) | — |
|
||||
| 2.5 | Glossary & Architecture Vision | (inline, no sub-skill) | Steps 1–5 |
|
||||
| 3 | Status | monorepo-status/SKILL.md | Sections 1–5 |
|
||||
| 3.5 | Suite Implement | implement/SKILL.md (suite-level invocation context) | Steps 1–14 + 16 (Step 14.5 + Step 15 skipped); conditional on `_docs/tasks/todo/` non-empty AND user opt-in |
|
||||
| 4 | Document Sync | monorepo-document/SKILL.md | Phase 1–7 (conditional on doc drift) |
|
||||
| 4.5 | Integration Test Sync | monorepo-e2e/SKILL.md | Phase 1–6 (conditional on suite-e2e drift; skipped if `suite_e2e:` block absent in config) |
|
||||
| 5 | CICD Sync | monorepo-cicd/SKILL.md | Phase 1–7 (conditional on CI drift) |
|
||||
@@ -184,11 +186,16 @@ The status report identifies:
|
||||
- Registry/config mismatches
|
||||
- Unresolved questions
|
||||
|
||||
Based on the report, auto-chain branches:
|
||||
Based on the report, auto-chain branches in this evaluation order (first match wins):
|
||||
|
||||
- If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
|
||||
- Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
||||
- Else if **registry mismatch** found (new components not in config) → present Choose format:
|
||||
1. **Registry mismatch** (new components not in config, or config component not in registry) → present the Choose format below FIRST. After the user resolves it (A: refresh discover, B: onboard, C: continue with mismatch acknowledged), proceed to the next rule. This rule has priority because a stale config would mislead Step 3.5's ownership-envelope synthesis and any sync skill's component scope.
|
||||
2. **Pre-routing gate (Step 3.5 detection)** — check `_docs/tasks/todo/` for suite-level task files (`*.md` excluding files starting with `_`). If ≥1 task is present, auto-chain to **Step 3.5 (Suite Implement)**. After Step 3.5 returns (regardless of A/B outcome), the post-implement re-status applies rules 3–6 below to the post-implementation state.
|
||||
3. If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
|
||||
4. Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
|
||||
5. Else if **suite-e2e drift** (only) found → auto-chain to **Step 4.5 (Integration Test Sync)** (only when `suite_e2e:` block exists in config)
|
||||
6. Else → **workflow done for this cycle**.
|
||||
|
||||
**Registry mismatch Choose format** (rule 1):
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
@@ -205,7 +212,134 @@ Based on the report, auto-chain branches:
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
- Else → **workflow done for this cycle**. Report "No drift. Meta-repo is in sync." Loop waits for next invocation.
|
||||
When rule 6 fires (no drift, no todo tasks), report "No drift. Meta-repo is in sync." and end the cycle. Loop waits for next invocation.
|
||||
|
||||
---
|
||||
|
||||
**Step 3.5 — Suite Implement**
|
||||
|
||||
Condition (folder fallback): `_docs/tasks/todo/` exists AND contains ≥1 file matching `*.md` excluding files starting with `_` (e.g., `_dependencies_table.md` is excluded by convention).
|
||||
|
||||
State-driven: reached by auto-chain from Step 3 when the pre-routing gate detected todo tasks. Inserted **before** the sync skills (Step 4 / 4.5 / 5) by deliberate design: implementing renames + cross-repo edits first means the subsequent sync skills propagate the actual landed state rather than the pre-change state, avoiding a second cycle to fix downstream drift.
|
||||
|
||||
**Skip condition**: `_docs/tasks/todo/` is empty, missing, or contains only `_*` files. In that case Step 3.5 is skipped entirely and the cycle proceeds with Step 3's existing drift-based routing.
|
||||
|
||||
**Goal**: Execute suite-level implementation tasks — cross-repo concerns (e.g., `autopilot` + `ui` + suite `e2e/` cutover in a coordinated change-set), folder renames (e.g., `git mv flights missions` + `.gitmodules` edit + `_infra/` path refs), and suite-root infrastructure additions (e.g., `_infra/dev/docker-compose.dev.yml`). Per-component implementation work stays in each component's own workspace `/autodev` cycle.
|
||||
|
||||
**Why this exists**: the meta-repo's existing sync skills (`monorepo-document`, `monorepo-cicd`, `monorepo-e2e`) only **propagate** changes that already landed. They cannot **execute** a task spec. Without Step 3.5, suite-level tickets like AZ-543 (B4 repo rename) or AZ-506 (new dev compose) have no flow path forward — they require operator action outside autodev.
|
||||
|
||||
**Inputs**:
|
||||
|
||||
- `_docs/tasks/todo/*.md` (excluding `_*`) — task specs in the existing format (`Task` / `Component` / `Dependencies` / `Acceptance criteria` headers)
|
||||
- `_docs/_repo-config.yaml` — `components[].path` list, used to compute the suite-level OWNED envelope (workspace root EXCLUDING any path under a component's folder)
|
||||
- `_docs/tasks/_dependencies_table.md` — synthesized by this step if missing (see Procedure)
|
||||
- `_docs/tasks/_suite_module_layout.md` — synthesized by this step if missing (see Procedure)
|
||||
|
||||
**Procedure**:
|
||||
|
||||
1. **Detection (already done by Step 3 pre-routing gate)**. List task files in `_docs/tasks/todo/` (excluding `_*`). If 0 → skip Step 3.5. If ≥1 → continue.
|
||||
|
||||
2. **Present Choose**:
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
DECISION REQUIRED: <N> suite-level task(s) in _docs/tasks/todo/
|
||||
══════════════════════════════════════
|
||||
Task(s) detected:
|
||||
- AZ-XXX: <title> (deps: <list or "—">)
|
||||
- AZ-YYY: <title> (deps: <list or "—">)
|
||||
...
|
||||
|
||||
A) Run implement skill on these task(s) now (then continue to Doc / E2E / CICD sync)
|
||||
B) Skip implement this cycle — continue to Doc / E2E / CICD sync without executing tasks
|
||||
C) Pause — review the tasks before deciding (end session, no state changes)
|
||||
══════════════════════════════════════
|
||||
Recommendation: A — running implement BEFORE syncs means subsequent
|
||||
sync skills propagate the post-implementation state.
|
||||
B is appropriate when tasks are blocked on user input
|
||||
or external coordination. C when the tasks themselves
|
||||
need owner clarification before execution.
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
3. **On user A — Pre-flight**:
|
||||
|
||||
a. **Working tree clean check**. Run `git status --porcelain`. If non-empty, surface to the user with a Choose A/B/C identical to the implement skill's prerequisite gate (commit/stash manually; agent commits as `chore: WIP pre-implement`; abort).
|
||||
|
||||
b. **Synthesize `_docs/tasks/_dependencies_table.md`** if missing. Parse each in-scope task's `Dependencies:` field. Write a minimal table of the form:
|
||||
|
||||
```markdown
|
||||
# Suite-Level Task Dependencies
|
||||
|
||||
| Task ID | Depends on | Notes |
|
||||
|---------|------------|-------|
|
||||
| AZ-XXX | (none) | — |
|
||||
| AZ-YYY | AZ-XXX | — |
|
||||
```
|
||||
|
||||
If a task lists a dependency that is neither in `todo/` nor `done/`, log a warning in the synthesized file but do not block — implement skill's Step 1 (Parse) will surface the issue if it actually blocks execution.
|
||||
|
||||
c. **Synthesize `_docs/tasks/_suite_module_layout.md`** if missing. Default content:
|
||||
|
||||
```markdown
|
||||
# Suite-Level Module Layout (synthetic)
|
||||
|
||||
Generated by autodev meta-repo Step 3.5. The suite root has no per-feature decomposition; ownership is defined at the component-boundary level only.
|
||||
|
||||
## Per-Component Mapping
|
||||
|
||||
| Component | Owns | Imports from |
|
||||
|-----------|----------------------------------|--------------|
|
||||
| suite | (workspace root) excluding any path listed under `_repo-config.yaml.components[].path` | (read-only) every component's primary doc + `_docs/*.md` |
|
||||
|
||||
Suite-level tasks operate on: `.gitmodules`, `_infra/**`, `_docs/**` (excluding `_docs/tasks/_*` regenerated files), root `README.md`, `e2e/**` (suite e2e harness only).
|
||||
|
||||
Forbidden paths for suite-level tasks: `<component>/**` for every component listed in `_repo-config.yaml.components[].path` — those edits live in the component's own workspace `/autodev` cycle.
|
||||
```
|
||||
|
||||
d. **Prepare invocation context**:
|
||||
|
||||
```
|
||||
suite_level: true
|
||||
TASKS_DIR: _docs/tasks/
|
||||
module_layout_path: _docs/tasks/_suite_module_layout.md
|
||||
```
|
||||
|
||||
4. **Invoke implement skill**. Read and execute `.cursor/skills/implement/SKILL.md` with the prepared context. The skill's "Suite-level invocation context" subsection (added in tandem with this flow change) honors the three flags above and skips:
|
||||
|
||||
- Step 14.5 (cumulative code review) — no `architecture_compliance_baseline.md` exists at the suite level; cross-task drift is captured by the next `monorepo-status` cycle instead.
|
||||
- Step 15 (Product Implementation Completeness Gate) — the gate's inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite tasks are infrastructure / coordination work, not feature implementation.
|
||||
|
||||
All other implement skill steps (1–14, 16) execute unchanged. Tracker integration (Step 5: In Progress, Step 12: In Testing) runs normally.
|
||||
|
||||
5. **Post-implement re-status**. After the implement skill completes (last batch committed, all originally-todo tasks moved to `_docs/tasks/done/`), silently re-run Step 3's drift detection logic — do NOT re-render the full Status report; just re-evaluate the drift signals against the post-implementation tree. Then auto-chain per the post-implementation drift findings:
|
||||
|
||||
- Doc drift → Step 4 (Document Sync)
|
||||
- Suite-e2e drift only → Step 4.5
|
||||
- CI drift only → Step 5
|
||||
- No drift → cycle complete
|
||||
|
||||
Note: the post-implement re-status is exactly why Step 3.5 is placed before sync. A repo rename will typically introduce doc + CI drift; the next invocation of Step 4 / Step 5 catches it on the same cycle.
|
||||
|
||||
6. **On user B (skip)** → mark Step 3.5 `skipped` in state file. Apply Step 3's original drift-based routing (compute from the pre-Step-3.5 Status report).
|
||||
|
||||
7. **On user C (pause)** → end session. Update state to `step: 3.5, status: in_progress, sub_step: {phase: 0, name: awaiting-task-review, detail: "<N> tasks pending review"}`. Tell the user to invoke `/autodev` again after deciding. **Do NOT modify any files** — pre-flight has not run yet.
|
||||
|
||||
**Self-verification** (executed before invoking implement):
|
||||
|
||||
- [ ] Working tree is clean (or user explicitly chose B in the WIP-stash sub-Choose)
|
||||
- [ ] `_docs/tasks/_dependencies_table.md` exists (synthesized if it didn't)
|
||||
- [ ] `_docs/tasks/_suite_module_layout.md` exists (synthesized if it didn't)
|
||||
- [ ] All in-scope task files have a `Component:` field (skip + report any that don't — don't guess ownership)
|
||||
- [ ] Tracker availability gate satisfied per `protocols.md` (or `tracker: local` previously chosen)
|
||||
|
||||
**Failure handling**:
|
||||
|
||||
- If implement returns FAILED → standard Failure Handling (`protocols.md`): retry up to 3 times, then escalate.
|
||||
- If implement is interrupted mid-batch → next invocation re-detects via the implement skill's resumability protocol (read latest `_docs/03_implementation/suite_batch_*.md`). Step 3.5 itself is reentrant: on re-entry, if `todo/` still has tasks, it presents the Choose again with the remaining set.
|
||||
- **Half-applied state risk** (acknowledged): if implement is interrupted between commits, the working tree is clean at the last commit boundary but the in-flight batch is lost. The user is responsible for inspecting and re-invoking. This is intentional — automated rollback of suite-level renames + `.gitmodules` edits is more dangerous than a human-driven recovery.
|
||||
|
||||
**Idempotency**: if `_docs/tasks/todo/` becomes empty after this step (all tasks moved to `done/`), the next `/autodev` invocation skips Step 3.5 entirely and proceeds with normal Status → sync flow.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,11 +421,16 @@ After onboarding completes, the config is updated. Auto-chain back to **Step 3 (
|
||||
| Config Review (2, user picked A, confirmed_by_user: true) | Auto-chain → Glossary & Architecture Vision (2.5) |
|
||||
| Config Review (2, user picked B) | **Session boundary** — end session, await re-invocation |
|
||||
| Glossary & Architecture Vision (2.5) | Auto-chain → Status (3) |
|
||||
| Status (3, doc drift) | Auto-chain → Document Sync (4) |
|
||||
| Status (3, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Status (3, CI drift only) | Auto-chain → CICD Sync (5) |
|
||||
| Status (3, no drift) | **Cycle complete** — end session, await re-invocation |
|
||||
| Status (3, todo tasks present) | Auto-chain → Suite Implement (3.5) — pre-routing gate fires before drift-based routing |
|
||||
| Status (3, no todo tasks, doc drift) | Auto-chain → Document Sync (4) |
|
||||
| Status (3, no todo tasks, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Status (3, no todo tasks, CI drift only) | Auto-chain → CICD Sync (5) |
|
||||
| Status (3, no todo tasks, no drift) | **Cycle complete** — end session, await re-invocation |
|
||||
| Status (3, registry mismatch) | Ask user (A: discover, B: onboard, C: continue) |
|
||||
| Suite Implement (3.5, user picked A, success) | Silent re-status; auto-chain per post-implementation drift (Step 4 / 4.5 / 5 / cycle complete) |
|
||||
| Suite Implement (3.5, user picked B) | Mark `skipped`; auto-chain per Step 3's original drift findings |
|
||||
| Suite Implement (3.5, user picked C) | **Session boundary** — end session, await re-invocation |
|
||||
| Suite Implement (3.5, FAILED ×3) | Standard Failure Handling escalation (`protocols.md`) |
|
||||
| Document Sync (4) + suite-e2e drift pending | Auto-chain → Integration Test Sync (4.5) |
|
||||
| Document Sync (4) + CI drift only pending | Auto-chain → CICD Sync (5) |
|
||||
| Document Sync (4) + no further drift | **Cycle complete** |
|
||||
@@ -317,11 +456,12 @@ Flow-specific slot values:
|
||||
| 2 | Config Review | `IN PROGRESS (awaiting human)` |
|
||||
| 2.5 | Glossary & Architecture Vision | `SKIPPED (already captured)` |
|
||||
| 3 | Status | `DONE (no drift)`, `DONE (N drifts)` |
|
||||
| 3.5 | Suite Implement | `DONE (N tasks)`, `SKIPPED (no todo tasks)`, `SKIPPED (user picked B)`, `IN PROGRESS (batch M of ~N)`, `IN PROGRESS (awaiting-task-review)` |
|
||||
| 4 | Document Sync | `DONE (N docs)`, `SKIPPED (no doc drift)` |
|
||||
| 4.5 | Integration Test Sync | `DONE (N files)`, `SKIPPED (no suite-e2e drift)`, `SKIPPED (no suite_e2e config block)` |
|
||||
| 5 | CICD Sync | `DONE (N files)`, `SKIPPED (no CI drift)` |
|
||||
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
|
||||
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 3.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
|
||||
|
||||
Row rendering format:
|
||||
|
||||
@@ -330,6 +470,7 @@ Row rendering format:
|
||||
Step 2 Config Review [<state token>]
|
||||
Step 2.5 Glossary & Architecture Vision [<state token>]
|
||||
Step 3 Status [<state token>]
|
||||
Step 3.5 Suite Implement [<state token>]
|
||||
Step 4 Document Sync [<state token>]
|
||||
Step 4.5 Integration Test Sync [<state token>]
|
||||
Step 5 CICD Sync [<state token>]
|
||||
@@ -337,8 +478,12 @@ Row rendering format:
|
||||
|
||||
## Notes for the meta-repo flow
|
||||
|
||||
- **No session boundary except Step 2 and Step 2.5**: unlike existing-code flow (which has boundaries around decompose), meta-repo flow only pauses at config review and the one-shot glossary/vision capture. Once both are confirmed, syncing is fast enough to complete in one session and Step 2.5 idempotently no-ops on every subsequent invocation.
|
||||
- **Session boundaries**: Step 2 (Config Review pending), Step 2.5 (one-shot glossary/vision review), and Step 3.5 (when user picks C "Pause"). Step 3.5's A/B picks do NOT cross a session boundary — they auto-chain to syncs in the same session.
|
||||
- **Cyclical, not terminal**: no "done forever" state. Each invocation completes a drift cycle; next invocation starts fresh.
|
||||
- **No tracker integration**: this flow does NOT create Jira/ADO tickets. Maintenance is not a feature — if a feature-level ticket spans the meta-repo's concerns, it lives in the per-component workspace.
|
||||
- **Tracker integration scope**: this flow does NOT create Jira/ADO tickets in its sync skills (Status / Document Sync / E2E / CICD). Step 3.5 (Suite Implement) IS tracker-integrated — it transitions existing tickets In Progress → In Testing per the implement skill's standard tracker handling. Suite-level tickets are authored manually by the operator (typically as children of an Epic that spans multiple components, like AZ-539); the flow doesn't auto-create them.
|
||||
- **Per-component vs. suite-level work**:
|
||||
- Tickets that touch component source code (`<component>/src/**`) belong in that component's own workspace `/autodev` cycle. The meta-repo flow does NOT execute them.
|
||||
- Tickets that touch suite-root paths only (`.gitmodules`, `_infra/**`, suite `e2e/**`, root `README.md`, suite `_docs/**` outside `tasks/_*`) are eligible for Step 3.5.
|
||||
- Tickets that span both (e.g., AZ-550 B11 consumer cutover, which touches `autopilot/`, `ui/`, AND suite `e2e/`) are NOT executable from a single workspace by design — split the ticket so the suite-level slice can run in Step 3.5 and the component slices run in their owning workspaces.
|
||||
- **Onboarding is opt-in**: never auto-onboarded. User must explicitly request.
|
||||
- **Failure handling**: uses the same retry/escalation protocol as other flows (see `protocols.md`).
|
||||
|
||||
@@ -114,6 +114,7 @@ Before entering a step from this table for the first time in a session, verify t
|
||||
| greenfield | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
|
||||
| existing-code | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
|
||||
| existing-code | New Task | Step 7 — Ticket | Create ticket per task, link to epic |
|
||||
| meta-repo | Suite Implement | Step 3.5 — implement skill Step 5 / Step 12 | Transition existing tickets In Progress → In Testing per implement skill (does NOT create new tickets — operator authors them) |
|
||||
|
||||
### State File Marker
|
||||
|
||||
@@ -388,7 +389,7 @@ The banner shell is defined here once. Each flow file contributes only its step-
|
||||
where `<state token>` comes from the state-token set defined per row in the flow's step-list table.
|
||||
- `<current-suffix>` — optional, flow-specific. The existing-code flow appends ` (cycle <N>)` when `state.cycle > 1`; other flows leave it empty.
|
||||
- `Retry:` row — omit entirely when `retry_count` is 0. Include it with `<N>/3` otherwise.
|
||||
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty.
|
||||
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty unless **parent suite docs** apply: if `<workspace-root>/../docs` exists and is a directory, append `Suite docs (parent): <absolute path>` on its own line (or `Suite docs (parent): absent` is **not** required — omit when missing). This line is orthogonal to flow-specific footer lines; both may appear.
|
||||
|
||||
### State token set (shared)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The autodev persists its position to `_docs/_autodev_state.md`. This is a lightw
|
||||
|
||||
## Current Step
|
||||
flow: [greenfield | existing-code | meta-repo]
|
||||
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo, or "done"]
|
||||
step: [1-17 for greenfield (incl. fractional 16.5), 1-17 for existing-code (incl. fractional 16.5), 1-6 for meta-repo (incl. fractional 2.5 and 3.5), or "done"]
|
||||
name: [step name from the active flow's Step Reference Table]
|
||||
status: [not_started / in_progress / completed / skipped / failed]
|
||||
sub_step:
|
||||
@@ -82,6 +82,19 @@ retry_count: 0
|
||||
cycle: 1
|
||||
```
|
||||
|
||||
```
|
||||
flow: meta-repo
|
||||
step: 3.5
|
||||
name: Suite Implement
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 7
|
||||
name: batch-loop
|
||||
detail: "AZ-543 batch 1 of 1; suite-level"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
```
|
||||
|
||||
```
|
||||
flow: existing-code
|
||||
step: 10
|
||||
@@ -100,7 +113,7 @@ cycle: 3
|
||||
1. **Create** on the first autodev invocation (after state detection determines Step 1)
|
||||
2. **Update** after every change — this includes: batch completion, sub-step progress, step completion, session boundary, failed retry, or any meaningful state transition. The state file must always reflect the current reality.
|
||||
3. **Read** as the first action on every invocation — before folder scanning
|
||||
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file
|
||||
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file. **Parent suite `docs/`**: on every invocation, also probe `<workspace-root>/../docs` (the parent directory’s `docs` folder — typical suite-level shared documentation next to a component repo). If it exists, mention it in the Status Summary footer per `protocols.md`; use it only as supplemental reading context unless a flow step explicitly ties detection to it. It never replaces workspace `_docs/` for step detection by default.
|
||||
5. **Never delete** the state file
|
||||
6. **Retry tracking**: increment `retry_count` on each failed auto-retry; reset to `0` on success. If `retry_count` reaches 3, set `status: failed`
|
||||
7. **Failed state on re-entry**: if `status: failed` with `retry_count: 3`, do NOT auto-retry — present the issue to the user first
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: code-review
|
||||
description: |
|
||||
Multi-phase code review against task specs with structured findings output.
|
||||
6-phase workflow: context loading, spec compliance, code quality, security quick-scan, performance scan, cross-task consistency.
|
||||
7-phase workflow: context loading, spec compliance, code quality, security quick-scan, performance scan, cross-task consistency, architecture compliance.
|
||||
Produces a structured report with severity-ranked findings and a PASS/FAIL/PASS_WITH_WARNINGS verdict.
|
||||
Invoked by /implement skill after each batch, or manually.
|
||||
Trigger phrases:
|
||||
@@ -103,15 +103,15 @@ When multiple tasks were implemented in the same batch:
|
||||
- No conflicting patterns (e.g., one task uses repository pattern, another does raw SQL)
|
||||
- Shared code is not duplicated across task implementations
|
||||
- Dependencies declared in task specs are properly wired
|
||||
- **Duplicate test helpers across test projects** (AZ-491): if two or more test projects (`Tests`, `IntegrationTests`, perf/load harnesses) contain near-identical helper logic — same method name plus structurally similar body, e.g. `MintValidToken` / `MintExpiredToken` / `TamperSignature` / image-fixture factories — flag as a **Medium / Maintainability** finding and recommend consolidation into the shared `SatelliteProvider.TestSupport` library. The cycle-2 retrospective documented why: the same bug existed in two copies (`SatelliteProvider.Tests/TestUtilities/JwtTokenFactory.cs` and `SatelliteProvider.IntegrationTests/JwtTestHelpers.cs`) and needed two separate fixes (commits `f64d0d7` + `11b7074`). Two near-identical implementations of credential-minting / fixture-generation logic are a security-relevance maintenance risk, not just a style issue.
|
||||
|
||||
## Phase 7: Architecture Compliance
|
||||
|
||||
Verify the implemented code respects the architecture documented in `_docs/02_document/architecture.md` and the component boundaries declared in `_docs/02_document/module-layout.md`.
|
||||
Verify the implemented code respects the architecture documented in `_docs/02_document/architecture.md`, the component boundaries declared in `_docs/02_document/module-layout.md`, and the **accepted Architectural Decision Records** under `_docs/02_document/adr/`.
|
||||
|
||||
**Inputs**:
|
||||
- `_docs/02_document/architecture.md` — layering, allowed dependencies, patterns
|
||||
- `_docs/02_document/module-layout.md` — per-component directories, Public API surface, `Imports from` lists, Allowed Dependencies table
|
||||
- `_docs/02_document/adr/` — every `Status: Accepted` ADR is an enforceable structural rule. `Status: Proposed`, `Status: Deprecated`, and `Status: Superseded` ADRs are NOT enforced (Proposed = not yet ratified; Deprecated/Superseded = a later ADR overturned it). If the directory does not exist or has only the index file, ADRs are skipped — log this skip in the report so the absence is visible.
|
||||
- The cumulative list of changed files (for per-batch invocation) or the full codebase (for baseline invocation)
|
||||
|
||||
**Checks**:
|
||||
@@ -126,6 +126,11 @@ Verify the implemented code respects the architecture documented in `_docs/02_do
|
||||
|
||||
5. **Cross-cutting concerns not locally re-implemented**: if a file under a component directory contains logic that should live in `shared/<concern>/` (e.g., custom logging setup, config loader, error envelope), flag it. Severity: Medium. Category: Architecture.
|
||||
|
||||
6. **ADR compliance**: for each `Status: Accepted` ADR, confirm the changed code does not contradict the ADR's `Decision`. Two failure modes are flagged:
|
||||
- **ADR-Violation**: the changed code does the opposite of an Accepted ADR's `Decision`. Example: ADR-002 says "We will use Postgres for transactional data" and the changed code introduces a SQLite dependency for a transactional path. Severity: **Critical**. Category: Architecture. The finding cites the ADR by `NNN_<slug>` and the offending file/line.
|
||||
- **ADR-Drift**: the changed code does something the ADR did not anticipate AND that materially affects the ADR's `Consequences` (positive or negative). Example: ADR-004 says "Event-driven cross-component comms" and a changed file introduces a new synchronous HTTP call between two components. Severity: **High**. Category: Architecture. The finding either proposes "Update ADR-NNN to acknowledge the new pattern" or "Remove the drift to align with ADR-NNN" — never silently accepts.
|
||||
The check skips ADRs that are explicitly out of scope of the changed batch (e.g., ADR-001 about deployment pipeline when the batch only touches business-logic files). Use the ADR's `Evidence` section to determine scope: if no Evidence path overlaps with any changed file, skip the ADR for this batch.
|
||||
|
||||
**Detection approach (per language)**:
|
||||
|
||||
- Python: parse `import` / `from ... import` statements; optionally AST with `ast` module for reliable symbol resolution.
|
||||
@@ -198,7 +203,7 @@ Produce a structured report with findings deduplicated and sorted by severity:
|
||||
|
||||
Bug, Spec-Gap, Security, Performance, Maintainability, Style, Scope, Architecture
|
||||
|
||||
`Architecture` findings come from Phase 7. They indicate layering violations, Public API bypasses, new cyclic dependencies, duplicate symbols, or cross-cutting concerns re-implemented locally.
|
||||
`Architecture` findings come from Phase 7. They indicate layering violations, Public API bypasses, new cyclic dependencies, duplicate symbols, cross-cutting concerns re-implemented locally, **ADR-Violation** (changed code contradicts an `Accepted` ADR's Decision — Critical), or **ADR-Drift** (changed code introduces a pattern that materially affects an `Accepted` ADR's Consequences without superseding it — High).
|
||||
|
||||
## Verdict Logic
|
||||
|
||||
@@ -233,7 +238,7 @@ The implement skill invokes code-review by:
|
||||
|
||||
1. Reading `.cursor/skills/code-review/SKILL.md`
|
||||
2. Providing the inputs above as context (read the files, pass content to the review phases)
|
||||
3. Executing all 6 phases sequentially
|
||||
3. Executing all 7 phases sequentially
|
||||
4. Consuming the verdict from the output
|
||||
|
||||
### Outputs (returned to the implement skill)
|
||||
|
||||
@@ -65,6 +65,7 @@ Announce the selected entrypoint and resolved paths to the user before proceedin
|
||||
| 1 Bootstrap Structure | `steps/01_bootstrap-structure.md` | ✓ | — | — |
|
||||
| 1t Test Infrastructure | `steps/01t_test-infrastructure.md` | — | — | ✓ |
|
||||
| 1.5 Module Layout | `steps/01-5_module-layout.md` | ✓ | — | — |
|
||||
| 1.7 System-Pipeline Tasks | `steps/01-7_system-pipeline-tasks.md` | ✓ | — | — |
|
||||
| 2 Task Decomposition | `steps/02_task-decomposition.md` | ✓ | ✓ | — |
|
||||
| 3 Blackbox Test Tasks | `steps/03_blackbox-test-decomposition.md` | — | — | ✓ |
|
||||
| 4 Cross-Verification | `steps/04_cross-verification.md` | ✓ | — | ✓ |
|
||||
@@ -191,6 +192,20 @@ Read and follow `steps/01-5_module-layout.md`.
|
||||
|
||||
---
|
||||
|
||||
### Step 1.7: System-Pipeline Tasks (implementation mode only)
|
||||
|
||||
Read and follow `steps/01-7_system-pipeline-tasks.md`.
|
||||
|
||||
This step exists because per-component task decomposition (Step 2)
|
||||
produces one task per component but NEVER produces a task whose
|
||||
deliverable is "the production code that drives the end-to-end
|
||||
pipeline by calling each component in order against real inputs".
|
||||
The architecture document describes the loop; nobody owns it. The
|
||||
GPS-passthrough incident (May 2026) is the canonical failure this
|
||||
step prevents.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Task Decomposition (implementation and single component modes)
|
||||
|
||||
Read and follow `steps/02_task-decomposition.md`.
|
||||
@@ -243,6 +258,8 @@ Read and follow `steps/04_cross-verification.md`.
|
||||
│ [BLOCKING: user confirms structure] │
|
||||
│ 1.5 Module Layout → steps/01-5_module-layout.md │
|
||||
│ [BLOCKING: user confirms layout] │
|
||||
│ 1.7 System-Pipeline → steps/01-7_system-pipeline-tasks.md │
|
||||
│ [BLOCKING: user confirms pipeline owners] │
|
||||
│ 2. Component Tasks → steps/02_task-decomposition.md │
|
||||
│ 4. Cross-Verification → steps/04_cross-verification.md │
|
||||
│ [BLOCKING: user confirms dependencies] │
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
3. Each component owns ONE top-level directory. Shared code goes under `<root>/shared/` (or language equivalent).
|
||||
4. Public API surface = files in the layout's `public:` list for each component; everything else is internal and MUST NOT be imported from other components.
|
||||
5. Cross-cutting concerns (logging, error handling, config, telemetry, auth middleware, feature flags, i18n) each get ONE entry under Shared / Cross-Cutting; per-component tasks consume them (see Step 2 cross-cutting rule).
|
||||
6. Write `_docs/02_document/module-layout.md` using `templates/module-layout.md` format.
|
||||
6. **ADR cross-check**: if `_docs/02_document/adr/` exists, read every `Status: Accepted` ADR. For each, confirm the proposed module layout does not contradict the ADR's `Decision` (e.g., an ADR mandating an event-bus boundary between two components must show up as a `Imports from` exclusion in the layout; an ADR locking a layering style must show up in the Layering table). If an ADR conflicts with the language-conventional layout from step 2, the ADR wins — record the conflict in a `## ADR-driven exceptions to the conventional layout` section of `module-layout.md` with `See ADR NNN_<slug>` references. If the ADR conflict is irreconcilable (the ADR demands something the language genuinely cannot express), STOP and ask the user A/B/C: (A) update the ADR via plan Step 4.5 supersede flow, (B) accept a layered exception with documented rationale, (C) re-open architecture.
|
||||
7. Write `_docs/02_document/module-layout.md` using `templates/module-layout.md` format. Each Per-Component Mapping entry that is governed by an ADR includes a trailing `> See ADR NNN_<slug>` line.
|
||||
|
||||
## Self-verification
|
||||
|
||||
@@ -26,6 +27,8 @@
|
||||
- [ ] No component's `Imports from` list points at a higher layer
|
||||
- [ ] Paths follow the detected language's convention
|
||||
- [ ] No two components own overlapping paths
|
||||
- [ ] If `_docs/02_document/adr/` exists with Accepted ADRs, every layout decision that an ADR governs has a trailing `> See ADR NNN_<slug>` reference
|
||||
- [ ] No Accepted ADR is contradicted by the layout without a documented exception
|
||||
|
||||
## Save action
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Step 1.7: System-Pipeline Tasks (implementation mode only)
|
||||
|
||||
**Role**: Professional software architect, integration-focused.
|
||||
**Goal**: For every end-to-end pipeline named in `_docs/02_document/architecture.md` and `_docs/02_document/system-flows.md`, ensure there is exactly ONE explicit task that owns the production code that drives that pipeline against real inputs. This step prevents the failure mode where every individual component is "complete" but no production code wires them together (May 2026 GPS-passthrough incident — see `meta-rule.mdc` "When a test reveals missing production code").
|
||||
|
||||
**Constraints**:
|
||||
|
||||
- This step produces *integration* tasks, not per-component tasks. Per-component tasks come from Step 2.
|
||||
- An integration task's owner is typically the composition root, runtime root, main loop, or whichever component the module layout (Step 1.5) names as the "system spine". It is NEVER a leaf component.
|
||||
- Each integration task must be sized at 5 points or fewer. If the pipeline is too large for one task, split it into per-stage integration tasks (e.g. "wire ingress → C1", then "wire C1 → C5") rather than one giant task.
|
||||
|
||||
## Inputs
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `_docs/02_document/architecture.md` | Source of named end-to-end pipelines and their component sequences |
|
||||
| `_docs/02_document/system-flows.md` | Source of operational flows (per-frame loop, request lifecycle, batch job, etc.) |
|
||||
| `_docs/02_document/module-layout.md` | Produced by Step 1.5. Names the "system spine" component(s) — typically `runtime_root`, `app`, `main`, `composition`, or equivalent. |
|
||||
| `_docs/02_document/components/*/description.md` | Per-component contracts so you can tell which side of a seam each method lives on |
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Enumerate end-to-end pipelines.** Read `architecture.md` and `system-flows.md`. For each named pipeline / flow that spans 2+ components, record:
|
||||
- The pipeline name (e.g. "per-frame nav loop", "tile-cache build", "operator pre-flight verification").
|
||||
- The ordered sequence of components it touches (e.g. `frame_source → c1_vio → c2_vpr → ... → c5_state → replay_sink`).
|
||||
- The trigger (per-frame, per-request, scheduled, manual).
|
||||
- The output (what the pipeline emits and to whom).
|
||||
2. **For each pipeline, locate the owner.** Use `module-layout.md` to find the component that owns the orchestration (the "spine"). If `module-layout.md` does not name one, STOP and ASK the user which component owns the pipeline. Do NOT silently default to the bootstrap structure task — bootstrap is about project skeleton, not behavior.
|
||||
3. **Check whether the pipeline is already covered by an existing task spec or by the bootstrap-structure task.** A pipeline is "covered" only if:
|
||||
- A task spec's `Outcome` or `Acceptance Criteria` section explicitly names "drives the {pipeline_name} end-to-end against real production components", AND
|
||||
- That task's owned files include the orchestration code (typically the spine component's main loop / entrypoint).
|
||||
4. **For every uncovered pipeline, create a system-integration task spec** in `_docs/02_tasks/todo/` using `.cursor/skills/decompose/templates/task.md`:
|
||||
- **Component**: the spine component from step 2 (e.g. `runtime_root`).
|
||||
- **Outcome**: the production callsite that drives the pipeline exists and runs end-to-end on real inputs.
|
||||
- **Scope / Included**: the orchestration code (loop body, dispatcher, scheduler, entrypoint); explicit list of every component it must call in order; the data type at each seam.
|
||||
- **Acceptance Criteria** (write each as testable):
|
||||
- At least one production caller of every component method in the pipeline can be found by grep — name the methods explicitly.
|
||||
- The orchestration runs against the real production component instances (NOT mocks, NOT a passthrough that bypasses them).
|
||||
- At least one integration test exercises the orchestration end-to-end against real inputs.
|
||||
- **Dependencies**: every per-component task whose component appears in the pipeline.
|
||||
- **Complexity points**: ≤5; split the pipeline if it doesn't fit.
|
||||
- **Tracker**: create a ticket immediately (per `decompose/SKILL.md` "Tracker inline" principle); rename the file to `[TRACKER-ID]_pipeline_<name>.md`.
|
||||
5. **Mark the integration task as `Dependencies` for the integration test task.** If `tests-only` decomposition has already produced an e2e/integration test task for this pipeline, append the new integration task to its `Dependencies` field so the test cannot be "made green" before the integration ships.
|
||||
|
||||
## Anti-patterns this step explicitly blocks
|
||||
|
||||
- **"compose_root returns a wired runtime"** prose interpreted as "the loop exists". Composition assembles the graph; it is NOT the loop. The loop is the code that pulls inputs, drives each node, and emits outputs. If grep finds zero callers of the leaf components, the loop does not exist regardless of what compose_root does.
|
||||
- **Treating the bootstrap-structure task as the home of the main loop.** Bootstrap is project skeleton (package layout, CLI scaffold, build files). It is NOT the main loop. Main loop is its own task.
|
||||
- **Per-component tasks claiming integration scope.** A C1 VIO task's deliverable is "C1 works in isolation against unit tests". A C1 task's acceptance criteria MUST NOT include "C1 is wired into the runtime" — that's the integration task's job.
|
||||
|
||||
## Self-verification
|
||||
|
||||
- [ ] Every pipeline named in `architecture.md` / `system-flows.md` is listed in your enumeration.
|
||||
- [ ] Every enumerated pipeline either (a) has an existing covered task, or (b) has a new integration task in `todo/`.
|
||||
- [ ] No integration task exceeds 5 complexity points.
|
||||
- [ ] Every integration task names every component in the pipeline as a `Dependencies` entry.
|
||||
- [ ] No integration task is owned by a leaf component — every owner is named in `module-layout.md` as a spine / orchestrator.
|
||||
- [ ] Every integration task has a tracker ticket created and the filename renamed to `[TRACKER-ID]_pipeline_<name>.md`.
|
||||
|
||||
## Save action
|
||||
|
||||
Write the new integration task files into `_docs/02_tasks/todo/`. They will be picked up by Step 2 (Task Decomposition's dependency-table writer) and by Step 4 (Cross-Verification).
|
||||
|
||||
## Blocking
|
||||
|
||||
**BLOCKING**: Present the pipeline enumeration + the list of new integration tasks to the user. Do NOT proceed to Step 2 until the user confirms:
|
||||
|
||||
- The enumeration matches what they expect from the architecture documents.
|
||||
- Every uncovered pipeline now has an integration task.
|
||||
- The chosen spine owners are correct.
|
||||
|
||||
If the user identifies a pipeline you missed, add it before proceeding. If the user names a different spine owner, update the task and re-run self-verification.
|
||||
@@ -29,7 +29,7 @@ Save as `_docs/04_deploy/ci_cd_pipeline.md`.
|
||||
### Test
|
||||
- Unit tests: [framework and command]
|
||||
- Blackbox tests: [framework and command, uses docker-compose.test.yml]
|
||||
- Coverage threshold: 75% overall, 90% critical paths
|
||||
- Coverage threshold: 75% overall, 90% critical-path floor (100% aim) — per `.cursor/rules/cursor-meta.mdc` Quality Thresholds
|
||||
- Coverage report published as pipeline artifact
|
||||
|
||||
### Security
|
||||
|
||||
@@ -64,6 +64,27 @@ TASKS_DIR/
|
||||
└── done/ ← completed tasks (moved here after implementation)
|
||||
```
|
||||
|
||||
### Suite-level invocation context (meta-repo flow)
|
||||
|
||||
When invoked from `.cursor/skills/autodev/flows/meta-repo.md` Step 3.5 (or any caller that supplies the same context envelope), the skill receives:
|
||||
|
||||
```
|
||||
suite_level: true
|
||||
TASKS_DIR: <override> # e.g., _docs/tasks/ (vs. default _docs/02_tasks/)
|
||||
module_layout_path: <override> # e.g., _docs/tasks/_suite_module_layout.md
|
||||
```
|
||||
|
||||
When `suite_level: true` is present, the following gate adjustments apply — and ONLY these. All other steps (1–14, 16) execute unchanged:
|
||||
|
||||
1. **TASKS_DIR override** is honored throughout the skill (Step 1 Parse, Step 13 Archive, Step 15 input paths if it ran). Default `_docs/02_tasks/` is replaced by the supplied path.
|
||||
2. **module_layout_path override** is read instead of the hardcoded `_docs/02_document/module-layout.md` in Step 4 (Assign File Ownership). The supplied file uses the same `Per-Component Mapping` schema. If both the override and the hardcoded path are missing, behavior is unchanged from default mode (STOP and instruct).
|
||||
3. **Step 14.5 (Cumulative Code Review) — SKIPPED**. The meta-repo has no `_docs/02_document/architecture_compliance_baseline.md`; cross-task drift is captured by the next `monorepo-status` cycle instead.
|
||||
4. **Step 15 (Product Implementation Completeness Gate) — SKIPPED**. The gate's hard inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite-level tasks are infrastructure / coordination work (renames, cross-repo edits, suite-root infra additions), not feature implementation; the equivalent completeness signal is the next `monorepo-status` drift report (which the meta-repo flow re-runs immediately after Step 3.5 returns).
|
||||
5. **Final report filename**: `_docs/03_implementation/suite_implementation_report_{run_name}.md` (in addition to the existing feature/test/refactor variants). Batch reports follow `_docs/03_implementation/suite_batch_{NN}_report.md`.
|
||||
6. **Tracker integration** (Step 5: In Progress, Step 12: In Testing) runs unchanged — suite-level tickets follow the same tracker rules as any other.
|
||||
|
||||
Without `suite_level: true`, none of these adjustments apply and the skill runs exactly as documented in default mode.
|
||||
|
||||
## Prerequisite Checks (BLOCKING)
|
||||
|
||||
1. `TASKS_DIR/todo/` exists and contains at least one task file for the selected context — **STOP if missing**
|
||||
@@ -103,7 +124,7 @@ TASKS_DIR/
|
||||
|
||||
### 4. Assign File Ownership
|
||||
|
||||
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
|
||||
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5), unless `suite_level: true` was supplied in the invocation context — in which case the `module_layout_path` override is read instead (see "Suite-level invocation context" above). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
|
||||
|
||||
For each task in the batch:
|
||||
- Read the task spec's **Component** field.
|
||||
@@ -222,6 +243,8 @@ For product implementation, this archive means "batch implementation accepted."
|
||||
|
||||
### 14.5. Cumulative Code Review (every K batches)
|
||||
|
||||
**Skipped entirely when `suite_level: true`** (see "Suite-level invocation context" above) — the meta-repo has no `architecture_compliance_baseline.md` to evaluate against; cross-task drift is captured by the next `monorepo-status` cycle.
|
||||
|
||||
- **Trigger**: every K completed batches (default `K = 3`; configurable per run via a `cumulative_review_interval` knob in the invocation context)
|
||||
- **Purpose**: per-batch review (Step 9) catches batch-local issues; cumulative review catches issues that only appear when tasks are combined — architecture drift, cross-task inconsistency, duplicate symbols introduced across different batches, contracts that drifted across producer/consumer batches
|
||||
- **Scope**: the union of files changed since the **last** cumulative review (or since the start of the run if this is the first)
|
||||
@@ -239,7 +262,7 @@ For product implementation, this archive means "batch implementation accepted."
|
||||
|
||||
### 15. Product Implementation Completeness Gate
|
||||
|
||||
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate only when the remaining context is explicitly test implementation or refactoring, as determined by the task files and report filename rules.
|
||||
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate when (a) the remaining context is explicitly test implementation or refactoring (as determined by the task files and report filename rules), OR (b) `suite_level: true` was supplied in the invocation context (the gate's inputs do not exist in the meta-repo artifact layout — see "Suite-level invocation context" above).
|
||||
|
||||
**Goal**: catch the failure mode where narrow tests validate scaffold behavior while the task's actual outcome, included scope, architecture promise, or named integration remains unimplemented.
|
||||
|
||||
@@ -268,19 +291,46 @@ For each completed product task:
|
||||
- **BLOCKED**: production code exists but cannot be fully verified due to external hardware/data/license/runtime prerequisites; the blocker is explicit and tests report blocked/skipped with reason.
|
||||
- **FAIL**: promised production behavior is missing, only scaffolded, or only represented in tests/reports.
|
||||
|
||||
#### 15.b System-Pipeline Check (runs ONCE per gate invocation, after per-task classification)
|
||||
|
||||
The per-task classification above (steps 1–8) operates on `_docs/02_tasks/done/`. It catches missing component-local behavior but it CANNOT catch a missing *integration* — there is no task to fail if no task ever owned the integration in the first place. The GPS-passthrough incident (May 2026) escaped this gate because every per-component task in `done/` was honestly complete; the missing piece was the cross-component loop, which had no owning task.
|
||||
|
||||
The system-pipeline check fixes that by walking the architecture documents directly, independent of `done/`.
|
||||
|
||||
**Inputs**:
|
||||
- `_docs/02_document/architecture.md`
|
||||
- `_docs/02_document/system-flows.md`
|
||||
- Full source tree under the project's production directory (e.g. `src/`).
|
||||
|
||||
**Procedure**:
|
||||
|
||||
1. **Enumerate end-to-end pipelines.** Read `architecture.md` and `system-flows.md`. For each named pipeline / operational flow that spans 2+ components, record the ordered component sequence and the trigger (per-frame, per-request, scheduled, manual).
|
||||
2. **Grep for production callers of each seam method.** For each adjacent pair `A → B` in a pipeline, find a production source file (not under `tests/`, not under a `bench/` package, not a doc) that calls `A`'s public output method AND passes the result into `B`'s public input method.
|
||||
3. **Classify the pipeline**:
|
||||
- **WIRED**: a production caller exists and the chain is complete from the first to the last component in the sequence.
|
||||
- **PARTIALLY WIRED**: some adjacent pairs have callers but at least one seam is missing.
|
||||
- **NOT WIRED**: no production code calls the pipeline's components in order. Bench tools, unit tests, and microbenchmarks do NOT count as "wiring".
|
||||
4. **Distinguish "wired but stubbed" from "wired with real components"**: a caller that invokes a passthrough / GPS-from-tlog / mock-output-generator instead of the real component is `NOT WIRED` for the purposes of this gate. The seam exists in the source file but the production behavior is faked. Grep for the same scaffold markers Step 15 already enumerates (`placeholder`, `stub`, `passthrough`, `scaffold until`, etc.) inside the caller's body.
|
||||
5. **Output**: append a `## System Pipeline Audit` section to `_docs/03_implementation/implementation_completeness_cycle[N]_report.md`. Per-pipeline row: name, sequence, classification, evidence file (the caller, or "NONE FOUND"), remediation suggestion if not `WIRED`.
|
||||
|
||||
**Pipeline classification feeds the combined gate below.** Any pipeline that is not `WIRED` is a system-level FAIL that the per-task gate cannot rescue.
|
||||
|
||||
**Why this is here and not only in decompose**: decompose Step 1.7 creates integration tasks up front; this check verifies the integration tasks actually got implemented (or, if they were never created, surfaces the gap before the cycle closes). The two layers are belt-and-suspenders by design.
|
||||
|
||||
Save the audit to `_docs/03_implementation/implementation_completeness_cycle[N]_report.md` with:
|
||||
|
||||
- Per-task classification
|
||||
- Evidence files/symbols checked
|
||||
- Any unresolved scaffold/native placeholders
|
||||
- Any named promised technologies not integrated
|
||||
- **System Pipeline Audit table** (per pipeline: name, sequence, WIRED / PARTIALLY WIRED / NOT WIRED, evidence file, remediation suggestion)
|
||||
- Required remediation task suggestions, each sized to 5 points or less
|
||||
|
||||
Gate:
|
||||
|
||||
- If every product task is `PASS` or `BLOCKED` with explicit prerequisite evidence, continue to Final Test Run.
|
||||
- If any product task is `FAIL`, STOP. Do not write the final product implementation report and do not proceed to any downstream autodev step. Completed original task files remain in `done/`; the missing work is represented by remediation tasks. Present a Choose block:
|
||||
- A) Create remediation tasks now and return to implementation
|
||||
- If every product task is `PASS` or `BLOCKED` with explicit prerequisite evidence, AND every enumerated pipeline is `WIRED`, continue to Final Test Run.
|
||||
- If any product task is `FAIL` OR any pipeline is `PARTIALLY WIRED` / `NOT WIRED`, STOP. Do not write the final product implementation report and do not proceed to any downstream autodev step. Completed original task files remain in `done/`; the missing work is represented by remediation tasks. Present a Choose block:
|
||||
- A) Create remediation tasks now and return to implementation. (For pipeline FAILs the remediation task is a NEW integration task owned by the spine component per `_docs/02_document/module-layout.md`; it is NOT a test task and NOT a doc task; its deliverable is production code that drives the pipeline against real components.)
|
||||
- B) Mark the missing behavior explicitly out of scope in task/docs, then re-run this gate
|
||||
- C) Abort for manual correction
|
||||
- Recommendation must normally be A unless the user deliberately accepts reduced scope.
|
||||
@@ -309,8 +359,9 @@ After each batch completes, save the batch report to `_docs/03_implementation/ba
|
||||
- **Test implementation** (tasks from test decomposition): `_docs/03_implementation/implementation_report_tests.md`
|
||||
- **Feature implementation**: `_docs/03_implementation/implementation_report_{feature_slug}_cycle{N}.md` where `{feature_slug}` is derived from the batch task names (e.g., `implementation_report_core_api_cycle2.md`) and `{N}` is the current `state.cycle` from `_docs/_autodev_state.md`. If `state.cycle` is absent (pre-migration), default to `cycle1`.
|
||||
- **Refactoring**: `_docs/03_implementation/implementation_report_refactor_{run_name}.md`
|
||||
- **Suite-level** (when `suite_level: true` was supplied — see "Suite-level invocation context" above): `_docs/03_implementation/suite_implementation_report_{run_name}.md`. Batch reports use `_docs/03_implementation/suite_batch_{NN}_report.md`. `{run_name}` is derived from the batch task IDs (e.g., `suite_implementation_report_az543_az549_az550.md`).
|
||||
|
||||
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; otherwise derive the feature slug from the component names and append the cycle suffix.
|
||||
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; if `suite_level: true` was supplied, use the suite filename; otherwise derive the feature slug from the component names and append the cycle suffix.
|
||||
|
||||
Batch report filenames must also include the cycle counter when running feature implementation: `_docs/03_implementation/batch_{NN}_cycle{N}_report.md` (test and refactor runs may use the plain `batch_{NN}_report.md` form since they are not cycle-scoped).
|
||||
|
||||
|
||||
@@ -84,29 +84,66 @@ Assess the change along these dimensions:
|
||||
- **Novelty**: does it involve libraries, protocols, or patterns not already in the codebase?
|
||||
- **Risk**: could it break existing functionality or require architectural changes?
|
||||
|
||||
Classification:
|
||||
### 2a. Complexity-Points Estimate
|
||||
|
||||
Project policy (per the workspace user-rule on ADO points): aim for tasks at 2–3 points (rarely 5). Tasks at 8 points are high risk; tasks at 13 are too complex and MUST be broken down. The new-task skill enforces this here, before producing a single-file task spec.
|
||||
|
||||
Map the Scope/Novelty/Risk profile to a points estimate using this table:
|
||||
|
||||
| Profile | Points | Examples |
|
||||
|---------|--------|----------|
|
||||
| All three low | **1–2** | One-line config change; trivial CRUD field addition |
|
||||
| Two low + one medium | **3** | Localized refactor; add one well-understood endpoint |
|
||||
| One low + two medium, OR all medium | **5** | New small feature touching 2–3 components; integration with a known library |
|
||||
| Any high, OR two medium + one high | **8** | Cross-cutting concern across 4+ components; integration with an unfamiliar protocol; significant architectural change |
|
||||
| Two or three high | **13** | New subsystem; unfamiliar tech across the stack; multiple unknown unknowns |
|
||||
|
||||
If a relevant LESSONS.md entry biases the estimate (e.g., "auth-related changes historically take 2× estimate"), apply the multiplier and round up to the next discrete point on the scale (1, 2, 3, 5, 8, 13).
|
||||
|
||||
### 2b. Routing by Complexity
|
||||
|
||||
| Estimate | Default routing | Override path |
|
||||
|----------|-----------------|---------------|
|
||||
| **1–5** | Continue this skill at Step 3 (Research) or Step 4 (Codebase Analysis) — see classification below | — |
|
||||
| **8** | **STOP this skill and recommend handoff to `/decompose @<feature_description>`** (single-component decompose mode if the affected scope fits inside one component, default mode if it does not). The user may override and proceed in `/new-task`, but the override must be explicitly chosen. | C) Proceed in /new-task anyway with the user's acknowledgement that the resulting task is high-risk and may need to be re-decomposed mid-implementation |
|
||||
| **13** | **STOP this skill — auto-handoff is mandatory.** A 13-point feature cannot be a single task spec. Invoke `/decompose @<feature_description>` (default mode) before writing any task file. Surface the handoff to the user with no override path; this is a hard policy gate. | None — must decompose |
|
||||
|
||||
For the auto-handoff path:
|
||||
|
||||
1. Render a one-paragraph description of the feature suitable to feed `/decompose` (combine Step 1's verbatim user description with the complexity-points reasoning).
|
||||
2. Save it to `_docs/02_task_plans/<feature_slug>/feature-description.md` so the decompose skill has a stable input file.
|
||||
3. Either (a) directly auto-chain into `.cursor/skills/decompose/SKILL.md` in default mode with this file as input, or (b) report the handoff to the user along with the exact `/decompose` invocation and stop. Pick (a) only if the user has explicitly enabled auto-chain across skills (e.g., we are inside an `/autodev` invocation); otherwise pick (b).
|
||||
|
||||
### 2c. Research vs Skip Research (only for ≤5 estimates)
|
||||
|
||||
Classification (independent of points; runs only when points ≤ 5 and Step 2b chose Continue):
|
||||
|
||||
| Category | Criteria | Action |
|
||||
|----------|----------|--------|
|
||||
| **Needs research** | New libraries/frameworks, unfamiliar protocols, significant architectural change, multiple unknowns | Proceed to Step 3 (Research) |
|
||||
| **Needs research** | New libraries/frameworks, unfamiliar protocols, multiple unknowns | Proceed to Step 3 (Research) |
|
||||
| **Skip research** | Extends existing functionality, uses patterns already in codebase, straightforward new component with known tech | Skip to Step 4 (Codebase Analysis) |
|
||||
|
||||
Present the assessment to the user:
|
||||
Present the full assessment to the user:
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
COMPLEXITY ASSESSMENT
|
||||
══════════════════════════════════════
|
||||
Scope: [low / medium / high]
|
||||
Novelty: [low / medium / high]
|
||||
Risk: [low / medium / high]
|
||||
Scope: [low / medium / high]
|
||||
Novelty: [low / medium / high]
|
||||
Risk: [low / medium / high]
|
||||
Points: [1 / 2 / 3 / 5 / 8 / 13] (project aim: 2–3, rarely 5)
|
||||
Routing: [Continue in /new-task | Hand off to /decompose]
|
||||
══════════════════════════════════════
|
||||
Recommendation: [Research needed / Skip research]
|
||||
Reason: [one-line justification]
|
||||
Recommendation: [Research needed | Skip research | Decompose required]
|
||||
Reason: [one-line justification, including any LESSONS.md influence]
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
**BLOCKING**: Ask the user to confirm or override the recommendation before proceeding.
|
||||
**BLOCKING**:
|
||||
- If points ≤ 5 → ask the user to confirm or override the research recommendation before proceeding.
|
||||
- If points = 8 → ask the user to choose between hand-off to /decompose (recommended) and continuing in /new-task with explicit risk acknowledgement.
|
||||
- If points = 13 → STOP and present the handoff plan; do not offer a continue-anyway override.
|
||||
|
||||
---
|
||||
|
||||
@@ -136,11 +173,9 @@ The `<task_slug>` is a short kebab-case name derived from the feature descriptio
|
||||
1. Read the codebase documentation from DOCUMENT_DIR:
|
||||
- `architecture.md` — overall structure (the `## Architecture Vision` H2 is user-confirmed intent and must not be violated by the new task without explicit approval)
|
||||
- `glossary.md` — project terminology; reuse the user's vocabulary in task names, AC, and component references
|
||||
- `components/` — component specs (one folder per Layer-3 service component)
|
||||
- `modules/` — process-level documentation (Layer-4 WebApi lives in `modules/api_program.md`, not in `components/`; see `module-layout.md` § Documentation Layout — AZ-495)
|
||||
- `components/` — component specs
|
||||
- `system-flows.md` — data flows (if exists)
|
||||
- `data_model.md` — data model (if exists)
|
||||
- When the task touches WebApi (`SatelliteProvider.Api`), the documentation anchor is `modules/api_program.md` — do NOT reference `components/01_web_api/description.md` or any `components/*/web_api*` path; that folder is intentionally absent per the AZ-495 convention.
|
||||
2. If research was performed (Step 3), incorporate findings
|
||||
3. Analyze and determine:
|
||||
- Which existing components are affected
|
||||
@@ -205,7 +240,13 @@ Apply the four shared-task triggers from `.cursor/skills/decompose/SKILL.md` Ste
|
||||
2. Add the layout edit to the task's deliverables; the implementer writes it alongside the code change.
|
||||
3. If `module-layout.md` does not exist, STOP and instruct the user to run `/document` first (existing-code flow) or `/decompose` default mode (greenfield). Do not guess.
|
||||
|
||||
Record the classification and any contract/layout deliverables in the working notes; they feed Step 5 (Validate Assumptions) and Step 6 (Create Task).
|
||||
- **ADR cross-check** — runs unconditionally for every new-task in any of the three classifications above:
|
||||
1. If `_docs/02_document/adr/` exists, scan every `Status: Accepted` ADR. For each, ask: "would the proposed task either contradict this ADR's `Decision` or materially affect its `Consequences`?"
|
||||
2. **Conflict** (task contradicts an Accepted ADR) → STOP and Choose A/B/C: **A)** Re-scope the task to comply with the ADR, **B)** Propose superseding the ADR — the task spec then includes a deliverable to invoke `/plan --adr-only` (or the next `/plan` cycle's Step 4.5) with `Supersedes: ADR-NNN`, and the new task does NOT proceed until that supersede ADR is `Accepted`, **C)** Park the task in `backlog/` with a `Blocked-By: ADR-NNN review` note. Do not silently approve a contradictory task.
|
||||
3. **Drift** (task changes assumptions an ADR depends on but does not directly contradict it) → record the affected ADR(s) under a new `### ADR Impact` section in the task spec with `> Affects ADR NNN_<slug>: <one-line summary>`. The implementer surfaces this at code-review Phase 7 (which then classifies it as ADR-Drift if not addressed).
|
||||
4. **Aligned** (task implements something an Accepted ADR mandates) → cite the ADR(s) under `### ADR Compliance` in the task spec with `> Implements ADR NNN_<slug>`. Code-review Phase 7 then expects matching evidence in the implemented code.
|
||||
|
||||
Record the classification, any contract/layout deliverables, and any ADR cross-check outcomes in the working notes; they feed Step 5 (Validate Assumptions) and Step 6 (Create Task).
|
||||
|
||||
**BLOCKING**: none — this step surfaces findings; the user confirms them in Step 5.
|
||||
|
||||
@@ -265,6 +306,9 @@ Present using the Choose format for each decision that has meaningful alternativ
|
||||
- [ ] If Step 4.5 classified the task as producer, the `## Contract` section exists and points at a contract file
|
||||
- [ ] If Step 4.5 classified the task as consumer, `### Document Dependencies` lists the relevant contract file
|
||||
- [ ] If Step 4.5 flagged a layout delta, the task's Scope.Included names the `module-layout.md` edit
|
||||
- [ ] If Step 4.5 flagged an ADR conflict, the task is either re-scoped (A), explicitly blocked on a supersede ADR (B), or parked in backlog (C) — never silently bypassed
|
||||
- [ ] If Step 4.5 flagged ADR drift, the task spec has an `### ADR Impact` section listing the affected ADR(s)
|
||||
- [ ] If Step 4.5 flagged ADR alignment, the task spec has an `### ADR Compliance` section citing the implemented ADR(s)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ disable-model-invocation: true
|
||||
|
||||
# Solution Planning
|
||||
|
||||
Decompose a problem and solution into architecture, data model, deployment plan, system flows, components, tests, and work item epics through a systematic 6-step workflow.
|
||||
Decompose a problem and solution into architecture, data model, deployment plan, system flows, components, ADRs, tests, and work item epics through a systematic workflow with seven step files (1, 2, 3, 4, 4.5, 5, 6) plus a Final quality checklist.
|
||||
|
||||
## Core Principles
|
||||
|
||||
@@ -55,7 +55,7 @@ Read `steps/01_artifact-management.md` for directory structure, save timing, sav
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
At the start of execution, create a TodoWrite with all steps (1 through 6 plus Final). Update status as each step completes.
|
||||
At the start of execution, create a TodoWrite with all steps (1, 2, 3, 4, 4.5, 5, 6 plus Final). Update status as each step completes. The fractional Step 4.5 (ADR Capture) sits between Architecture Review (Step 4) and Test Specifications (Step 5).
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -85,6 +85,16 @@ Read and follow `steps/04_review-risk.md`.
|
||||
|
||||
---
|
||||
|
||||
### Step 4.5: Architecture Decision Records (ADRs)
|
||||
|
||||
Read and follow `steps/04-5_adr-capture.md`.
|
||||
|
||||
This step captures the architecture and tech-stack decisions that were made (or revised) in Steps 2–4 as durable, dated, immutable records under `_docs/02_document/adr/`. ADRs are the single thing in `_docs/` that explain the **why** of each major decision after the conversation history is gone. They are consumed by `decompose` (when bootstrapping module layout), `new-task` (when assessing a new feature against existing decisions), `refactor` (when proposing replacements), and any future code-review cycle that needs to confirm a structural choice was deliberate.
|
||||
|
||||
This step is **BLOCKING**: the ADR set must be reviewed and confirmed by the user before Step 5 begins.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Test Specifications
|
||||
|
||||
Read and follow `steps/05_test-specifications.md`.
|
||||
@@ -120,7 +130,7 @@ Read and follow `steps/07_quality-checklist.md`.
|
||||
|-----------|--------|
|
||||
| Missing acceptance_criteria.md, restrictions.md, or input_data/ | **STOP** — planning cannot proceed |
|
||||
| Ambiguous requirements | ASK user |
|
||||
| Input data coverage below 75% | Search internet for supplementary data, ASK user to validate |
|
||||
| Input data coverage below the canonical threshold (`cursor-meta.mdc` Quality Thresholds) | Search internet for supplementary data, ASK user to validate |
|
||||
| Technology choice with multiple valid options | ASK user |
|
||||
| Component naming | PROCEED, confirm at next BLOCKING gate |
|
||||
| File structure within templates | PROCEED |
|
||||
@@ -146,6 +156,8 @@ Read and follow `steps/07_quality-checklist.md`.
|
||||
│ [BLOCKING: user confirms components] │
|
||||
│ 4. Review & Risk → risk register, iterations │
|
||||
│ [BLOCKING: user confirms mitigations] │
|
||||
│ 4.5 ADR Capture → _docs/02_document/adr/NNN_*.md │
|
||||
│ [BLOCKING: user confirms ADR set] │
|
||||
│ 5. Test Specifications → per-component test specs │
|
||||
│ 6. Work Item Epics → epic per component + bootstrap │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
|
||||
@@ -26,6 +26,10 @@ DOCUMENT_DIR/
|
||||
│ └── deployment_procedures.md
|
||||
├── risk_mitigations.md
|
||||
├── risk_mitigations_02.md (iterative, ## as sequence)
|
||||
├── adr/
|
||||
│ ├── 001_[decision_slug].md
|
||||
│ ├── 002_[decision_slug].md
|
||||
│ └── ...
|
||||
├── components/
|
||||
│ ├── 01_[name]/
|
||||
│ │ ├── description.md
|
||||
@@ -66,6 +70,8 @@ DOCUMENT_DIR/
|
||||
| Step 3 | Common helpers generated | `common-helpers/[##]_helper_[name].md` |
|
||||
| Step 3 | Diagrams generated | `diagrams/` |
|
||||
| Step 4 | Risk assessment complete | `risk_mitigations.md` |
|
||||
| Step 4.5 | Each ADR captured | `adr/NNN_[decision_slug].md` |
|
||||
| Step 4.5 | ADR index updated | `adr/README.md` |
|
||||
| Step 5 | Tests written per component | `components/[##]_[name]/tests.md` |
|
||||
| Step 6 | Epics created in work item tracker | Tracker via MCP |
|
||||
| Final | All steps complete | `FINAL_report.md` |
|
||||
@@ -85,3 +91,15 @@ If DOCUMENT_DIR already contains artifacts:
|
||||
2. Identify the last completed step based on which artifacts exist
|
||||
3. Resume from the next incomplete step
|
||||
4. Inform the user which steps are being skipped
|
||||
|
||||
#### Step 4.5 (ADR Capture) resumption rule
|
||||
|
||||
ADR files have a `Status` field that disambiguates "step in progress" from "step done":
|
||||
|
||||
- `Status: Proposed` → Step 4.5 is **in progress**. The user has not yet hit the BLOCKING gate (or hit it and chose B/C/D, which kept files at `Proposed`). Resume Step 4.5 at Phase 4.5f and re-present the BLOCKING Choose to the user. Do NOT skip to Step 5.
|
||||
- `Status: Accepted` AND `adr/README.md` index exists AND every Accepted ADR is referenced in the index → Step 4.5 is **done**. Skip to Step 5.
|
||||
- `Status: Accepted` but `adr/README.md` is missing or out of date → Step 4.5 is **partially complete**. Resume at Phase 4.5d (Maintain the ADR Index) before moving on.
|
||||
- Mixed `Proposed` + `Accepted` files in the same directory → Step 4.5 is **in progress** with prior partial confirmations. Resume at Phase 4.5f and re-present only the still-`Proposed` ADRs.
|
||||
- Empty `adr/` directory or no `adr/` directory → Step 4.5 has not started yet. Begin at Phase 4.5a.
|
||||
|
||||
The `Date` field on every Accepted ADR is the date the user confirmed it; do not regenerate it during resumption.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Step 4.5: Architecture Decision Records (ADRs)
|
||||
|
||||
**Role**: Architect / technical writer
|
||||
**Goal**: Capture every major architecture, tech-stack, data-model, and integration decision made during Steps 2–4 as a durable, dated, immutable record under `_docs/02_document/adr/`.
|
||||
**Constraints**: ADRs only — do not re-open architecture; do not make new decisions in this step. Document what has been decided, not what is still open.
|
||||
|
||||
ADRs are the single thing in `_docs/` that explains the **why** of each major decision after the conversation history is gone. They are consumed by:
|
||||
|
||||
- `decompose` Step 1.5 (`steps/01-5_module-layout.md`) — every Accepted ADR is cross-checked against the module-layout proposal; conflicts trigger an explicit Choose between supersede / exception / re-open.
|
||||
- `new-task` Step 4.5 (`SKILL.md` § "Step 4.5: Contract & Layout Check") — every new task is classified against Accepted ADRs as Conflict / Drift / Aligned; conflicts STOP the task with a Choose A/B/C; drift adds an `### ADR Impact` section; alignment adds an `### ADR Compliance` section.
|
||||
- `refactor` Phase 2b.1 (`phases/02-analysis.md`) — every Accepted ADR is diffed against the proposed roadmap; Violations trigger a BLOCKING supersede gate that produces a `supersede_adr_NNN.md` task before any refactor task is created.
|
||||
- `code-review` Phase 7 (`SKILL.md` § "Phase 7: Architecture Compliance") — every changed-files batch is checked against Accepted ADRs; ADR-Violation findings are Critical, ADR-Drift findings are High.
|
||||
|
||||
Discipline that still relies on the human: when a downstream skill detects a Drift case, the resulting task spec MUST land its `## ADR Impact` / `## ADR Compliance` section; the implementer must address it; the next code-review batch then has the context it needs. Drift left undocumented is the silent-failure path — every consumer hook above is designed to make it visible.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `_docs/02_document/architecture.md` (incl. confirmed `## Architecture Vision`)
|
||||
- `_docs/02_document/glossary.md`
|
||||
- `_docs/02_document/data_model.md`
|
||||
- `_docs/02_document/system-flows.md`
|
||||
- `_docs/02_document/risk_mitigations.md` (and any `risk_mitigations_NN.md` iterations from Step 4)
|
||||
- `_docs/02_document/components/[##]_[name]/description.md`
|
||||
- `_docs/02_document/deployment/` (CI/CD, environments, observability)
|
||||
- `_docs/00_problem/restrictions.md` and `_docs/00_problem/acceptance_criteria.md` (each ADR must reference relevant constraints / AC by ID)
|
||||
- Optional: `_docs/01_solution/solution.md` and `_docs/01_solution/tech_stack.md` (research output)
|
||||
- Optional: `_docs/LESSONS.md` — surface any lesson categories of `architecture` / `dependencies` that bias the recommendation
|
||||
|
||||
## What is an ADR (and what is not)
|
||||
|
||||
Capture an ADR when **all** of the following hold:
|
||||
|
||||
1. The decision picks between two or more genuinely valid approaches with meaningful trade-offs.
|
||||
2. The decision has **downstream consequences** that other decisions, code, or tasks inherit from.
|
||||
3. The decision is **non-obvious** to a future reader who only sees the final code — they would ask "why was it built this way?" rather than discovering the answer by reading the source.
|
||||
|
||||
Do NOT create an ADR for:
|
||||
|
||||
- Naming, formatting, or purely cosmetic choices.
|
||||
- A choice that is fully implied by a single explicit restriction (`restrictions.md` is itself the record — link to it from the architecture doc instead).
|
||||
- A choice the team has not actually made yet — open questions live in `risk_mitigations.md` or `_docs/_process_leftovers/`, not in ADRs.
|
||||
- A technology selection where research already produced an exact-fit selection with one viable option (the research doc is the record — link to the relevant `solution_draft*.md` section).
|
||||
|
||||
## Process
|
||||
|
||||
### Phase 4.5a: Decision Inventory
|
||||
|
||||
Walk the inputs and list candidate decisions. For each candidate, record a one-liner:
|
||||
|
||||
```
|
||||
- [decision] — [trade-off summary] — [downstream consumers] — [evidence file:section]
|
||||
```
|
||||
|
||||
Inspect at minimum:
|
||||
|
||||
| Inspection target | Typical decisions surfaced |
|
||||
|-------------------|----------------------------|
|
||||
| `architecture.md` § layering | Layering style (clean vs hex vs n-tier), which layer owns transactions, how cross-cutting concerns enter |
|
||||
| `architecture.md` § Architecture Vision | The North Star principle (e.g., "edge-first, sync-second"); ADR captures the implication for one specific subsystem |
|
||||
| `data_model.md` | Datastore choice (Postgres vs Mongo), partitioning, soft vs hard deletes, schema evolution strategy |
|
||||
| `system-flows.md` | Sync vs async boundaries, idempotency strategy, retry policy ownership, error envelope shape |
|
||||
| `components/*/description.md` § interfaces | Public-API style (REST vs RPC vs event), versioning strategy, auth/authorization placement |
|
||||
| `deployment/containerization.md` | Single container vs sidecar vs init container, base image lineage |
|
||||
| `deployment/ci_cd_pipeline.md` | Trunk-based vs feature-branch, gate ordering, deploy strategy (blue-green / canary / all-at-once) |
|
||||
| `deployment/observability.md` | Logging stack, metric backend, sampling rate decisions, retention |
|
||||
| `risk_mitigations.md` | Risk-acceptance trade-offs (e.g., "we accept N% data loss in exchange for sub-100ms p99") |
|
||||
| Tech-stack from `_docs/01_solution/tech_stack.md` | Anything where research recorded ≥2 candidates and a winner |
|
||||
|
||||
Drop any candidate that fails the three "what is an ADR" criteria above. Keep the rest.
|
||||
|
||||
### Phase 4.5b: Numbering and Slugs
|
||||
|
||||
ADRs are numbered globally per project, monotonically, never re-used.
|
||||
|
||||
1. List existing files under `_docs/02_document/adr/` matching `^[0-9]{3}_.+\.md$`.
|
||||
2. The next ADR number is `max(existing) + 1`, zero-padded to 3 digits.
|
||||
3. The slug is kebab-case, ≤6 words, derived from the decision summary. Example: `001_use-postgres-for-transactional-data.md`, `004_event-driven-cross-component-comms.md`.
|
||||
|
||||
### Phase 4.5c: Render One ADR Per Decision
|
||||
|
||||
For each kept candidate, render the ADR using `templates/adr.md`. Required sections (do NOT omit any):
|
||||
|
||||
| Section | Content |
|
||||
|---------|---------|
|
||||
| **Number** | `NNN` |
|
||||
| **Title** | One-line decision statement (matches slug) |
|
||||
| **Status** | `Proposed` (only during Step 4.5 iteration) → `Accepted` (after user confirmation at the BLOCKING gate) |
|
||||
| **Date** | YYYY-MM-DD (the date the user confirmed) |
|
||||
| **Deciders** | The user (project owner) — the AI is not a decider |
|
||||
| **Context** | The problem this decision addresses, including links to AC IDs, restriction IDs, risks, and (where relevant) the research draft section |
|
||||
| **Decision** | The chosen approach in one sentence, then the supporting detail |
|
||||
| **Alternatives Considered** | Each alternative with a one-line "rejected because…" |
|
||||
| **Consequences** | Positive (what becomes easier / cheaper / faster) and negative (what becomes harder / locked in / costly to undo). Be honest — every decision has a downside. |
|
||||
| **Supersedes / Superseded by** | Empty initially; updated when a future ADR overturns this one |
|
||||
| **Evidence** | File-and-section pointers into `_docs/` showing where the decision is reflected (architecture.md § layering, components/02_*/description.md § interface, etc.) |
|
||||
|
||||
After rendering, write each file to `_docs/02_document/adr/NNN_<slug>.md`. Keep `Status: Proposed` until the BLOCKING gate.
|
||||
|
||||
### Phase 4.5d: Maintain the ADR Index
|
||||
|
||||
Write or update `_docs/02_document/adr/README.md` with this exact shape:
|
||||
|
||||
```markdown
|
||||
# Architecture Decision Records
|
||||
|
||||
This index lists every ADR for this project, in number order. ADRs are immutable once `Accepted` —
|
||||
new decisions that overturn a prior ADR are recorded as new ADRs whose `Supersedes` field points
|
||||
back, and the original ADR's `Superseded by` field is updated.
|
||||
|
||||
| # | Title | Status | Date | Supersedes |
|
||||
|---|-------|--------|------|------------|
|
||||
| 001 | Use Postgres for transactional data | Accepted | 2026-05-21 | — |
|
||||
| 002 | Event-driven cross-component comms | Accepted | 2026-05-21 | — |
|
||||
| ... | ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
Sort by `#` ascending. Include all ADRs ever written, even superseded ones — the audit trail is the point.
|
||||
|
||||
### Phase 4.5e: Cross-Link from architecture.md
|
||||
|
||||
In `architecture.md`, every section that reflects an ADR decision gets a one-line trailing reference:
|
||||
|
||||
```markdown
|
||||
> See ADR 001 (Use Postgres for transactional data), ADR 003 (Event-driven cross-component comms).
|
||||
```
|
||||
|
||||
Place the reference at the end of the section, after the prose. This lets a future reader of `architecture.md` jump straight to the rationale.
|
||||
|
||||
### Phase 4.5f: BLOCKING Gate — User Confirmation
|
||||
|
||||
Present the ADR set to the user using the Choose format from `.cursor/skills/autodev/protocols.md` (or plain text if AskQuestion is unavailable):
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
DECISION REQUIRED: ADR set captured (N records)
|
||||
══════════════════════════════════════
|
||||
001 — [title]
|
||||
002 — [title]
|
||||
...
|
||||
══════════════════════════════════════
|
||||
A) Accept all ADRs as written
|
||||
B) Edit specific ADRs (numbers and edits)
|
||||
C) Add a missed decision (description)
|
||||
D) Remove an ADR (number and reason)
|
||||
══════════════════════════════════════
|
||||
Recommendation: A — review the rendered set and confirm; corrections are quick on Round 2
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
Loop:
|
||||
|
||||
- **A** → flip every ADR's `Status` from `Proposed` to `Accepted`, set `Date` to today's date, save, exit step.
|
||||
- **B** → apply edits, re-present the modified ADRs, loop.
|
||||
- **C** → run Phase 4.5a–4.5e for the missed decision only, append to the set, re-present, loop.
|
||||
- **D** → confirm with the user that the candidate fails the three "what is an ADR" criteria, remove the file, update the index, loop.
|
||||
|
||||
Do NOT mark `Accepted` without an explicit user A.
|
||||
|
||||
## Self-verification
|
||||
|
||||
- [ ] Every kept candidate from Phase 4.5a has a corresponding file under `adr/`
|
||||
- [ ] Every ADR has all required sections (none empty except `Supersedes` / `Superseded by`)
|
||||
- [ ] `Decision` sections are one-sentence-then-detail, not "we'll figure it out"
|
||||
- [ ] `Alternatives Considered` lists at least one rejected alternative per ADR
|
||||
- [ ] `Consequences` lists both positive AND negative consequences (an ADR with no negatives is suspect)
|
||||
- [ ] `Evidence` points at real `_docs/` sections that exist on disk
|
||||
- [ ] `adr/README.md` index lists every file in the directory and matches their `Status` / `Date`
|
||||
- [ ] `architecture.md` has a trailing `See ADR …` reference at every section that an ADR reflects
|
||||
- [ ] The user confirmed the set via Choose A; every ADR is `Accepted` with today's date
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **Re-opening architecture**: Step 4.5 records, it does not decide. If a candidate decision turns out to be unsettled, that's a Step 2 / Step 4 gap — return there, do not paper over it with a wishy-washy ADR.
|
||||
- **Decision-of-the-week**: do not write an ADR for every minor pattern choice. The bar is "non-obvious to a future reader". 5–15 ADRs is typical for a planning round; 40+ is over-capture.
|
||||
- **Negative consequences left empty**: every real decision has costs. If you cannot name one, the decision was not actually weighed.
|
||||
- **Vague evidence**: `architecture.md` is not enough — point at the specific section. `architecture.md § Layering` ≠ `architecture.md`.
|
||||
- **Numbering reuse**: never recycle a number from a deleted ADR. The audit trail is more important than tidy numbering.
|
||||
- **Superseding without recording**: when a later cycle overturns an ADR, the new ADR must point at the old one via `Supersedes`, AND the old ADR's `Superseded by` field must be updated. Index reflects both. (This is enforced when `decompose` or `refactor` later updates ADRs.)
|
||||
|
||||
## Escalation
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Candidate decision is unsettled (the team has not actually decided) | Return to the originating step (2 / 3 / 4); do NOT write a placeholder ADR |
|
||||
| Two candidates in Phase 4.5a turn out to be the same decision phrased differently | Merge into one ADR, list both phrasings in `Context` |
|
||||
| User picks D (remove an ADR) and the AI judges the decision is genuinely worth recording | Surface the disagreement, ASK why the user wants it removed, defer to user |
|
||||
| Existing `adr/` directory has files but `adr/README.md` is missing or stale | Rebuild the index from the directory before adding new ADRs |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Role**: Professional Quality Assurance Engineer
|
||||
|
||||
**Goal**: Write test specs for each component achieving minimum 75% acceptance criteria coverage
|
||||
**Goal**: Write test specs for each component achieving the canonical minimum acceptance-criteria coverage (currently 75% — see `.cursor/rules/cursor-meta.mdc` Quality Thresholds; do not restate a different number here)
|
||||
|
||||
**Constraints**: Test specs only — no test code. Each test must trace to an acceptance criterion.
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-{NNN}: {decision-title}
|
||||
|
||||
- **Status**: {Proposed | Accepted | Deprecated | Superseded}
|
||||
- **Date**: {YYYY-MM-DD}
|
||||
- **Deciders**: {user / project owner}
|
||||
- **Supersedes**: {ADR-NNN | —}
|
||||
- **Superseded by**: {ADR-NNN | —}
|
||||
|
||||
## Context
|
||||
|
||||
What problem does this decision address? Cite the relevant constraint(s), acceptance criterion / criteria, and risk(s) by ID.
|
||||
|
||||
- Acceptance criteria addressed: AC-{ID-1}, AC-{ID-2}
|
||||
- Restrictions addressed: R-{ID-1}, R-{ID-2}
|
||||
- Risks addressed: RISK-{ID-1}
|
||||
- Research source (if any): `_docs/01_solution/solution_draftN.md` § {section}
|
||||
|
||||
A short paragraph (3–6 sentences) explaining why a choice is required now and what makes it non-trivial. Do not pre-announce the decision here — that goes in `Decision`. Focus on the forces at play (load, scale, team familiarity, hardware constraints, regulatory drivers, third-party limits).
|
||||
|
||||
## Decision
|
||||
|
||||
One declarative sentence: **"We will …"** Then 1–3 paragraphs of supporting detail explaining how the decision will be implemented at the boundaries between components.
|
||||
|
||||
Be specific. "We will use Postgres" is too thin; "We will use Postgres 16 with logical replication for read scaling, restricting JSONB columns to top-level metadata only, with all transactional data in normalized tables" is the right resolution.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Rejected because |
|
||||
|-------------|------------------|
|
||||
| {Alt 1 — short label} | {one line: the cost / mismatch / risk that ruled it out, ideally referencing a measurable criterion} |
|
||||
| {Alt 2 — short label} | {one line} |
|
||||
| {Alt 3 — short label} | {one line} |
|
||||
|
||||
At least one rejected alternative is mandatory. If only one option was ever considered, this is not an ADR — link to the source restriction or research selection from the parent doc instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- {What becomes easier / cheaper / faster, with concrete examples where possible}
|
||||
- {…}
|
||||
|
||||
### Negative
|
||||
|
||||
- {What becomes harder / locked in / costly to undo}
|
||||
- {…}
|
||||
|
||||
Every real decision has both. If the negatives section is hard to fill, the alternatives were probably not weighed seriously — return to the prior step.
|
||||
|
||||
### Neutral / Open
|
||||
|
||||
- {What is unchanged but worth flagging for future readers (e.g., "this does not change the auth boundary; auth remains in component 02_user_management as decided in ADR-003")}
|
||||
|
||||
## Evidence
|
||||
|
||||
Where this decision is reflected on disk. Use `file:section` links so future readers can jump.
|
||||
|
||||
- `_docs/02_document/architecture.md` § {section}
|
||||
- `_docs/02_document/data_model.md` § {section}
|
||||
- `_docs/02_document/components/{##_name}/description.md` § {section}
|
||||
- `_docs/02_document/system-flows.md` § {flow name}
|
||||
- `_docs/02_document/deployment/{file}.md` § {section}
|
||||
- {add more as needed}
|
||||
|
||||
## Notes
|
||||
|
||||
Optional. Use for caveats that did not fit above, links to external research, or follow-ups that the team agreed to revisit on a known trigger ("re-evaluate after 6 months in production" / "re-evaluate when load exceeds 10× baseline").
|
||||
@@ -1,6 +1,6 @@
|
||||
# Final Planning Report Template
|
||||
|
||||
Use this template after completing all 6 steps and the quality checklist. Save as `_docs/02_document/FINAL_report.md`.
|
||||
Use this template after completing all steps (1, 2, 3, 4, 4.5, 5, 6) and the quality checklist. Save as `_docs/02_document/FINAL_report.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,6 +39,44 @@ Write `RUN_DIR/analysis/research_findings.md`:
|
||||
4. Prioritize changes by impact and effort
|
||||
5. Reject or escalate any proposed refactor that improves code structure while weakening required behavior, integration contracts, runtime constraints, safety/security posture, or acceptance criteria
|
||||
|
||||
### 2b.1. ADR Superseding Gate (BLOCKING)
|
||||
|
||||
A refactor that improves code structure while overturning a documented architecture decision is the silent-drift class the project repeatedly burns on (see `meta-rule.mdc` § GPS-passthrough postmortem and the auto-lessons it produced). This gate makes drift visible and forces a deliberate ADR update.
|
||||
|
||||
1. **List candidate ADRs**: read every `Status: Accepted` file in `_docs/02_document/adr/`. If the directory does not exist or contains only the index, log `No ADRs in scope` to `RUN_DIR/analysis/adr_impact.md` and skip the rest of this gate.
|
||||
2. **Diff each candidate against the proposed refactor roadmap**: for each ADR, ask the same two questions as code-review Phase 7:
|
||||
- **Violation**: does any roadmap item do the *opposite* of the ADR's `Decision`?
|
||||
- **Drift**: does any roadmap item materially affect the ADR's `Consequences` (positive or negative) without contradicting the Decision outright?
|
||||
3. **Classify each impacted ADR** in `RUN_DIR/analysis/adr_impact.md`:
|
||||
|
||||
| ADR | Roadmap item | Impact | Required action |
|
||||
|-----|--------------|--------|-----------------|
|
||||
| NNN | `roadmap-item-NN` | Violation / Drift / Aligned | (filled by Choose A/B/C below) |
|
||||
|
||||
4. **For every Violation row, present a BLOCKING Choose**:
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
DECISION REQUIRED: Refactor would violate ADR-NNN (<title>)
|
||||
══════════════════════════════════════
|
||||
A) Update the ADR via supersede: the refactor produces a NEW ADR
|
||||
(`Supersedes: NNN`) capturing the new Decision, and ADR-NNN's
|
||||
`Superseded by` field is updated. The supersede ADR is itself a
|
||||
deliverable of this refactor run (added to RUN_DIR/analysis/adr_impact.md
|
||||
and to TASKS_DIR as a task) and must be `Accepted` before Phase 4.
|
||||
B) Reduce the refactor scope to NOT violate ADR-NNN
|
||||
C) Re-evaluate ADR-NNN: keep the refactor but only after ADR-NNN is
|
||||
formally re-opened in a new /plan Step 4.5 round
|
||||
══════════════════════════════════════
|
||||
Recommendation: A — supersede is the only path that keeps the audit
|
||||
trail intact while letting the refactor land
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
5. **For every Drift row**: do not block, but the roadmap item must include a `## ADR Impact` section in its task spec citing the affected ADR(s). The implementer surfaces this at code-review Phase 7, which would otherwise classify the change as ADR-Drift (High) without context.
|
||||
6. **For every Aligned row**: cite the ADR in the roadmap item's task spec under `## ADR Compliance`. No further action.
|
||||
7. **Self-supersede deliverable**: any Choose A path adds a `[##]_supersede_adr_NNN.md` task file to the refactor run's TASKS_DIR with the new ADR text drafted (using `.cursor/skills/plan/templates/adr.md`). The task's only Acceptance Criterion is "ADR file exists at `_docs/02_document/adr/<next>_<slug>.md` with `Status: Accepted`, ADR-NNN's `Superseded by` field updated, and `_docs/02_document/adr/README.md` index reflects both."
|
||||
|
||||
Present optional hardening tracks for user to include in the roadmap:
|
||||
|
||||
```
|
||||
@@ -67,6 +105,8 @@ Write `RUN_DIR/analysis/refactoring_roadmap.md`:
|
||||
|
||||
**BLOCKING applicability gate**: Before 2c and 2d, every recommendation in the roadmap must be `Selected`. Items marked `Rejected` are excluded. Items marked `Experimental only` or `Needs user decision` require a user decision before task creation.
|
||||
|
||||
**BLOCKING ADR-supersede gate**: Before 2c and 2d, every Violation row in `RUN_DIR/analysis/adr_impact.md` (from 2b.1) must be resolved via Choose A, B, or C. A Violation row with no chosen path blocks task creation.
|
||||
|
||||
## 2c. Create Epic
|
||||
|
||||
Create a work item tracker epic for this refactoring run:
|
||||
@@ -111,6 +151,10 @@ Convert the finalized `RUN_DIR/list-of-changes.md` into implementable task files
|
||||
- [ ] Task dependencies are consistent (no circular dependencies)
|
||||
- [ ] `_dependencies_table.md` includes all refactoring tasks
|
||||
- [ ] Every task has a work item ticket (or PENDING placeholder)
|
||||
- [ ] If `_docs/02_document/adr/` exists with Accepted ADRs, `RUN_DIR/analysis/adr_impact.md` has been written and every Violation row is resolved (A/B/C) — no implicit overrides
|
||||
- [ ] For every Violation resolved via Choose A, a `[##]_supersede_adr_NNN.md` task exists in TASKS_DIR with the drafted supersede ADR
|
||||
- [ ] For every Drift row, the corresponding roadmap-item task spec has a `## ADR Impact` section
|
||||
- [ ] For every Aligned row, the corresponding roadmap-item task spec has a `## ADR Compliance` section
|
||||
|
||||
**Save action**: Write analysis artifacts to RUN_DIR, task files to TASKS_DIR
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ Before designing or implementing any new tests, check what already exists:
|
||||
1. Scan the project for existing test files (unit tests, integration tests, blackbox tests)
|
||||
2. Run the existing test suite — record pass/fail counts
|
||||
3. Measure current coverage against the areas being refactored (from `RUN_DIR/list-of-changes.md` file paths)
|
||||
4. Assess coverage against thresholds:
|
||||
4. Assess coverage against thresholds (canonical: see `.cursor/rules/cursor-meta.mdc` Quality Thresholds — never hardcode a different number):
|
||||
- Minimum overall coverage: 75%
|
||||
- Critical path coverage: 90%
|
||||
- Critical path coverage: **90% floor / 100% aim** — 90% is the enforcement floor (blocks Phase 4 if not met); 100% is the aspirational target. Refactors are NOT permitted to drop below 90% on the critical paths covered by the in-scope changes.
|
||||
- All public APIs must have blackbox tests
|
||||
- All error handling paths must be tested
|
||||
|
||||
@@ -47,7 +47,7 @@ For each uncovered critical area, write test specs to `RUN_DIR/test_specs/[##]_[
|
||||
4. Document any discovered issues
|
||||
|
||||
**Self-verification**:
|
||||
- [ ] Coverage requirements met (75% overall, 90% critical paths) across existing + new tests
|
||||
- [ ] Coverage requirements met (75% overall, 90% critical-path floor — 100% aim — per canonical `cursor-meta.mdc` Quality Thresholds) across existing + new tests
|
||||
- [ ] All tests pass on current codebase
|
||||
- [ ] All public APIs in refactoring scope have blackbox tests
|
||||
- [ ] Test data fixtures are configured
|
||||
|
||||
@@ -45,7 +45,7 @@ Write `RUN_DIR/test_sync/new_tests.md`:
|
||||
- [ ] All obsolete tests removed or merged
|
||||
- [ ] All pre-existing tests pass after updates
|
||||
- [ ] New code from Phase 4 has test coverage
|
||||
- [ ] Overall coverage meets or exceeds Phase 3 baseline (75% overall, 90% critical paths)
|
||||
- [ ] Overall coverage meets or exceeds Phase 3 baseline (75% overall, 90% critical-path floor / 100% aim — per `.cursor/rules/cursor-meta.mdc` Quality Thresholds)
|
||||
- [ ] No tests reference removed or renamed code
|
||||
|
||||
**Save action**: Write test_sync artifacts; implemented tests go into the project's test folder
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
name: release
|
||||
description: |
|
||||
Executes the deployment plan produced by /deploy against a target environment.
|
||||
Closes the loop between "we have a plan" and "the new version is running in production with a verdict on disk."
|
||||
6-phase workflow: pre-release gate, strategy select, execute, smoke test, watch window, commit-or-rollback.
|
||||
Outputs _docs/04_release/release_<version>.md with a definitive Released / Rolled-Back / Aborted verdict.
|
||||
Trigger phrases:
|
||||
- "release", "ship", "go live", "release this version"
|
||||
- "deploy to prod", "promote to staging", "roll out"
|
||||
- "rollback", "abort the release"
|
||||
category: ship
|
||||
tags: [release, deployment, rollback, smoke-test, observability, production]
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Release Execution
|
||||
|
||||
The `/deploy` skill produces a plan and scripts. The `/release` skill **runs** them, verifies the live system, watches it for a defined window, and produces a definitive verdict on disk.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Real execution, not simulation**: every phase must actually run against the target environment. If a phase cannot be executed (missing scripts, no SSH access, disabled secrets, registry auth failure), STOP — do not pretend a step succeeded. See `meta-rule.mdc` § "Real Results, Not Simulated Ones".
|
||||
- **Verifiable rollback path**: the release does not start until rollback is proven viable for this version. "We can roll back" without evidence is not a rollback path.
|
||||
- **Quiet failure is a release failure**: a deploy script that exits 0 but emits no observable signal in the watch window is treated as a regression, not a success.
|
||||
- **One release per invocation**: a single `/release` execution targets exactly one version against exactly one environment. Multi-stage promotion (staging → prod) is two invocations, not one.
|
||||
- **Never skip the watch window**: even successful deploys can degrade after 5–60 minutes (cache warm-up, scheduled jobs, downstream backpressure). The watch window is mandatory.
|
||||
- **Autonomous rollback on hard regressions**: critical health-check failure, error-rate spike above threshold, or smoke-test failure → automatic rollback. Soft regressions (latency drift, capacity warnings) escalate to the user.
|
||||
|
||||
## Context Resolution
|
||||
|
||||
Fixed paths:
|
||||
|
||||
- DEPLOY_DIR: `_docs/04_deploy/`
|
||||
- RELEASE_DIR: `_docs/04_release/`
|
||||
- SCRIPTS_DIR: `scripts/`
|
||||
- DEPLOY_SCRIPT: `scripts/deploy.sh`
|
||||
- HEALTH_SCRIPT: `scripts/health-check.sh`
|
||||
- ENV_TEMPLATE: `.env.example`
|
||||
- OBSERVABILITY_DOC: `_docs/04_deploy/observability.md`
|
||||
- ENVIRONMENT_DOC: `_docs/04_deploy/environment_strategy.md`
|
||||
- PROCEDURES_DOC: `_docs/04_deploy/deployment_procedures.md`
|
||||
- ARCHITECTURE: `_docs/02_document/architecture.md`
|
||||
- RESTRICTIONS: `_docs/00_problem/restrictions.md`
|
||||
|
||||
Announce the resolved paths and the **target environment + version + strategy** to the user before any phase that touches the live system.
|
||||
|
||||
## Inputs (BLOCKING prerequisites)
|
||||
|
||||
| Input | Required | Source |
|
||||
|-------|----------|--------|
|
||||
| Target environment | Yes — ASK user | `environment_strategy.md` enumerates valid options |
|
||||
| Target version / image tag | Yes — ASK user | Must exist in the registry; verified in Phase 1 |
|
||||
| Rollback target version | Yes — ASK user | Defaults to currently-deployed version if discoverable |
|
||||
| `scripts/deploy.sh` | Yes | Produced by `/deploy` Step 7. STOP if missing → run `/deploy` first |
|
||||
| `scripts/health-check.sh` | Yes | Same |
|
||||
| `_docs/04_deploy/deployment_procedures.md` | Yes | Defines per-environment runbook, manual approval rules, change-window restrictions |
|
||||
| `_docs/04_deploy/observability.md` | Yes | Defines watch metrics, thresholds, and dashboards |
|
||||
| `_docs/04_deploy/environment_strategy.md` | Yes | Defines target hostnames, registries, secrets, deploy strategy per env |
|
||||
|
||||
## Outputs
|
||||
|
||||
```
|
||||
RELEASE_DIR/
|
||||
├── release_<version>_<env>_<YYYY-MM-DD-HHmm>.md (mandatory; one per invocation)
|
||||
├── rollback_<version>_<env>_<YYYY-MM-DD-HHmm>.md (only when rollback fires; pairs with the release file)
|
||||
└── manual_approvals/
|
||||
└── approval_<version>_<env>.md (when restrictions require manual approval, written before Phase 3)
|
||||
```
|
||||
|
||||
The release report (`templates/release-report.md`) is appended to as each phase completes — it is durable across phase failures and reflects partial progress so the next operator can resume or audit.
|
||||
|
||||
## Phases
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Release Execution (6-Phase Method) │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ PREREQ: deploy artifacts on disk; tests green at HEAD │
|
||||
│ │
|
||||
│ 1. Pre-Release Gate → AC + change summary + readiness │
|
||||
│ [BLOCKING: user confirms or aborts] │
|
||||
│ 2. Strategy Select → all-at-once / blue-green / canary │
|
||||
│ [BLOCKING: user picks strategy] │
|
||||
│ 3. Execute → run deploy.sh, capture exit + logs │
|
||||
│ [AUTO-ROLLBACK on non-zero exit] │
|
||||
│ 4. Smoke Test → /test-run prod-smoke in target env │
|
||||
│ [AUTO-ROLLBACK on failure] │
|
||||
│ 5. Watch Window → poll observability for N minutes │
|
||||
│ [AUTO-ROLLBACK on hard threshold breach] │
|
||||
│ 6. Commit or Rollback → finalize verdict, update tracker │
|
||||
│ [BLOCKING: user confirms only if soft regression escalated] │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ Verdicts: Released · Rolled-Back · Aborted │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Phase 1: Pre-Release Gate
|
||||
|
||||
**Goal**: Refuse to start if the system is not ready for a real release.
|
||||
|
||||
1. **Acceptance criteria check**: read `_docs/00_problem/acceptance_criteria.md`. If any AC is marked unmet OR if any AC has no associated test marked `Passed` in the latest `test-run` report, STOP and surface the unmet items. Do not let the user override with "ship anyway" without a recorded reason in the release report.
|
||||
2. **Test status check**: read the most recent `_docs/06_metrics/perf_*.md` (if perf is required by restrictions) and the latest functional test report. Any failing or skipped test that maps to a critical-path AC blocks the release.
|
||||
3. **Change summary**: read the git log between the version-tag-of-last-release and HEAD (or, if no prior release exists, from the project root commit). Render a short list grouped by component: features, fixes, breaking changes, security fixes. Cross-reference against the latest implementation reports under `_docs/03_implementation/`.
|
||||
4. **Rollback readiness**:
|
||||
- Confirm the previous version's image is still pullable from the registry (do not deploy without this).
|
||||
- Confirm `scripts/deploy.sh --rollback` works as documented (read the script; if `--rollback` flag is missing, STOP — that is a deploy-skill bug).
|
||||
- Confirm a rollback target exists (e.g., previously-deployed image tag) and is recorded in the release report under `Rollback Plan`.
|
||||
5. **Restrictions**: read `_docs/00_problem/restrictions.md` for change-window rules, manual-approval rules, blackout windows, regulatory requirements (e.g., 4-eyes review, ITAR controls). If any apply, gate accordingly — write a `manual_approvals/approval_<version>_<env>.md` file once received.
|
||||
6. **Tracker check**: list tracker tickets in the release scope (per `tracker.mdc` rules). Any ticket still in `In Progress` or `Code Review` that maps to a change in the release scope blocks Phase 1. Move-and-deploy is not allowed.
|
||||
|
||||
**BLOCKING gate**: present the assembled summary to the user using Choose A/B/C:
|
||||
|
||||
```
|
||||
══════════════════════════════════════
|
||||
PRE-RELEASE GATE
|
||||
══════════════════════════════════════
|
||||
Target env: {env}
|
||||
Target version: {version} ({git-sha})
|
||||
Rollback target: {previous-version}
|
||||
Changes: N tickets, M components
|
||||
- {summary list}
|
||||
Open risks: {summary or "none"}
|
||||
Blocking issues: {summary or "none"}
|
||||
══════════════════════════════════════
|
||||
A) Proceed to Strategy Select
|
||||
B) Abort — fix blocking issue and re-invoke
|
||||
C) Edit release scope — exclude a ticket and reassemble
|
||||
══════════════════════════════════════
|
||||
```
|
||||
|
||||
If A → write Phase 1 section to release report, proceed. If B → write `Aborted` verdict to release report with reason, exit. If C → loop back into Phase 1 with edited scope.
|
||||
|
||||
### Phase 2: Strategy Select
|
||||
|
||||
**Goal**: Pick the deployment strategy that fits the change risk and environment capability.
|
||||
|
||||
Read `environment_strategy.md` and `deployment_procedures.md` to learn which strategies the target env supports. Strategies and when each is appropriate:
|
||||
|
||||
| Strategy | When to pick | Risk if wrong |
|
||||
|----------|--------------|---------------|
|
||||
| **all-at-once** | Internal tools, low traffic, well-rehearsed change, env supports nothing else | All users hit the new version simultaneously — bug blast radius is 100% |
|
||||
| **blue-green** | Stateless services with a load balancer, env has dual-stack capability | Cutover is binary — observability must be ready to detect issues fast |
|
||||
| **canary** | Customer-facing, traffic-tier load balancer in place, gradual rollout possible | Canary metric thresholds must be well-tuned or canary fails for harmless reasons |
|
||||
| **manual** | Non-automatable env (one-off VMs, regulated infrastructure, non-Docker host) | The whole release becomes a runbook and the watch window phases are operator-driven; the release skill records but does not execute |
|
||||
|
||||
Recommend a default based on:
|
||||
- Risk level inferred from change summary (any breaking change → bias toward canary or blue-green)
|
||||
- Restrictions (e.g., regulatory rules forcing manual approval at each step)
|
||||
- Environment capability (some envs may only support all-at-once)
|
||||
|
||||
**BLOCKING gate**: Choose A/B/C/D between strategies. Record the choice in the release report.
|
||||
|
||||
### Phase 3: Execute
|
||||
|
||||
**Goal**: Actually run the deploy. Capture exit code and full stdout/stderr.
|
||||
|
||||
1. Validate environment file (`.env`) exists, all required vars from `.env.example` are set, no placeholder secrets remain.
|
||||
2. Source the env file and run `scripts/deploy.sh` against the target host. The script produced by `/deploy` Step 7 is the point of execution; do NOT bypass it. If a strategy-specific flag is needed (e.g., `--canary 5%`), pass it through.
|
||||
3. Stream stdout/stderr to the release report, with timestamps, in a fenced code block under `## Phase 3: Execute`.
|
||||
4. Capture exit code.
|
||||
5. **AUTO-ROLLBACK trigger**: non-zero exit code → immediately invoke Phase 6 with verdict `Rolled-Back: deploy script failure`. Do NOT continue to Phase 4.
|
||||
|
||||
If `deploy.sh` emits no output for more than the configured idle threshold (default 5 minutes; check `deployment_procedures.md` for an explicit value), treat it as hung — capture a snapshot of what's running on the target, kill the script, and AUTO-ROLLBACK with reason `Deploy hung — manual investigation required`.
|
||||
|
||||
**Manual strategy**: if Phase 2 picked `manual`, write a checklist of operator steps from `deployment_procedures.md` to the release report and pause until the user types `done` or `failed`. Phase 3 then records the user's report verbatim.
|
||||
|
||||
### Phase 4: Smoke Test
|
||||
|
||||
**Goal**: Verify the new version is *actually serving traffic correctly* in the target environment.
|
||||
|
||||
1. Resolve the smoke-test command from `_docs/02_document/tests/blackbox-tests.md` § Production Smoke Tests, OR delegate to `/test-run` in `--prod-smoke` mode against the target environment.
|
||||
2. The smoke-test set must (a) hit each public endpoint of each component, (b) include at least one read AND one write per public endpoint where applicable, and (c) complete in under 5 minutes total.
|
||||
3. Capture pass/fail per case to the release report.
|
||||
4. **AUTO-ROLLBACK trigger**: any smoke-test failure → invoke Phase 6 with verdict `Rolled-Back: smoke test failure: <test-name>`.
|
||||
|
||||
If smoke tests are **missing** for the target environment (no production-mode test set), STOP — write a leftover entry to `_docs/_process_leftovers/` per `tracker.mdc`, do not proceed to watch window without smoke coverage. Write `Aborted: smoke tests missing for prod-mode target` and ASK the user.
|
||||
|
||||
### Phase 5: Watch Window
|
||||
|
||||
**Goal**: Observe the live system for a defined window to catch latent regressions.
|
||||
|
||||
1. Read `observability.md` for the project's metrics, dashboards, and threshold definitions. Required watch metrics for any production target (per cursor-meta convention) include error rate, request rate, p99 latency, and saturation (CPU/memory/queue-depth).
|
||||
2. Compute the watch-window duration from `deployment_procedures.md`. If unspecified, default to **15 minutes** for staging and **60 minutes** for production.
|
||||
3. Poll the observability backend at 1-minute intervals (or the configured cadence). For each interval, record metric snapshots to the release report.
|
||||
4. Threshold rules:
|
||||
- **Hard breach** (auto-rollback): error-rate ≥ 2× baseline, p99 latency ≥ 3× baseline, any health-check failure persisting for 2 consecutive intervals.
|
||||
- **Soft breach** (escalate): metric drift between 1.5× and 2× baseline, single-interval health blip, queue-depth steady but elevated.
|
||||
- **No data** (escalate): if metrics are not flowing within the first 3 minutes, treat the absence as a hard breach — observability is itself broken.
|
||||
5. **AUTO-ROLLBACK trigger**: hard breach at any interval. Move to Phase 6 with verdict `Rolled-Back: <metric> breached <multiplier>× baseline at T+<minutes>`.
|
||||
6. **ESCALATE trigger**: soft breach. Pause polling, surface the metric, and ask the user A/B/C:
|
||||
- A) Continue watch — accept current drift, keep polling
|
||||
- B) Roll back now — treat soft drift as hard
|
||||
- C) Extend watch window by N minutes
|
||||
7. End of watch window with no breach → proceed to Phase 6.
|
||||
|
||||
The watch window cannot be skipped. If the user explicitly demands skipping (e.g., emergency rollforward), record the override reason in the release report and continue, but mark the verdict as `Released-with-override` — this triggers an automatic incident retrospective per `retrospective/SKILL.md`.
|
||||
|
||||
### Phase 6: Commit or Rollback
|
||||
|
||||
**Goal**: Finalize the release with a definitive verdict on disk.
|
||||
|
||||
**Path A — Commit (clean release)**:
|
||||
1. Update tracker tickets: every ticket in scope moves to `Released` (or `Done`, per project convention defined in `tracker.mdc` / `_docs/_repo-config.yaml`).
|
||||
2. Tag the git HEAD with `release/<version>` (or the project's tag convention from `deployment_procedures.md`).
|
||||
3. Write the final `Released` verdict to the release report with a summary table.
|
||||
4. Trigger `/retrospective --cycle-end` with this release as the cycle terminus.
|
||||
5. Auto-chain to autodev's next step (Retrospective in greenfield, or feature-cycle loop start in existing-code).
|
||||
|
||||
**Path B — Rollback (auto-fired or user-elected)**:
|
||||
1. Run `scripts/deploy.sh --rollback` with the rollback target captured in Phase 1.
|
||||
2. Stream output to a new file `RELEASE_DIR/rollback_<version>_<env>_<YYYY-MM-DD-HHmm>.md` AND append a summary to the original release report under `## Rollback`.
|
||||
3. Re-run Phase 4 (smoke test) and a 5-minute mini watch window against the rolled-back version. If THAT also fails, escalate immediately — the system is in an unknown state and needs human takeover.
|
||||
4. Update tracker tickets back to `Ready for Release` (or the project's pre-release status).
|
||||
5. Write the final `Rolled-Back` verdict with full reason chain.
|
||||
6. Auto-trigger `/retrospective --incident` with this release as the incident anchor (per `retrospective/SKILL.md` incident mode).
|
||||
7. Do NOT auto-chain to anything else — the user owns the next step.
|
||||
|
||||
**Path C — Aborted**:
|
||||
Reached only via Phase 1 Choose B, Phase 4 smoke-tests-missing escalation, or any phase that detects a precondition violation. Write `Aborted: <reason>` to the release report. Do not auto-chain.
|
||||
|
||||
## Self-verification
|
||||
|
||||
- [ ] Release report exists at `RELEASE_DIR/release_<version>_<env>_<timestamp>.md` with verdict (Released / Rolled-Back / Aborted)
|
||||
- [ ] Every phase that ran has a section in the release report with timestamps and tool output
|
||||
- [ ] On Released: tracker tickets moved to release status; git tag pushed (if convention)
|
||||
- [ ] On Rolled-Back: rollback report exists at `RELEASE_DIR/rollback_<version>_<env>_<timestamp>.md`; tracker tickets moved back to pre-release status; incident retrospective scheduled
|
||||
- [ ] On Aborted: reason recorded; no live-system changes attempted; no tracker movement
|
||||
- [ ] No phase was skipped without an explicit reason recorded in the release report
|
||||
|
||||
## Escalation Rules
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| `scripts/deploy.sh` missing or `--rollback` unsupported | STOP — return to `/deploy` Step 7, do not patch the script in `/release` |
|
||||
| Registry auth failure during pre-release | STOP — fix credentials at infra layer (per `coderule.mdc`); do not embed creds in the script |
|
||||
| Smoke tests missing for prod target | STOP — write a leftover; do not improvise smoke tests in `/release` |
|
||||
| Observability backend unreachable | STOP — observability blindness is itself a release blocker |
|
||||
| User asks to skip the watch window | Record override, mark verdict `Released-with-override`, fire incident retro |
|
||||
| Rollback also fails its smoke test | ESCALATE to user — system is in unknown state; do not loop deploys |
|
||||
| Tracker MCP returns Unauthorized during ticket movement | Per `tracker.mdc`, write a leftover entry; do NOT silently continue without confirming the move |
|
||||
| Multiple environments named in user request | STOP — one release per invocation; ask user to pick one |
|
||||
| Production smoke test would touch real customer data | STOP — that is a `coderule.mdc` violation; ask user to define a smoke endpoint or test account |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Skipping the watch window when "everything looks fine after deploy"** — a deploy that exited 0 is not a release that's stable. Watch is mandatory.
|
||||
- **Faking smoke tests** to pass the gate when the prod test set is incomplete. STOP and surface the gap; do not embed prod URLs into ad-hoc curl commands.
|
||||
- **Rolling forward through a failure** ("the next deploy will fix it"). Roll back first, fix the cause, then deploy a real fix.
|
||||
- **Treating the release report as optional** when only an internal tool changed. Every release writes a report — the audit trail is the value, not the prose volume.
|
||||
- **Approving manual gates yourself** without the user's input when restrictions require human approval. The release skill records, the human approves.
|
||||
- **Reusing `release_<version>` filenames** across attempted releases. Always include the timestamp in the filename so re-attempts are visible side-by-side.
|
||||
- **Letting tracker drift silently** between release attempts. If Phase 6 cannot move tickets, the release is not complete — write a leftover and stop.
|
||||
|
||||
## Project Mode vs Standalone
|
||||
|
||||
- **Project mode** (default): autodev invokes `/release` after `/deploy`. State writes occur under `_docs/_autodev_state.md`. Full integration with retrospective and feature-cycle loop.
|
||||
- **Standalone mode**: `/release` invoked directly with `@<artifact>` (rare; usually only for re-running a rollback against a specific version). All outputs still go to `RELEASE_DIR/`.
|
||||
|
||||
## Methodology Quick Reference
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Release (6 phases, 3 verdicts) │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ Phase 1 Pre-Release Gate │
|
||||
│ AC + tests + change summary + rollback path │
|
||||
│ [BLOCKING — user A/B/C] │
|
||||
│ Phase 2 Strategy Select │
|
||||
│ all-at-once · blue-green · canary · manual │
|
||||
│ [BLOCKING — user picks] │
|
||||
│ Phase 3 Execute │
|
||||
│ scripts/deploy.sh, capture exit code + logs │
|
||||
│ [AUTO-ROLLBACK on non-zero or hang] │
|
||||
│ Phase 4 Smoke Test │
|
||||
│ /test-run --prod-smoke against target │
|
||||
│ [AUTO-ROLLBACK on any failure] │
|
||||
│ Phase 5 Watch Window │
|
||||
│ Poll observability for N minutes │
|
||||
│ [AUTO-ROLLBACK on hard breach; escalate on soft] │
|
||||
│ Phase 6 Commit or Rollback │
|
||||
│ Released → tracker, tag, retrospective │
|
||||
│ Rolled-Back → tracker reset, incident retrospective │
|
||||
│ Aborted → no live-system change │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ Principles: real execution · verifiable rollback · │
|
||||
│ quiet failure = release failure · │
|
||||
│ watch window mandatory │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# Release Report — {version} → {env}
|
||||
|
||||
- **Date**: {YYYY-MM-DD HH:MM} {timezone}
|
||||
- **Operator**: {user}
|
||||
- **Strategy**: {all-at-once | blue-green | canary | manual}
|
||||
- **Verdict**: {Released | Released-with-override | Rolled-Back | Aborted}
|
||||
- **Verdict reason**: {one-line summary}
|
||||
|
||||
## Pre-Release Gate (Phase 1)
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
| AC ID | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| AC-001 | Met / Unmet | path:section, test report, etc. |
|
||||
|
||||
### Test Status
|
||||
|
||||
| Suite | Pass | Fail | Skip | Source |
|
||||
|-------|------|------|------|--------|
|
||||
| Functional | N | N | N | _docs/03_implementation/{batch}.md |
|
||||
| Performance | N | N | N | _docs/06_metrics/perf_*.md |
|
||||
|
||||
### Change Summary
|
||||
|
||||
| Component | Tickets | Type |
|
||||
|-----------|---------|------|
|
||||
| {component} | TKT-001, TKT-002 | feature / fix / breaking / security |
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
- Previous version: `{previous-version}` (registry digest: `{sha}`)
|
||||
- Rollback script: `scripts/deploy.sh --rollback`
|
||||
- Rollback target verified pullable: yes / no
|
||||
- Rollback target verified bootable in target env: yes / no
|
||||
|
||||
### Restrictions / Approvals
|
||||
|
||||
- Change-window restrictions: {none | description}
|
||||
- Manual approvals required: {none | reference to approval file}
|
||||
|
||||
### Tracker State at Gate
|
||||
|
||||
- Tickets in scope: {N}
|
||||
- Tickets blocking release: {0 — list any}
|
||||
|
||||
## Strategy Select (Phase 2)
|
||||
|
||||
- Recommended: {strategy} — reasoning
|
||||
- Chosen: {strategy} — reasoning (if differs from recommended)
|
||||
|
||||
## Execute (Phase 3)
|
||||
|
||||
- Start: {timestamp}
|
||||
- End: {timestamp}
|
||||
- Exit code: {0 / non-zero}
|
||||
|
||||
```
|
||||
<scripts/deploy.sh stdout/stderr stream, with timestamps>
|
||||
```
|
||||
|
||||
## Smoke Test (Phase 4)
|
||||
|
||||
- Mode: {/test-run --prod-smoke | manual smoke set}
|
||||
- Start: {timestamp}
|
||||
- End: {timestamp}
|
||||
|
||||
| Test | Result | Notes |
|
||||
|------|--------|-------|
|
||||
| {name} | Pass / Fail | response time, status, etc. |
|
||||
|
||||
## Watch Window (Phase 5)
|
||||
|
||||
- Duration: {minutes}
|
||||
- Cadence: {minutes per poll}
|
||||
- Backend: {observability source — Prometheus, CloudWatch, Datadog, etc.}
|
||||
|
||||
| T+min | error_rate | rps | p99_latency | saturation | health | notes |
|
||||
|-------|------------|-----|-------------|------------|--------|-------|
|
||||
| 0 | … | … | … | … | OK | … |
|
||||
| 1 | … | … | … | … | OK | … |
|
||||
| … | … | … | … | … | … | … |
|
||||
|
||||
### Threshold breaches
|
||||
|
||||
- {None | "p99 latency 1.7× baseline at T+8 — soft breach, user accepted continuation"}
|
||||
|
||||
## Commit or Rollback (Phase 6)
|
||||
|
||||
### If Released
|
||||
|
||||
- Tracker tickets moved: {list}
|
||||
- Git tag pushed: {tag} → {sha}
|
||||
- Retrospective scheduled: yes — {/retrospective --cycle-end output path}
|
||||
|
||||
### If Rolled-Back
|
||||
|
||||
- Trigger: {auto / user-elected}
|
||||
- Reason: {phase + one-line cause}
|
||||
- Rollback start: {timestamp}
|
||||
- Rollback end: {timestamp}
|
||||
- Post-rollback smoke: pass / fail
|
||||
- Tracker tickets moved back: {list}
|
||||
- Incident retrospective scheduled: yes — {/retrospective --incident output path}
|
||||
|
||||
### If Aborted
|
||||
|
||||
- Phase that aborted: {1 / 2 / 3 / 4 / 5}
|
||||
- Reason: {one-line cause}
|
||||
- No live-system changes attempted: yes / no (if live changes, document under Phase 3 above and treat as Rolled-Back instead)
|
||||
|
||||
## Lessons (one-liners; full incident retro if Rolled-Back / Released-with-override)
|
||||
|
||||
- {Optional: short one-liner observations the operator wants the next /retrospective to consider}
|
||||
@@ -2,9 +2,9 @@
|
||||
name: retrospective
|
||||
description: |
|
||||
Collect metrics from implementation batch reports and code review findings, analyze trends across cycles,
|
||||
and produce improvement reports with actionable recommendations.
|
||||
3-step workflow: collect metrics, analyze trends, produce report.
|
||||
Outputs to _docs/06_metrics/.
|
||||
and produce improvement reports plus a lessons-log update with actionable recommendations.
|
||||
4-step workflow: collect metrics, analyze trends, produce report, update lessons log.
|
||||
Outputs to _docs/06_metrics/ and appends to _docs/LESSONS.md (ring buffer, last 15).
|
||||
Trigger phrases:
|
||||
- "retrospective", "retro", "run retro"
|
||||
- "metrics review", "feedback loop"
|
||||
@@ -232,7 +232,7 @@ Present the report summary to the user.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Retrospective (3-Step Method) │
|
||||
│ Retrospective (4-Step Method) │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ PREREQ: batch reports exist in _docs/03_implementation/ │
|
||||
│ │
|
||||
|
||||
@@ -202,12 +202,12 @@ If invoked in `cycle-update` mode (see "Invocation Modes" above), read and follo
|
||||
| Missing acceptance_criteria.md, restrictions.md, or input_data/ | **STOP** — specification cannot proceed |
|
||||
| Missing input_data/expected_results/results_report.md | **STOP** — ask user to provide expected results mapping using the template |
|
||||
| Ambiguous requirements | ASK user |
|
||||
| Input data coverage below 75% (Phase 1) | Search internet for supplementary data, ASK user to validate |
|
||||
| Input data coverage below the canonical threshold (Phase 1) | Search internet for supplementary data, ASK user to validate. See `.cursor/rules/cursor-meta.mdc` Quality Thresholds for the canonical 75% number — do not hardcode a different threshold here. |
|
||||
| Expected results missing or not quantifiable (Phase 1) | ASK user to provide quantifiable expected results before proceeding |
|
||||
| Test scenario conflicts with restrictions | ASK user to clarify intent |
|
||||
| System interfaces unclear (no architecture.md) | ASK user or derive from solution.md |
|
||||
| Test data or expected result not provided for a test scenario (Phase 3) | WARN user and REMOVE the test |
|
||||
| Final coverage below 75% after removals (Phase 3) | BLOCK — require user to supply data or accept reduced spec |
|
||||
| Final coverage below the canonical threshold after removals (Phase 3) | BLOCK — require user to supply data or accept reduced spec (see `cursor-meta.mdc` Quality Thresholds) |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
@@ -252,7 +252,8 @@ When the user wants to:
|
||||
│ │
|
||||
│ Phase 3: Test Data & Expected Results Validation Gate (HARD GATE) │
|
||||
│ → phases/03-data-validation-gate.md │
|
||||
│ [BLOCKING: coverage ≥ 75% required to pass] │
|
||||
│ [BLOCKING: coverage ≥ canonical threshold required to pass — │
|
||||
│ see cursor-meta.mdc Quality Thresholds (75%)] │
|
||||
│ │
|
||||
│ Hardware-Dependency Assessment (BLOCKING, pre-Phase-4) │
|
||||
│ → phases/hardware-assessment.md │
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Phase 3: Test Data & Expected Results Validation Gate (HARD GATE)
|
||||
|
||||
**Role**: Professional Quality Assurance Engineer
|
||||
**Goal**: Ensure every test scenario produced in Phase 2 has concrete, sufficient test data. Remove tests that lack data. Verify final coverage stays above 75%.
|
||||
**Goal**: Ensure every test scenario produced in Phase 2 has concrete, sufficient test data. Remove tests that lack data. Verify final coverage stays above the canonical threshold (currently 75% — see `.cursor/rules/cursor-meta.mdc` Quality Thresholds; never hardcode a different number in any phase).
|
||||
**Constraints**: This phase is MANDATORY and cannot be skipped.
|
||||
|
||||
## Step 1 — Build the requirements checklist
|
||||
|
||||
@@ -16,3 +16,5 @@ coverage.cobertura.xml
|
||||
coverage.opencover.xml
|
||||
*.coverage
|
||||
_docs/03_implementation/test_runs/
|
||||
_docs/04_run_results/
|
||||
certs/
|
||||
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
// Use IntelliSense to find out which attributes exist for C# debugging
|
||||
// Use hover for the description of the existing attributes
|
||||
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
|
||||
"name": ".NET Core Launch (web)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
// If you have changed target frameworks, make sure to update the program path.
|
||||
"program": "${workspaceFolder}/SatelliteProvider.Api/bin/Debug/net10.0/SatelliteProvider.Api.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/SatelliteProvider.Api",
|
||||
"stopAtEntry": false,
|
||||
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/SatelliteProvider.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/SatelliteProvider.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/SatelliteProvider.sln"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ labels:
|
||||
|
||||
steps:
|
||||
- name: unit-tests
|
||||
image: mcr.microsoft.com/dotnet/sdk:8.0
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
commands:
|
||||
- dotnet restore SatelliteProvider.sln
|
||||
- dotnet test SatelliteProvider.Tests/SatelliteProvider.Tests.csproj --no-restore --configuration Release --logger "console;verbosity=normal" --logger "trx;LogFileName=test-results.trx" --results-directory /app/test-results
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## System Overview
|
||||
|
||||
This is a .NET 8.0 ASP.NET Web API service that downloads, stores, and manages satellite imagery tiles from Google Maps. The service supports region-based tile requests, route planning with intermediate points, and geofencing capabilities.
|
||||
This is a .NET 10 ASP.NET Web API service that downloads, stores, and manages satellite imagery tiles from Google Maps. The service supports region-based tile requests, route planning with intermediate points, and geofencing capabilities.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **.NET 8.0** with ASP.NET Core Web API
|
||||
- **.NET 10** with ASP.NET Core Web API
|
||||
- **PostgreSQL** database (via Docker)
|
||||
- **Dapper** for data access (ORM)
|
||||
- **DbUp** for database migrations
|
||||
@@ -177,7 +177,7 @@ docker-compose -f docker-compose.yml -f docker-compose.tests.yml up --build --ab
|
||||
### Configuration Values
|
||||
|
||||
Development defaults:
|
||||
- PostgreSQL: localhost:5432, user/pass: postgres/postgres
|
||||
- PostgreSQL: localhost:5433 (host-side, mapped to container port 5432), user/pass: postgres/postgres
|
||||
- API: http://localhost:5100
|
||||
- Max zoom level: 20
|
||||
- Default zoom level: 18
|
||||
@@ -236,10 +236,12 @@ Development defaults:
|
||||
|
||||
## Dependencies and Versions
|
||||
|
||||
Key packages (all .NET 8.0):
|
||||
- Microsoft.AspNetCore.OpenApi 8.0.21
|
||||
Key packages (all .NET 10):
|
||||
- Microsoft.AspNetCore.OpenApi 10.0.7
|
||||
- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.7
|
||||
- Microsoft.Extensions.* 10.0.7
|
||||
- Swashbuckle.AspNetCore 6.6.2
|
||||
- Serilog.AspNetCore 8.0.3
|
||||
- Serilog.AspNetCore 8.0.3 (intentional — 10.0.0 requires Serilog.Sinks.File ≥ 7.0.0; bumping Serilog.Sinks.File is out of AZ-500 scope per "no unrelated package bumps")
|
||||
- SixLabors.ImageSharp 3.1.11
|
||||
- Newtonsoft.Json 13.0.4
|
||||
- Dapper (check DataAccess csproj)
|
||||
|
||||
@@ -73,7 +73,7 @@ The service follows a layered architecture:
|
||||
### Download Single Tile
|
||||
|
||||
```http
|
||||
GET /api/satellite/tiles/latlon?Latitude={lat}&Longitude={lon}&ZoomLevel={zoom}
|
||||
GET /api/satellite/tiles/latlon?lat={lat}&lon={lon}&zoom={zoom}
|
||||
```
|
||||
|
||||
Downloads a single tile at specified coordinates and zoom level.
|
||||
@@ -434,7 +434,7 @@ Log level can be adjusted in `appsettings.json` under `Serilog:MinimumLevel`.
|
||||
|
||||
### Service won't start
|
||||
- Check Docker is running
|
||||
- Verify ports 5100 and 5432 are available
|
||||
- Verify ports 5100 and 5433 are available (Postgres host-side; the container itself listens on 5432 inside the docker network)
|
||||
- Check logs: `docker-compose logs api`
|
||||
|
||||
### Tiles not downloading
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace SatelliteProvider.Api.DTOs;
|
||||
|
||||
// AZ-811: query-string record for GET /api/satellite/tiles/latlon.
|
||||
// Bound via `[AsParameters]` so each property maps to one query parameter.
|
||||
// `[FromQuery(Name = "...")]` pins the wire name explicitly — case-sensitive
|
||||
// match against `?lat=&lon=&zoom=`, matching the OSM convention shared with
|
||||
// the rest of the satellite-provider API (`{z, x, y}` for inventory,
|
||||
// `{lat, lon}` for region and route DTOs).
|
||||
//
|
||||
// **Why nullable types**: minimal-API parameter binding throws
|
||||
// BadHttpRequestException for missing-required non-nullable query params
|
||||
// BEFORE endpoint filters run. That short-circuit produces a plain
|
||||
// ProblemDetails via GlobalExceptionHandler — no `errors{}` envelope, no
|
||||
// per-field key. Per AZ-811 ACs 1 & 4 every missing/unknown param must
|
||||
// surface as `errors.<paramName>` in ValidationProblemDetails. Nullable
|
||||
// types let binding always succeed, so:
|
||||
// 1. RejectUnknownQueryParamsEndpointFilter handles unknown keys
|
||||
// (e.g. legacy `?Latitude=`, hostile `?debug=1`).
|
||||
// 2. GetTileByLatLonQueryValidator handles `null` (missing) plus range.
|
||||
// Validator guarantees non-null by the time the handler dereferences.
|
||||
public sealed record GetTileByLatLonQuery(
|
||||
[property: FromQuery(Name = "lat")] double? Lat,
|
||||
[property: FromQuery(Name = "lon")] double? Lon,
|
||||
[property: FromQuery(Name = "zoom")] int? Zoom);
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["SatelliteProvider.Api/SatelliteProvider.Api.csproj", "SatelliteProvider.Api/"]
|
||||
COPY ["SatelliteProvider.Common/SatelliteProvider.Common.csproj", "SatelliteProvider.Common/"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -60,6 +61,30 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
httpContext.Response.StatusCode = badRequest.StatusCode;
|
||||
|
||||
// AZ-795: deserialization failures (unknown field via UnmappedMemberHandling.Disallow,
|
||||
// type mismatch, malformed JSON) surface here as BadHttpRequestException with a
|
||||
// System.Text.Json `JsonException` somewhere in the inner-exception chain. Convert
|
||||
// them to RFC 7807 ValidationProblemDetails so wire-format errors share the same
|
||||
// shape as FluentValidation business-rule errors — see
|
||||
// `_docs/02_document/contracts/api/error-shape.md`.
|
||||
var deserializationErrors = TryExtractDeserializationErrors(badRequest);
|
||||
if (deserializationErrors is not null && badRequest.StatusCode == StatusCodes.Status400BadRequest)
|
||||
{
|
||||
var validation = new ValidationProblemDetails(deserializationErrors)
|
||||
{
|
||||
Status = badRequest.StatusCode,
|
||||
Title = "One or more validation errors occurred.",
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
};
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
validation,
|
||||
options: null,
|
||||
contentType: "application/problem+json",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = badRequest.StatusCode,
|
||||
@@ -73,4 +98,36 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
contentType: "application/problem+json",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static IDictionary<string, string[]>? TryExtractDeserializationErrors(BadHttpRequestException ex)
|
||||
{
|
||||
var current = ex.InnerException;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is JsonException jsonEx)
|
||||
{
|
||||
var path = NormalizeJsonPath(jsonEx.Path);
|
||||
var message = string.IsNullOrEmpty(jsonEx.Message)
|
||||
? "Invalid JSON."
|
||||
: jsonEx.Message;
|
||||
|
||||
return new Dictionary<string, string[]>
|
||||
{
|
||||
[path] = new[] { message }
|
||||
};
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeJsonPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return "$";
|
||||
return path.StartsWith("$.", StringComparison.Ordinal)
|
||||
? path.Substring(2)
|
||||
: path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using SatelliteProvider.Api;
|
||||
using SatelliteProvider.Api.Authentication;
|
||||
using SatelliteProvider.Api.DTOs;
|
||||
using SatelliteProvider.Api.Swagger;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.DataAccess;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
using SatelliteProvider.DataAccess.TypeHandlers;
|
||||
@@ -39,6 +41,19 @@ var uavBatchBodyLimit = checked((long)uavQuality.MaxBatchSize * uavQuality.MaxBy
|
||||
builder.Services.Configure<KestrelServerOptions>(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = uavBatchBodyLimit;
|
||||
// AZ-505: enable HTTP/2 alongside HTTP/1.1 on every Kestrel endpoint so
|
||||
// programmatic clients (httpx http2=True, .NET HttpClient) can multiplex
|
||||
// tile reads on a single TCP connection. Kestrel requires TLS+ALPN for
|
||||
// HTTP/2 — the dev/test compose files mount a self-signed cert at
|
||||
// /app/certs/api.pfx and set ASPNETCORE_URLS=https://+:8080; production
|
||||
// is expected to terminate TLS at the same layer or upstream. Browsers
|
||||
// negotiate HTTP/2 via ALPN once TLS is present; legacy HTTP/1.1
|
||||
// callers continue to work over the same listener. HTTP/3/QUIC is
|
||||
// intentionally out of scope (see AZ-505 task spec § Excluded).
|
||||
options.ConfigureEndpointDefaults(listen =>
|
||||
{
|
||||
listen.Protocols = HttpProtocols.Http1AndHttp2;
|
||||
});
|
||||
});
|
||||
builder.Services.Configure<FormOptions>(options =>
|
||||
{
|
||||
@@ -85,14 +100,33 @@ builder.Services.AddCors(options =>
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
|
||||
// AZ-795: strict JSON parsing — unknown fields are rejected at the deserializer
|
||||
// level instead of being silently dropped. Pairs with the per-endpoint
|
||||
// FluentValidation filter (`WithValidation<T>()`) so the API has a single
|
||||
// uniform RFC 7807 error contract for both wire-format failures and
|
||||
// business-rule failures (`_docs/02_document/contracts/api/error-shape.md`).
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow;
|
||||
options.SerializerOptions.Converters.Add(
|
||||
new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase));
|
||||
});
|
||||
|
||||
// AZ-795: register every IValidator<T> in this assembly with DI so the
|
||||
// generic ValidationEndpointFilter<T> can resolve them at request time.
|
||||
// GlobalValidatorConfig.ApplyOnce() centralizes process-wide FluentValidation
|
||||
// configuration (camelCase property paths, etc.) so the API host and the
|
||||
// unit-test fixture share one source of truth — see error-shape.md Inv-4.
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
|
||||
// AZ-810: explicit registration so `.AddEndpointFilter<UavUploadValidationFilter>()`
|
||||
// on the UAV upload endpoint resolves the filter with its `IValidator<…>` + JSON
|
||||
// options constructor deps. Transient so each request gets a fresh instance.
|
||||
builder.Services.AddTransient<UavUploadValidationFilter>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
@@ -108,36 +142,29 @@ builder.Services.AddSwaggerGen(c =>
|
||||
Description = "JWT Authorization header using the Bearer scheme. Example: 'Bearer {token}'"
|
||||
});
|
||||
|
||||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
c.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
Array.Empty<string>()
|
||||
new OpenApiSecuritySchemeReference("Bearer"),
|
||||
new List<string>()
|
||||
}
|
||||
});
|
||||
|
||||
c.MapType<UavTileBatchUploadRequest>(() => new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Properties = new Dictionary<string, OpenApiSchema>
|
||||
Type = JsonSchemaType.Object,
|
||||
Properties = new Dictionary<string, IOpenApiSchema>
|
||||
{
|
||||
["metadata"] = new()
|
||||
["metadata"] = new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Type = JsonSchemaType.String,
|
||||
Description = "JSON document `{ \"items\": [ { \"latitude\", \"longitude\", \"tileZoom\", \"tileSizeMeters\", \"capturedAt\" } ] }` where item ordinal index aligns with the matching file in `files`."
|
||||
},
|
||||
["files"] = new()
|
||||
["files"] = new OpenApiSchema
|
||||
{
|
||||
Type = "array",
|
||||
Type = JsonSchemaType.Array,
|
||||
Description = "UAV tile JPEG files in the same order as `metadata.items`.",
|
||||
Items = new OpenApiSchema { Type = "string", Format = "binary" }
|
||||
Items = new OpenApiSchema { Type = JsonSchemaType.String, Format = "binary" }
|
||||
}
|
||||
},
|
||||
Required = new HashSet<string> { "metadata", "files" }
|
||||
@@ -184,6 +211,10 @@ app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile)
|
||||
|
||||
app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(new[] { "lat", "lon", "zoom" }))
|
||||
.WithValidation<GetTileByLatLonQuery>()
|
||||
.Produces<DownloadTileResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
|
||||
|
||||
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
|
||||
@@ -191,24 +222,41 @@ app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
|
||||
.ProducesProblem(StatusCodes.Status501NotImplemented)
|
||||
.WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates (NOT IMPLEMENTED)" });
|
||||
|
||||
app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
|
||||
.RequireAuthorization()
|
||||
.WithValidation<TileInventoryRequest>()
|
||||
.Accepts<TileInventoryRequest>("application/json")
|
||||
.Produces<TileInventoryResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Bulk tile inventory lookup by (z,x,y) coords or location_hash",
|
||||
Description = "Body MUST populate exactly one of `tiles` (array of `{z, x, y}` slippy-map coordinates) OR `locationHashes` (array of UUIDv5 hashes) — sending both, or neither, is HTTP 400. Response order matches request order; each entry reports `present: true|false`, and when present includes `id`, `capturedAt`, `source`, `flightId`, `resolutionMPerPx`. Hard cap: 5000 entries per request."
|
||||
});
|
||||
|
||||
app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
||||
.RequireAuthorization(SatellitePermissions.UavUploadPolicy)
|
||||
.AddEndpointFilter<UavUploadValidationFilter>()
|
||||
.Accepts<UavTileBatchUploadRequest>("multipart/form-data")
|
||||
.Produces<UavTileBatchUploadResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Upload a batch of UAV-captured satellite tiles",
|
||||
Description = "AZ-488 / `uav-tile-upload.md` v1.0.0. Multipart form: a JSON `metadata` field and an aligned `files` collection. Each item is graded by the 5-rule quality gate and persisted with `source='uav'` when accepted. Returns 200 with per-item results (mixed accept/reject), 400 for envelope-level errors (malformed metadata, missing files, oversized batch), 401 without a valid JWT, 403 without the `GPS` permission claim."
|
||||
Description = "Multipart form: a JSON `metadata` field and an aligned `files` collection. Each item is graded by the 5-rule quality gate and persisted with `source='uav'` when accepted. Returns 200 with per-item results (mixed accept/reject), 400 for envelope-level errors (malformed metadata, missing files, oversized batch), 401 without a valid JWT, 403 without the `GPS` permission claim."
|
||||
})
|
||||
.DisableAntiforgery();
|
||||
|
||||
app.MapPost("/api/satellite/request", RequestRegion)
|
||||
.RequireAuthorization()
|
||||
.WithValidation<RequestRegionRequest>()
|
||||
.Accepts<RequestRegionRequest>("application/json")
|
||||
.Produces<RegionStatusResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Request tiles for a region",
|
||||
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.",
|
||||
Description = "Idempotent: POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.",
|
||||
});
|
||||
|
||||
app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
|
||||
@@ -217,10 +265,14 @@ app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
|
||||
|
||||
app.MapPost("/api/satellite/route", CreateRoute)
|
||||
.RequireAuthorization()
|
||||
.WithValidation<CreateRouteRequest>()
|
||||
.Accepts<CreateRouteRequest>("application/json")
|
||||
.Produces<RouteResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Create a route with intermediate points",
|
||||
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.",
|
||||
Description = "Idempotent: POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.",
|
||||
});
|
||||
|
||||
app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
|
||||
@@ -237,9 +289,11 @@ async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITil
|
||||
return Results.Bytes(tile.Bytes, tile.ContentType);
|
||||
}
|
||||
|
||||
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, HttpContext httpContext, ITileService tileService)
|
||||
async Task<IResult> GetTileByLatLon([AsParameters] GetTileByLatLonQuery query, HttpContext httpContext, ITileService tileService)
|
||||
{
|
||||
var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel, httpContext.RequestAborted);
|
||||
// AZ-811: GetTileByLatLonQueryValidator guarantees lat/lon/zoom are non-null
|
||||
// by the time the handler runs (CascadeMode.Stop + NotNull rules).
|
||||
var tile = await tileService.DownloadAndStoreSingleTileAsync(query.Lat!.Value, query.Lon!.Value, query.Zoom!.Value, httpContext.RequestAborted);
|
||||
|
||||
var response = new DownloadTileResponse
|
||||
{
|
||||
@@ -267,6 +321,15 @@ IResult GetSatelliteTilesByMgrs(string mgrs, double squareSideMeters)
|
||||
detail: "MGRS-based tile retrieval is not implemented.");
|
||||
}
|
||||
|
||||
async Task<IResult> GetTilesInventory(
|
||||
[FromBody] TileInventoryRequest request,
|
||||
HttpContext httpContext,
|
||||
ITileService tileService)
|
||||
{
|
||||
var response = await tileService.GetInventoryAsync(request, httpContext.RequestAborted);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
async Task<IResult> UploadUavTileBatch(
|
||||
HttpContext httpContext,
|
||||
IUavTileUploadHandler handler,
|
||||
@@ -298,15 +361,10 @@ async Task<IResult> UploadUavTileBatch(
|
||||
|
||||
async Task<IResult> RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService)
|
||||
{
|
||||
if (request.SizeMeters < 100 || request.SizeMeters > 10000)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" });
|
||||
}
|
||||
|
||||
var status = await regionService.RequestRegionAsync(
|
||||
request.Id,
|
||||
request.Latitude,
|
||||
request.Longitude,
|
||||
request.Lat,
|
||||
request.Lon,
|
||||
request.SizeMeters,
|
||||
request.ZoomLevel,
|
||||
request.StitchTiles);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.25" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.25"/>
|
||||
<PackageReference Include="FluentValidation" Version="12.0.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7"/>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace SatelliteProvider.Api.Swagger;
|
||||
@@ -11,13 +11,11 @@ public class ParameterDescriptionFilter : IOperationFilter
|
||||
|
||||
var parameterDescriptions = new Dictionary<string, string>
|
||||
{
|
||||
["lat"] = "Latitude coordinate where image was captured",
|
||||
["lon"] = "Longitude coordinate where image was captured",
|
||||
["lat"] = "Latitude coordinate (WGS84, decimal degrees, [-90, 90])",
|
||||
["lon"] = "Longitude coordinate (WGS84, decimal degrees, [-180, 180])",
|
||||
["zoom"] = "Slippy-map zoom level [0, 22] (higher = more detail)",
|
||||
["mgrs"] = "MGRS coordinate string",
|
||||
["squareSideMeters"] = "Square side size in meters",
|
||||
["Latitude"] = "Latitude coordinate of the tile center",
|
||||
["Longitude"] = "Longitude coordinate of the tile center",
|
||||
["ZoomLevel"] = "Zoom level for the tile (higher values = more detail)"
|
||||
["squareSideMeters"] = "Square side size in meters"
|
||||
};
|
||||
|
||||
foreach (var parameter in operation.Parameters)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-809: FluentValidation rules for POST /api/satellite/route. Wired
|
||||
// through ValidationEndpointFilter<CreateRouteRequest> at endpoint
|
||||
// registration time (.WithValidation<CreateRouteRequest>() in Program.cs).
|
||||
// Failures are converted to RFC 7807 ValidationProblemDetails per
|
||||
// _docs/02_document/contracts/api/error-shape.md v1.0.0.
|
||||
//
|
||||
// Required-field detection is handled at the deserializer level via
|
||||
// [JsonRequired] on CreateRouteRequest, RoutePoint, GeofencePolygon, and
|
||||
// GeoPoint, plus JsonSerializerOptions.UnmappedMemberHandling.Disallow
|
||||
// (AZ-795 global). This validator covers post-deserialization business
|
||||
// rules: non-zero id, name + description length, range checks on size /
|
||||
// zoom / points-count, per-point lat/lon ranges (via RoutePointValidator),
|
||||
// per-polygon corner ranges + NW-of-SE invariant (via GeofencePolygonValidator),
|
||||
// and the cross-field createTilesZip-implies-requestMaps rule.
|
||||
public sealed class CreateRouteRequestValidator : AbstractValidator<CreateRouteRequest>
|
||||
{
|
||||
private const double MinRegionSizeMeters = 100.0;
|
||||
private const double MaxRegionSizeMeters = 10000.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
private const int MinPoints = 2;
|
||||
private const int MaxPoints = 500;
|
||||
private const int MaxNameLength = 200;
|
||||
private const int MaxDescriptionLength = 1000;
|
||||
// Geofences are axis-aligned bbox rectangles used for AOI restriction
|
||||
// during route planning (see route-creation.md). Realistic use is 1-10
|
||||
// polygons per route; cap at 50 to give 5x headroom while bounding the
|
||||
// validator's worst-case allocation. The global Kestrel body limit
|
||||
// (500 MiB, sized for the UAV upload endpoint) is not a useful gate
|
||||
// here because polygon JSON is small (~90 bytes per minimum-shape
|
||||
// polygon); without this cap a single authenticated request could
|
||||
// submit millions of polygons and saturate the LOH.
|
||||
private const int MaxPolygons = 50;
|
||||
|
||||
public CreateRouteRequestValidator()
|
||||
{
|
||||
RuleFor(req => req.Id)
|
||||
.NotEmpty()
|
||||
.WithMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
|
||||
|
||||
RuleFor(req => req.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("`name` is required and must not be empty or whitespace.")
|
||||
.MaximumLength(MaxNameLength)
|
||||
.WithMessage($"`name` must be at most {MaxNameLength} characters.");
|
||||
|
||||
RuleFor(req => req.Description)
|
||||
.MaximumLength(MaxDescriptionLength)
|
||||
.When(req => req.Description is not null)
|
||||
.WithMessage($"`description` must be at most {MaxDescriptionLength} characters.");
|
||||
|
||||
RuleFor(req => req.RegionSizeMeters)
|
||||
.InclusiveBetween(MinRegionSizeMeters, MaxRegionSizeMeters)
|
||||
.WithMessage($"`regionSizeMeters` must be between {MinRegionSizeMeters} and {MaxRegionSizeMeters} meters.");
|
||||
|
||||
RuleFor(req => req.ZoomLevel)
|
||||
.InclusiveBetween(MinZoom, MaxZoom)
|
||||
.WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
|
||||
RuleFor(req => req.Points)
|
||||
.NotNull().WithMessage("`points` is required.")
|
||||
.Must(p => p is null || p.Count >= MinPoints)
|
||||
.WithMessage($"`points` must contain at least {MinPoints} entries.")
|
||||
.Must(p => p is null || p.Count <= MaxPoints)
|
||||
.WithMessage($"`points` must contain at most {MaxPoints} entries.");
|
||||
|
||||
RuleForEach(req => req.Points)
|
||||
.SetValidator(new RoutePointValidator());
|
||||
|
||||
// Geofences are optional; per-polygon rules apply only when present.
|
||||
// FluentValidation's default property-name policy drops the parent
|
||||
// chain on deep expressions like `req.Geofences!.Polygons` — it emits
|
||||
// only the leaf `polygons`. We OverridePropertyName explicitly so the
|
||||
// wire-format error keys match the JSON path callers actually post:
|
||||
// `errors["geofences.polygons"]` and `errors["geofences.polygons[i].…"]`.
|
||||
When(req => req.Geofences is not null, () =>
|
||||
{
|
||||
RuleFor(req => req.Geofences!.Polygons)
|
||||
.NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
|
||||
.NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
|
||||
.Must(polygons => polygons is null || polygons.Count <= MaxPolygons)
|
||||
.WithMessage($"`geofences.polygons` must contain at most {MaxPolygons} polygons.")
|
||||
.OverridePropertyName("geofences.polygons");
|
||||
|
||||
RuleForEach(req => req.Geofences!.Polygons)
|
||||
.SetValidator(new GeofencePolygonValidator())
|
||||
.OverridePropertyName("geofences.polygons");
|
||||
});
|
||||
|
||||
// Cross-field invariant: cannot zip what wasn't downloaded.
|
||||
RuleFor(req => req)
|
||||
.Must(req => !(req.CreateTilesZip && !req.RequestMaps))
|
||||
.WithName("createTilesZip")
|
||||
.WithMessage("`createTilesZip` requires `requestMaps` to be true (can't zip what wasn't downloaded).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-809: per-polygon validator invoked via RuleForEach on the parent
|
||||
// CreateRouteRequest (guarded by When(geofences != null) at the parent).
|
||||
// Enforces both corner-point shape and the "NW is north-of and west-of SE"
|
||||
// invariant.
|
||||
//
|
||||
// Error path: errors keys land at `geofences.polygons[i].northWest.lat` etc.
|
||||
public sealed class GeofencePolygonValidator : AbstractValidator<GeofencePolygon>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
|
||||
public GeofencePolygonValidator()
|
||||
{
|
||||
// Both corners must be present. Without them no useful range/cross-field
|
||||
// check can run, so short-circuit via .Cascade(CascadeMode.Stop).
|
||||
RuleFor(p => p.NorthWest)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`northWest` corner is required.")
|
||||
.SetValidator(new GeoCornerValidator("northWest")!);
|
||||
|
||||
RuleFor(p => p.SouthEast)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`southEast` corner is required.")
|
||||
.SetValidator(new GeoCornerValidator("southEast")!);
|
||||
|
||||
// Cross-field invariant: NW must be genuinely north-of (lat greater)
|
||||
// AND west-of (lon smaller) SE. Only runs when both corners survived
|
||||
// the NotNull check above; FluentValidation skips the rule if either
|
||||
// is null (.When(...) guard below).
|
||||
RuleFor(p => p)
|
||||
.Must(HaveNorthWestActuallyNorthOfSouthEast)
|
||||
.When(p => p.NorthWest is not null && p.SouthEast is not null)
|
||||
.WithName("northWest")
|
||||
.WithMessage("`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE).");
|
||||
|
||||
RuleFor(p => p)
|
||||
.Must(HaveNorthWestActuallyWestOfSouthEast)
|
||||
.When(p => p.NorthWest is not null && p.SouthEast is not null)
|
||||
.WithName("northWest")
|
||||
.WithMessage("`northWest.lon` must be less than `southEast.lon` (NW is west-of SE).");
|
||||
}
|
||||
|
||||
private static bool HaveNorthWestActuallyNorthOfSouthEast(GeofencePolygon polygon)
|
||||
=> polygon.NorthWest!.Lat > polygon.SouthEast!.Lat;
|
||||
|
||||
private static bool HaveNorthWestActuallyWestOfSouthEast(GeofencePolygon polygon)
|
||||
=> polygon.NorthWest!.Lon < polygon.SouthEast!.Lon;
|
||||
|
||||
// Inner per-corner validator. Kept private to this file because the
|
||||
// polygon corners are the only consumer; if a sibling endpoint needs
|
||||
// point-shape validation, promote and rename.
|
||||
private sealed class GeoCornerValidator : AbstractValidator<GeoPoint>
|
||||
{
|
||||
public GeoCornerValidator(string cornerLabel)
|
||||
{
|
||||
RuleFor(g => g.Lat)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`{cornerLabel}.lat` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(g => g.Lon)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`{cornerLabel}.lon` must be between {MinLon} and {MaxLon}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Api.DTOs;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-811: FluentValidation rules for the query-string surface of
|
||||
// GET /api/satellite/tiles/latlon. Wired through
|
||||
// ValidationEndpointFilter<GetTileByLatLonQuery> at endpoint registration
|
||||
// time (.WithValidation<GetTileByLatLonQuery>() in Program.cs).
|
||||
//
|
||||
// Each rule maps 1:1 to a query parameter; errors[] keys are camelCase per
|
||||
// GlobalValidatorConfig (matching the wire-format param names `lat`, `lon`,
|
||||
// `zoom`). Required-field detection is `NotNull()` on the nullable-bound
|
||||
// DTO (see GetTileByLatLonQuery for why properties are nullable). Each rule
|
||||
// uses CascadeMode.Stop so a missing param surfaces ONLY as
|
||||
// "`lat` is required" — not also "`lat` must be between -90 and 90" with a
|
||||
// null value. Unknown query parameters are caught upstream by
|
||||
// RejectUnknownQueryParamsEndpointFilter.
|
||||
public sealed class GetTileByLatLonQueryValidator : AbstractValidator<GetTileByLatLonQuery>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public GetTileByLatLonQueryValidator()
|
||||
{
|
||||
RuleFor(q => q.Lat)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`lat` is required.")
|
||||
.InclusiveBetween(MinLat, MaxLat).WithMessage($"`lat` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(q => q.Lon)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`lon` is required.")
|
||||
.InclusiveBetween(MinLon, MaxLon).WithMessage($"`lon` must be between {MinLon} and {MaxLon}.");
|
||||
|
||||
RuleFor(q => q.Zoom)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotNull().WithMessage("`zoom` is required.")
|
||||
.InclusiveBetween(MinZoom, MaxZoom).WithMessage($"`zoom` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-795 / AZ-796: process-wide FluentValidation configuration shared by the
|
||||
// API host and unit tests. Tests must call ApplyOnce() in their fixture setup
|
||||
// so the property-name casing they assert against matches what the running
|
||||
// API will produce — see `_docs/02_document/contracts/api/error-shape.md`
|
||||
// invariant Inv-4 (camelCase paths in `errors` map).
|
||||
public static class GlobalValidatorConfig
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
private static bool _applied;
|
||||
|
||||
public static void ApplyOnce()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_applied) return;
|
||||
|
||||
ValidatorOptions.Global.PropertyNameResolver = (type, member, expression) =>
|
||||
{
|
||||
var name = member?.Name;
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
return char.ToLowerInvariant(name[0]) + name[1..];
|
||||
};
|
||||
|
||||
_applied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-796: FluentValidation rules for POST /api/satellite/tiles/inventory.
|
||||
// Wired through ValidationEndpointFilter<TileInventoryRequest> at endpoint
|
||||
// registration time (`WithValidation<TileInventoryRequest>()` in Program.cs).
|
||||
// Failures are converted to RFC 7807 ValidationProblemDetails per
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
//
|
||||
// Required-field detection (rules 5+) is partially handled at the deserializer
|
||||
// level via `[JsonRequired]` on TileCoord.Z/X/Y plus
|
||||
// `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). This
|
||||
// validator covers the non-deserializer-detectable rules: XOR populated,
|
||||
// per-array entry caps, and slippy-map range constraints.
|
||||
public sealed class InventoryRequestValidator : AbstractValidator<TileInventoryRequest>
|
||||
{
|
||||
public InventoryRequestValidator()
|
||||
{
|
||||
RuleFor(req => req).Custom((req, ctx) =>
|
||||
{
|
||||
var hasTiles = req.Tiles is { Count: > 0 };
|
||||
var hasHashes = req.LocationHashes is { Count: > 0 };
|
||||
if (hasTiles == hasHashes)
|
||||
{
|
||||
ctx.AddFailure(
|
||||
"$",
|
||||
"Populate exactly one of `tiles` or `locationHashes` (sending both, or neither, is not allowed).");
|
||||
}
|
||||
});
|
||||
|
||||
RuleFor(req => req.Tiles!.Count)
|
||||
.LessThanOrEqualTo(TileInventoryLimits.MaxEntriesPerRequest)
|
||||
.OverridePropertyName("tiles")
|
||||
.WithMessage($"`tiles` must contain at most {TileInventoryLimits.MaxEntriesPerRequest} entries.")
|
||||
.When(req => req.Tiles is not null);
|
||||
|
||||
RuleFor(req => req.LocationHashes!.Count)
|
||||
.LessThanOrEqualTo(TileInventoryLimits.MaxEntriesPerRequest)
|
||||
.OverridePropertyName("locationHashes")
|
||||
.WithMessage($"`locationHashes` must contain at most {TileInventoryLimits.MaxEntriesPerRequest} entries.")
|
||||
.When(req => req.LocationHashes is not null);
|
||||
|
||||
RuleForEach(req => req.Tiles)
|
||||
.SetValidator(new TileCoordValidator())
|
||||
.When(req => req.Tiles is not null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TileCoordValidator : AbstractValidator<TileCoord>
|
||||
{
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public TileCoordValidator()
|
||||
{
|
||||
RuleFor(c => c.Z)
|
||||
.InclusiveBetween(0, MaxZoom)
|
||||
.WithMessage($"`z` must be between 0 and {MaxZoom} (slippy-map zoom range).");
|
||||
|
||||
RuleFor(c => c.X)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("`x` must be ≥ 0.")
|
||||
.Must((coord, x) => coord.Z >= 0 && coord.Z <= MaxZoom && x < (1L << coord.Z))
|
||||
.WithMessage(coord => $"`x` must be < 2^z = {(coord.Z >= 0 && coord.Z <= MaxZoom ? (1L << coord.Z).ToString() : "<invalid z>")} for z={coord.Z}.");
|
||||
|
||||
RuleFor(c => c.Y)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("`y` must be ≥ 0.")
|
||||
.Must((coord, y) => coord.Z >= 0 && coord.Z <= MaxZoom && y < (1L << coord.Z))
|
||||
.WithMessage(coord => $"`y` must be < 2^z = {(coord.Z >= 0 && coord.Z <= MaxZoom ? (1L << coord.Z).ToString() : "<invalid z>")} for z={coord.Z}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-808: FluentValidation rules for POST /api/satellite/request.
|
||||
// Wired through ValidationEndpointFilter<RequestRegionRequest> at endpoint
|
||||
// registration time (.WithValidation<RequestRegionRequest>() in Program.cs).
|
||||
// Failures are converted to RFC 7807 ValidationProblemDetails per
|
||||
// _docs/02_document/contracts/api/error-shape.md v1.0.0.
|
||||
//
|
||||
// Required-field detection is handled at the deserializer level via
|
||||
// [JsonRequired] on RequestRegionRequest properties plus
|
||||
// JsonSerializerOptions.UnmappedMemberHandling.Disallow (AZ-795). This
|
||||
// validator covers the post-deserialization business rules: non-zero Id,
|
||||
// lat/lon/sizeMeters/zoomLevel range constraints.
|
||||
public sealed class RegionRequestValidator : AbstractValidator<RequestRegionRequest>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
private const double MinSizeMeters = 100.0;
|
||||
private const double MaxSizeMeters = 10000.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public RegionRequestValidator()
|
||||
{
|
||||
RuleFor(req => req.Id)
|
||||
.NotEmpty()
|
||||
.WithMessage("`id` must be a non-zero GUID (the caller's idempotency key).");
|
||||
|
||||
RuleFor(req => req.Lat)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`lat` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(req => req.Lon)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`lon` must be between {MinLon} and {MaxLon}.");
|
||||
|
||||
RuleFor(req => req.SizeMeters)
|
||||
.InclusiveBetween(MinSizeMeters, MaxSizeMeters)
|
||||
.WithMessage($"`sizeMeters` must be between {MinSizeMeters} and {MaxSizeMeters} meters.");
|
||||
|
||||
RuleFor(req => req.ZoomLevel)
|
||||
.InclusiveBetween(MinZoom, MaxZoom)
|
||||
.WithMessage($"`zoomLevel` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-811: endpoint filter that rejects any query-string parameter outside an
|
||||
// allowed-set. ASP.NET model binding silently ignores unknown query params,
|
||||
// which means typos (e.g. `?latitude=` after AZ-812's rename to `lat`) bind
|
||||
// to the default value (0.0) and may produce a misleading 200 or a confusing
|
||||
// out-of-range 400 from the value-validator. This filter catches the typo at
|
||||
// the envelope level and returns a structured RFC 7807 ValidationProblemDetails
|
||||
// with errors[<paramName>] = "Unknown query parameter ...", matching the
|
||||
// shape produced by ValidationEndpointFilter<T> + GlobalExceptionHandler.
|
||||
//
|
||||
// Apply BEFORE ValidationEndpointFilter<T> so unknown-param errors precede
|
||||
// range checks against the bound default value.
|
||||
public sealed class RejectUnknownQueryParamsEndpointFilter : IEndpointFilter
|
||||
{
|
||||
private readonly HashSet<string> _allowedKeys;
|
||||
|
||||
public RejectUnknownQueryParamsEndpointFilter(IEnumerable<string> allowedKeys)
|
||||
{
|
||||
_allowedKeys = new HashSet<string>(allowedKeys, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var query = context.HttpContext.Request.Query;
|
||||
var unknown = query.Keys.Where(k => !_allowedKeys.Contains(k)).ToList();
|
||||
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
var errors = unknown.ToDictionary(
|
||||
k => k,
|
||||
k => new[]
|
||||
{
|
||||
$"Unknown query parameter `{k}`. Allowed: {string.Join(", ", _allowedKeys.Select(a => $"`{a}`"))}."
|
||||
});
|
||||
|
||||
return Results.ValidationProblem(errors);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-809: per-point validator invoked via RuleForEach on the parent
|
||||
// CreateRouteRequest. Each route waypoint must declare a valid WGS84
|
||||
// coordinate; the parent validator checks min/max count of the points
|
||||
// collection separately.
|
||||
//
|
||||
// Error path: errors keys land at `points[i].lat` / `points[i].lon` per
|
||||
// FluentValidation's default child-property naming + GlobalValidatorConfig
|
||||
// camelCase normalization (matches the wire format set by
|
||||
// [JsonPropertyName("lat"|"lon")] on RoutePoint).
|
||||
public sealed class RoutePointValidator : AbstractValidator<RoutePoint>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
|
||||
public RoutePointValidator()
|
||||
{
|
||||
// `RoutePoint.Latitude` is the C# property name but the wire name is
|
||||
// `lat` via [JsonPropertyName]. OverridePropertyName chains AFTER the
|
||||
// first concrete rule (which provides the `TProperty` for the generic
|
||||
// extension) and aligns the FluentValidation error key with the wire
|
||||
// format — callers see `errors["points[i].lat"]` matching what they
|
||||
// posted rather than the camelCased C# name `latitude`.
|
||||
RuleFor(p => p.Latitude)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`lat` must be between {MinLat} and {MaxLat}.")
|
||||
.OverridePropertyName("lat");
|
||||
|
||||
RuleFor(p => p.Longitude)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`lon` must be between {MinLon} and {MaxLon}.")
|
||||
.OverridePropertyName("lon");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-810: root validator for the UAV upload metadata envelope. Runs from
|
||||
// inside the custom `UavUploadValidationFilter` (the endpoint takes a
|
||||
// multipart form, so the standard `WithValidation<T>()` JSON-body filter
|
||||
// doesn't apply). Error keys come out as `errors.items[…]` from this
|
||||
// validator and are prefixed with `metadata.` by the filter, producing
|
||||
// `errors.metadata.items[…]` in the final ValidationProblemDetails per
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
public sealed class UavTileBatchMetadataPayloadValidator : AbstractValidator<UavTileBatchMetadataPayload>
|
||||
{
|
||||
public UavTileBatchMetadataPayloadValidator(
|
||||
IOptions<UavQualityConfig> qualityConfig,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(qualityConfig);
|
||||
var maxBatchSize = qualityConfig.Value.MaxBatchSize;
|
||||
|
||||
RuleFor(p => p.Items)
|
||||
.NotNull().WithMessage("`items` is required.")
|
||||
.NotEmpty().WithMessage("`items` must contain at least one entry.")
|
||||
.Must(items => items is null || items.Count <= maxBatchSize)
|
||||
.WithMessage($"`items` must contain at most {maxBatchSize} entries.");
|
||||
|
||||
RuleForEach(p => p.Items)
|
||||
.SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-810: per-item metadata validator for the UAV upload endpoint. Runs as
|
||||
// a `RuleForEach.SetValidator(...)` chain child of `UavTileBatchMetadataPayloadValidator`,
|
||||
// so error keys come out as `errors.metadata.items[i].latitude`, `…tileZoom`,
|
||||
// `…capturedAt`, etc. once the `UavUploadValidationFilter` prefixes the result.
|
||||
//
|
||||
// CapturedAt freshness (rule 11) is the same window that
|
||||
// `IUavTileQualityGate.Validate` enforces; running the same check at the API
|
||||
// boundary lets us short-circuit before any file bytes are inspected. The
|
||||
// gate remains as a defence-in-depth backstop for unit tests of the gate
|
||||
// itself and for the unlikely path of a caller invoking
|
||||
// `IUavTileUploadHandler` directly (bypassing the filter).
|
||||
public sealed class UavTileMetadataValidator : AbstractValidator<UavTileMetadata>
|
||||
{
|
||||
private const double MinLat = -90.0;
|
||||
private const double MaxLat = 90.0;
|
||||
private const double MinLon = -180.0;
|
||||
private const double MaxLon = 180.0;
|
||||
private const int MinZoom = 0;
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public UavTileMetadataValidator(IOptions<UavQualityConfig> qualityConfig, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(qualityConfig);
|
||||
var cfg = qualityConfig.Value;
|
||||
var tp = timeProvider ?? TimeProvider.System;
|
||||
var maxAgeDays = cfg.MaxAgeDays;
|
||||
var futureSkewSeconds = cfg.CapturedAtFutureSkewSeconds;
|
||||
|
||||
RuleFor(m => m.Latitude)
|
||||
.InclusiveBetween(MinLat, MaxLat)
|
||||
.WithMessage($"`latitude` must be between {MinLat} and {MaxLat}.");
|
||||
|
||||
RuleFor(m => m.Longitude)
|
||||
.InclusiveBetween(MinLon, MaxLon)
|
||||
.WithMessage($"`longitude` must be between {MinLon} and {MaxLon}.");
|
||||
|
||||
RuleFor(m => m.TileZoom)
|
||||
.InclusiveBetween(MinZoom, MaxZoom)
|
||||
.WithMessage($"`tileZoom` must be between {MinZoom} and {MaxZoom} (slippy-map range).");
|
||||
|
||||
RuleFor(m => m.TileSizeMeters)
|
||||
.GreaterThan(0.0)
|
||||
.WithMessage("`tileSizeMeters` must be greater than 0.");
|
||||
|
||||
// Freshness window: capturedAt ∈ [now - MaxAgeDays, now + CapturedAtFutureSkewSeconds].
|
||||
// `Must` lambdas close over `tp` so the comparison fetches fresh
|
||||
// time per call (rule executes at validation time, not constructor
|
||||
// time). Equivalent to AZ-488 Rule 4 in UavTileQualityGate.
|
||||
RuleFor(m => m.CapturedAt)
|
||||
.Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
|
||||
.WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
|
||||
.Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
|
||||
.WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
|
||||
|
||||
// `FlightId` is intentionally not validated beyond JSON shape — AZ-503
|
||||
// anonymous-flight semantics require null/missing to be a valid case.
|
||||
// System.Text.Json already rejects malformed UUID strings at the
|
||||
// deserializer with `JsonException` → 400 via GlobalExceptionHandler.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-810: endpoint filter for `POST /api/satellite/upload`. The endpoint is
|
||||
// `multipart/form-data`, not a plain JSON body, so the standard
|
||||
// `WithValidation<T>()` filter (which expects an `[FromBody]` argument
|
||||
// already deserialized by the binder) cannot be used. This filter reads
|
||||
// the multipart `metadata` form field, deserializes it with the strict
|
||||
// global `JsonSerializerOptions` (which includes
|
||||
// `UnmappedMemberHandling.Disallow` from AZ-795), runs the FluentValidation
|
||||
// rules on `UavTileBatchMetadataPayload`, and adds the cross-field
|
||||
// alignment check (`metadata.items.Count == files.Count`).
|
||||
//
|
||||
// Failures are returned as RFC 7807 `ValidationProblemDetails` matching
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0; error-map keys
|
||||
// are prefixed with `metadata.` so paths like `items[0].latitude` from
|
||||
// the per-item validator surface to the caller as
|
||||
// `errors["metadata.items[0].latitude"]`.
|
||||
//
|
||||
// The downstream `IUavTileUploadHandler` retains its own envelope checks
|
||||
// as a defence-in-depth backstop (also covers callers invoking the
|
||||
// handler directly in unit tests). When the filter has already validated,
|
||||
// the handler's checks are no-ops by construction.
|
||||
public sealed class UavUploadValidationFilter : IEndpointFilter
|
||||
{
|
||||
private const string MetadataKeyPrefix = "metadata.";
|
||||
private const string MetadataField = "metadata";
|
||||
private const string FilesField = "files";
|
||||
|
||||
private readonly IValidator<UavTileBatchMetadataPayload> _validator;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public UavUploadValidationFilter(
|
||||
IValidator<UavTileBatchMetadataPayload> validator,
|
||||
IOptions<JsonOptions> jsonOptions)
|
||||
{
|
||||
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
|
||||
ArgumentNullException.ThrowIfNull(jsonOptions);
|
||||
_jsonOptions = jsonOptions.Value.SerializerOptions;
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
if (!request.HasFormContentType)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { "Request must be `multipart/form-data`." },
|
||||
});
|
||||
}
|
||||
|
||||
var form = await request.ReadFormAsync(context.HttpContext.RequestAborted);
|
||||
var metadataField = form[MetadataField].ToString();
|
||||
var files = form.Files;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(metadataField))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { "`metadata` form field is required." },
|
||||
});
|
||||
}
|
||||
|
||||
UavTileBatchMetadataPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(metadataField, _jsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// System.Text.Json with UnmappedMemberHandling.Disallow + [JsonRequired]
|
||||
// covers: unknown root/nested fields, missing required fields, type
|
||||
// mismatches. Surface uniformly as `errors.metadata`.
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" },
|
||||
});
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataField] = new[] { "`metadata` must be a non-null JSON object." },
|
||||
});
|
||||
}
|
||||
|
||||
var result = await _validator.ValidateAsync(payload, context.HttpContext.RequestAborted);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
var prefixed = new Dictionary<string, string[]>(StringComparer.Ordinal);
|
||||
foreach (var group in result.ToDictionary())
|
||||
{
|
||||
prefixed[MetadataKeyPrefix + group.Key] = group.Value;
|
||||
}
|
||||
return Results.ValidationProblem(prefixed);
|
||||
}
|
||||
|
||||
if (payload.Items.Count != files.Count)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[MetadataKeyPrefix + "items"] = new[]
|
||||
{
|
||||
$"`metadata.items` has {payload.Items.Count} entries but `files` has {files.Count}.",
|
||||
},
|
||||
[FilesField] = new[]
|
||||
{
|
||||
$"`files` has {files.Count} entries but `metadata.items` has {payload.Items.Count}.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-795: shared validation infrastructure. A generic IEndpointFilter that
|
||||
// resolves IValidator<T> from DI for the first argument of type T in the
|
||||
// invoked endpoint and returns RFC 7807 ValidationProblemDetails (HTTP 400)
|
||||
// with a structured `errors` map when the validator rejects. When validation
|
||||
// passes, the filter forwards to the next stage unchanged.
|
||||
//
|
||||
// The filter is generic per request type; per-endpoint wire-up is done via
|
||||
// `RouteHandlerBuilder.WithValidation<T>()` (see ValidationEndpointFilterExtensions).
|
||||
// Per AZ-795 Outcome: callers must NOT need per-endpoint try/catch boilerplate;
|
||||
// the filter provides the uniform error contract documented in
|
||||
// `_docs/02_document/contracts/api/error-shape.md`.
|
||||
public sealed class ValidationEndpointFilter<T> : IEndpointFilter where T : class
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
var argument = context.Arguments.OfType<T>().FirstOrDefault();
|
||||
if (argument is null)
|
||||
{
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
|
||||
if (validator is null)
|
||||
{
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return Results.ValidationProblem(result.ToDictionary());
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-795: ergonomic extension method for opting an endpoint into
|
||||
// FluentValidation. Applied at MapPost/MapGet registration time:
|
||||
//
|
||||
// app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
|
||||
// .WithValidation<TileInventoryRequest>();
|
||||
//
|
||||
// One line per endpoint; no per-handler try/catch boilerplate; uniform
|
||||
// RFC 7807 error shape — see `_docs/02_document/contracts/api/error-shape.md`.
|
||||
public static class ValidationEndpointFilterExtensions
|
||||
{
|
||||
public static RouteHandlerBuilder WithValidation<T>(this RouteHandlerBuilder builder)
|
||||
where T : class
|
||||
{
|
||||
builder.AddEndpointFilter<ValidationEndpointFilter<T>>();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"
|
||||
"DefaultConnection": "Host=localhost;Port=5433;Database=satelliteprovider;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var",
|
||||
|
||||
@@ -4,18 +4,35 @@ namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class CreateRouteRequest
|
||||
{
|
||||
// AZ-809: [JsonRequired] enforces presence at the deserializer; range and
|
||||
// shape checks live in `SatelliteProvider.Api/Validators/CreateRouteRequestValidator`.
|
||||
// Description and Geofences remain optional. The legacy in-service
|
||||
// `RouteValidator` is left in place as defense-in-depth for direct
|
||||
// service-layer callers (e.g. unit tests).
|
||||
[JsonRequired]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public double RegionSizeMeters { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int ZoomLevel { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public List<RoutePoint> Points { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("geofences")]
|
||||
public Geofences? Geofences { get; set; }
|
||||
|
||||
public bool RequestMaps { get; set; } = false;
|
||||
public bool CreateTilesZip { get; set; } = false;
|
||||
[JsonRequired]
|
||||
public bool RequestMaps { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public bool CreateTilesZip { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ public class GeoPoint
|
||||
{
|
||||
const double PRECISION_TOLERANCE = 0.00005;
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class GeofencePolygon
|
||||
{
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("northWest")]
|
||||
public GeoPoint? NorthWest { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("southEast")]
|
||||
public GeoPoint? SouthEast { get; set; }
|
||||
}
|
||||
|
||||
public class Geofences
|
||||
{
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("polygons")]
|
||||
public List<GeofencePolygon> Polygons { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
// AZ-812 (cycle 8): wire-format renamed Latitude/Longitude → Lat/Lon (OSM
|
||||
// convention) and added [JsonPropertyName("lat"/"lon")] so the wire is
|
||||
// unambiguous under JsonSerializerOptions.UnmappedMemberHandling.Disallow
|
||||
// (AZ-795 cycle 7).
|
||||
//
|
||||
// AZ-808 (cycle 8): switched [Required] → [JsonRequired] on every property.
|
||||
// [Required] is DataAnnotations and is NOT enforced by System.Text.Json — the
|
||||
// 2026-05-22 black-box probe confirmed it: omitting `id` returned HTTP 200
|
||||
// with id=Guid.Empty (silent coercion). [JsonRequired] is enforced by the
|
||||
// STJ deserializer and fails with BadHttpRequestException(JsonException),
|
||||
// which the GlobalExceptionHandler converts to RFC 7807 ValidationProblemDetails.
|
||||
// Removed the in-property defaults (= 18 for ZoomLevel, = false for StitchTiles)
|
||||
// because [JsonRequired] forces the caller to declare intent.
|
||||
public record RequestRegionRequest
|
||||
{
|
||||
[Required]
|
||||
[JsonRequired]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public double Latitude { get; set; }
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
[Required]
|
||||
public double Longitude { get; set; }
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonRequired]
|
||||
public double SizeMeters { get; set; }
|
||||
|
||||
[Required]
|
||||
public int ZoomLevel { get; set; } = 18;
|
||||
[JsonRequired]
|
||||
public int ZoomLevel { get; set; }
|
||||
|
||||
public bool StitchTiles { get; set; } = false;
|
||||
[JsonRequired]
|
||||
public bool StitchTiles { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
public class RoutePoint
|
||||
{
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lat")]
|
||||
public double Latitude { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
[JsonPropertyName("lon")]
|
||||
public double Longitude { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
// AZ-505: bulk-list / inventory request envelope. Either `Tiles` OR
|
||||
// `LocationHashes` is populated — never both, never neither. The handler
|
||||
// converts every `(z, x, y)` coord into a `location_hash` via UUIDv5 and
|
||||
// queries `tiles_leaflet_path` once. Response order matches request order.
|
||||
//
|
||||
// Max entries per request: see TileInventoryLimits.MaxEntriesPerRequest.
|
||||
public sealed class TileInventoryRequest
|
||||
{
|
||||
public IReadOnlyList<TileCoord>? Tiles { get; set; }
|
||||
public IReadOnlyList<Guid>? LocationHashes { get; set; }
|
||||
}
|
||||
|
||||
// AZ-505: Slippy-map tile coordinate triple. AZ-794 (cycle 7) renamed the
|
||||
// wire-format fields from `tileZoom/tileX/tileY` → `z/x/y` to align with the
|
||||
// OSM / slippy-map convention already used by `GET /tiles/{z}/{x}/{y}` and
|
||||
// to shave wire-size on inventory requests carrying thousands of entries.
|
||||
// The C# property names (`Z`, `X`, `Y`) intentionally mirror the wire names
|
||||
// 1:1 so consumers don't need to mentally translate at the deserialization
|
||||
// boundary. The DataAccess `TileEntity.TileZoom/TileX/TileY` columns are
|
||||
// unchanged — that's a database identity, not a wire format.
|
||||
public sealed class TileCoord
|
||||
{
|
||||
[JsonRequired]
|
||||
public int Z { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int X { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public int Y { get; set; }
|
||||
}
|
||||
|
||||
// AZ-505: Inventory response. Entries are returned in the SAME ORDER as the
|
||||
// matching request input (per AC-1). When Request.Tiles was populated, each
|
||||
// entry's `Z`/`X`/`Y` echoes the request entry; when Request.LocationHashes
|
||||
// was populated, the coord triple fields are 0 (the caller already knows
|
||||
// the hash and can map it back themselves). AZ-794 (cycle 7) renamed the
|
||||
// coord triple to `z/x/y` to align wire format with the URL-path
|
||||
// convention.
|
||||
public sealed class TileInventoryResponse
|
||||
{
|
||||
public IReadOnlyList<TileInventoryEntry> Results { get; set; } = Array.Empty<TileInventoryEntry>();
|
||||
}
|
||||
|
||||
// AZ-505: One entry per request input. `Present` indicates whether a row
|
||||
// exists in the `tiles` table for the resolved `LocationHash`. When
|
||||
// `Present == false` only `LocationHash` (and the echoed coord triple, if the
|
||||
// request used coords) is populated — the rest are null.
|
||||
//
|
||||
// `EstimatedBytes` is intentionally absent in v1.0.0 — adding the per-row
|
||||
// `stat()` cost is deferred until production profiling justifies it (see
|
||||
// AZ-505 Outcome bullet 1 + Excluded list).
|
||||
//
|
||||
// AZ-794 (cycle 7): coord triple renamed `tileZoom/tileX/tileY` → `z/x/y`
|
||||
// (contract bumped to v2.0.0).
|
||||
public sealed class TileInventoryEntry
|
||||
{
|
||||
public int Z { get; set; }
|
||||
public int X { get; set; }
|
||||
public int Y { get; set; }
|
||||
public Guid LocationHash { get; set; }
|
||||
public bool Present { get; set; }
|
||||
|
||||
public Guid? Id { get; set; }
|
||||
public DateTime? CapturedAt { get; set; }
|
||||
public string? Source { get; set; }
|
||||
public Guid? FlightId { get; set; }
|
||||
public double? ResolutionMPerPx { get; set; }
|
||||
}
|
||||
|
||||
// AZ-505: per-task constants exposed for the request validator + tests.
|
||||
// Living under DTO so both the API handler and test assertions can reference
|
||||
// the same value without re-deriving it.
|
||||
public static class TileInventoryLimits
|
||||
{
|
||||
// 2x headroom over the AC-4 perf gate of 2500 tiles. Anything larger is
|
||||
// rejected with HTTP 400 by the API handler.
|
||||
public const int MaxEntriesPerRequest = 5000;
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SatelliteProvider.Common.DTO;
|
||||
|
||||
// AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each
|
||||
// batch item. `CapturedAt` is normalized to UTC by the upload handler before
|
||||
// reaching the persistence layer.
|
||||
//
|
||||
// AZ-503: `FlightId` is optional. When provided, two UAVs uploading the same
|
||||
// (z, x, y) cell from different flights coexist as distinct DB rows and write
|
||||
// to per-flight on-disk paths (./tiles/uav/{flight_id}/{z}/{x}/{y}.jpg). When
|
||||
// absent, the row is treated as flight-anonymous and the UPSERT collapses to
|
||||
// the AZ-484 "single row per (cell, source)" semantics via COALESCE-to-zero.
|
||||
//
|
||||
// AZ-810 (cycle 8) added [JsonRequired] to every non-optional axis so the
|
||||
// deserializer rejects partial payloads with HTTP 400 + ValidationProblemDetails
|
||||
// via GlobalExceptionHandler BEFORE the FluentValidation + IUavTileQualityGate
|
||||
// layers run. FlightId stays optional per AZ-503 anonymous-flight semantics.
|
||||
public record UavTileMetadata
|
||||
{
|
||||
[JsonRequired]
|
||||
public double Latitude { get; init; }
|
||||
[JsonRequired]
|
||||
public double Longitude { get; init; }
|
||||
[JsonRequired]
|
||||
public int TileZoom { get; init; }
|
||||
[JsonRequired]
|
||||
public double TileSizeMeters { get; init; }
|
||||
[JsonRequired]
|
||||
public DateTime CapturedAt { get; init; }
|
||||
public Guid? FlightId { get; init; }
|
||||
}
|
||||
|
||||
public record UavTileBatchMetadataPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public List<UavTileMetadata> Items { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -9,5 +9,11 @@ public interface ITileService
|
||||
Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel);
|
||||
Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default);
|
||||
Task<TileMetadata> DownloadAndStoreSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken cancellationToken = default);
|
||||
// AZ-505: bulk-list / inventory endpoint. Maps every request entry to its
|
||||
// location_hash, queries the repository in one round-trip, and returns one
|
||||
// response entry per request entry — in the same order. Callers are
|
||||
// expected to validate the request shape (`Tiles` XOR `LocationHashes`,
|
||||
// entry count cap) BEFORE invoking this method.
|
||||
Task<TileInventoryResponse> GetInventoryAsync(TileInventoryRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SatelliteProvider.Common.Utils;
|
||||
|
||||
// AZ-503: pure-C# RFC 9562 (formerly RFC 4122 §4.3) UUIDv5 implementation.
|
||||
//
|
||||
// .NET 10 ships Guid.CreateVersion7 but NOT a version-5 builder, so we implement
|
||||
// the SHA-1-based algorithm here. Onboard `gps-denied-onboard/components/c6_tile_cache/_uuid.py`
|
||||
// MUST use the same TileNamespace constant and the same algorithm (Python's stdlib
|
||||
// uuid.uuid5 is identical by construction) so both sides of the wire compute
|
||||
// byte-identical IDs for the same (z, x, y, source, flight_id) inputs.
|
||||
//
|
||||
// Cross-repo namespace coordination: TileNamespace below is THE pinned value.
|
||||
// Any change here must be paired with the same change on the onboard side; the
|
||||
// AZ-503 task spec requires this and AC-1 (Python reference vectors) gates it.
|
||||
public static class Uuidv5
|
||||
{
|
||||
// Pinned cross-repo namespace for tile identity. Must match
|
||||
// gps-denied-onboard `c6_tile_cache/_uuid.py:TILE_NAMESPACE`.
|
||||
// Chosen as a fresh random UUID (no semantic meaning beyond being a stable
|
||||
// 128-bit constant shared between the two repos).
|
||||
public static readonly Guid TileNamespace = new("5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c");
|
||||
|
||||
// AZ-505 consolidation: the canonical formula for a tile cell's
|
||||
// location_hash. Both TileRepository.GetByTileCoordinatesAsync and
|
||||
// TileService.GetInventoryAsync compute it; centralising here means the
|
||||
// cross-repo invariant (must byte-match gps-denied-onboard
|
||||
// `c6_tile_cache/_uuid.py:location_hash`) only has one source-of-truth in
|
||||
// this codebase. Format string is `"{z}/{x}/{y}"` under invariant culture —
|
||||
// matches the Python side's f-string output.
|
||||
public static Guid LocationHashForTile(int tileZoom, int tileX, int tileY)
|
||||
{
|
||||
var name = string.Create(CultureInfo.InvariantCulture, $"{tileZoom}/{tileX}/{tileY}");
|
||||
return Create(TileNamespace, name);
|
||||
}
|
||||
|
||||
public static Guid Create(Guid namespaceId, string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
// Namespace UUIDs are concatenated as 16 bytes in network (big-endian)
|
||||
// order. .NET's Guid.ToByteArray() returns mixed-endian (RFC 4122
|
||||
// "Microsoft" layout), so we cannot use it directly — we must rebuild
|
||||
// the byte array in big-endian order, matching what Python's
|
||||
// uuid.UUID.bytes produces.
|
||||
Span<byte> namespaceBytes = stackalloc byte[16];
|
||||
WriteGuidBigEndian(namespaceId, namespaceBytes);
|
||||
|
||||
var nameBytes = Encoding.UTF8.GetBytes(name);
|
||||
|
||||
Span<byte> hash = stackalloc byte[20];
|
||||
var buffer = new byte[16 + nameBytes.Length];
|
||||
namespaceBytes.CopyTo(buffer);
|
||||
Buffer.BlockCopy(nameBytes, 0, buffer, 16, nameBytes.Length);
|
||||
if (!SHA1.TryHashData(buffer, hash, out _))
|
||||
{
|
||||
throw new InvalidOperationException("SHA-1 hash computation failed.");
|
||||
}
|
||||
|
||||
// Take first 16 bytes, set version to 5 (upper nibble of byte 6) and
|
||||
// variant to RFC 4122 (upper two bits of byte 8 set to `10`).
|
||||
Span<byte> uuidBytes = stackalloc byte[16];
|
||||
hash[..16].CopyTo(uuidBytes);
|
||||
uuidBytes[6] = (byte)((uuidBytes[6] & 0x0F) | 0x50);
|
||||
uuidBytes[8] = (byte)((uuidBytes[8] & 0x3F) | 0x80);
|
||||
|
||||
return ReadGuidBigEndian(uuidBytes);
|
||||
}
|
||||
|
||||
private static void WriteGuidBigEndian(Guid value, Span<byte> destination)
|
||||
{
|
||||
Span<byte> mixed = stackalloc byte[16];
|
||||
value.TryWriteBytes(mixed);
|
||||
// Convert from Microsoft mixed-endian (first 3 fields little-endian) to
|
||||
// network (big-endian) order.
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[..4], BinaryPrimitives.ReadUInt32LittleEndian(mixed[..4]));
|
||||
BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(4, 2), BinaryPrimitives.ReadUInt16LittleEndian(mixed.Slice(4, 2)));
|
||||
BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(6, 2), BinaryPrimitives.ReadUInt16LittleEndian(mixed.Slice(6, 2)));
|
||||
mixed.Slice(8, 8).CopyTo(destination.Slice(8, 8));
|
||||
}
|
||||
|
||||
private static Guid ReadGuidBigEndian(ReadOnlySpan<byte> bigEndian)
|
||||
{
|
||||
Span<byte> mixed = stackalloc byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(mixed[..4], BinaryPrimitives.ReadUInt32BigEndian(bigEndian[..4]));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(mixed.Slice(4, 2), BinaryPrimitives.ReadUInt16BigEndian(bigEndian.Slice(4, 2)));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(mixed.Slice(6, 2), BinaryPrimitives.ReadUInt16BigEndian(bigEndian.Slice(6, 2)));
|
||||
bigEndian.Slice(8, 8).CopyTo(mixed.Slice(8, 8));
|
||||
return new Guid(mixed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
-- AZ-503-foundation: deterministic tile identity (UUIDv5) + multi-flight evidence preservation.
|
||||
--
|
||||
-- Adds four columns to `tiles`:
|
||||
-- - flight_id (uuid NULL) — per-UAV-flight identifier. NULL for google_maps and
|
||||
-- legacy UAV rows; populated for AZ-503+ UAV uploads.
|
||||
-- - location_hash (uuid NOT NULL) — UUIDv5(TILE_NAMESPACE, "{tile_zoom}/{tile_x}/{tile_y}").
|
||||
-- Drives leaflet hot-path lookups and future voting layer.
|
||||
-- - content_sha256 (bytea NULL) — SHA-256 of the JPEG body at insert time. NULL for legacy
|
||||
-- rows (pre-AZ-503), NOT NULL for new rows enforced at the
|
||||
-- application layer (TileEntity / repositories). Kept NULL-able
|
||||
-- at the column level because the migration cannot read tile
|
||||
-- files from disk safely (path may have moved, file may be
|
||||
-- gone). Application invariant: SHA-256 only meaningful when
|
||||
-- not NULL.
|
||||
-- - legacy_id (uuid NULL) — preserves the pre-AZ-503 random id of each row for one
|
||||
-- deprecation cycle (per AZ-503 Risk 1). Dropped in a
|
||||
-- follow-up migration once external references to legacy
|
||||
-- ids are confirmed flushed.
|
||||
--
|
||||
-- Switches the UPSERT conflict key from (latitude, longitude, tile_zoom, tile_size_meters, source)
|
||||
-- to an integer-only key with per-flight separation:
|
||||
-- (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-...'::uuid))
|
||||
-- so two UAV flights uploading the same (z, x, y) cell coexist as distinct rows.
|
||||
--
|
||||
-- TILE_NAMESPACE is pinned cross-repo at 5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c (matches
|
||||
-- SatelliteProvider.Common.Utils.Uuidv5.TileNamespace and gps-denied-onboard
|
||||
-- c6_tile_cache/_uuid.py). DO NOT change without updating both sides.
|
||||
--
|
||||
-- Whole migration runs inside one transaction; partial failure leaves the table without the
|
||||
-- new columns rather than half-migrated (per AZ-484 precedent for tile-table migrations).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Helper: pure-SQL UUIDv5 (SHA-1-based, RFC 9562 §5.5). Used ONLY for the
|
||||
-- location_hash backfill below. Application writes compute the same UUIDv5
|
||||
-- via SatelliteProvider.Common.Utils.Uuidv5.Create (verified byte-identical
|
||||
-- against Python uuid.uuid5 in AZ-503 AC-1).
|
||||
CREATE OR REPLACE FUNCTION pg_temp.uuidv5(namespace_uuid uuid, name text) RETURNS uuid AS $$
|
||||
DECLARE
|
||||
ns_bytes bytea;
|
||||
hash bytea;
|
||||
b6 int;
|
||||
b8 int;
|
||||
BEGIN
|
||||
-- Namespace UUID as 16 big-endian bytes.
|
||||
ns_bytes := decode(replace(namespace_uuid::text, '-', ''), 'hex');
|
||||
hash := substring(digest(ns_bytes || convert_to(name, 'UTF8'), 'sha1') from 1 for 16);
|
||||
-- Set version = 5 (upper nibble of byte 6).
|
||||
b6 := (get_byte(hash, 6) & 15) | 80;
|
||||
hash := set_byte(hash, 6, b6);
|
||||
-- Set RFC 4122 variant (upper 2 bits of byte 8 = 10).
|
||||
b8 := (get_byte(hash, 8) & 63) | 128;
|
||||
hash := set_byte(hash, 8, b8);
|
||||
RETURN encode(hash, 'hex')::uuid;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS flight_id uuid;
|
||||
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS location_hash uuid;
|
||||
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS content_sha256 bytea;
|
||||
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS legacy_id uuid;
|
||||
|
||||
-- Preserve the pre-AZ-503 random id under legacy_id for the deprecation window.
|
||||
UPDATE tiles
|
||||
SET legacy_id = id
|
||||
WHERE legacy_id IS NULL;
|
||||
|
||||
-- Backfill location_hash for every existing row. Deterministic; same algorithm
|
||||
-- the application uses for new writes.
|
||||
UPDATE tiles
|
||||
SET location_hash = pg_temp.uuidv5(
|
||||
'5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid,
|
||||
tile_zoom::text || '/' || tile_x::text || '/' || tile_y::text)
|
||||
WHERE location_hash IS NULL;
|
||||
|
||||
-- location_hash is now populated for every row; promote to NOT NULL.
|
||||
ALTER TABLE tiles ALTER COLUMN location_hash SET NOT NULL;
|
||||
|
||||
-- content_sha256 is intentionally left nullable for legacy rows (the migration cannot
|
||||
-- safely re-read tile files: paths may have rotated, files may be absent). The application
|
||||
-- layer enforces NOT NULL for all writes starting at AZ-503; legacy NULLs are treated as
|
||||
-- "unverified content" and surfaced as such if/when integrity checks are added downstream.
|
||||
|
||||
-- Drop AZ-484's lat/lon-keyed unique index and replace with the integer + flight_id key.
|
||||
DROP INDEX IF EXISTS idx_tiles_unique_location_source;
|
||||
DROP INDEX IF EXISTS idx_tiles_unique_location;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tiles_unique_identity
|
||||
ON tiles (
|
||||
tile_zoom,
|
||||
tile_x,
|
||||
tile_y,
|
||||
tile_size_meters,
|
||||
source,
|
||||
COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
|
||||
-- Lookup index on location_hash for application reads (kept lightweight here;
|
||||
-- the larger covering index `tiles_leaflet_path` is owned by AZ-505).
|
||||
CREATE INDEX IF NOT EXISTS idx_tiles_location_hash ON tiles (location_hash);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- AZ-505: Leaflet covering index on `tiles` keyed by location_hash.
|
||||
--
|
||||
-- Forward migration:
|
||||
-- 1. Create `tiles_leaflet_path` covering index over (location_hash,
|
||||
-- captured_at DESC, updated_at DESC, id DESC) with INCLUDE (file_path, source).
|
||||
-- The leading column matches the equality predicate used by the AZ-505
|
||||
-- Leaflet hot path (`SELECT file_path FROM tiles WHERE location_hash = $1
|
||||
-- ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1`); the INCLUDE
|
||||
-- columns make that exact projection an index-only scan once VACUUM ANALYZE
|
||||
-- has set the visibility map.
|
||||
-- 2. Drop the lightweight `idx_tiles_location_hash` introduced by migration
|
||||
-- 014 — it is superseded because equality lookups by `location_hash` use
|
||||
-- the leading column of the new covering index.
|
||||
--
|
||||
-- Back-migration (manual):
|
||||
-- DROP INDEX IF EXISTS tiles_leaflet_path;
|
||||
-- CREATE INDEX IF NOT EXISTS idx_tiles_location_hash ON tiles (location_hash);
|
||||
--
|
||||
-- INCLUDE columns are intentionally narrow (`file_path, source`). The richer
|
||||
-- inventory endpoint legitimately requires extra columns that are NOT in the
|
||||
-- INCLUDE list (`id, captured_at, flight_id, image_type, tile_size_meters,
|
||||
-- tile_size_pixels, location_hash`); inventory queries therefore trigger a
|
||||
-- bounded heap fetch, which is acceptable per the AZ-505 NFR-Perf-2 budget
|
||||
-- (≤ 1000 ms p95 / 2500 tiles). See AZ-505 Risk 1 in the task spec.
|
||||
--
|
||||
-- Lock window: this migration runs inside DbUp's per-script transaction, which
|
||||
-- is incompatible with `CREATE INDEX CONCURRENTLY`. On a populated `tiles`
|
||||
-- table the `CREATE INDEX` takes an `ACCESS SHARE` + `SHARE` lock on the table
|
||||
-- for the duration of the build, blocking writes. Schedule deploys to a
|
||||
-- low-traffic window or pre-build the index out-of-band before running this
|
||||
-- migration. See AZ-505 Risk 2.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tiles_leaflet_path
|
||||
ON tiles (location_hash, captured_at DESC, updated_at DESC, id DESC)
|
||||
INCLUDE (file_path, source);
|
||||
|
||||
DROP INDEX IF EXISTS idx_tiles_location_hash;
|
||||
|
||||
COMMIT;
|
||||
@@ -24,4 +24,26 @@ public class TileEntity
|
||||
public DateTime CapturedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// AZ-503: per-UAV-flight identifier. NULL for google_maps and pre-AZ-503
|
||||
// legacy UAV rows; populated for AZ-503+ UAV uploads. Part of the UPSERT
|
||||
// conflict key (via COALESCE to the zero-UUID) so two flights uploading
|
||||
// the same (z, x, y) cell coexist as distinct rows.
|
||||
public Guid? FlightId { get; set; }
|
||||
|
||||
// AZ-503: UUIDv5(TILE_NAMESPACE, "{tile_zoom}/{tile_x}/{tile_y}"). Always
|
||||
// populated (column is NOT NULL after migration 014); deterministic across
|
||||
// C# and Python (see SatelliteProvider.Common.Utils.Uuidv5).
|
||||
public Guid LocationHash { get; set; }
|
||||
|
||||
// AZ-503: SHA-256 of the tile body at insert time. NULL for pre-AZ-503
|
||||
// legacy rows (the migration cannot read tile files); NOT NULL by
|
||||
// application invariant for AZ-503+ inserts (TileService.BuildTileEntity
|
||||
// and UavTileUploadHandler.PersistAsync compute this before insert).
|
||||
public byte[]? ContentSha256 { get; set; }
|
||||
|
||||
// AZ-503: pre-AZ-503 random Id value, preserved for one deprecation cycle
|
||||
// so external references to the legacy random id can be reconciled (per
|
||||
// AZ-503 Risk 1 mitigation). Dropped in a follow-up migration.
|
||||
public Guid? LegacyId { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ public interface ITileRepository
|
||||
Task<TileEntity?> GetByIdAsync(Guid id);
|
||||
Task<TileEntity?> GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY);
|
||||
Task<IEnumerable<TileEntity>> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel);
|
||||
// AZ-505: bulk-list endpoint backing query. Returns the most-recent row
|
||||
// across sources/flights for each requested `location_hash`. Result order
|
||||
// is unspecified; callers (TileService.GetInventoryAsync) re-align entries
|
||||
// to the request order via dictionary lookup.
|
||||
Task<IReadOnlyDictionary<Guid, TileEntity>> GetTilesByLocationHashesAsync(IReadOnlyList<Guid> locationHashes);
|
||||
Task<Guid> InsertAsync(TileEntity tile);
|
||||
Task<int> UpdateAsync(TileEntity tile);
|
||||
Task<int> DeleteAsync(Guid id);
|
||||
|
||||
@@ -17,7 +17,9 @@ public class TileRepository : ITileRepository
|
||||
tile_size_meters as TileSizeMeters, tile_size_pixels as TileSizePixels,
|
||||
image_type as ImageType, maps_version as MapsVersion, version,
|
||||
file_path as FilePath, source, captured_at as CapturedAt,
|
||||
created_at as CreatedAt, updated_at as UpdatedAt";
|
||||
created_at as CreatedAt, updated_at as UpdatedAt,
|
||||
flight_id as FlightId, location_hash as LocationHash,
|
||||
content_sha256 as ContentSha256, legacy_id as LegacyId";
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<TileRepository> _logger;
|
||||
@@ -42,16 +44,122 @@ public class TileRepository : ITileRepository
|
||||
public async Task<TileEntity?> GetByTileCoordinatesAsync(int tileZoom, int tileX, int tileY)
|
||||
{
|
||||
using var connection = new NpgsqlConnection(_connectionString);
|
||||
// AZ-484 selection rule: most-recent across sources, deterministic tie-break on
|
||||
// (captured_at DESC, updated_at DESC, id DESC).
|
||||
// AZ-505 read-rewrite: filter by `location_hash` so the new
|
||||
// `tiles_leaflet_path` covering index drives the scan. Selection rule
|
||||
// is unchanged from AZ-484: most-recent across sources/flights with
|
||||
// deterministic tie-break on (captured_at DESC, updated_at DESC, id DESC).
|
||||
// Heap fetch is unavoidable here (the column list spans columns not in
|
||||
// the index INCLUDE list); the slim `SELECT file_path` Leaflet hot path
|
||||
// — which is what AC-3 measures — is index-only-scannable.
|
||||
var locationHash = Uuidv5.LocationHashForTile(tileZoom, tileX, tileY);
|
||||
const string sql = $@"
|
||||
SELECT {ColumnList}
|
||||
FROM tiles
|
||||
WHERE tile_zoom = @TileZoom AND tile_x = @TileX AND tile_y = @TileY
|
||||
WHERE location_hash = @LocationHash
|
||||
ORDER BY captured_at DESC, updated_at DESC, id DESC
|
||||
LIMIT 1";
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TileEntity>(sql, new { TileZoom = tileZoom, TileX = tileX, TileY = tileY });
|
||||
return await connection.QuerySingleOrDefaultAsync<TileEntity>(sql, new { LocationHash = locationHash });
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<Guid, TileEntity>> GetTilesByLocationHashesAsync(IReadOnlyList<Guid> locationHashes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(locationHashes);
|
||||
if (locationHashes.Count == 0)
|
||||
{
|
||||
return new Dictionary<Guid, TileEntity>();
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// AZ-505: one-row-per-hash bulk lookup. `DISTINCT ON (location_hash)`
|
||||
// collapses the per-(z, x, y) cell to its most-recent variant across
|
||||
// sources/flights using the same tie-break as AZ-484. Caller dedupes
|
||||
// input + re-aligns response order; this query returns at most one
|
||||
// row per distinct hash.
|
||||
//
|
||||
// The query is intentionally NOT routed through Dapper: Dapper's
|
||||
// parameter expander rewrites any IEnumerable parameter (including
|
||||
// `Guid[]`) into `(@p0, @p1, ...)`, which would turn `ANY(@p)` into
|
||||
// `ANY((@p0, @p1, ...))` and break the SQL. Using NpgsqlParameter with
|
||||
// `Array | Uuid` lets Npgsql bind the array as a single `uuid[]`,
|
||||
// which is the form the AZ-505 spec query expects.
|
||||
const string sql = @"
|
||||
SELECT id, tile_zoom AS TileZoom, tile_x AS TileX, tile_y AS TileY,
|
||||
latitude, longitude,
|
||||
tile_size_meters AS TileSizeMeters, tile_size_pixels AS TileSizePixels,
|
||||
image_type AS ImageType, maps_version AS MapsVersion, version,
|
||||
file_path AS FilePath, source, captured_at AS CapturedAt,
|
||||
created_at AS CreatedAt, updated_at AS UpdatedAt,
|
||||
flight_id AS FlightId, location_hash AS LocationHash,
|
||||
content_sha256 AS ContentSha256, legacy_id AS LegacyId
|
||||
FROM (
|
||||
SELECT DISTINCT ON (location_hash)
|
||||
id, tile_zoom, tile_x, tile_y,
|
||||
latitude, longitude,
|
||||
tile_size_meters, tile_size_pixels,
|
||||
image_type, maps_version, version,
|
||||
file_path, source, captured_at,
|
||||
created_at, updated_at,
|
||||
flight_id, location_hash,
|
||||
content_sha256, legacy_id
|
||||
FROM tiles
|
||||
WHERE location_hash = ANY(@LocationHashes)
|
||||
ORDER BY location_hash, captured_at DESC, updated_at DESC, id DESC
|
||||
) most_recent";
|
||||
|
||||
var distinctHashes = locationHashes.Distinct().ToArray();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
var arrayParam = new NpgsqlParameter("LocationHashes", NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Uuid)
|
||||
{
|
||||
Value = distinctHashes
|
||||
};
|
||||
cmd.Parameters.Add(arrayParam);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var rows = new Dictionary<Guid, TileEntity>(distinctHashes.Length);
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var tile = new TileEntity
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TileZoom = reader.GetInt32(1),
|
||||
TileX = reader.GetInt32(2),
|
||||
TileY = reader.GetInt32(3),
|
||||
Latitude = reader.GetDouble(4),
|
||||
Longitude = reader.GetDouble(5),
|
||||
TileSizeMeters = reader.GetDouble(6),
|
||||
TileSizePixels = reader.GetInt32(7),
|
||||
ImageType = reader.GetString(8),
|
||||
MapsVersion = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
Version = reader.IsDBNull(10) ? null : reader.GetInt32(10),
|
||||
FilePath = reader.GetString(11),
|
||||
Source = reader.GetString(12),
|
||||
CapturedAt = reader.GetDateTime(13),
|
||||
CreatedAt = reader.GetDateTime(14),
|
||||
UpdatedAt = reader.GetDateTime(15),
|
||||
FlightId = reader.IsDBNull(16) ? null : reader.GetGuid(16),
|
||||
LocationHash = reader.GetGuid(17),
|
||||
ContentSha256 = reader.IsDBNull(18) ? null : (byte[])reader.GetValue(18),
|
||||
LegacyId = reader.IsDBNull(19) ? null : reader.GetGuid(19)
|
||||
};
|
||||
rows[tile.LocationHash] = tile;
|
||||
}
|
||||
}
|
||||
stopwatch.Stop();
|
||||
|
||||
if (stopwatch.ElapsedMilliseconds > SlowQueryThresholdMs)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Slow GetTilesByLocationHashesAsync: {ElapsedMs} ms (threshold {ThresholdMs} ms) for {RequestedHashes} requested ({DistinctHashes} distinct) hashes",
|
||||
stopwatch.ElapsedMilliseconds, SlowQueryThresholdMs, locationHashes.Count, distinctHashes.Length);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TileEntity>> GetTilesByRegionAsync(double latitude, double longitude, double sizeMeters, int zoomLevel)
|
||||
@@ -110,24 +218,35 @@ public class TileRepository : ITileRepository
|
||||
public async Task<Guid> InsertAsync(TileEntity tile)
|
||||
{
|
||||
using var connection = new NpgsqlConnection(_connectionString);
|
||||
// AZ-484: per-source UPSERT — conflict key now includes `source` so that two
|
||||
// producers (e.g. google_maps + uav) can coexist for the same cell. A re-insert
|
||||
// for the SAME source updates file_path / tile_x / tile_y plus refreshes
|
||||
// captured_at and updated_at to reflect the new acquisition.
|
||||
// AZ-503: integer-keyed UPSERT with per-flight separation. The conflict key
|
||||
// is (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00...0')).
|
||||
// Two UAV flights uploading the same (z, x, y) cell coexist as distinct rows
|
||||
// because their flight_id values differ; legacy/google_maps rows collapse on
|
||||
// the zero-UUID coalesce, preserving AZ-484 single-row-per-cell semantics for
|
||||
// those producers. Float-based latitude/longitude is no longer part of the key
|
||||
// so independently-rounded center coords always converge on the same row.
|
||||
//
|
||||
// `id` is deliberately NOT updated on conflict — legacy random ids and AZ-503
|
||||
// deterministic ids both stay stable, matching AC-2 ("the `id` column is not
|
||||
// regenerated").
|
||||
const string sql = @"
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters,
|
||||
tile_size_pixels, image_type, maps_version, version, file_path,
|
||||
source, captured_at, created_at, updated_at)
|
||||
source, captured_at, created_at, updated_at,
|
||||
flight_id, location_hash, content_sha256, legacy_id)
|
||||
VALUES (@Id, @TileZoom, @TileX, @TileY, @Latitude, @Longitude, @TileSizeMeters,
|
||||
@TileSizePixels, @ImageType, @MapsVersion, @Version, @FilePath,
|
||||
@Source, @CapturedAt, @CreatedAt, @UpdatedAt)
|
||||
ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source)
|
||||
@Source, @CapturedAt, @CreatedAt, @UpdatedAt,
|
||||
@FlightId, @LocationHash, @ContentSha256, @LegacyId)
|
||||
ON CONFLICT (tile_zoom, tile_x, tile_y, tile_size_meters, source,
|
||||
COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid))
|
||||
DO UPDATE SET
|
||||
file_path = EXCLUDED.file_path,
|
||||
tile_x = EXCLUDED.tile_x,
|
||||
tile_y = EXCLUDED.tile_y,
|
||||
latitude = EXCLUDED.latitude,
|
||||
longitude = EXCLUDED.longitude,
|
||||
captured_at = EXCLUDED.captured_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
content_sha256 = EXCLUDED.content_sha256
|
||||
RETURNING id";
|
||||
|
||||
return await connection.ExecuteScalarAsync<Guid>(sql, tile);
|
||||
@@ -151,7 +270,10 @@ public class TileRepository : ITileRepository
|
||||
file_path = @FilePath,
|
||||
source = @Source,
|
||||
captured_at = @CapturedAt,
|
||||
updated_at = @UpdatedAt
|
||||
updated_at = @UpdatedAt,
|
||||
flight_id = @FlightId,
|
||||
location_hash = @LocationHash,
|
||||
content_sha256 = @ContentSha256
|
||||
WHERE id = @Id";
|
||||
|
||||
return await connection.ExecuteAsync(sql, tile);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
@@ -10,8 +10,8 @@
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="dbup-postgresql" Version="6.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-809: end-to-end coverage for POST /api/satellite/route strict input
|
||||
// validation. Each test exercises one rule from the AZ-809 validator triplet
|
||||
// (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator)
|
||||
// and asserts the response body conforms to the RFC 7807
|
||||
// ValidationProblemDetails contract in `_docs/02_document/contracts/api/error-shape.md`
|
||||
// v1.0.0. Required-field detection is enforced at the deserializer layer via
|
||||
// [JsonRequired] + UnmappedMemberHandling.Disallow (AZ-795).
|
||||
//
|
||||
// The route-creation happy path is intentionally `requestMaps=false` here to
|
||||
// keep this suite fast; the existing RouteCreationTests.cs exercises the
|
||||
// `requestMaps=true` flow (with background F5 processing).
|
||||
public static class CreateRouteValidationTests
|
||||
{
|
||||
private const string RoutePath = "/api/satellite/route";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: POST /api/satellite/route strict validation (AZ-809)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Rule 1: body present
|
||||
await EmptyBody_Returns400(httpClient);
|
||||
|
||||
// Rule 2: id required, non-zero Guid (probe-confirmed gap)
|
||||
await MissingId_Returns400(httpClient);
|
||||
await ZeroGuidId_Returns400(httpClient);
|
||||
|
||||
// Rule 3: name required, length [1, 200]
|
||||
await EmptyName_Returns400(httpClient);
|
||||
|
||||
// Rule 5: regionSizeMeters required, [100, 10000]
|
||||
await RegionSizeOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 6: zoomLevel required, [0, 22]
|
||||
await ZoomLevelOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 7: points required, [2, 500]
|
||||
await PointsTooFew_Returns400(httpClient);
|
||||
|
||||
// Rule 8: per-point lat/lon ranges
|
||||
await PointLatOutOfRange_Returns400(httpClient);
|
||||
await PointLonOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 9: geofence corners + NW-of-SE invariant
|
||||
await GeofenceNwLatNotGreaterThanSeLat_Returns400(httpClient);
|
||||
|
||||
// Rule 9b: geofence polygon-count cap (F-AZ809-1 security-audit fix)
|
||||
await GeofencePolygonsTooMany_Returns400(httpClient);
|
||||
|
||||
// Rule 10/11: requestMaps + createTilesZip required
|
||||
await MissingRequestMaps_Returns400(httpClient);
|
||||
|
||||
// Rule 12: cross-field createTilesZip implies requestMaps
|
||||
await CreateTilesZipWithoutRequestMaps_Returns400(httpClient);
|
||||
|
||||
// Rule 13: unknown root field rejected
|
||||
await UnknownRootField_Returns400(httpClient);
|
||||
|
||||
// Rule 14: type mismatch (per-point lat)
|
||||
await PointLatTypeMismatch_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ Create-route validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 AC-2: well-formed body → HTTP 200 (no background processing — requestMaps=false)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-809 happy path: expected HTTP 200, got {status}. Body: {bodyText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task EmptyBody_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 1: empty body → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, "");
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
// Assert
|
||||
if (status != 400)
|
||||
{
|
||||
throw new Exception($"AZ-809 rule 1: expected HTTP 400, got {status}.");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Empty body rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task MissingId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)");
|
||||
|
||||
// Arrange — same exact pattern as the AZ-808 probe finding.
|
||||
var body = """
|
||||
{
|
||||
"name": "derkachi-flight-1",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 missing id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 missing id");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-809 missing id");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)");
|
||||
}
|
||||
|
||||
private static async Task ZeroGuidId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 2: zero-Guid `id` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var body = BuildValidBody(Guid.Empty, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 zero-Guid id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 zero-Guid id", expectedErrorPath: "id");
|
||||
|
||||
Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]");
|
||||
}
|
||||
|
||||
private static async Task EmptyName_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 3: empty `name` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 empty name");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 empty name", expectedErrorPath: "name");
|
||||
|
||||
Console.WriteLine(" ✓ Empty `name` rejected with errors[\"name\"]");
|
||||
}
|
||||
|
||||
private static async Task RegionSizeOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 5: `regionSizeMeters` out of range (100..10000) → HTTP 400");
|
||||
|
||||
// Arrange — same 1M cap-exceeder as AZ-808.
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, regionSize: 1_000_000, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 regionSize out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 regionSize out of range", expectedErrorPath: "regionSizeMeters");
|
||||
|
||||
Console.WriteLine(" ✓ `regionSizeMeters=1000000` rejected with errors[\"regionSizeMeters\"]");
|
||||
}
|
||||
|
||||
private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 6: `zoomLevel` out of range (0..22) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, zoom: 30, requestMaps: false, createTilesZip: false);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 zoomLevel out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 zoomLevel out of range", expectedErrorPath: "zoomLevel");
|
||||
|
||||
Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]");
|
||||
}
|
||||
|
||||
private static async Task PointsTooFew_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 7: `points` count < 2 → HTTP 400");
|
||||
|
||||
// Arrange — single point.
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "single-point-route",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 points too few");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 points too few", expectedErrorPath: "points");
|
||||
|
||||
Console.WriteLine(" ✓ `points` count=1 rejected with errors[\"points\"]");
|
||||
}
|
||||
|
||||
private static async Task PointLatOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 8: per-point `lat` out of range → HTTP 400 (errors[points[i].lat])");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "out-of-range-lat",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 91.0, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lat out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "points[1].lat", label: "AZ-809 point lat out of range");
|
||||
|
||||
Console.WriteLine(" ✓ `points[1].lat=91` rejected with errors[\"points[1].lat\"]");
|
||||
}
|
||||
|
||||
private static async Task PointLonOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 8: per-point `lon` out of range → HTTP 400 (errors[points[i].lon])");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "out-of-range-lon",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 181.0 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lon out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "points[1].lon", label: "AZ-809 point lon out of range");
|
||||
|
||||
Console.WriteLine(" ✓ `points[1].lon=181` rejected with errors[\"points[1].lon\"]");
|
||||
}
|
||||
|
||||
private static async Task GeofenceNwLatNotGreaterThanSeLat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 9: geofence NW.lat <= SE.lat → HTTP 400 (cross-field invariant)");
|
||||
|
||||
// Arrange — NW.lat == SE.lat → NW not north-of SE.
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "inverted-geofence",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"geofences": {
|
||||
"polygons": [
|
||||
{ "northWest": { "lat": 50.05, "lon": 36.05 },
|
||||
"southEast": { "lat": 50.05, "lon": 36.15 } }
|
||||
]
|
||||
},
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 NW lat not > SE lat");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 NW lat not > SE lat");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "northWest", label: "AZ-809 NW lat not > SE lat");
|
||||
|
||||
Console.WriteLine(" ✓ NW.lat <= SE.lat rejected by cross-field invariant");
|
||||
}
|
||||
|
||||
private static async Task GeofencePolygonsTooMany_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 9b (security-audit F-AZ809-1): geofence polygon-count > 50 → HTTP 400");
|
||||
|
||||
// Arrange — 51 polygons, each valid bbox. Only the count rule should fire.
|
||||
var routeId = Guid.NewGuid();
|
||||
var polygonsJson = string.Join(
|
||||
",\n ",
|
||||
Enumerable
|
||||
.Range(0, 51)
|
||||
.Select(_ => "{ \"northWest\": { \"lat\": 50.15, \"lon\": 36.05 }, \"southEast\": { \"lat\": 50.05, \"lon\": 36.15 } }"));
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "too-many-polygons",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"geofences": {
|
||||
"polygons": [
|
||||
{{polygonsJson}}
|
||||
]
|
||||
},
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 geofence polygons too many");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 geofence polygons too many", expectedErrorPath: "geofences.polygons");
|
||||
|
||||
Console.WriteLine(" ✓ 51 polygons rejected with errors[\"geofences.polygons\"] (cap is 50)");
|
||||
}
|
||||
|
||||
private static async Task MissingRequestMaps_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 10: missing `requestMaps` → HTTP 400 (no defaulting)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "no-requestMaps",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 missing requestMaps");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 missing requestMaps");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "requestMaps", label: "AZ-809 missing requestMaps");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `requestMaps` rejected");
|
||||
}
|
||||
|
||||
private static async Task CreateTilesZipWithoutRequestMaps_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 12: `createTilesZip=true` AND `requestMaps=false` → HTTP 400 (cross-field invariant)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = BuildValidBody(routeId, requestMaps: false, createTilesZip: true);
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 createTilesZip without requestMaps");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 createTilesZip without requestMaps", expectedErrorPath: "createTilesZip");
|
||||
|
||||
Console.WriteLine(" ✓ `createTilesZip=true requestMaps=false` rejected by cross-field invariant");
|
||||
}
|
||||
|
||||
private static async Task UnknownRootField_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 13: unknown root field → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "with-unknown-field",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false,
|
||||
"debug": "fingerprint-probe"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 unknown root field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 unknown root field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-809 unknown root field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors mention");
|
||||
}
|
||||
|
||||
private static async Task PointLatTypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-809 rule 14: nested type mismatch (`points[0].lat` as string) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var routeId = Guid.NewGuid();
|
||||
var body = $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "nested-type-mismatch",
|
||||
"regionSizeMeters": 1000,
|
||||
"zoomLevel": 18,
|
||||
"points": [
|
||||
{ "lat": "fifty", "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": false,
|
||||
"createTilesZip": false
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 point lat type mismatch");
|
||||
|
||||
// Assert — GlobalExceptionHandler converts BadHttpRequestException to
|
||||
// ValidationProblemDetails when the inner JsonException's Path is set.
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 point lat type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ `points[0].lat:\"fifty\"` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static string BuildValidBody(
|
||||
Guid routeId,
|
||||
double regionSize = 1000.0,
|
||||
int zoom = 18,
|
||||
bool requestMaps = false,
|
||||
bool createTilesZip = false)
|
||||
{
|
||||
// Lat/lon picked from gps-denied-onboard AZ-777 Phase 2 probe.
|
||||
return $$"""
|
||||
{
|
||||
"id": "{{routeId}}",
|
||||
"name": "az-809-integration-test",
|
||||
"description": "AZ-809 integration test route",
|
||||
"regionSizeMeters": {{regionSize.ToString(System.Globalization.CultureInfo.InvariantCulture)}},
|
||||
"zoomLevel": {{zoom}},
|
||||
"points": [
|
||||
{ "lat": 50.10, "lon": 36.10 },
|
||||
{ "lat": 50.11, "lon": 36.11 }
|
||||
],
|
||||
"requestMaps": {{(requestMaps ? "true" : "false")}},
|
||||
"createTilesZip": {{(createTilesZip ? "true" : "false")}}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(RoutePath, content);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj", "SatelliteProvider.IntegrationTests/"]
|
||||
COPY ["SatelliteProvider.TestSupport/SatelliteProvider.TestSupport.csproj", "SatelliteProvider.TestSupport/"]
|
||||
@@ -10,7 +10,7 @@ RUN dotnet build "SatelliteProvider.IntegrationTests.csproj" -c Release -o /app/
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "SatelliteProvider.IntegrationTests.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS final
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "SatelliteProvider.IntegrationTests.dll"]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-811: end-to-end coverage for GET /api/satellite/tiles/latlon strict input
|
||||
// validation. Two enforcement layers:
|
||||
// 1. RejectUnknownQueryParamsEndpointFilter — rejects any query key outside
|
||||
// {lat, lon, zoom}, catching typos like `?latitude=` that pre-AZ-811
|
||||
// silently bound to 0.
|
||||
// 2. WithValidation<GetTileByLatLonQuery> — range-checks lat, lon, zoom.
|
||||
// Both surface RFC 7807 ValidationProblemDetails per error-shape.md v1.0.0.
|
||||
public static class GetTileByLatLonValidationTests
|
||||
{
|
||||
private const string LatLonPath = "/api/satellite/tiles/latlon";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: GET /api/satellite/tiles/latlon strict validation (AZ-811)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Validator rules (range)
|
||||
await LatOutOfRange_Returns400(httpClient);
|
||||
await LonOutOfRange_Returns400(httpClient);
|
||||
await ZoomOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Validator rules (missing required)
|
||||
await MissingLat_Returns400(httpClient);
|
||||
|
||||
// Envelope rule: unknown query params
|
||||
await UnknownQueryParam_LegacyLatitude_Returns400(httpClient);
|
||||
await UnknownQueryParam_Hostile_Returns400(httpClient);
|
||||
|
||||
// Type mismatch (delegates to GlobalExceptionHandler via model-binding)
|
||||
await LatTypeMismatch_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ GET lat/lon validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 AC-2: well-formed query → HTTP 200");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18");
|
||||
var status = (int)response.StatusCode;
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-811 happy path: expected HTTP 200, got {status}. Body: {bodyText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ {lat,lon,zoom} accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task LatOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 1: lat out of range (-90..90) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=91&lon=37.647063&zoom=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lat out of range", expectedErrorPath: "lat");
|
||||
|
||||
Console.WriteLine(" ✓ lat=91 rejected with errors[\"lat\"]");
|
||||
}
|
||||
|
||||
private static async Task LonOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 2: lon out of range (-180..180) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=181&zoom=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 lon out of range", expectedErrorPath: "lon");
|
||||
|
||||
Console.WriteLine(" ✓ lon=181 rejected with errors[\"lon\"]");
|
||||
}
|
||||
|
||||
private static async Task ZoomOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 3: zoom out of range (0..22) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=30");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 zoom out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 zoom out of range", expectedErrorPath: "zoom");
|
||||
|
||||
Console.WriteLine(" ✓ zoom=30 rejected with errors[\"zoom\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingLat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 1: missing `lat` query param → HTTP 400 with errors.lat");
|
||||
|
||||
// Act — only lon + zoom supplied; the validator's NotNull rule on Lat must
|
||||
// fire (binder produces Lat=null because the DTO is nullable; see
|
||||
// GetTileByLatLonQuery for why).
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lon=37.647063&zoom=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 missing lat");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 missing lat", expectedErrorPath: "lat");
|
||||
|
||||
Console.WriteLine(" ✓ Missing lat rejected with errors[\"lat\"] = `lat` is required");
|
||||
}
|
||||
|
||||
private static async Task UnknownQueryParam_LegacyLatitude_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 4: legacy `?Latitude=&Longitude=&ZoomLevel=` (pre-AZ-811 wire format) → HTTP 400 (envelope filter)");
|
||||
|
||||
// Act — exact pre-AZ-811 wire format; must now fail explicitly instead
|
||||
// of silently binding to lat=0/lon=0/zoom=0 (typo class).
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 legacy param names");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 legacy param names");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "Latitude", label: "AZ-811 legacy param names");
|
||||
|
||||
Console.WriteLine(" ✓ Legacy ?Latitude=&Longitude=&ZoomLevel= rejected by envelope filter");
|
||||
}
|
||||
|
||||
private static async Task UnknownQueryParam_Hostile_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 4: hostile/typo query keys → HTTP 400 (envelope filter)");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=47.461747&lon=37.647063&zoom=18&debug=1&admin=true");
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-811 hostile params");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-811 hostile params");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "debug", label: "AZ-811 hostile params");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "admin", label: "AZ-811 hostile params");
|
||||
|
||||
Console.WriteLine(" ✓ ?debug=1&admin=true rejected; errors map names BOTH unknown keys");
|
||||
}
|
||||
|
||||
private static async Task LatTypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-811 rule 5: lat type mismatch (non-numeric) → HTTP 400");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.GetAsync($"{LatLonPath}?lat=fifty&lon=37.647063&zoom=18");
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
// Assert — ASP.NET query-param binding produces 400 for type mismatch via
|
||||
// BadHttpRequestException; the exact ProblemDetails shape varies depending
|
||||
// on whether the GlobalExceptionHandler intercepts. Either way the wire
|
||||
// contract is HTTP 400, no body leak.
|
||||
if (status != 400)
|
||||
{
|
||||
throw new Exception($"AZ-811 type mismatch: expected HTTP 400, got {status}.");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ lat=fifty rejected with HTTP 400");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-505 AC-5: HTTP/2 multiplexed responses.
|
||||
//
|
||||
// Kestrel is configured with `HttpProtocols.Http1AndHttp2` over TLS
|
||||
// (docker-compose.yml mounts the dev cert; ASPNETCORE_URLS=https://+:8080).
|
||||
// ALPN negotiates HTTP/2 with HTTP/2-capable clients and falls back to
|
||||
// HTTP/1.1 for browsers and legacy callers. The integration-tests
|
||||
// container trusts the dev cert via /usr/local/share/ca-certificates,
|
||||
// so HttpClient negotiates HTTP/2 transparently — no h2c / unencrypted
|
||||
// support switch is needed.
|
||||
public static class Http2MultiplexingTests
|
||||
{
|
||||
private const int ConcurrentRequestCount = 20;
|
||||
|
||||
public static async Task RunAll(string apiUrl, string secret)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: HTTP/2 multiplexing on /tiles/{z}/{x}/{y} (AZ-505)");
|
||||
|
||||
var apiUri = new Uri(apiUrl);
|
||||
using var handler = new SocketsHttpHandler
|
||||
{
|
||||
// AC-5 requires the responses to multiplex on a SINGLE TCP
|
||||
// connection. Limiting the connection pool to 1 forces this.
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
EnableMultipleHttp2Connections = false
|
||||
};
|
||||
|
||||
using var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = apiUri,
|
||||
Timeout = TimeSpan.FromMinutes(1),
|
||||
DefaultRequestVersion = HttpVersion.Version20,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
JwtTestHelpers.MintAuthenticated(secret));
|
||||
|
||||
// Pick a single (z, x, y) — caching means all 20 calls hit the same
|
||||
// tile, which is exactly what we want: prove the responses come back
|
||||
// over HTTP/2 with their CDN-style headers preserved.
|
||||
//
|
||||
// Coords (158485, 91707) at z=18 are the slippy projection of
|
||||
// (47.461747, 37.647063), the same lat/lon JwtIntegrationTests hits
|
||||
// — confirmed to have Google Maps satellite coverage by every prior
|
||||
// cycle's run, so the warmup download is reliable.
|
||||
const int z = 18;
|
||||
const int x = 158485;
|
||||
const int y = 91707;
|
||||
var path = $"/tiles/{z}/{x}/{y}";
|
||||
|
||||
// Prime the cache with a single warm-up call so the 20 concurrent
|
||||
// calls don't pay the GoogleMaps download cost.
|
||||
var warmup = await client.GetAsync(path);
|
||||
await EnsureSuccess(warmup, "AC-5 warmup");
|
||||
|
||||
var concurrentTasks = Enumerable.Range(0, ConcurrentRequestCount)
|
||||
.Select(_ => client.GetAsync(path))
|
||||
.ToArray();
|
||||
var responses = await Task.WhenAll(concurrentTasks);
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < responses.Length; i++)
|
||||
{
|
||||
var response = responses[i];
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
throw new Exception($"AC-5: response {i} expected HTTP 200, got HTTP {(int)response.StatusCode}");
|
||||
}
|
||||
if (response.Version != HttpVersion.Version20)
|
||||
{
|
||||
throw new Exception($"AC-5: response {i} expected HTTP/2.0, got HTTP/{response.Version}");
|
||||
}
|
||||
if (response.Headers.ETag is null)
|
||||
{
|
||||
throw new Exception($"AC-5: response {i} is missing the ETag header — header preservation regressed.");
|
||||
}
|
||||
if (response.Headers.CacheControl is null)
|
||||
{
|
||||
throw new Exception($"AC-5: response {i} is missing the Cache-Control header — header preservation regressed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var r in responses)
|
||||
{
|
||||
r.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ All {ConcurrentRequestCount} concurrent GETs returned HTTP/2.0 with preserved ETag + Cache-Control");
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccess(HttpResponseMessage response, string label)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"{label}: expected success, got HTTP {(int)response.StatusCode}. Body: {body}");
|
||||
}
|
||||
if (response.Version != HttpVersion.Version20)
|
||||
{
|
||||
throw new Exception($"{label}: expected HTTP/2 even on warmup, got HTTP/{response.Version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ public static class IdempotentPostTests
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
id = regionId,
|
||||
latitude = 47.4617,
|
||||
longitude = 37.6470,
|
||||
lat = 47.4617,
|
||||
lon = 37.6470,
|
||||
sizeMeters = 200,
|
||||
zoomLevel = 18,
|
||||
stitchTiles = false,
|
||||
@@ -103,8 +103,8 @@ public static class IdempotentPostTests
|
||||
createTilesZip = false,
|
||||
points = new[]
|
||||
{
|
||||
new { latitude = 47.4617, longitude = 37.6470 },
|
||||
new { latitude = 47.4630, longitude = 37.6485 },
|
||||
new { lat = 47.4617, lon = 37.6470 },
|
||||
new { lat = 47.4630, lon = 37.6485 },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
public static class JwtIntegrationTests
|
||||
{
|
||||
private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18";
|
||||
private const string ProtectedTilesPath = "/api/satellite/tiles/latlon?lat=47.461747&lon=37.647063&zoom=18";
|
||||
private const string ProtectedRegionPath = "/api/satellite/region/00000000-0000-0000-0000-000000000000";
|
||||
|
||||
public static async Task RunAll(string apiUrl, string secret)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Npgsql;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-505 AC-3: prove the Leaflet hot path is an index-only scan over the new
|
||||
// `tiles_leaflet_path` covering index.
|
||||
//
|
||||
// The test seeds enough rows so PostgreSQL chooses the index over a seq scan,
|
||||
// runs `VACUUM ANALYZE` to populate the visibility map, then EXPLAINs the
|
||||
// canonical AZ-505 Leaflet hot-path query
|
||||
// (`SELECT file_path FROM tiles WHERE location_hash = $1 ORDER BY captured_at
|
||||
// DESC, updated_at DESC, id DESC LIMIT 1`) and asserts:
|
||||
// 1. plan contains `Index Only Scan using tiles_leaflet_path`
|
||||
// 2. `Heap Fetches: 0` (or ≤ 1 — the spec allows the relaxation for
|
||||
// environment-dependent visibility-map state)
|
||||
//
|
||||
// The spec calls for ≥ 100 000 rows to make the optimizer choice unambiguous;
|
||||
// the smoke run uses a smaller fixture (≥ 10 000) for runner-cycle time
|
||||
// while still being large enough for the planner to prefer the index.
|
||||
public static class LeafletPathIndexOnlyTests
|
||||
{
|
||||
private const int FullRowCount = 100_000;
|
||||
private const int SmokeRowCount = 10_000;
|
||||
|
||||
private static readonly Regex IndexOnlyScanLine = new(
|
||||
@"Index Only Scan using tiles_leaflet_path\b",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex HeapFetchesLine = new(
|
||||
@"Heap Fetches:\s*(\d+)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public static async Task RunAll(string connectionString)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Leaflet hot path is index-only-scan over tiles_leaflet_path (AZ-505 AC-3)");
|
||||
|
||||
var rowCount = TestRunMode.Smoke ? SmokeRowCount : FullRowCount;
|
||||
Console.WriteLine($" Seeding {rowCount} rows (smoke={TestRunMode.Smoke})...");
|
||||
|
||||
await SeedRowsAsync(connectionString, rowCount);
|
||||
Console.WriteLine(" ✓ Seed complete");
|
||||
|
||||
await VacuumAnalyzeAsync(connectionString);
|
||||
Console.WriteLine(" ✓ VACUUM ANALYZE complete");
|
||||
|
||||
// Pick a single hash to probe. Use a deterministic (z, x, y) from the
|
||||
// seeded fixture so the row definitely exists and the planner gets a
|
||||
// useful selectivity statistic.
|
||||
const int zoom = 18;
|
||||
const int probeX = 200_000;
|
||||
const int probeY = 300_000;
|
||||
var probeHash = Uuidv5.LocationHashForTile(zoom, probeX, probeY);
|
||||
|
||||
// Make sure the probe row actually exists.
|
||||
await SeedSingleAsync(connectionString, zoom, probeX, probeY, probeHash);
|
||||
await VacuumAnalyzeAsync(connectionString);
|
||||
|
||||
var explainLines = await ExplainLeafletHotPathAsync(connectionString, probeHash);
|
||||
|
||||
var fullPlan = string.Join("\n", explainLines);
|
||||
Console.WriteLine(" EXPLAIN output:");
|
||||
foreach (var line in explainLines)
|
||||
{
|
||||
Console.WriteLine($" {line}");
|
||||
}
|
||||
|
||||
// Force the index to be used. The optimizer might still pick a seq
|
||||
// scan on tiny fixtures if statistics are stale or if the row count
|
||||
// is below the planner's index-scan threshold. If the smoke fixture
|
||||
// is below threshold, retry with enable_seqscan = off to force the
|
||||
// index choice — AC-3 measures the index-only capability, not the
|
||||
// optimizer's selection heuristic on a stripped-down fixture.
|
||||
if (!IndexOnlyScanLine.IsMatch(fullPlan))
|
||||
{
|
||||
Console.WriteLine(" (optimizer picked a non-index plan on the seed fixture; retrying with enable_seqscan = off)");
|
||||
explainLines = await ExplainLeafletHotPathAsync(connectionString, probeHash, forceIndex: true);
|
||||
fullPlan = string.Join("\n", explainLines);
|
||||
Console.WriteLine(" EXPLAIN output (forced):");
|
||||
foreach (var line in explainLines)
|
||||
{
|
||||
Console.WriteLine($" {line}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!IndexOnlyScanLine.IsMatch(fullPlan))
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-505 AC-3: expected `Index Only Scan using tiles_leaflet_path` in the EXPLAIN plan but it was not present.\n" +
|
||||
fullPlan);
|
||||
}
|
||||
|
||||
var heapMatch = HeapFetchesLine.Match(fullPlan);
|
||||
if (!heapMatch.Success)
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-505 AC-3: expected a `Heap Fetches: N` line in the EXPLAIN output for an Index Only Scan.\n" +
|
||||
fullPlan);
|
||||
}
|
||||
|
||||
var heapFetches = int.Parse(heapMatch.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
// Spec: 0 is the target; ≤ 1 accepted because the visibility map state
|
||||
// on freshly-loaded rows is environment-dependent.
|
||||
if (heapFetches > 1)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-505 AC-3: Heap Fetches = {heapFetches}, expected 0 (or ≤ 1 with the visibility-map relaxation).\n" +
|
||||
fullPlan);
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Plan contains `Index Only Scan using tiles_leaflet_path`; Heap Fetches = {heapFetches}");
|
||||
}
|
||||
|
||||
private static async Task SeedRowsAsync(string connectionString, int rowCount)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels,
|
||||
image_type, file_path, source, captured_at, created_at, updated_at, location_hash)
|
||||
VALUES (@id, @z, @x, @y, @lat, @lon, 200.0, 256, 'jpg', @fp, 'google_maps', @t, @t, @t, @loc)
|
||||
ON CONFLICT DO NOTHING;", conn, transaction);
|
||||
|
||||
var idP = cmd.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid);
|
||||
var zP = cmd.Parameters.Add("z", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
var xP = cmd.Parameters.Add("x", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
var yP = cmd.Parameters.Add("y", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
var latP = cmd.Parameters.Add("lat", NpgsqlTypes.NpgsqlDbType.Double);
|
||||
var lonP = cmd.Parameters.Add("lon", NpgsqlTypes.NpgsqlDbType.Double);
|
||||
var fpP = cmd.Parameters.Add("fp", NpgsqlTypes.NpgsqlDbType.Varchar);
|
||||
var tP = cmd.Parameters.Add("t", NpgsqlTypes.NpgsqlDbType.Timestamp);
|
||||
var locP = cmd.Parameters.Add("loc", NpgsqlTypes.NpgsqlDbType.Uuid);
|
||||
|
||||
const int zoom = 18;
|
||||
var baseTime = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-1), DateTimeKind.Unspecified);
|
||||
for (var i = 0; i < rowCount; i++)
|
||||
{
|
||||
var x = 100_000 + (i % 1024);
|
||||
var y = 100_000 + (i / 1024);
|
||||
var hash = Uuidv5.LocationHashForTile(zoom, x, y);
|
||||
|
||||
idP.Value = Guid.NewGuid();
|
||||
zP.Value = zoom;
|
||||
xP.Value = x;
|
||||
yP.Value = y;
|
||||
latP.Value = 60.0 + i * 1e-7;
|
||||
lonP.Value = 30.0 + i * 1e-7;
|
||||
fpP.Value = $"tiles/leaflet-seed/{i}.jpg";
|
||||
tP.Value = baseTime.AddSeconds(i);
|
||||
locP.Value = hash;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedSingleAsync(string connectionString, int zoom, int x, int y, Guid hash)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels,
|
||||
image_type, file_path, source, captured_at, created_at, updated_at, location_hash)
|
||||
VALUES (@id, @z, @x, @y, @lat, @lon, 200.0, 256, 'jpg', @fp, 'google_maps', @t, @t, @t, @loc)
|
||||
ON CONFLICT DO NOTHING;", conn);
|
||||
cmd.Parameters.AddWithValue("id", Guid.NewGuid());
|
||||
cmd.Parameters.AddWithValue("z", zoom);
|
||||
cmd.Parameters.AddWithValue("x", x);
|
||||
cmd.Parameters.AddWithValue("y", y);
|
||||
cmd.Parameters.AddWithValue("lat", 60.5);
|
||||
cmd.Parameters.AddWithValue("lon", 30.5);
|
||||
cmd.Parameters.AddWithValue("fp", "tiles/leaflet-probe.jpg");
|
||||
cmd.Parameters.AddWithValue("t", DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
|
||||
cmd.Parameters.AddWithValue("loc", hash);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task VacuumAnalyzeAsync(string connectionString)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand("VACUUM ANALYZE tiles;", conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task<List<string>> ExplainLeafletHotPathAsync(
|
||||
string connectionString,
|
||||
Guid locationHash,
|
||||
bool forceIndex = false)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
if (forceIndex)
|
||||
{
|
||||
await using var disableSeq = new NpgsqlCommand("SET enable_seqscan = off;", conn);
|
||||
await disableSeq.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
const string sql = @"
|
||||
EXPLAIN (ANALYZE, BUFFERS)
|
||||
SELECT file_path
|
||||
FROM tiles
|
||||
WHERE location_hash = @hash
|
||||
ORDER BY captured_at DESC, updated_at DESC, id DESC
|
||||
LIMIT 1;";
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("hash", locationHash);
|
||||
|
||||
var lines = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
lines.Add(reader.GetString(0));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,20 @@ public static class MigrationTests
|
||||
await MultiSourceInsertCoexistsUnderNewIndex_AZ484_AC1(connectionString);
|
||||
await MostRecentAcrossSourcesSelection_AZ484_AC2(connectionString);
|
||||
await SameSourceUpsertReplacesPreviousRow_AZ484_AC3(connectionString);
|
||||
await NewUniqueConstraintIncludesSourceColumn_AZ484_AC1(connectionString);
|
||||
await Az503MigrationSupersedesAz484UniqueIndex(connectionString);
|
||||
|
||||
Console.WriteLine("✓ Migration 013 tests: PASSED");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Test: Migration 014 (AZ-503-foundation)");
|
||||
Console.WriteLine("========================================");
|
||||
Console.WriteLine();
|
||||
|
||||
await Az503ColumnsExistAndLocationHashIsNotNull(connectionString);
|
||||
await Az503NewUniqueIndexCoversIntegerKeyAndFlightId(connectionString);
|
||||
await Az503LocationHashBackfillIsDeterministic(connectionString);
|
||||
|
||||
Console.WriteLine("✓ Migration 014 tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task DedupeSqlCollapsesDuplicatesByLatestUpdatedAt_AZ357_AC2(string connectionString)
|
||||
@@ -115,15 +126,246 @@ public static class MigrationTests
|
||||
Console.WriteLine(" ✓ Unique row (idF) preserved");
|
||||
}
|
||||
|
||||
private static async Task NewUniqueConstraintIncludesSourceColumn_AZ484_AC1(string connectionString)
|
||||
private static async Task Az503MigrationSupersedesAz484UniqueIndex(string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-484 AC-1 part 2: post-migration-013 unique index includes the source column");
|
||||
Console.WriteLine("AZ-484/AZ-503 supersession: AZ-503 migration 014 drops the AZ-484 unique index in favour of the integer-key + flight_id index");
|
||||
|
||||
// Arrange / Act
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var rows = await QueryIndexesAsync(conn);
|
||||
|
||||
// Assert — AZ-484's idx_tiles_unique_location_source must NOT exist anymore after migration 014.
|
||||
var supersededIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location_source", StringComparison.Ordinal));
|
||||
if (supersededIndex.Def is not null)
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-503: legacy AZ-484 index 'idx_tiles_unique_location_source' still exists after migration 014 — migration did not drop it. " +
|
||||
$"Definition: {supersededIndex.Def}");
|
||||
}
|
||||
|
||||
// Pre-AZ-484 4-column index must also remain dropped.
|
||||
var preAz484Index = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location", StringComparison.Ordinal));
|
||||
if (preAz484Index.Def is not null)
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-503: pre-AZ-484 4-column index 'idx_tiles_unique_location' reappeared after migration 014. " +
|
||||
$"Definition: {preAz484Index.Def}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ AZ-484 'idx_tiles_unique_location_source' dropped by migration 014 (superseded)");
|
||||
Console.WriteLine(" ✓ Pre-AZ-484 'idx_tiles_unique_location' remains dropped");
|
||||
}
|
||||
|
||||
private static async Task Az503ColumnsExistAndLocationHashIsNotNull(string connectionString)
|
||||
{
|
||||
Console.WriteLine("AZ-503 AC-6: migration 014 adds flight_id, location_hash, content_sha256, legacy_id with correct nullability");
|
||||
|
||||
// Arrange
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
const string sql = @"
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'tiles'
|
||||
AND column_name IN ('flight_id', 'location_hash', 'content_sha256', 'legacy_id');";
|
||||
|
||||
var columns = new Dictionary<string, (string DataType, bool IsNullable)>(StringComparer.Ordinal);
|
||||
await using (var cmd = new NpgsqlCommand(sql, conn))
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
columns[reader.GetString(0)] = (
|
||||
reader.GetString(1),
|
||||
string.Equals(reader.GetString(2), "YES", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
// Assert — flight_id, location_hash, content_sha256, legacy_id must exist with the contractual shape.
|
||||
AssertColumn(columns, "flight_id", expectedType: "uuid", expectedNullable: true);
|
||||
AssertColumn(columns, "location_hash", expectedType: "uuid", expectedNullable: false);
|
||||
AssertColumn(columns, "content_sha256", expectedType: "bytea", expectedNullable: true);
|
||||
AssertColumn(columns, "legacy_id", expectedType: "uuid", expectedNullable: true);
|
||||
|
||||
Console.WriteLine(" ✓ flight_id (uuid, nullable), location_hash (uuid, NOT NULL), content_sha256 (bytea, nullable), legacy_id (uuid, nullable)");
|
||||
}
|
||||
|
||||
private static async Task Az503NewUniqueIndexCoversIntegerKeyAndFlightId(string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-503 AC-9: idx_tiles_unique_identity is unique on (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, ...))");
|
||||
|
||||
// Arrange / Act
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var rows = await QueryIndexesAsync(conn);
|
||||
|
||||
// Assert
|
||||
var newIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_identity", StringComparison.Ordinal));
|
||||
if (newIndex.Def is null)
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-503 AC-9: expected unique index 'idx_tiles_unique_identity' on tiles after migration 014, but it is not present. " +
|
||||
$"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
|
||||
}
|
||||
|
||||
var lower = newIndex.Def.ToLowerInvariant();
|
||||
if (!lower.Contains("unique"))
|
||||
{
|
||||
throw new Exception($"AZ-503 AC-9: idx_tiles_unique_identity is not UNIQUE. Definition: {newIndex.Def}");
|
||||
}
|
||||
foreach (var col in new[] { "tile_zoom", "tile_x", "tile_y", "tile_size_meters", "source", "flight_id" })
|
||||
{
|
||||
if (!lower.Contains(col))
|
||||
{
|
||||
throw new Exception($"AZ-503 AC-9: idx_tiles_unique_identity missing column '{col}'. Definition: {newIndex.Def}");
|
||||
}
|
||||
}
|
||||
if (!lower.Contains("coalesce"))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-9: idx_tiles_unique_identity must wrap flight_id in COALESCE so NULL flights collide deterministically. Definition: {newIndex.Def}");
|
||||
}
|
||||
|
||||
// An index whose leading column is `location_hash` must exist so equality lookups
|
||||
// by hash have an index-driven access path. AZ-503 introduced this as
|
||||
// `idx_tiles_location_hash` in migration 014; AZ-505 supersedes it in migration 015
|
||||
// with `tiles_leaflet_path` (a covering index that keeps location_hash as the
|
||||
// leading column and adds ORDER BY columns + INCLUDE projection). Either name
|
||||
// satisfies the AC-9 intent — accept both so the AZ-503 contract remains
|
||||
// verifiable after migration 015 has applied.
|
||||
var locationHashIndex = rows.FirstOrDefault(r =>
|
||||
string.Equals(r.Name, "idx_tiles_location_hash", StringComparison.Ordinal) ||
|
||||
string.Equals(r.Name, "tiles_leaflet_path", StringComparison.Ordinal));
|
||||
if (locationHashIndex.Def is null)
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-503 AC-9: expected an index keyed by location_hash (either 'idx_tiles_location_hash' from migration 014 " +
|
||||
"or its AZ-505 successor 'tiles_leaflet_path' from migration 015) on tiles, but neither is present. " +
|
||||
$"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ New unique index present: {newIndex.Def}");
|
||||
Console.WriteLine($" ✓ Supporting location_hash index present: {locationHashIndex.Def}");
|
||||
}
|
||||
|
||||
private static async Task Az503LocationHashBackfillIsDeterministic(string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-503 AC-6: the location_hash backfill function used by migration 014 is deterministic and matches RFC 9562 §5.5");
|
||||
|
||||
// Arrange — the migration installs pg_temp.uuidv5 then drops it; replay the same SHA-1 logic in a session
|
||||
// to confirm that two identical inputs produce byte-identical UUIDv5 values, and that two distinct inputs
|
||||
// produce different values.
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await ExecAsync(conn, "CREATE EXTENSION IF NOT EXISTS pgcrypto;");
|
||||
await ExecAsync(conn, """
|
||||
CREATE OR REPLACE FUNCTION pg_temp.uuidv5_probe(namespace uuid, name text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
namespace_bytes bytea;
|
||||
input_bytes bytea;
|
||||
hash_bytes bytea;
|
||||
v5_bytes bytea;
|
||||
BEGIN
|
||||
namespace_bytes := decode(replace(namespace::text, '-', ''), 'hex');
|
||||
input_bytes := namespace_bytes || convert_to(name, 'UTF8');
|
||||
hash_bytes := digest(input_bytes, 'sha1');
|
||||
v5_bytes := substring(hash_bytes from 1 for 16);
|
||||
v5_bytes := set_byte(v5_bytes, 6, (get_byte(v5_bytes, 6) & 15) | 80);
|
||||
v5_bytes := set_byte(v5_bytes, 8, (get_byte(v5_bytes, 8) & 63) | 128);
|
||||
RETURN encode(v5_bytes, 'hex')::uuid;
|
||||
END;
|
||||
$$;
|
||||
""");
|
||||
|
||||
// Act — location_hash canonical name is "{zoom}/{x}/{y}" (matches the migration backfill
|
||||
// and SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity).
|
||||
const string probeSql = @"
|
||||
SELECT
|
||||
pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12345/23456') AS v1,
|
||||
pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12345/23456') AS v1_again,
|
||||
pg_temp.uuidv5_probe('5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid, '18/12346/23456') AS v2;";
|
||||
|
||||
Guid v1, v1Again, v2;
|
||||
await using (var cmd = new NpgsqlCommand(probeSql, conn))
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
if (!await reader.ReadAsync())
|
||||
{
|
||||
throw new Exception("AZ-503 AC-6: backfill probe returned no rows.");
|
||||
}
|
||||
v1 = reader.GetGuid(0);
|
||||
v1Again = reader.GetGuid(1);
|
||||
v2 = reader.GetGuid(2);
|
||||
}
|
||||
|
||||
// Assert
|
||||
if (v1 != v1Again)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-6: location_hash backfill is non-deterministic. v1={v1}, v1_again={v1Again}.");
|
||||
}
|
||||
if (v1 == v2)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-6: location_hash backfill produced the same UUID for different (x,y) tuples. v1={v1}, v2={v2}.");
|
||||
}
|
||||
|
||||
// Cross-check that the live tiles.location_hash column matches the same function for at least one row, if any rows exist.
|
||||
// (Pre-existing rows are backfilled by migration 014; new rows would be written by app code that uses the C# Uuidv5.Create.)
|
||||
long sampleRowCount = await ScalarLongAsync(conn, "SELECT COUNT(*) FROM tiles;");
|
||||
if (sampleRowCount > 0)
|
||||
{
|
||||
const string sampleSql = @"
|
||||
SELECT
|
||||
location_hash,
|
||||
pg_temp.uuidv5_probe(
|
||||
'5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid,
|
||||
tile_zoom::text || '/' || tile_x::text || '/' || tile_y::text
|
||||
) AS expected_hash
|
||||
FROM tiles
|
||||
LIMIT 1;";
|
||||
|
||||
Guid storedHash, expectedHash;
|
||||
await using (var cmd = new NpgsqlCommand(sampleSql, conn))
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
{
|
||||
if (await reader.ReadAsync())
|
||||
{
|
||||
storedHash = reader.GetGuid(0);
|
||||
expectedHash = reader.GetGuid(1);
|
||||
if (storedHash != expectedHash)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-6: tiles.location_hash drift for sample row. stored={storedHash}, expected={expectedHash}. " +
|
||||
"Backfill formula and live UUIDv5 implementation must agree on the canonical name string.");
|
||||
}
|
||||
Console.WriteLine($" ✓ Sample row location_hash matches the canonical UUIDv5 formula: {storedHash}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" (no rows in tiles table; deterministic-probe-only assertion)");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ UUIDv5 backfill probe is deterministic across two identical inputs");
|
||||
Console.WriteLine(" ✓ UUIDv5 backfill probe distinguishes different (x,y) tuples");
|
||||
}
|
||||
|
||||
private static async Task<List<(string Name, string Def)>> QueryIndexesAsync(NpgsqlConnection conn)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
@@ -131,47 +373,37 @@ public static class MigrationTests
|
||||
AND tablename = 'tiles';";
|
||||
|
||||
var rows = new List<(string Name, string Def)>();
|
||||
await using (var cmd = new NpgsqlCommand(sql, conn))
|
||||
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
rows.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
rows.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Assert
|
||||
var newIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location_source", StringComparison.Ordinal));
|
||||
if (newIndex.Def is null)
|
||||
private static void AssertColumn(
|
||||
Dictionary<string, (string DataType, bool IsNullable)> columns,
|
||||
string columnName,
|
||||
string expectedType,
|
||||
bool expectedNullable)
|
||||
{
|
||||
if (!columns.TryGetValue(columnName, out var info))
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-484 AC-1: expected unique index 'idx_tiles_unique_location_source' on tiles after migration 013, but it is not present. " +
|
||||
$"Found indexes: {string.Join(", ", rows.Select(r => r.Name))}");
|
||||
$"AZ-503 AC-6: column 'tiles.{columnName}' was not created by migration 014. " +
|
||||
$"Found columns: {string.Join(", ", columns.Keys)}");
|
||||
}
|
||||
|
||||
var lower = newIndex.Def.ToLowerInvariant();
|
||||
if (!lower.Contains("unique"))
|
||||
{
|
||||
throw new Exception($"AZ-484 AC-1: idx_tiles_unique_location_source is not UNIQUE. Definition: {newIndex.Def}");
|
||||
}
|
||||
foreach (var col in new[] { "latitude", "longitude", "tile_zoom", "tile_size_meters", "source" })
|
||||
{
|
||||
if (!lower.Contains(col))
|
||||
{
|
||||
throw new Exception($"AZ-484 AC-1: idx_tiles_unique_location_source missing column '{col}'. Definition: {newIndex.Def}");
|
||||
}
|
||||
}
|
||||
|
||||
var oldIndex = rows.FirstOrDefault(r => string.Equals(r.Name, "idx_tiles_unique_location", StringComparison.Ordinal));
|
||||
if (oldIndex.Def is not null)
|
||||
if (!string.Equals(info.DataType, expectedType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new Exception(
|
||||
"AZ-484 AC-1: legacy 4-column index 'idx_tiles_unique_location' still exists after migration 013 — migration did not drop it. " +
|
||||
$"Definition: {oldIndex.Def}");
|
||||
$"AZ-503 AC-6: column 'tiles.{columnName}' has data_type='{info.DataType}', expected '{expectedType}'.");
|
||||
}
|
||||
if (info.IsNullable != expectedNullable)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-6: column 'tiles.{columnName}' is_nullable={info.IsNullable}, expected {expectedNullable}.");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ New 5-column unique index present: {newIndex.Def}");
|
||||
Console.WriteLine(" ✓ Legacy 4-column unique index dropped");
|
||||
}
|
||||
|
||||
private static async Task BackfillUpdateAssignsGoogleMapsAndCapturedAt_AZ484_AC4(string connectionString)
|
||||
|
||||
@@ -17,8 +17,13 @@ public record DownloadTileResponse
|
||||
public record RequestRegionRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("lat")]
|
||||
public double Lat { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("lon")]
|
||||
public double Lon { get; set; }
|
||||
|
||||
public double SizeMeters { get; set; }
|
||||
public int ZoomLevel { get; set; }
|
||||
public bool StitchTiles { get; set; } = false;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-795: shared ProblemDetails / ValidationProblemDetails assertion helper
|
||||
// for integration tests. Every endpoint that emits a 4xx error MUST produce
|
||||
// a body matching the contract in
|
||||
// `_docs/02_document/contracts/api/error-shape.md` (v1.0.0). Tests use this
|
||||
// helper instead of re-deriving the shape per call site.
|
||||
public static class ProblemDetailsAssertions
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static async Task<JsonElement> ReadProblemDetailsAsync(HttpResponseMessage response, string label)
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (contentType is null || !contentType.Contains("application/problem+json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception(
|
||||
$"{label}: expected Content-Type 'application/problem+json', got '{contentType}'. Body: {body}");
|
||||
}
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
public static void AssertValidationProblem(
|
||||
JsonElement problem,
|
||||
int expectedStatus,
|
||||
string label,
|
||||
string? expectedErrorPath = null,
|
||||
string? expectedErrorContains = null)
|
||||
{
|
||||
if (!problem.TryGetProperty("status", out var statusEl) || statusEl.GetInt32() != expectedStatus)
|
||||
{
|
||||
throw new Exception(
|
||||
$"{label}: expected status={expectedStatus}, got {(statusEl.ValueKind == JsonValueKind.Number ? statusEl.GetInt32().ToString() : "missing")}");
|
||||
}
|
||||
|
||||
if (!problem.TryGetProperty("title", out var titleEl) || string.IsNullOrEmpty(titleEl.GetString()))
|
||||
{
|
||||
throw new Exception($"{label}: expected non-empty 'title', got missing/empty.");
|
||||
}
|
||||
|
||||
if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new Exception($"{label}: expected 'errors' object, got {errorsEl.ValueKind}.");
|
||||
}
|
||||
|
||||
if (expectedErrorPath is not null)
|
||||
{
|
||||
if (!errorsEl.TryGetProperty(expectedErrorPath, out var fieldEl) || fieldEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new Exception(
|
||||
$"{label}: expected errors['{expectedErrorPath}'] array, got {(errorsEl.TryGetProperty(expectedErrorPath, out var raw) ? raw.ValueKind.ToString() : "missing")}. " +
|
||||
$"Available paths: {string.Join(", ", EnumeratePaths(errorsEl))}.");
|
||||
}
|
||||
|
||||
if (expectedErrorContains is not null)
|
||||
{
|
||||
var first = fieldEl.EnumerateArray().FirstOrDefault();
|
||||
var firstStr = first.ValueKind == JsonValueKind.String ? first.GetString() : null;
|
||||
if (firstStr is null || !firstStr.Contains(expectedErrorContains, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new Exception(
|
||||
$"{label}: expected errors['{expectedErrorPath}'][0] to contain '{expectedErrorContains}', got '{firstStr}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void AssertProblemDetails(
|
||||
JsonElement problem,
|
||||
int expectedStatus,
|
||||
string label)
|
||||
{
|
||||
if (!problem.TryGetProperty("status", out var statusEl) || statusEl.GetInt32() != expectedStatus)
|
||||
{
|
||||
throw new Exception(
|
||||
$"{label}: expected status={expectedStatus}, got {(statusEl.ValueKind == JsonValueKind.Number ? statusEl.GetInt32().ToString() : "missing")}");
|
||||
}
|
||||
|
||||
if (!problem.TryGetProperty("title", out var titleEl) || string.IsNullOrEmpty(titleEl.GetString()))
|
||||
{
|
||||
throw new Exception($"{label}: expected non-empty 'title', got missing/empty.");
|
||||
}
|
||||
}
|
||||
|
||||
// AZ-808 cycle 8: promoted from per-test-file private helpers (was
|
||||
// duplicated in TileInventoryValidationTests + RegionFieldRenameTests +
|
||||
// RegionRequestValidationTests) so every validation test points at one
|
||||
// source of truth for "is this field-name or substring mentioned anywhere
|
||||
// in the errors map?".
|
||||
public static void AssertErrorsContainsMention(JsonElement problem, string expectedMention, string label)
|
||||
{
|
||||
if (!problem.TryGetProperty("errors", out var errorsEl) || errorsEl.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new Exception($"{label}: expected 'errors' object in ProblemDetails body.");
|
||||
}
|
||||
|
||||
var found = false;
|
||||
foreach (var prop in errorsEl.EnumerateObject())
|
||||
{
|
||||
if (prop.Name.Contains(expectedMention, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var msg in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (msg.GetString()?.Contains(expectedMention, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
var paths = string.Join(", ", errorsEl.EnumerateObject().Select(p => p.Name));
|
||||
throw new Exception($"{label}: expected '{expectedMention}' to appear in errors keys or messages. Available paths: {paths}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumeratePaths(JsonElement errorsEl)
|
||||
{
|
||||
foreach (var prop in errorsEl.EnumerateObject())
|
||||
{
|
||||
yield return prop.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class Program
|
||||
}
|
||||
}
|
||||
|
||||
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "http://api:8080";
|
||||
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "https://api:8080";
|
||||
var modeEnv = Environment.GetEnvironmentVariable("INTEGRATION_TESTS_MODE")?.Trim().ToLowerInvariant();
|
||||
var modeArg = args.FirstOrDefault(a => a.Equals("--smoke", StringComparison.OrdinalIgnoreCase) || a.Equals("--full", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -103,14 +103,16 @@ class Program
|
||||
|
||||
await JwtIntegrationTests.RunAll(apiUrl, jwtSecret);
|
||||
await UavUploadTests.RunAll(apiUrl, jwtSecret);
|
||||
await UavUploadValidationTests.RunAll(apiUrl, jwtSecret);
|
||||
await Http2MultiplexingTests.RunAll(apiUrl, jwtSecret);
|
||||
|
||||
if (TestRunMode.Smoke)
|
||||
{
|
||||
await RunSmokeSuite(httpClient);
|
||||
await RunSmokeSuite(httpClient, connectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunFullSuite(httpClient);
|
||||
await RunFullSuite(httpClient, connectionString);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
@@ -128,7 +130,7 @@ class Program
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunSmokeSuite(HttpClient httpClient)
|
||||
static async Task RunSmokeSuite(HttpClient httpClient, string connectionString)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
await RegionTests.RunRegionProcessingTest_200m_Zoom18(httpClient);
|
||||
@@ -137,10 +139,17 @@ class Program
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await TileInventoryTests.RunAll(httpClient);
|
||||
await TileInventoryValidationTests.RunAll(httpClient);
|
||||
await RegionFieldRenameTests.RunAll(httpClient);
|
||||
await RegionRequestValidationTests.RunAll(httpClient);
|
||||
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||
await CreateRouteValidationTests.RunAll(httpClient);
|
||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
static async Task RunFullSuite(HttpClient httpClient)
|
||||
static async Task RunFullSuite(HttpClient httpClient, string connectionString)
|
||||
{
|
||||
await TileTests.RunGetTileByLatLonTest(httpClient);
|
||||
|
||||
@@ -158,6 +167,13 @@ class Program
|
||||
await SecurityTests.RunAll(httpClient);
|
||||
await StubAndErrorContractTests.RunAll(httpClient);
|
||||
await IdempotentPostTests.RunAll(httpClient);
|
||||
await TileInventoryTests.RunAll(httpClient);
|
||||
await TileInventoryValidationTests.RunAll(httpClient);
|
||||
await RegionFieldRenameTests.RunAll(httpClient);
|
||||
await RegionRequestValidationTests.RunAll(httpClient);
|
||||
await GetTileByLatLonValidationTests.RunAll(httpClient);
|
||||
await CreateRouteValidationTests.RunAll(httpClient);
|
||||
await LeafletPathIndexOnlyTests.RunAll(connectionString);
|
||||
await MigrationTests.RunAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-812: wire-format rename for POST /api/satellite/request.
|
||||
// `RequestRegionRequest` now uses `lat`/`lon` (OSM convention) on the wire,
|
||||
// replacing the previous verbose `latitude`/`longitude`. The strict-parsing
|
||||
// infrastructure landed by AZ-795 (UnmappedMemberHandling.Disallow +
|
||||
// GlobalExceptionHandler) means the old wire format must now be rejected
|
||||
// explicitly, not silently coerced. AC-4 from the AZ-812 task spec.
|
||||
public static class RegionFieldRenameTests
|
||||
{
|
||||
private const string RegionPath = "/api/satellite/request";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Region endpoint OSM field-name rename (AZ-812)");
|
||||
|
||||
await NewLatLonFormat_Returns200(httpClient);
|
||||
await OldLatitudeLongitudeFormat_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ Region field-rename tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task NewLatLonFormat_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-812 AC-4 (positive): new {lat,lon} wire format → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-812 AC-4 positive: expected HTTP 200 for {{lat,lon}} body, got {status}. Body: {responseBody}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ {lat,lon} body accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task OldLatitudeLongitudeFormat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-812 AC-4 (negative): legacy {latitude,longitude} wire format → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange — exact pre-AZ-812 wire format; must now fail explicitly instead
|
||||
// of silently mapping to the renamed Lat/Lon properties.
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-812 legacy field names");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-812 legacy field names");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "latitude", label: "AZ-812 legacy field names");
|
||||
|
||||
Console.WriteLine(" ✓ Legacy {latitude,longitude} body rejected with HTTP 400; errors map names the unknown field");
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(RegionPath, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-808: end-to-end coverage for the region-request endpoint's strict input
|
||||
// validation. Each test exercises one rule from the validator (FluentValidation
|
||||
// for business rules, JsonSerializerOptions for wire-format rules) and asserts
|
||||
// the response body conforms to the RFC 7807 ValidationProblemDetails contract
|
||||
// in `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
//
|
||||
// Field names use the post-AZ-812 OSM convention (`lat`/`lon`). The legacy
|
||||
// `latitude`/`longitude` wire format is verified to be rejected by
|
||||
// RegionFieldRenameTests.cs (AZ-812 AC-4).
|
||||
public static class RegionRequestValidationTests
|
||||
{
|
||||
private const string RegionPath = "/api/satellite/request";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Region endpoint strict validation (AZ-808)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Rule 1: body present
|
||||
await EmptyBody_Returns400(httpClient);
|
||||
|
||||
// Rule 2: id required, non-zero Guid
|
||||
await MissingId_Returns400(httpClient);
|
||||
await ZeroGuidId_Returns400(httpClient);
|
||||
|
||||
// Rule 3: lat required, [-90, 90]
|
||||
await MissingLat_Returns400(httpClient);
|
||||
await LatOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 4: lon required, [-180, 180]
|
||||
await MissingLon_Returns400(httpClient);
|
||||
await LonOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 5: sizeMeters required, [100, 10000]
|
||||
await MissingSizeMeters_Returns400(httpClient);
|
||||
await SizeMetersOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 6: zoomLevel required, [0, 22]
|
||||
await MissingZoomLevel_Returns400(httpClient);
|
||||
await ZoomLevelOutOfRange_Returns400(httpClient);
|
||||
|
||||
// Rule 7: stitchTiles required (bool, no default)
|
||||
await MissingStitchTiles_Returns400(httpClient);
|
||||
|
||||
// Rule 9: type mismatch
|
||||
await LatTypeMismatch_Returns400(httpClient);
|
||||
|
||||
// Rule 8 (unknown root fields) is covered by RegionFieldRenameTests (AZ-812 AC-4).
|
||||
|
||||
Console.WriteLine("✓ Region-request validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 AC-2: well-formed request → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
if (status != 200)
|
||||
{
|
||||
throw new Exception($"AZ-808 AC-2 happy path: expected HTTP 200, got {status}. Body: {bodyText}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Well-formed body accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task EmptyBody_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 1: empty body → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = "";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
// Assert
|
||||
if (status != 400)
|
||||
{
|
||||
throw new Exception($"AZ-808 rule 1: expected HTTP 400, got {status}.");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Empty body rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task MissingId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 2 (probe-confirmed gap): missing `id` → HTTP 400 (no silent zero-Guid coercion)");
|
||||
|
||||
// Arrange — the exact 2026-05-22 probe payload that silently coerced to Guid.Empty pre-AZ-808.
|
||||
const string body = "{\"lat\":49.94,\"lon\":36.31,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing id");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "id", label: "AZ-808 missing id");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `id` rejected with HTTP 400 (no silent coercion)");
|
||||
}
|
||||
|
||||
private static async Task ZeroGuidId_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 2: zero-Guid `id` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = "{\"id\":\"00000000-0000-0000-0000-000000000000\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zero-Guid id");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zero-Guid id", expectedErrorPath: "id");
|
||||
|
||||
Console.WriteLine(" ✓ Zero-Guid `id` rejected with errors[\"id\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingLat_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 3: missing `lat` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lat");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lat");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lat", label: "AZ-808 missing lat");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `lat` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task LatOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 3: `lat` out of range (-90..90) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":91.0,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat out of range", expectedErrorPath: "lat");
|
||||
|
||||
Console.WriteLine(" ✓ `lat=91.0` rejected with errors[\"lat\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingLon_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 4: missing `lon` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing lon");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing lon");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "lon", label: "AZ-808 missing lon");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `lon` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task LonOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 4: `lon` out of range (-180..180) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":181.0,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lon out of range", expectedErrorPath: "lon");
|
||||
|
||||
Console.WriteLine(" ✓ `lon=181.0` rejected with errors[\"lon\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingSizeMeters_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 5: missing `sizeMeters` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing sizeMeters");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing sizeMeters");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "sizeMeters", label: "AZ-808 missing sizeMeters");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `sizeMeters` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task SizeMetersOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 5: `sizeMeters` out of range (100..10000) → HTTP 400");
|
||||
|
||||
// Arrange — same 1M cap-exceeder used by SEC-03; this validator replaces the old inline check.
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 sizeMeters out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 sizeMeters out of range", expectedErrorPath: "sizeMeters");
|
||||
|
||||
Console.WriteLine(" ✓ `sizeMeters=1000000` rejected with errors[\"sizeMeters\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingZoomLevel_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 6: missing `zoomLevel` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing zoomLevel");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing zoomLevel");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "zoomLevel", label: "AZ-808 missing zoomLevel");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `zoomLevel` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task ZoomLevelOutOfRange_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 6: `zoomLevel` out of range (0..22) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":30,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 zoomLevel out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 zoomLevel out of range", expectedErrorPath: "zoomLevel");
|
||||
|
||||
Console.WriteLine(" ✓ `zoomLevel=30` rejected with errors[\"zoomLevel\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingStitchTiles_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 7: missing `stitchTiles` → HTTP 400 (no defaulting to false)");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 missing stitchTiles");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 missing stitchTiles");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "stitchTiles", label: "AZ-808 missing stitchTiles");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `stitchTiles` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task LatTypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-808 rule 9: type mismatch (`lat` as string) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":\"fifty\",\"lon\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-808 lat type mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-808 lat type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ `lat:\"fifty\"` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(RegionPath, content);
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,8 @@ public static class RegionTests
|
||||
var requestRegion = new RequestRegionRequest
|
||||
{
|
||||
Id = regionId,
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
Lat = latitude,
|
||||
Lon = longitude,
|
||||
SizeMeters = sizeMeters,
|
||||
ZoomLevel = zoomLevel,
|
||||
StitchTiles = stitchTiles
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.TestSupport\SatelliteProvider.TestSupport.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.TestSupport\SatelliteProvider.TestSupport.csproj" />
|
||||
<!-- AZ-503: integration tests need Uuidv5 + TileNamespace so raw SQL seeds
|
||||
can populate tiles.location_hash (NOT NULL after migration 014) using
|
||||
the same algorithm the application uses for new writes. -->
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class SecurityTests
|
||||
Console.WriteLine("SEC-01: SQL injection attempt in coordinate query string");
|
||||
|
||||
var injection = "' OR 1=1 --";
|
||||
var url = $"/api/satellite/tiles/latlon?Latitude={Uri.EscapeDataString(injection)}&Longitude=37.647063&ZoomLevel=18";
|
||||
var url = $"/api/satellite/tiles/latlon?lat={Uri.EscapeDataString(injection)}&lon=37.647063&zoom=18";
|
||||
var response = await httpClient.GetAsync(url);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.BadRequest && response.StatusCode != HttpStatusCode.UnprocessableEntity)
|
||||
@@ -66,7 +66,7 @@ public static class SecurityTests
|
||||
Console.WriteLine("SEC-03: Oversized region request (sizeMeters beyond allowed cap)");
|
||||
|
||||
var regionId = Guid.NewGuid();
|
||||
var body = $"{{\"id\":\"{regionId}\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
var body = $"{{\"id\":\"{regionId}\",\"lat\":47.461747,\"lon\":37.647063,\"sizeMeters\":1000000,\"zoomLevel\":18,\"stitchTiles\":false}}";
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
var response = await httpClient.PostAsync("/api/satellite/request", content);
|
||||
var status = (int)response.StatusCode;
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-505: integration coverage for `POST /api/satellite/tiles/inventory` AND
|
||||
// the location-hash-keyed Leaflet read path. Covers AC-1 (ordering +
|
||||
// present/absent shaping), AC-2 (most-recent-via-location-hash selection rule
|
||||
// preservation across the GetByTileCoordinatesAsync rewrite), AC-4 (perf
|
||||
// budget on 2500 entries), and AC-6 (request validation: both-populated,
|
||||
// neither-populated, 5001-entry, no-token).
|
||||
public static class TileInventoryTests
|
||||
{
|
||||
private const string InventoryPath = "/api/satellite/tiles/inventory";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Tile inventory (AZ-505)");
|
||||
|
||||
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
|
||||
?? "Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres";
|
||||
|
||||
await OrderingAndPresentAbsentShaping_AC1(httpClient, connectionString);
|
||||
await LeafletReadReturnsMostRecentViaLocationHash_AC2(connectionString);
|
||||
await ValidationRejectsBothPopulated_AC6(httpClient);
|
||||
await ValidationRejectsNeitherPopulated_AC6(httpClient);
|
||||
await ValidationRejectsOversizedBatch_AC6(httpClient);
|
||||
await UnauthenticatedRequestReturns401_AC6(httpClient.BaseAddress!);
|
||||
|
||||
if (!TestRunMode.Smoke)
|
||||
{
|
||||
await PerformanceBudget_AC4(httpClient, connectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" (smoke mode — AZ-505 AC-4 perf check skipped; full suite covers it)");
|
||||
}
|
||||
|
||||
Console.WriteLine("✓ Tile inventory tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task OrderingAndPresentAbsentShaping_AC1(HttpClient httpClient, string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-1: 25-entry batch (12 present, 13 absent) preserves order");
|
||||
|
||||
// Arrange — pick 12 cells we will seed and 13 cells we will leave empty,
|
||||
// shuffled so 'present' and 'absent' interleave in the request body.
|
||||
const int zoom = 18;
|
||||
var seed = (int)(DateTime.UtcNow.Ticks % int.MaxValue);
|
||||
var random = new Random(seed);
|
||||
var presentCoords = Enumerable.Range(0, 12)
|
||||
.Select(i => new TileCoord { Z = zoom, X = 50_000 + (seed % 1000) * 100 + i, Y = 60_000 + (seed % 1000) * 100 + i })
|
||||
.ToArray();
|
||||
var absentCoords = Enumerable.Range(0, 13)
|
||||
.Select(i => new TileCoord { Z = zoom, X = 80_000 + (seed % 1000) * 100 + i, Y = 100_000 + (seed % 1000) * 100 + i })
|
||||
.ToArray();
|
||||
|
||||
// Pre-seed the present cells. Mix sources / flights to exercise the
|
||||
// most-recent-across-sources rule. Half google_maps, half UAV with a
|
||||
// captured_at slightly newer than the google_maps row.
|
||||
var seededIds = new Dictionary<Guid, Guid>();
|
||||
var seededCapturedAt = new Dictionary<Guid, DateTime>();
|
||||
for (var i = 0; i < presentCoords.Length; i++)
|
||||
{
|
||||
var coord = presentCoords[i];
|
||||
var locationHash = Uuidv5.LocationHashForTile(coord.Z, coord.X, coord.Y);
|
||||
|
||||
// Seed at least one google_maps row for every present cell.
|
||||
var googleId = Guid.NewGuid();
|
||||
var googleCapturedAt = DateTime.UtcNow.AddHours(-2);
|
||||
await SeedTileAsync(connectionString, googleId, coord, locationHash, "google_maps", flightId: null, capturedAt: googleCapturedAt);
|
||||
|
||||
if (i % 2 == 0)
|
||||
{
|
||||
// Add a UAV row with a strictly newer capturedAt; the most-recent-
|
||||
// across-sources rule must pick this one.
|
||||
var uavId = Guid.NewGuid();
|
||||
var uavCapturedAt = DateTime.UtcNow.AddMinutes(-30);
|
||||
var flightId = Guid.NewGuid();
|
||||
await SeedTileAsync(connectionString, uavId, coord, locationHash, "uav", flightId, capturedAt: uavCapturedAt);
|
||||
seededIds[locationHash] = uavId;
|
||||
seededCapturedAt[locationHash] = uavCapturedAt;
|
||||
}
|
||||
else
|
||||
{
|
||||
seededIds[locationHash] = googleId;
|
||||
seededCapturedAt[locationHash] = googleCapturedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Interleave the 25 coords pseudo-randomly so 'present' and 'absent'
|
||||
// are not contiguous in the request.
|
||||
var allCoords = presentCoords.Concat(absentCoords).OrderBy(_ => random.Next()).ToArray();
|
||||
|
||||
var request = new TileInventoryRequest { Tiles = allCoords };
|
||||
|
||||
// Act
|
||||
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.OK, "AC-1 inventory");
|
||||
var body = await response.Content.ReadFromJsonAsync<TileInventoryResponse>(JsonOptions)
|
||||
?? throw new Exception("AC-1: empty response body");
|
||||
|
||||
if (body.Results.Count != 25)
|
||||
{
|
||||
throw new Exception($"AC-1: expected 25 result entries, got {body.Results.Count}");
|
||||
}
|
||||
|
||||
var presentHashes = presentCoords
|
||||
.Select(c => Uuidv5.LocationHashForTile(c.Z, c.X, c.Y))
|
||||
.ToHashSet();
|
||||
|
||||
for (var i = 0; i < allCoords.Length; i++)
|
||||
{
|
||||
var requestedCoord = allCoords[i];
|
||||
var entry = body.Results[i];
|
||||
|
||||
if (entry.Z != requestedCoord.Z || entry.X != requestedCoord.X || entry.Y != requestedCoord.Y)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AC-1: entry {i} coords mismatch — request was ({requestedCoord.Z},{requestedCoord.X},{requestedCoord.Y}), " +
|
||||
$"response is ({entry.Z},{entry.X},{entry.Y})");
|
||||
}
|
||||
|
||||
var expectedHash = Uuidv5.LocationHashForTile(requestedCoord.Z, requestedCoord.X, requestedCoord.Y);
|
||||
if (entry.LocationHash != expectedHash)
|
||||
{
|
||||
throw new Exception($"AC-1: entry {i} location_hash mismatch — expected {expectedHash}, got {entry.LocationHash}");
|
||||
}
|
||||
|
||||
var shouldBePresent = presentHashes.Contains(expectedHash);
|
||||
if (entry.Present != shouldBePresent)
|
||||
{
|
||||
throw new Exception($"AC-1: entry {i} present={entry.Present}, expected {shouldBePresent}");
|
||||
}
|
||||
|
||||
if (shouldBePresent)
|
||||
{
|
||||
if (entry.Id is null || entry.Id != seededIds[expectedHash])
|
||||
{
|
||||
throw new Exception($"AC-1: entry {i} id={entry.Id}, expected {seededIds[expectedHash]}");
|
||||
}
|
||||
if (entry.CapturedAt is null)
|
||||
{
|
||||
throw new Exception($"AC-1: entry {i} capturedAt is null but row exists");
|
||||
}
|
||||
if (string.IsNullOrEmpty(entry.Source))
|
||||
{
|
||||
throw new Exception($"AC-1: entry {i} source is empty but row exists");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (entry.Id is not null)
|
||||
{
|
||||
throw new Exception($"AC-1: absent entry {i} should have id=null, got {entry.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Order preserved across 25 interleaved entries; 12 present, 13 absent (seed={seed})");
|
||||
}
|
||||
|
||||
private static async Task LeafletReadReturnsMostRecentViaLocationHash_AC2(string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-2: GET /tiles/{z}/{x}/{y} selection rule (most-recent across sources) preserved across the location_hash rewrite");
|
||||
|
||||
// Arrange — pick a fresh (z, x, y) cell; seed two rows for it:
|
||||
// 1. google_maps with captured_at = now - 2h
|
||||
// 2. uav with captured_at = now - 30 min (strictly newer)
|
||||
// AC-2 says the SELECT must pick the UAV row. The endpoint-level
|
||||
// assertion (HTTP body equals UAV's JPEG content) needs a shared file
|
||||
// volume between the integration-test container and the API container,
|
||||
// which the test harness does not provide. Instead we exercise the
|
||||
// EXACT query that TileRepository.GetByTileCoordinatesAsync runs after
|
||||
// the AZ-505 rewrite (`WHERE location_hash = $1 ORDER BY captured_at
|
||||
// DESC, updated_at DESC, id DESC LIMIT 1`) and assert it returns the
|
||||
// UAV row. That is the only behaviour the AZ-505 rewrite changes — the
|
||||
// ServeTile handler is a one-line wrapper around this row and was not
|
||||
// touched.
|
||||
const int zoom = 18;
|
||||
var seed = (int)(DateTime.UtcNow.Ticks % int.MaxValue);
|
||||
var coord = new TileCoord
|
||||
{
|
||||
Z = zoom,
|
||||
X = 130_000 + (seed % 1000),
|
||||
Y = 150_000 + (seed % 1000)
|
||||
};
|
||||
var locationHash = Uuidv5.LocationHashForTile(coord.Z, coord.X, coord.Y);
|
||||
|
||||
var googleId = Guid.NewGuid();
|
||||
var googleCapturedAt = DateTime.UtcNow.AddHours(-2);
|
||||
await SeedTileAsync(connectionString, googleId, coord, locationHash, "google_maps", flightId: null, capturedAt: googleCapturedAt);
|
||||
|
||||
var uavId = Guid.NewGuid();
|
||||
var uavCapturedAt = DateTime.UtcNow.AddMinutes(-30);
|
||||
var flightId = Guid.NewGuid();
|
||||
await SeedTileAsync(connectionString, uavId, coord, locationHash, "uav", flightId, capturedAt: uavCapturedAt);
|
||||
|
||||
// Act — issue the exact SELECT that AZ-505 wired into
|
||||
// GetByTileCoordinatesAsync (location_hash-keyed, captured_at-ordered).
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT id, source, captured_at
|
||||
FROM tiles
|
||||
WHERE location_hash = @loc
|
||||
ORDER BY captured_at DESC, updated_at DESC, id DESC
|
||||
LIMIT 1;", conn);
|
||||
cmd.Parameters.AddWithValue("loc", locationHash);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync())
|
||||
{
|
||||
throw new Exception("AC-2: SELECT returned 0 rows — seed did not persist.");
|
||||
}
|
||||
|
||||
var pickedId = reader.GetGuid(0);
|
||||
var pickedSource = reader.GetString(1);
|
||||
var pickedCapturedAt = reader.GetDateTime(2);
|
||||
|
||||
// Assert
|
||||
if (pickedId != uavId)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AC-2: most-recent-rule regressed — expected id={uavId} (source=uav captured_at={uavCapturedAt:o}), " +
|
||||
$"got id={pickedId} source={pickedSource} captured_at={pickedCapturedAt:o}. " +
|
||||
$"google_maps id={googleId} captured_at={googleCapturedAt:o}.");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ location_hash={locationHash} → uav row (id={uavId}) selected over older google_maps row");
|
||||
}
|
||||
|
||||
private static async Task ValidationRejectsBothPopulated_AC6(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-6: both `tiles` and `locationHashes` populated → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var request = new TileInventoryRequest
|
||||
{
|
||||
Tiles = new[] { new TileCoord { Z = 18, X = 1, Y = 1 } },
|
||||
LocationHashes = new[] { Guid.NewGuid() }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 both populated");
|
||||
Console.WriteLine(" ✓ Both-populated request returns HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task ValidationRejectsNeitherPopulated_AC6(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-6: neither `tiles` nor `locationHashes` populated → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var request = new TileInventoryRequest();
|
||||
|
||||
// Act
|
||||
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 neither populated");
|
||||
Console.WriteLine(" ✓ Neither-populated request returns HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task ValidationRejectsOversizedBatch_AC6(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-6: > 5000 entries → HTTP 400");
|
||||
|
||||
// Arrange — 5001 distinct hashes; cheaper to send than 5001 coord
|
||||
// triples and exercises the same cap.
|
||||
var hashes = Enumerable.Range(0, TileInventoryLimits.MaxEntriesPerRequest + 1)
|
||||
.Select(_ => Guid.NewGuid())
|
||||
.ToArray();
|
||||
var request = new TileInventoryRequest { LocationHashes = hashes };
|
||||
|
||||
// Act
|
||||
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.BadRequest, "AC-6 oversized");
|
||||
Console.WriteLine($" ✓ {hashes.Length}-entry request rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task UnauthenticatedRequestReturns401_AC6(Uri baseAddress)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-6: anonymous request → HTTP 401");
|
||||
|
||||
// Arrange
|
||||
using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(30) };
|
||||
var request = new TileInventoryRequest
|
||||
{
|
||||
Tiles = new[] { new TileCoord { Z = 18, X = 1, Y = 1 } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await anonymous.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.Unauthorized, "AC-6 anonymous");
|
||||
Console.WriteLine(" ✓ Anonymous request returns HTTP 401");
|
||||
}
|
||||
|
||||
private static async Task PerformanceBudget_AC4(HttpClient httpClient, string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-505 AC-4: 2500-entry inventory p95 ≤ 1000 ms over 20 calls");
|
||||
|
||||
// Arrange — seed 2500 cells (one google_maps row each) then issue 20
|
||||
// identical inventory requests; gather the per-call duration and
|
||||
// assert the p95 is ≤ 1000 ms.
|
||||
const int zoom = 18;
|
||||
const int sampleCount = 2500;
|
||||
const int callCount = 20;
|
||||
const long p95BudgetMs = 1000;
|
||||
|
||||
var coords = new TileCoord[sampleCount];
|
||||
var seedSeed = (int)(DateTime.UtcNow.Ticks % 100_000_000);
|
||||
var random = new Random(seedSeed);
|
||||
await using (var conn = new NpgsqlConnection(connectionString))
|
||||
{
|
||||
await conn.OpenAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels,
|
||||
image_type, file_path, source, captured_at, created_at, updated_at, location_hash)
|
||||
VALUES (@id, @z, @x, @y, @lat, @lon, @size, 256, 'jpg', @fp, 'google_maps', @t, @t, @t, @loc)
|
||||
ON CONFLICT DO NOTHING;", conn, transaction);
|
||||
|
||||
var idP = cmd.Parameters.Add("id", NpgsqlTypes.NpgsqlDbType.Uuid);
|
||||
var zP = cmd.Parameters.Add("z", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
var xP = cmd.Parameters.Add("x", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
var yP = cmd.Parameters.Add("y", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
var latP = cmd.Parameters.Add("lat", NpgsqlTypes.NpgsqlDbType.Double);
|
||||
var lonP = cmd.Parameters.Add("lon", NpgsqlTypes.NpgsqlDbType.Double);
|
||||
var sizeP = cmd.Parameters.Add("size", NpgsqlTypes.NpgsqlDbType.Double);
|
||||
var fpP = cmd.Parameters.Add("fp", NpgsqlTypes.NpgsqlDbType.Varchar);
|
||||
var tP = cmd.Parameters.Add("t", NpgsqlTypes.NpgsqlDbType.Timestamp);
|
||||
var locP = cmd.Parameters.Add("loc", NpgsqlTypes.NpgsqlDbType.Uuid);
|
||||
|
||||
for (var i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var x = 100_000 + random.Next(0, 65_536);
|
||||
var y = 100_000 + random.Next(0, 65_536);
|
||||
coords[i] = new TileCoord { Z = zoom, X = x, Y = y };
|
||||
var hash = Uuidv5.LocationHashForTile(zoom, x, y);
|
||||
idP.Value = Guid.NewGuid();
|
||||
zP.Value = zoom;
|
||||
xP.Value = x;
|
||||
yP.Value = y;
|
||||
latP.Value = 60.0 + random.NextDouble();
|
||||
lonP.Value = 30.0 + random.NextDouble();
|
||||
sizeP.Value = 200.0;
|
||||
fpP.Value = $"tiles/perf-seed/{i}.jpg";
|
||||
tP.Value = DateTime.SpecifyKind(DateTime.UtcNow.AddMinutes(-i), DateTimeKind.Unspecified);
|
||||
locP.Value = hash;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
await using (var analyze = new NpgsqlConnection(connectionString))
|
||||
{
|
||||
await analyze.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand("VACUUM ANALYZE tiles;", analyze);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var request = new TileInventoryRequest { Tiles = coords };
|
||||
var durationsMs = new List<long>(callCount);
|
||||
for (var i = 0; i < callCount; i++)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var response = await httpClient.PostAsJsonAsync(InventoryPath, request, JsonOptions);
|
||||
sw.Stop();
|
||||
await EnsureStatus(response, HttpStatusCode.OK, $"AC-4 call {i + 1}");
|
||||
durationsMs.Add(sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var sorted = durationsMs.OrderBy(d => d).ToArray();
|
||||
// p95 over 20 samples lands at the 19th element (index 18 with 0-based,
|
||||
// since ceil(0.95 * 20) - 1 = 18). The largest sample is index 19 (max).
|
||||
var p95 = sorted[18];
|
||||
var max = sorted[^1];
|
||||
|
||||
Console.WriteLine($" durations(ms): min={sorted[0]} median={sorted[10]} p95={p95} max={max}");
|
||||
|
||||
if (p95 > p95BudgetMs)
|
||||
{
|
||||
throw new Exception($"AZ-505 AC-4 perf gate: p95 {p95} ms > {p95BudgetMs} ms (samples: [{string.Join(", ", sorted)}])");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ p95 = {p95} ms ≤ {p95BudgetMs} ms");
|
||||
}
|
||||
|
||||
private static async Task SeedTileAsync(
|
||||
string connectionString,
|
||||
Guid id,
|
||||
TileCoord coord,
|
||||
Guid locationHash,
|
||||
string source,
|
||||
Guid? flightId,
|
||||
DateTime capturedAt)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters, tile_size_pixels,
|
||||
image_type, file_path, source, captured_at, created_at, updated_at, flight_id, location_hash)
|
||||
VALUES (@id, @z, @x, @y, @lat, @lon, 200.0, 256, 'jpg', @fp, @src, @t, @t, @t, @flight, @loc)
|
||||
ON CONFLICT DO NOTHING;", conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("z", coord.Z);
|
||||
cmd.Parameters.AddWithValue("x", coord.X);
|
||||
cmd.Parameters.AddWithValue("y", coord.Y);
|
||||
cmd.Parameters.AddWithValue("lat", 60.0 + coord.X * 1e-9);
|
||||
cmd.Parameters.AddWithValue("lon", 30.0 + coord.Y * 1e-9);
|
||||
cmd.Parameters.AddWithValue("fp", $"tiles/seed/{coord.Z}/{coord.X}/{coord.Y}.jpg");
|
||||
cmd.Parameters.AddWithValue("src", source);
|
||||
// schema column is TIMESTAMP (no tz); Npgsql v6+ refuses to bind a
|
||||
// Kind=Utc DateTime into a plain timestamp column. Callers pass UTC
|
||||
// for clarity; normalize Kind here.
|
||||
cmd.Parameters.AddWithValue("t", DateTime.SpecifyKind(capturedAt, DateTimeKind.Unspecified));
|
||||
cmd.Parameters.AddWithValue("flight", (object?)flightId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("loc", locationHash);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label)
|
||||
{
|
||||
if (response.StatusCode != expected)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-796: end-to-end coverage for the inventory endpoint's strict input
|
||||
// validation. Each test exercises one rule from the validator (FluentValidation
|
||||
// for business rules, JsonSerializerOptions for wire-format rules) and asserts
|
||||
// the response body conforms to the RFC 7807 ValidationProblemDetails contract
|
||||
// in `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
public static class TileInventoryValidationTests
|
||||
{
|
||||
private const string InventoryPath = "/api/satellite/tiles/inventory";
|
||||
|
||||
public static async Task RunAll(HttpClient httpClient)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: Inventory endpoint strict validation (AZ-796)");
|
||||
|
||||
await HappyPath_Returns200(httpClient);
|
||||
|
||||
// Rule 1: body present
|
||||
await EmptyBody_Returns400(httpClient);
|
||||
// Rule 2: tiles required (one of tiles/locationHashes must be populated)
|
||||
await NeitherPopulated_Returns400(httpClient);
|
||||
await BothPopulated_Returns400(httpClient);
|
||||
// Rule 3: tiles non-empty
|
||||
await EmptyTilesArray_Returns400(httpClient);
|
||||
// Rule 4: tiles max size
|
||||
await TilesOverCap_Returns400(httpClient);
|
||||
// Rule 5: each entry has z, x, y
|
||||
await MissingZ_Returns400WithFieldPath(httpClient);
|
||||
await MissingXAndY_Returns400(httpClient);
|
||||
// Rule 6: non-negative integer fields
|
||||
await NegativeAxis_Returns400(httpClient);
|
||||
await TypeMismatch_Returns400(httpClient);
|
||||
// Rule 7: z within supported zoom range
|
||||
await ZoomOutOfRange_Returns400WithFieldPath(httpClient);
|
||||
// Rule 8: x / y within tile-axis bounds
|
||||
await XBeyondZoomBounds_Returns400(httpClient);
|
||||
await YBeyondZoomBounds_Returns400(httpClient);
|
||||
// Rule 9: unknown fields rejected
|
||||
await UnknownRootField_Returns400(httpClient);
|
||||
await UnknownNestedField_Returns400(httpClient);
|
||||
await OldV1FieldName_Returns400(httpClient);
|
||||
|
||||
Console.WriteLine("✓ Inventory validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: well-formed request with z/x/y triple → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"tiles":[{"z":18,"x":1,"y":1}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
|
||||
// Assert
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"AZ-796 happy path: expected 200, got {(int)response.StatusCode}. Body: {errorBody}");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Valid {z, x, y} request returns HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task EmptyBody_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796 rule 1: empty body → HTTP 400");
|
||||
|
||||
// Arrange — POST with zero-byte body and JSON content type. The framework
|
||||
// rejects the missing required body parameter before any handler/filter
|
||||
// runs, so the response is basic RFC 7807 ProblemDetails with no `errors`
|
||||
// map (vs. ValidationProblemDetails on field-level violations).
|
||||
var content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await httpClient.PostAsync(InventoryPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 empty body");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertProblemDetails(problem, expectedStatus: 400, label: "AZ-796 empty body");
|
||||
|
||||
Console.WriteLine(" ✓ Empty body rejected with HTTP 400 + ProblemDetails");
|
||||
}
|
||||
|
||||
private static async Task NeitherPopulated_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796 rule 2: neither tiles nor locationHashes populated → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = """{}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 neither populated");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 neither populated",
|
||||
expectedErrorPath: "$");
|
||||
|
||||
Console.WriteLine(" ✓ Empty object rejected with errors[\"$\"] (XOR rule)");
|
||||
}
|
||||
|
||||
private static async Task BothPopulated_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796 rule 2: both tiles and locationHashes populated → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"tiles":[{"z":18,"x":1,"y":1}],"locationHashes":["00000000-0000-0000-0000-000000000000"]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 both populated");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 both populated",
|
||||
expectedErrorPath: "$");
|
||||
|
||||
Console.WriteLine(" ✓ Both populated rejected with errors[\"$\"] (XOR rule)");
|
||||
}
|
||||
|
||||
private static async Task EmptyTilesArray_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796 rule 3: empty tiles array → HTTP 400");
|
||||
|
||||
// Arrange — XOR rule treats empty arrays as not-populated
|
||||
const string body = """{"tiles":[]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 empty tiles array");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 empty tiles array",
|
||||
expectedErrorPath: "$");
|
||||
|
||||
Console.WriteLine(" ✓ Empty tiles array rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task TilesOverCap_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796 rule 4: > 5000 tile entries → HTTP 400");
|
||||
|
||||
// Arrange — TileInventoryLimits.MaxEntriesPerRequest is 5000
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("""{"tiles":[""");
|
||||
for (var i = 0; i < 5001; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append("""{"z":18,"x":1,"y":1}""");
|
||||
}
|
||||
sb.Append("]}");
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, sb.ToString());
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 over cap");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 over cap",
|
||||
expectedErrorPath: "tiles");
|
||||
|
||||
Console.WriteLine(" ✓ 5001-entry tiles array rejected with errors[\"tiles\"]");
|
||||
}
|
||||
|
||||
private static async Task MissingZ_Returns400WithFieldPath(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: missing `z` → HTTP 400 with structured errors map");
|
||||
|
||||
// Arrange — JsonRequired on TileCoord.Z catches this at the deserializer layer.
|
||||
const string body = """{"tiles":[{"x":1,"y":1}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 missing z");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 missing z");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "z", label: "AZ-796 missing z");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `z` rejected with errors map mentioning the field");
|
||||
}
|
||||
|
||||
private static async Task MissingXAndY_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: missing `x` and `y` → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"tiles":[{"z":18}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 missing x/y");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 missing x/y");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `x` and `y` rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task ZoomOutOfRange_Returns400WithFieldPath(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: z out of slippy-map range → HTTP 400 with errors[\"tiles[0].z\"]");
|
||||
|
||||
// Arrange — z=30 is beyond the supported max of 22
|
||||
const string body = """{"tiles":[{"z":30,"x":0,"y":0}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 z=30");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 z=30",
|
||||
expectedErrorPath: "tiles[0].z",
|
||||
expectedErrorContains: "between 0 and 22");
|
||||
|
||||
Console.WriteLine(" ✓ z=30 rejected with errors[\"tiles[0].z\"] mentioning range");
|
||||
}
|
||||
|
||||
private static async Task XBeyondZoomBounds_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: x ≥ 2^z → HTTP 400");
|
||||
|
||||
// Arrange — at z=2, valid x is 0..3; x=4 is invalid
|
||||
const string body = """{"tiles":[{"z":2,"x":4,"y":0}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 x=4 z=2");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 x=4 z=2",
|
||||
expectedErrorPath: "tiles[0].x");
|
||||
|
||||
Console.WriteLine(" ✓ x=4 at z=2 rejected with errors[\"tiles[0].x\"]");
|
||||
}
|
||||
|
||||
private static async Task YBeyondZoomBounds_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: y ≥ 2^z → HTTP 400");
|
||||
|
||||
// Arrange — at z=0, valid y is 0..0; y=1 is invalid
|
||||
const string body = """{"tiles":[{"z":0,"x":0,"y":1}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 y=1 z=0");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 y=1 z=0",
|
||||
expectedErrorPath: "tiles[0].y");
|
||||
|
||||
Console.WriteLine(" ✓ y=1 at z=0 rejected with errors[\"tiles[0].y\"]");
|
||||
}
|
||||
|
||||
private static async Task NegativeAxis_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: negative coordinate → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"tiles":[{"z":18,"x":-1,"y":0}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 x=-1");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(
|
||||
problem,
|
||||
expectedStatus: 400,
|
||||
label: "AZ-796 x=-1",
|
||||
expectedErrorPath: "tiles[0].x");
|
||||
|
||||
Console.WriteLine(" ✓ x=-1 rejected with errors[\"tiles[0].x\"]");
|
||||
}
|
||||
|
||||
private static async Task UnknownRootField_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: unknown root field → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"unknownField":42,"tiles":[{"z":18,"x":1,"y":1}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 unknown root field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown root field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "unknownField", label: "AZ-796 unknown root field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown root field rejected; errors map names the field");
|
||||
}
|
||||
|
||||
private static async Task UnknownNestedField_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: unknown nested field on tile entry → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"tiles":[{"z":18,"x":1,"y":1,"foo":42}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 unknown nested field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 unknown nested field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "foo", label: "AZ-796 unknown nested field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown nested field rejected; errors map names the field");
|
||||
}
|
||||
|
||||
private static async Task OldV1FieldName_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-794 + AZ-796: legacy `tileZoom`/`tileX`/`tileY` field name → HTTP 400");
|
||||
|
||||
// Arrange — exact AZ-777 Phase 1 reproduction; v1 callers must now fail explicitly
|
||||
// instead of silently coercing to (0,0,0).
|
||||
const string body = """{"tiles":[{"tileZoom":18,"tileX":1,"tileY":1}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-794 legacy field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-794 legacy field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "tileZoom", label: "AZ-794 legacy field");
|
||||
|
||||
Console.WriteLine(" ✓ Legacy v1.x field names rejected with explicit error (no silent coercion)");
|
||||
}
|
||||
|
||||
private static async Task TypeMismatch_Returns400(HttpClient httpClient)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-796: type mismatch (string where integer expected) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
const string body = """{"tiles":[{"z":"eighteen","x":1,"y":1}]}""";
|
||||
|
||||
// Act
|
||||
var response = await PostJsonAsync(httpClient, body);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-796 type mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-796 type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ String-where-int rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient httpClient, string body)
|
||||
{
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
return httpClient.PostAsync(InventoryPath, content);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public static class TileTests
|
||||
|
||||
Console.WriteLine($"Getting tile at coordinates ({latitude}, {longitude}) with zoom level {zoomLevel}");
|
||||
|
||||
var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}");
|
||||
var response = await httpClient.GetAsync($"/api/satellite/tiles/latlon?lat={latitude}&lon={longitude}&zoom={zoomLevel}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -74,7 +74,7 @@ public static class TileTests
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Testing tile reuse (getting same tile again)...");
|
||||
|
||||
var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?Latitude={latitude}&Longitude={longitude}&ZoomLevel={zoomLevel}");
|
||||
var response2 = await httpClient.GetAsync($"/api/satellite/tiles/latlon?lat={latitude}&lon={longitude}&zoom={zoomLevel}");
|
||||
|
||||
if (!response2.IsSuccessStatusCode)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using Npgsql;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
@@ -27,6 +29,8 @@ public static class UavUploadTests
|
||||
await MixedBatch_ReturnsPerItemResults(apiUrl, secret, connectionString);
|
||||
await MultiSourceCoexistence_AZ484_Cycle2(apiUrl, secret, connectionString);
|
||||
await SameSourceUpsert_AZ484_Cycle2(apiUrl, secret, connectionString);
|
||||
await MultiFlightUavRowsCoexist_AZ503_AC3(apiUrl, secret, connectionString);
|
||||
await FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(apiUrl, secret, connectionString);
|
||||
await NoToken_Returns401(apiUrl);
|
||||
await ValidTokenWithoutGpsPermission_Returns403(apiUrl, secret);
|
||||
await OversizedBatch_Returns400(apiUrl, secret);
|
||||
@@ -127,19 +131,25 @@ public static class UavUploadTests
|
||||
Console.WriteLine("AZ-488 AC-3: UAV upload coexists with a pre-seeded google_maps row");
|
||||
|
||||
// Arrange — pre-seed a google_maps row at T1 directly via SQL.
|
||||
// AZ-503: location_hash is NOT NULL after migration 014; compute it
|
||||
// inline using the same Uuidv5 algorithm production code uses (see
|
||||
// SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity).
|
||||
var coord = NextTestCoordinate();
|
||||
const int zoom = 18;
|
||||
const double sizeMeters = 200.0;
|
||||
var t1 = DateTime.UtcNow.AddHours(-2);
|
||||
var googleRowId = Guid.NewGuid();
|
||||
var seedLocationHash = Uuidv5.Create(
|
||||
Uuidv5.TileNamespace,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{zoom}/0/0"));
|
||||
await ExecuteAsync(connectionString, """
|
||||
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters,
|
||||
tile_size_pixels, image_type, file_path, source, captured_at,
|
||||
created_at, updated_at)
|
||||
VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1);
|
||||
created_at, updated_at, location_hash)
|
||||
VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1, @loc);
|
||||
""",
|
||||
("id", googleRowId), ("zoom", zoom), ("lat", coord.Latitude), ("lon", coord.Longitude),
|
||||
("size", sizeMeters), ("t1", t1));
|
||||
("size", sizeMeters), ("t1", t1), ("loc", seedLocationHash));
|
||||
|
||||
var metadata = new
|
||||
{
|
||||
@@ -210,6 +220,142 @@ public static class UavUploadTests
|
||||
Console.WriteLine(" ✓ Same-source UPSERT collapsed to exactly one uav row");
|
||||
}
|
||||
|
||||
private static async Task MultiFlightUavRowsCoexist_AZ503_AC3(string apiUrl, string secret, string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-503 AC-3: two UAV uploads at the same (z, x, y) from different flight_ids coexist as distinct DB rows sharing the same location_hash");
|
||||
|
||||
// Arrange — two distinct flightIds, identical lat/lon/zoom/size.
|
||||
var coord = NextTestCoordinate();
|
||||
const int zoom = 18;
|
||||
const double sizeMeters = 200.0;
|
||||
var flightA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
var flightB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
using var client = CreateClient(apiUrl);
|
||||
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
||||
|
||||
var metaA = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-10).ToString("o"), flightId = flightA }
|
||||
}
|
||||
};
|
||||
var metaB = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId = flightB }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var first = await PostBatch(client, metaA, new[] { CreateValidJpeg(seed: 11) });
|
||||
await EnsureStatus(first, HttpStatusCode.OK, "AC-3 first flight upload");
|
||||
var second = await PostBatch(client, metaB, new[] { CreateValidJpeg(seed: 22) });
|
||||
await EnsureStatus(second, HttpStatusCode.OK, "AC-3 second flight upload");
|
||||
|
||||
// Assert
|
||||
var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters);
|
||||
if (rows.Count != 2)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-3: expected 2 distinct uav rows for the same cell with different flight_ids, got {rows.Count}. Rows: [{string.Join(", ", rows.Select(r => $"flight_id={r.FlightId} id={r.Id}"))}]");
|
||||
}
|
||||
if (!rows.Any(r => r.FlightId == flightA) || !rows.Any(r => r.FlightId == flightB))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-3: expected rows with flight_id={flightA} AND flight_id={flightB}, got [{string.Join(", ", rows.Select(r => r.FlightId?.ToString() ?? "NULL"))}]");
|
||||
}
|
||||
var ids = rows.Select(r => r.Id).Distinct().ToList();
|
||||
if (ids.Count != 2)
|
||||
{
|
||||
throw new Exception($"AZ-503 AC-3: per-flight rows must have distinct ids, got {ids.Count} distinct id(s).");
|
||||
}
|
||||
var locationHashes = rows.Select(r => r.LocationHash).Distinct().ToList();
|
||||
if (locationHashes.Count != 1)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-3: per-flight rows must share the same location_hash (same (z, x, y)), got {locationHashes.Count} distinct hashes: [{string.Join(", ", locationHashes)}]");
|
||||
}
|
||||
|
||||
// AC-11 cross-check at the DB level: each row's file_path embeds its flight_id.
|
||||
var rowA = rows.Single(r => r.FlightId == flightA);
|
||||
var rowB = rows.Single(r => r.FlightId == flightB);
|
||||
if (!rowA.FilePath.Contains(flightA.ToString()) || !rowB.FilePath.Contains(flightB.ToString()))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-11: per-flight file_path must contain the flight_id segment. " +
|
||||
$"rowA.file_path='{rowA.FilePath}', rowB.file_path='{rowB.FilePath}'.");
|
||||
}
|
||||
if (string.Equals(rowA.FilePath, rowB.FilePath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-11: per-flight file_path must differ between flights, got identical '{rowA.FilePath}'.");
|
||||
}
|
||||
|
||||
Console.WriteLine($" ✓ Two distinct uav rows for flight_id={flightA} and flight_id={flightB} coexist");
|
||||
Console.WriteLine($" ✓ Both rows share location_hash={locationHashes[0]}");
|
||||
Console.WriteLine($" ✓ Per-flight file_path differs ({rowA.FilePath} != {rowB.FilePath})");
|
||||
}
|
||||
|
||||
private static async Task FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(string apiUrl, string secret, string connectionString)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-503 AC-4: two UAV uploads for the same (z, x, y) with float-different lat/lon collapse to one row");
|
||||
|
||||
// Arrange — same (z, x, y) coords but two slightly-different lat/lon values.
|
||||
// The new integer-keyed UPSERT must collapse them; the AZ-484 lat/lon-keyed
|
||||
// UPSERT would have left two duplicate rows.
|
||||
var coord = NextTestCoordinate();
|
||||
const int zoom = 18;
|
||||
const double sizeMeters = 200.0;
|
||||
var flightId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
|
||||
using var client = CreateClient(apiUrl);
|
||||
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
|
||||
|
||||
// First upload: exact center of the cell as returned by NextTestCoordinate.
|
||||
var firstMeta = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-20).ToString("o"), flightId }
|
||||
}
|
||||
};
|
||||
|
||||
// Second upload: a coordinate offset by < 1 m so it lands in the same (tile_x,
|
||||
// tile_y) bucket but with a different float bit pattern.
|
||||
var nudgedLat = coord.Latitude + 1e-7;
|
||||
var nudgedLon = coord.Longitude + 1e-7;
|
||||
var secondMeta = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = nudgedLat, longitude = nudgedLon, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var first = await PostBatch(client, firstMeta, new[] { CreateValidJpeg(seed: 31) });
|
||||
await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload");
|
||||
var second = await PostBatch(client, secondMeta, new[] { CreateValidJpeg(seed: 32) });
|
||||
await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload");
|
||||
|
||||
// Assert
|
||||
var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters, alsoTryLatitude: nudgedLat, alsoTryLongitude: nudgedLon);
|
||||
var flightRows = rows.Where(r => r.FlightId == flightId).ToList();
|
||||
if (flightRows.Count != 1)
|
||||
{
|
||||
throw new Exception(
|
||||
$"AZ-503 AC-4: expected exactly 1 uav row after float-different upload (integer-keyed UPSERT must collapse), got {flightRows.Count}. " +
|
||||
$"Rows: [{string.Join(", ", flightRows.Select(r => $"id={r.Id} lat={r.Latitude} lon={r.Longitude}"))}]");
|
||||
}
|
||||
|
||||
Console.WriteLine(" ✓ Two uploads at float-different lat/lon but same (tile_x, tile_y) collapsed to a single row");
|
||||
}
|
||||
|
||||
private static async Task NoToken_Returns401(string apiUrl)
|
||||
{
|
||||
Console.WriteLine();
|
||||
@@ -365,9 +511,14 @@ public static class UavUploadTests
|
||||
private static (double Latitude, double Longitude) NextTestCoordinate()
|
||||
{
|
||||
// Spread test coordinates far enough apart to fall into distinct tile cells
|
||||
// so concurrent runs don't collide on the per-source unique index.
|
||||
// so concurrent runs don't collide on the per-source unique index. Wrap on
|
||||
// 40_000-cell axes so the result always stays strictly inside the
|
||||
// OSM-valid ranges enforced by UavTileMetadataValidator (AZ-810):
|
||||
// lat in [50.0, 70.0), lon in [10.0, 40.0).
|
||||
var n = Interlocked.Increment(ref _coordinateCounter);
|
||||
return (60.0 + n * 0.0005, 30.0 + n * 0.0005);
|
||||
var lat = 50.0 + ((uint)n % 40_000u) * 0.0005;
|
||||
var lon = 10.0 + ((uint)n % 60_000u) * 0.0005;
|
||||
return (lat, lon);
|
||||
}
|
||||
|
||||
private static async Task<int> CountUavRowsAsync(string connectionString, double latitude, double longitude)
|
||||
@@ -402,6 +553,56 @@ public static class UavUploadTests
|
||||
return sources;
|
||||
}
|
||||
|
||||
private sealed record UavRowProjection(Guid Id, Guid? FlightId, Guid LocationHash, double Latitude, double Longitude, string FilePath);
|
||||
|
||||
private static async Task<List<UavRowProjection>> QueryUavRowsByFlightAsync(
|
||||
string connectionString,
|
||||
double latitude,
|
||||
double longitude,
|
||||
int zoom,
|
||||
double sizeMeters,
|
||||
double? alsoTryLatitude = null,
|
||||
double? alsoTryLongitude = null)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// The UPSERT preserves the latitude/longitude of the row that won the
|
||||
// race; for AC-3 / AC-4 we need to find rows produced from EITHER input
|
||||
// coordinate, so widen the lookup by a few meters of float wiggle room.
|
||||
const string sql = @"
|
||||
SELECT id, flight_id, location_hash, latitude, longitude, file_path
|
||||
FROM tiles
|
||||
WHERE source = 'uav'
|
||||
AND tile_zoom = @zoom
|
||||
AND tile_size_meters = @size
|
||||
AND (
|
||||
(latitude = @lat AND longitude = @lon)
|
||||
OR (latitude = @lat2 AND longitude = @lon2)
|
||||
);";
|
||||
|
||||
var rows = new List<UavRowProjection>();
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("lat", latitude);
|
||||
cmd.Parameters.AddWithValue("lon", longitude);
|
||||
cmd.Parameters.AddWithValue("lat2", alsoTryLatitude ?? latitude);
|
||||
cmd.Parameters.AddWithValue("lon2", alsoTryLongitude ?? longitude);
|
||||
cmd.Parameters.AddWithValue("zoom", zoom);
|
||||
cmd.Parameters.AddWithValue("size", sizeMeters);
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
rows.Add(new UavRowProjection(
|
||||
reader.GetGuid(0),
|
||||
reader.IsDBNull(1) ? null : reader.GetGuid(1),
|
||||
reader.GetGuid(2),
|
||||
reader.GetDouble(3),
|
||||
reader.GetDouble(4),
|
||||
reader.GetString(5)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(string connectionString, string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(connectionString);
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace SatelliteProvider.IntegrationTests;
|
||||
|
||||
// AZ-810: end-to-end coverage for POST /api/satellite/upload strict metadata
|
||||
// validation. Each test exercises one of the 14 rules listed in the AZ-810
|
||||
// task spec and asserts the response conforms to the RFC 7807
|
||||
// ValidationProblemDetails contract in
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
//
|
||||
// The endpoint is multipart/form-data, so the validator wires in through the
|
||||
// custom `UavUploadValidationFilter` (NOT the generic `WithValidation<T>()`
|
||||
// filter that the JSON-body endpoints use). Three enforcement layers compose:
|
||||
// 1. UnmappedMemberHandling.Disallow + [JsonRequired] — the metadata JSON
|
||||
// is deserialized inside the filter via the strict global
|
||||
// `JsonSerializerOptions`; missing-required and unknown fields raise
|
||||
// JsonException which the filter surfaces under `errors["metadata"]`.
|
||||
// 2. UavTileBatchMetadataPayloadValidator + UavTileMetadataValidator —
|
||||
// FluentValidation rules on the deserialized payload (item count, per-
|
||||
// item lat/lon/zoom/size/freshness). Errors are prefixed with
|
||||
// `metadata.` so paths look like `errors["metadata.items[0].latitude"]`.
|
||||
// 3. Cross-field envelope rule (items.Count == files.Count) — runs after
|
||||
// the per-payload validator; surfaces under `errors["metadata.items"]`
|
||||
// AND `errors["files"]`.
|
||||
//
|
||||
// AC-9 (no regression in existing UavUploadTests) is enforced by leaving the
|
||||
// pre-AZ-810 happy path here as a separate scenario and by exercising the
|
||||
// existing AZ-488 suite unchanged from Program.Main.
|
||||
public static class UavUploadValidationTests
|
||||
{
|
||||
private const string UploadPath = "/api/satellite/upload";
|
||||
private const string GpsPermission = "GPS";
|
||||
private const string PermissionsClaimType = "permissions";
|
||||
|
||||
public static async Task RunAll(string apiUrl, string secret)
|
||||
{
|
||||
RouteTestHelpers.PrintTestHeader("Test: POST /api/satellite/upload strict metadata validation (AZ-810)");
|
||||
|
||||
// AC-2: happy path unchanged (well-formed multipart envelope still 200).
|
||||
await HappyPath_Returns200(apiUrl, secret);
|
||||
|
||||
// Rule 2: metadata form field absent
|
||||
await MissingMetadataField_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 3: metadata JSON malformed
|
||||
await MalformedMetadataJson_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 4: items missing (empty)
|
||||
await EmptyItems_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 5: items count > MaxBatchSize
|
||||
await ItemsOverCap_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 6: items.Count != files.Count
|
||||
await ItemsFilesMismatch_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 7: per-item lat out of range
|
||||
await ItemLatOutOfRange_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 8: per-item lon out of range
|
||||
await ItemLonOutOfRange_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 9: per-item tileZoom out of range
|
||||
await ItemTileZoomOutOfRange_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 10: per-item tileSizeMeters <= 0
|
||||
await ItemTileSizeMetersNonPositive_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 11a: capturedAt too far in the future
|
||||
await ItemCapturedAtFuture_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 11b: capturedAt older than MaxAgeDays
|
||||
await ItemCapturedAtTooOld_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 12: malformed flightId UUID (deserializer JsonException path)
|
||||
await ItemFlightIdMalformed_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 13: unknown field at the root of metadata
|
||||
await UnknownRootField_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 13b: unknown field nested under items[i]
|
||||
await UnknownNestedField_Returns400(apiUrl, secret);
|
||||
|
||||
// Rule 14: type mismatch (lat as string)
|
||||
await ItemLatTypeMismatch_Returns400(apiUrl, secret);
|
||||
|
||||
Console.WriteLine("✓ UAV upload metadata validation tests: PASSED");
|
||||
}
|
||||
|
||||
private static async Task HappyPath_Returns200(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 AC-2: well-formed metadata + 1 valid file → HTTP 200");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
latitude = coord.Latitude,
|
||||
longitude = coord.Longitude,
|
||||
tileZoom = 18,
|
||||
tileSizeMeters = 200.0,
|
||||
capturedAt = DateTime.UtcNow.ToString("o"),
|
||||
},
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
|
||||
// Assert
|
||||
await EnsureStatus(response, HttpStatusCode.OK, "AZ-810 AC-2 happy path");
|
||||
Console.WriteLine(" ✓ Well-formed multipart envelope accepted with HTTP 200");
|
||||
}
|
||||
|
||||
private static async Task MissingMetadataField_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 2: missing `metadata` form field → HTTP 400");
|
||||
|
||||
// Arrange — multipart body with only the `files` part, no `metadata`.
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 missing metadata");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 missing metadata");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 missing metadata");
|
||||
|
||||
Console.WriteLine(" ✓ Missing `metadata` form field rejected with HTTP 400");
|
||||
}
|
||||
|
||||
private static async Task MalformedMetadataJson_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 3: malformed metadata JSON → HTTP 400");
|
||||
|
||||
// Arrange — unterminated JSON object.
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
const string brokenJson = "{\"items\": [{ \"latitude\": 50.10, \"longitude\": 36.10";
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(brokenJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 malformed JSON");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 malformed JSON");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 malformed JSON");
|
||||
|
||||
Console.WriteLine(" ✓ Malformed metadata JSON rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task EmptyItems_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 4: empty `items` → HTTP 400");
|
||||
|
||||
// Arrange — well-formed JSON, but items: [] tripping FluentValidation.
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
var metadata = new { items = Array.Empty<object>() };
|
||||
|
||||
// Act — no files either; the items rule fires before the count-mismatch rule.
|
||||
using var response = await PostBatch(client, metadata, Array.Empty<byte[]>());
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 empty items");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 empty items");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 empty items");
|
||||
|
||||
Console.WriteLine(" ✓ Empty items rejected with errors mention of `items`");
|
||||
}
|
||||
|
||||
private static async Task ItemsOverCap_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 5: items.Count > MaxBatchSize → HTTP 400 from validator");
|
||||
|
||||
// Arrange — 101 metadata entries + 101 tiny placeholders so this exercises
|
||||
// the AZ-810 validator path specifically (the count-mismatch rule does not
|
||||
// fire because items.Count == files.Count).
|
||||
const int oversize = 101;
|
||||
var baseCoord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = Enumerable.Range(0, oversize).Select(i => new
|
||||
{
|
||||
latitude = baseCoord.Latitude + i * 0.0001,
|
||||
longitude = baseCoord.Longitude,
|
||||
tileZoom = 18,
|
||||
tileSizeMeters = 200.0,
|
||||
capturedAt = DateTime.UtcNow.ToString("o"),
|
||||
}).ToArray(),
|
||||
};
|
||||
var placeholder = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
|
||||
var files = Enumerable.Range(0, oversize).Select(_ => placeholder).ToArray();
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, files);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items over cap");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items over cap");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items over cap");
|
||||
|
||||
Console.WriteLine(" ✓ items.Count=101 (> 100 cap) rejected with errors mention of `items`");
|
||||
}
|
||||
|
||||
private static async Task ItemsFilesMismatch_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 6: items.Count != files.Count → HTTP 400");
|
||||
|
||||
// Arrange — 2 metadata items but only 1 file.
|
||||
var c1 = NextTestCoordinate();
|
||||
var c2 = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = c1.Latitude, longitude = c1.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
new { latitude = c2.Latitude, longitude = c2.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 items/files mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 items/files mismatch");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items", label: "AZ-810 items/files mismatch");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "files", label: "AZ-810 items/files mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ items.Count=2 / files.Count=1 rejected with both `items` and `files` mentioned");
|
||||
}
|
||||
|
||||
private static async Task ItemLatOutOfRange_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 7: per-item latitude out of range → HTTP 400 (errors[metadata.items[i].latitude])");
|
||||
|
||||
// Arrange — second item has lat = 91.0 (above the +90 bound).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
new { latitude = 91.0, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg(), CreateValidJpeg(seed: 2) });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lat out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lat out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[1].latitude", label: "AZ-810 item lat out of range");
|
||||
|
||||
Console.WriteLine(" ✓ items[1].latitude=91.0 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemLonOutOfRange_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 8: per-item longitude out of range → HTTP 400 (errors[metadata.items[i].longitude])");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = 181.0, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item lon out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item lon out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].longitude", label: "AZ-810 item lon out of range");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].longitude=181 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemTileZoomOutOfRange_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 9: per-item tileZoom out of range → HTTP 400");
|
||||
|
||||
// Arrange — zoom = 30 (above the 22 cap).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 30, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileZoom out of range");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileZoom out of range");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileZoom", label: "AZ-810 item tileZoom out of range");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].tileZoom=30 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemTileSizeMetersNonPositive_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 10: per-item tileSizeMeters <= 0 → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 0.0, capturedAt = DateTime.UtcNow.ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item tileSizeMeters non-positive");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item tileSizeMeters non-positive");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].tileSizeMeters", label: "AZ-810 item tileSizeMeters non-positive");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].tileSizeMeters=0.0 rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemCapturedAtFuture_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 11a: per-item capturedAt > now + CapturedAtFutureSkewSeconds → HTTP 400");
|
||||
|
||||
// Arrange — 1 hour in the future (default skew is 30s).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddHours(1).ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt future");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt future");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt future");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].capturedAt = now+1h rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemCapturedAtTooOld_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 11b: per-item capturedAt older than MaxAgeDays → HTTP 400");
|
||||
|
||||
// Arrange — 60 days old (default MaxAgeDays is 7).
|
||||
var coord = NextTestCoordinate();
|
||||
var metadata = new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = DateTime.UtcNow.AddDays(-60).ToString("o") },
|
||||
},
|
||||
};
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
|
||||
// Act
|
||||
using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() });
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 item capturedAt too old");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 item capturedAt too old");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "items[0].capturedAt", label: "AZ-810 item capturedAt too old");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path");
|
||||
}
|
||||
|
||||
private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 12: malformed flightId → HTTP 400 (JsonException at deserializer)");
|
||||
|
||||
// Arrange — flightId is a non-UUID string. System.Text.Json rejects this at
|
||||
// the deserializer; the filter catches the JsonException and surfaces it as
|
||||
// errors["metadata"].
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": {{{coord.Latitude}}},
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}",
|
||||
"flightId": "not-a-uuid"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 flightId malformed");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 flightId malformed");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 flightId malformed");
|
||||
|
||||
Console.WriteLine(" ✓ flightId=\"not-a-uuid\" rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task UnknownRootField_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 13: unknown root field in metadata → HTTP 400 (UnmappedMemberHandling.Disallow)");
|
||||
|
||||
// Arrange — `debug` is not a member of UavTileBatchMetadataPayload.
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": {{{coord.Latitude}}},
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}"
|
||||
}
|
||||
],
|
||||
"debug": "fingerprint-probe"
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown root field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown root field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown root field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown root field `debug` rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task UnknownNestedField_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 13b: unknown nested field under items[i] → HTTP 400");
|
||||
|
||||
// Arrange — `altitude` is not a member of UavTileMetadata.
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": {{{coord.Latitude}}},
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}",
|
||||
"altitude": 500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 unknown nested field");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 unknown nested field");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 unknown nested field");
|
||||
|
||||
Console.WriteLine(" ✓ Unknown nested field `altitude` rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static async Task ItemLatTypeMismatch_Returns400(string apiUrl, string secret)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("AZ-810 rule 14: nested type mismatch (`items[0].latitude` as string) → HTTP 400");
|
||||
|
||||
// Arrange
|
||||
var coord = NextTestCoordinate();
|
||||
var metadataJson = string.Create(CultureInfo.InvariantCulture, $$$"""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"latitude": "fifty",
|
||||
"longitude": {{{coord.Longitude}}},
|
||||
"tileZoom": 18,
|
||||
"tileSizeMeters": 200.0,
|
||||
"capturedAt": "{{{DateTime.UtcNow.ToString("o")}}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using var client = CreateClientWithGpsToken(apiUrl, secret);
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(metadataJson), "metadata" },
|
||||
};
|
||||
var file = new ByteArrayContent(CreateValidJpeg());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(file, "files", "tile_0.jpg");
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync(UploadPath, content);
|
||||
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-810 lat type mismatch");
|
||||
|
||||
// Assert
|
||||
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-810 lat type mismatch");
|
||||
ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "metadata", label: "AZ-810 lat type mismatch");
|
||||
|
||||
Console.WriteLine(" ✓ items[0].latitude=\"fifty\" rejected with errors[\"metadata\"]");
|
||||
}
|
||||
|
||||
private static HttpClient CreateClientWithGpsToken(string apiUrl, string secret)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(apiUrl), Timeout = TimeSpan.FromMinutes(1) };
|
||||
var token = JwtTestHelpers.MintAuthenticated(secret, extraClaims: new[] { new Claim(PermissionsClaimType, GpsPermission) });
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files)
|
||||
{
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(JsonSerializer.Serialize(metadata)), "metadata" },
|
||||
};
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
var item = new ByteArrayContent(files[i]);
|
||||
item.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
content.Add(item, "files", $"tile_{i}.jpg");
|
||||
}
|
||||
|
||||
return await client.PostAsync(UploadPath, content);
|
||||
}
|
||||
|
||||
private static async Task EnsureStatus(HttpResponseMessage response, HttpStatusCode expected, string label)
|
||||
{
|
||||
if (response.StatusCode != expected)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"{label}: expected HTTP {(int)expected}, got HTTP {(int)response.StatusCode}. Body: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
var random = new Random(seed);
|
||||
image.ProcessPixelRows(accessor =>
|
||||
{
|
||||
for (var y = 0; y < accessor.Height; y++)
|
||||
{
|
||||
var row = accessor.GetRowSpan(y);
|
||||
for (var x = 0; x < row.Length; x++)
|
||||
{
|
||||
row[x] = new Rgba32(
|
||||
(byte)random.Next(256),
|
||||
(byte)random.Next(256),
|
||||
(byte)random.Next(256));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
image.Save(stream, new JpegEncoder { Quality = 95 });
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
// Use a southern-hemisphere range that does NOT overlap UavUploadTests'
|
||||
// northern range ([50,70) x [10,40)). Non-overlap (not counter offset) is
|
||||
// what guarantees the AZ-488 and AZ-810 suites don't collide on the
|
||||
// per-source UNIQUE index when both run against the same DB. Wrap on
|
||||
// 40_000-cell axes so the result always stays strictly inside the
|
||||
// OSM-valid ranges enforced by UavTileMetadataValidator:
|
||||
// lat in [-70.0, -50.0), lon in [-40.0, -10.0).
|
||||
private static int _coordinateCounter = (int)((DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond) % 1_000_000);
|
||||
|
||||
private static (double Latitude, double Longitude) NextTestCoordinate()
|
||||
{
|
||||
var n = Interlocked.Increment(ref _coordinateCounter);
|
||||
var lat = -50.0 - ((uint)n % 40_000u) * 0.0005;
|
||||
var lon = -10.0 - ((uint)n % 60_000u) * 0.0005;
|
||||
return (lat, lon);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user