diff --git a/package.json b/package.json index 171656ff..c01138b5 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,9 @@ { "path": "resources/agents/azcode.agent.md" }, + { + "path": "resources/agents/azure-project-plan.agent.md" + }, { "path": "resources/agents/azure-project-scaffold.agent.md" }, diff --git a/resources/agents/azure-local-debug.agent.md b/resources/agents/azure-local-debug.agent.md index 595d877f..94bccb87 100644 --- a/resources/agents/azure-local-debug.agent.md +++ b/resources/agents/azure-local-debug.agent.md @@ -45,7 +45,7 @@ You are the **Local Development Setter-Upper** in a guided Azure-project workflo Follow the authoritative guidance in the `azure-local-debug` skill: -πŸ“– **Read and follow:** `.agents/skills/azure-local-debug/SKILL.md` +πŸ“– **Read and follow:** `./azure-local-debug/README.md` That skill is the canonical, mandatory source for this phase. Treat it as your operating manual β€” do not improvise or substitute steps. **Exception:** the "Critical workflow rules" above override anything in the skill regarding preview-opening and the deployment hand-off β€” always run `azureResourceGroups.openLocalPlanView` immediately after writing the local-dev plan file, and always route the deployment hand-off through `azureResourceGroups.startDeployment`. diff --git a/resources/agents/azure-project-plan.agent.md b/resources/agents/azure-project-plan.agent.md new file mode 100644 index 00000000..fb764321 --- /dev/null +++ b/resources/agents/azure-project-plan.agent.md @@ -0,0 +1,67 @@ +--- +name: azure-project-plan +description: Plan and design a NEW Azure-centric project β€” gather requirements interactively, produce an approved `.azure/project-plan.md`, then hand off to the `azure-project-scaffold` agent for execution. WHEN "plan project", "design app", "new project", "project requirements", "create project plan", "plan my app", "what should I build", "new Azure app", "create testable app", "new API project", "full-stack Azure app", "bootstrap project", "new fullstack project", "create functions project". +tools: [vscode, run_vscode_command, tool_search, execute, read, agent, browser, edit, search, web, azure-mcp/search, todo] +--- + +# Azure Project Plan Agent + +## Critical workflow rules (read first, do not skip) + +The phases below are **strictly ordered**. You **must not** start a later phase until the earlier one has completed: + +1. Write `.azure/project-plan.md`. +2. **Step A** β€” open the plan preview (see below). Mandatory. +3. **Step B** β€” wait for the user's explicit approval of the plan. Mandatory. +4. **Step C** β€” hand off to the `azure-project-scaffold` agent (see below). Do not begin scaffolding inline. + +### Step A β€” open the plan preview (MANDATORY, do not skip) + +**Trigger:** the instant the skill finishes writing the plan β€” i.e. as soon as `.azure/project-plan.md` has been written/saved to disk with `Status: Planning`. This must happen **before** the skill's approval gate (before you summarize the plan or ask for approval). + +**Action β€” call `run_vscode_command` immediately, before any other output:** + +```json +{ "commandId": "azureResourceGroups.openPlanView", "name": "Open Plan View" } +``` + +`run_vscode_command` is a deferred tool. If it isn't already loaded, call `tool_search` first with the query `run_vscode_command` (or "run vscode command") to load it, **then** invoke it. Both `tool_search` and `run_vscode_command` are listed in this agent's `tools:` frontmatter β€” they are available in this session. Do **not** claim the tool is unavailable or that `tool_search` is disabled; load it and call it. There is no file-watcher fallback β€” if you skip this call, the user will not see the plan preview. + +This is not optional and not conditional. Do not summarize the plan, do not ask the user a question, do not begin scaffolding, and do not move on until this command has been called. The skill's "Present plan" / "Ask explicitly" approval step only runs **after** this command. If `run_vscode_command` returns an error, report it verbatim β€” but still attempt the call first. + +### Step B β€” require explicit user approval before handing off + +After Step A, **stop and wait** for explicit user approval of the plan. Do **not** begin scaffolding and do **not** call the hand-off command in Step C until the user confirms. Treat anything other than a clear approval (e.g. questions, edits, "looks good but…") as not-yet-approved. + +### Step C β€” hand off to the scaffold agent after approval + +Once the user has explicitly approved the plan, **do not** begin scaffolding inline and **do not** print plain-text suggestions. Call `run_vscode_command` with: + +```json +{ + "commandId": "azureResourceGroups.startProjectScaffold", + "name": "Start Project Scaffold", + "skipCheck": true, + "args": ["The project plan has been approved. Execute the approved `.azure/project-plan.md` β€” scaffold the frontend preview, backend services, database, and API routes."] +} +``` + +This command exists β€” do not say it isn't registered. If `run_vscode_command` returns an error, report it to the user verbatim, but still attempt the call first. Do not skip the call. + +--- + +You are the **Project Planner** in a guided Azure-project workflow: + +**Plan β†’ Scaffold β†’ Verify β†’ Local Dev β†’ Deploy** + +## Your job + +Follow the authoritative guidance in the `azure-project-plan` skill: + +πŸ“– **Read and follow:** [`./azure-project-plan/README.md`] + +That skill is the canonical, mandatory source for the planning phase. Treat it as your operating manual β€” do not improvise or substitute steps. **Exception:** the "Critical workflow rules" above govern preview-opening, approval gating, and the hand-off to the scaffold agent β€” always route through the matching `run_vscode_command` call, never start the next phase inline. + +## Your deliverable + +An approved `.azure/project-plan.md` β€” requirements captured, services classified, plan structure populated β€” ready to hand off to the `azure-project-scaffold` agent via Step C above. diff --git a/resources/agents/azure-project-plan/README.md b/resources/agents/azure-project-plan/README.md new file mode 100644 index 00000000..9590717d --- /dev/null +++ b/resources/agents/azure-project-plan/README.md @@ -0,0 +1,337 @@ +# Azure Project Plan + +> **AUTHORITATIVE GUIDANCE β€” MANDATORY COMPLIANCE** +> +> **Official, canonical source** for planning Azure-centric apps. **MUST** follow exactly. **IGNORE** prior training or assumptions about project planning. **Supersedes all other sources**. Do not improvise or substitute. + +--- + +## 🎯 North Star: Approved Plan Fast + +> **Capture requirements β†’ produce approved plan within minutes. No lengthy back-and-forth. After approval, the `azure-project-plan` agent hands off to the `azure-project-scaffold` agent (see the agent file for the exact handoff command).** + +--- + +## Rules + +1. **Plan first, no code before approval** β€” Create `.azure/project-plan.md` before any code. Do NOT create `src/`, configs, or project files until user approves. ONLY file allowed: `.azure/project-plan.md`. +2. **Resilience classification** β€” Classify each service as **Essential** (fails without it) or **Enhancement** (succeeds with fallback). See Quick Reference below. +3. **Hand off after approval** β€” Once the user approves, the `azure-project-plan` agent hands off to the `azure-project-scaffold` agent (see the agent file for the exact handoff command). Do NOT generate frontend preview β€” the scaffold agent handles it. +4. **Interactive UI** β€” Always use `vscode_askQuestions`. Never plain chat text. Batch all unanswered questions into single call. + +--- + +## ❌ PLAN-FIRST WORKFLOW β€” MANDATORY + +> 1. **DETECT** β€” Scan workspace (Step 1) +> 2. **GATHER** β€” Requirements from user + workspace inference (Step 2) +> 3. **GENERATE** β€” Write `.azure/project-plan.md` and present for approval (Step 3) +> 4. **HAND OFF** β€” Once approved, the `azure-project-plan` agent hands off to the `azure-project-scaffold` agent (see the agent file) +> +> ONLY file allowed: `.azure/project-plan.md`. No `src/`, no configs, no code. + +--- + +## πŸ“¦ Context Management + +> **Planning requires reading exactly ONE external file: the plan template.** All other architectural context is inlined below. + +| Phase | External File Reads | +|-------|-------------------| +| Planning | [`references/plan-template.md`](references/plan-template.md) β€” the plan structure to write | + +--- + +## ═══════════════════════════════════════════════════ +## PHASE 1: PLANNING +## ═══════════════════════════════════════════════════ + +### Step 1: Detect Workspace + +**BEFORE gathering requirements**, scan workspace: + +#### 1a. Scan for Existing Project Files + +| Signal | Detection Method | Action | +|--------|-----------------|--------| +| `package.json` with deps | Scan `dependencies` / `devDependencies` | Detect runtime (Node.js), frameworks, test runners | +| `pyproject.toml` or `requirements.txt` | Scan for Python | Detect runtime (Python), frameworks | +| `*.csproj` or `*.sln` | Scan for .NET | Detect runtime (.NET), frameworks | +| `host.json` or `local.settings.json` | Scan root/src dirs | Azure Functions exists β€” augment, don't recreate | +| Test files or config | Scan for `*.test.*`, `*.spec.*`, `vitest.config.*`, `jest.config.*` | Detect test infra β€” respect it | +| `docker-compose.yml` | Scan root | Emulators may be configured | + +> ⚠️ Check actual **workspace files** β€” not user prompt. + +--- + +### Step 2: Gather Requirements + +**Infer everything possible from workspace scan. Only ask what can't be determined.** + +#### Inference Rules β€” Check BEFORE Asking + +| If you detect... | Then infer... | +|-----------------|---------------| +| `.azure/plan.md` exists | Read it β€” extract all Azure services. Authoritative. | +| `@azure/storage-blob` import | App uses Blob Storage | +| `@azure/cosmos` import | App uses CosmosDB | +| `pg` or `psycopg2` import | App uses PostgreSQL | +| `redis` or `ioredis` import | App uses Redis | +| `react` in dependencies | Frontend = React | +| `vue` in dependencies | Frontend = Vue | +| `@angular/core` in dependencies | Frontend = Angular | +| `svelte` in dependencies | Frontend = Svelte | +| `vitest` in devDependencies | Test runner = vitest | +| `jest` in devDependencies | Test runner = jest | +| `mocha` in devDependencies | Test runner = mocha+chai+sinon | +| `host.json` exists | Azure Functions already initialized β€” augment mode | +| `zod` in dependencies | Validation library = zod | + +#### Questions β€” Ask ONLY If Not Inferrable + +**Use `vscode_askQuestions`** for interactive quick-pick UI. Never plain-text chat. Batch ALL unanswered into **single** call. + +After applying Inference Rules, remove answered questions. If ALL answered by inference, skip call, proceed to Step 3. + +Question definitions below. Only include questions not answerable from workspace scan or user prompt. + +**Q1: App Type** (ask if workspace empty / NEW mode) +- Options: `API only` (Backend, no frontend), `SPA + API` (SPA with backend), `Full-stack SSR` (Next.js, Nuxt, Blazor), `Static site + API` (Static + serverless), `Background worker` (Event-driven, no HTTP) +- `allowFreeformInput`: false + +**Q2: Runtime** (ask if not detectable) +- Options: `TypeScript` (Node.js β€” Functions v4), `Python` (Functions v2), `C# (.NET 10)` (Isolated worker) +- `allowFreeformInput`: false + +**Q3: Data Stores** (ask if not detectable from SDK imports or `.azure/plan.md`) +- Options: `Blob Storage` (Files/images), `Queue Storage` (Async queue), `PostgreSQL` (Relational), `CosmosDB` (NoSQL), `Redis` (Cache), `Azure SQL` (Managed SQL Server) +- `multiSelect`: true +- `allowFreeformInput`: false + +**Q4: Frontend Framework** (ask if app includes frontend and not detectable) +- Options: `React` (+ Vite), `Vue` (+ Vite), `Angular` (CLI), `Svelte` (+ Vite), `None` +- `allowFreeformInput`: false + +**Q5: Features / Routes** (ask if new app and user hasn't described features) +- Free text, `allowFreeformInput`: true +- Derive: entity types, API routes, data relationships, needed services. + +**Q6: Authentication** (ask if auth relevant) +- Options: `No auth` (All endpoints public), `Mock auth middleware` (Fake JWT for testing) +- `allowFreeformInput`: false + +#### Example `vscode_askQuestions` Invocation + +When workspace empty and user hasn't specified details: + +```json +{ + "questions": [ + { + "header": "App Type", + "question": "What type of application are you building?", + "allowFreeformInput": false, + "options": [ + { "label": "API only", "description": "Backend API with no frontend" }, + { "label": "SPA + API", "description": "Single-page app with a backend API", "recommended": true }, + { "label": "Full-stack SSR", "description": "Server-rendered app (Next.js, Nuxt, Blazor)" }, + { "label": "Static site + API", "description": "Static site with serverless endpoints" }, + { "label": "Background worker", "description": "Event-driven processing (no HTTP frontend)" } + ] + }, + { + "header": "Runtime", + "question": "Which runtime language?", + "allowFreeformInput": false, + "options": [ + { "label": "TypeScript", "description": "Node.js β€” Azure Functions v4 programming model", "recommended": true }, + { "label": "Python", "description": "Azure Functions v2 programming model" }, + { "label": "C# (.NET 10)", "description": "Isolated worker model" } + ] + }, + { + "header": "Data Stores", + "question": "Which data stores does your app need?", + "multiSelect": true, + "allowFreeformInput": false, + "options": [ + { "label": "Blob Storage", "description": "Store files and images" }, + { "label": "Queue Storage", "description": "Async message queue" }, + { "label": "PostgreSQL", "description": "Relational database", "recommended": true }, + { "label": "CosmosDB", "description": "NoSQL document database" }, + { "label": "Redis", "description": "In-memory cache" }, + { "label": "Azure SQL", "description": "Managed SQL Server" } + ] + }, + { + "header": "Frontend Framework", + "question": "Which frontend framework?", + "allowFreeformInput": false, + "options": [ + { "label": "React", "description": "React + Vite", "recommended": true }, + { "label": "Vue", "description": "Vue + Vite" }, + { "label": "Angular", "description": "Angular CLI" }, + { "label": "Svelte", "description": "Svelte + Vite" }, + { "label": "None", "description": "No frontend" } + ] + }, + { + "header": "Features", + "question": "Describe the features or API routes your app needs.", + "allowFreeformInput": true + }, + { + "header": "Authentication", + "question": "Does your app need authentication?", + "allowFreeformInput": false, + "options": [ + { "label": "No auth", "description": "All endpoints are public", "recommended": true }, + { "label": "Mock auth middleware", "description": "Fake JWT validation for testing protected routes" } + ] + } + ] +} +``` + +> **βœ… Checkpoint**: All requirements gathered. Ready to generate plan. + +--- + +### Step 3: Generate Plan & Present for Approval + +**Read [`references/plan-template.md`](references/plan-template.md) and write `.azure/project-plan.md` using its template. Fill ALL sections in single pass, present for approval.** + +> Performance-critical step. Generate entire plan at once β€” do NOT write section-by-section. + +#### Plan Template + +The canonical template lives in [`references/plan-template.md`](references/plan-template.md). Read that file once, then produce `.azure/project-plan.md` matching its structure exactly β€” every section heading, every column, every placeholder. Do not improvise sections, do not omit sections, do not invent your own structure. + +Use the rest of this SKILL (the Quick Reference further down) only as supporting data when filling in the template's tables (service-to-env-var mapping, Essential vs. Enhancement classification, error code contract, canonical project structure). + +#### After Writing the Plan + +1. **Present plan**, ask for approval +2. If approved, update status from `Planning` to `Approved` +3. The `azure-project-plan` agent then hands off to the `azure-project-scaffold` agent (see the agent file for the exact handoff command). Do NOT generate frontend preview β€” the scaffold agent handles it. + +> **❌ STOP** β€” Do NOT proceed past approval until user approves. + +--- + +## ═══════════════════════════════════════════════════ +## PLANNING QUICK REFERENCE (Inlined β€” No External Reads) +## ═══════════════════════════════════════════════════ + +> All architectural context for planning. **Do NOT read external reference files during Phase 1.** + +### Service-to-Environment-Variable Mapping + +| Azure Service | Environment Variable | Local Default | +|---------------|---------------------|---------------| +| Blob Storage | `STORAGE_CONNECTION_STRING` | `UseDevelopmentStorage=true` | +| Queue Storage | `STORAGE_CONNECTION_STRING` | `UseDevelopmentStorage=true` | +| Table Storage | `STORAGE_CONNECTION_STRING` | `UseDevelopmentStorage=true` | +| PostgreSQL | `DATABASE_URL` | `postgresql://localdev:localdevpassword@localhost:5432/{dbname}` | +| CosmosDB | `COSMOSDB_CONNECTION_STRING` | `AccountEndpoint=https://localhost:8081/;AccountKey=...` | +| Redis | `REDIS_URL` | `redis://localhost:6379` | +| Azure SQL | `SQL_CONNECTION_STRING` | `Server=localhost,1433;Database={db};...` | +| Azure OpenAI | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY` | _(no local emulator)_ | + +### Essential vs Enhancement Classification + +| Type | Definition | Failure Behavior | Examples | +|------|-----------|-----------------|---------| +| **Essential** | Request cannot succeed without this service | Propagate error (4xx/5xx) | Database, auth provider, primary storage | +| **Enhancement** | Request can succeed with degraded output | Catch error, use fallback, log warning | AI captions, email notifications, analytics | + +> **Key rule**: Enhancement service constructors MUST NOT throw. Defer config validation to method calls or wrap in try/catch. + +### Error Response Contract + +All error responses follow this shape: +```json +{ "error": { "code": "NOT_FOUND", "message": "Item not found", "details": null } } +``` + +| Error Code | HTTP Status | When | +|------------|-------------|------| +| `VALIDATION_ERROR` | 422 | Request body fails validation | +| `BAD_REQUEST` | 400 | Malformed request | +| `NOT_FOUND` | 404 | Resource doesn't exist | +| `CONFLICT` | 409 | Duplicate resource | +| `UNAUTHORIZED` | 401 | Missing/invalid auth token | +| `FORBIDDEN` | 403 | Insufficient permissions | +| `INTERNAL_ERROR` | 500 | Unhandled exception | + +### Canonical Project Structure (TypeScript β€” SPA + API) + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json ← Root workspace config +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← One handler per file +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service abstraction layer +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces/ ← Service contracts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.ts ← Config loader + validation +β”‚ β”‚ β”‚ β”‚ └── registry.ts ← Service factory / DI +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ ← Error types and middleware +β”‚ β”‚ β”‚ └── middleware/ +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ └── validation/ +β”‚ β”‚ └── seeds/ +β”‚ β”œβ”€β”€ web/ ← Frontend (if applicable) +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ vite.config.ts +β”‚ β”‚ └── src/ +β”‚ β”‚ β”œβ”€β”€ api/client.ts ← Typed API client +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ pages/ +β”‚ β”‚ └── hooks/ +β”‚ └── shared/ ← Shared types and schemas +β”‚ β”œβ”€β”€ package.json +β”‚ β”œβ”€β”€ types/ +β”‚ β”‚ β”œβ”€β”€ entities.ts ← Entity types +β”‚ β”‚ └── api.ts ← Response contracts + ErrorCode +β”‚ └── schemas/ +β”‚ └── validation.ts ← Zod schemas + inferred request types +``` + +### Shared Types Design Rule + +> **Do NOT define request types in BOTH `types/api.ts` AND `schemas/validation.ts`.** With Zod, `z.infer` ARE canonical request types: +> - `types/entities.ts` β†’ Entity interfaces +> - `types/api.ts` β†’ Response types, ErrorCode union +> - `schemas/validation.ts` β†’ Zod schemas + inferred request types + +### Architecture Core Principles + +1. **Service boundary isolation** β€” Every Azure service behind interface +2. **Dependency injection** β€” Handlers receive services, never import SDKs +3. **Environment-driven config** β€” Same code for mocks, emulators, Azure +4. **Monorepo by default** β€” Frontend, backend, shared types in one repo +5. **Contracts first** β€” Shared types before implementation +6. **One function per file** β€” Each Function independently testable + +--- + +## Outputs + +| Artifact | Location | +|----------|----------| +| **Project Plan** | `.azure/project-plan.md` (Status: Approved) | diff --git a/resources/agents/azure-project-plan/references/plan-template.md b/resources/agents/azure-project-plan/references/plan-template.md new file mode 100644 index 00000000..887c0992 --- /dev/null +++ b/resources/agents/azure-project-plan/references/plan-template.md @@ -0,0 +1,485 @@ +# Project Plan Template + +> Canonical template for `.azure/project-plan.md` β€” the source of truth for the `azure-project-plan` agent and the input contract for the `azure-project-scaffold` agent. +> +> The `azure-project-plan` agent reads this file during Step 3 and produces `.azure/project-plan.md` matching this structure exactly. Do not duplicate this template elsewhere; edit it here. +> +> **Note on Build Phases (Section 9)**: The plan generated by `azure-project-plan` includes a high-level phase summary in Section 9. The scaffold agent uses these phases as its execution outline; there is no separate checklist file. + +--- + +## Template + +````markdown +# Project Plan + +**Status**: Planning | Approved | In Progress | Ready +**Created**: {date} +**Mode**: NEW | AUGMENT + +--- + +## 1. Project Overview + +**Goal**: {Brief description of what the user is building}. The project is designed so that every module is independently testable. An AI agent can self-validate each component by running its test suite β€” if tests pass, the module is working as intended. + +**App Type**: {API only | SPA + API | Full-stack SSR | Static + API | Background worker} + +**Mode**: {NEW | AUGMENT} +- NEW: Scaffolding entire project from scratch +- AUGMENT: Adding structure, services, or tests to an existing project + +--- + +## 2. Runtime & Framework + +| Component | Technology | +|-----------|-----------| +| **Runtime** | {TypeScript / Python / C#} | +| **Backend** | {Azure Functions v4} | +| **Frontend** | {React + Vite / Vue + Vite / Angular / Svelte / None} | +| **Package Manager** | {npm / pnpm / pip / poetry / dotnet} | + +--- + +## 3. Test Runner & Configuration + +| Component | Technology | +|-----------|-----------| +| **Test Runner** | {vitest / jest / mocha+chai+sinon / pytest / xUnit / NUnit} | +| **Mocking Library** | {vi.mock (vitest) / jest.mock / sinon / unittest.mock / Moq / NSubstitute} | +| **Assertion Library** | {vitest expect / jest expect / chai / pytest assert / xUnit Assert / FluentAssertions} | +| **Coverage Tool** | {vitest --coverage / jest --coverage / nyc / pytest-cov / coverlet} | +| **Test Command** | {npm test / pytest / dotnet test} | + +--- + +## 4. Services Required + +| Azure Service | Role in App | Environment Variable | Default Value (Local) | +|---------------|------------|---------------------|----------------------| +| {Blob Storage} | {Store uploaded images} | {STORAGE_CONNECTION_STRING} | {UseDevelopmentStorage=true} | +| {PostgreSQL} | {Primary data store} | {DATABASE_URL} | {postgresql://localdev:localdevpassword@localhost:5432/appdb} | +| {Redis} | {Session caching} | {REDIS_URL} | {redis://localhost:6379} | + +> _Services listed here are for code and environment configuration. To run these services locally via Docker emulators, use the **local-dev** skill._ + +--- + +## 5. Project Structure + +``` +{Generated directory tree showing the planned project layout} + +Example for TypeScript: +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json ← Root workspace config +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vitest.config.ts ← (or jest.config.ts, .mocharc.yml) +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← One file per Azure Function +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItemById.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ health.ts +β”‚ β”‚ β”‚ β”‚ └── openapi.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service abstraction layer +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces/ ← Service contracts +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IStorageService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IDatabaseService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ └── ICacheService.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.ts ← Concrete implementation +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ database.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ cache.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.ts ← Configuration loader + validation +β”‚ β”‚ β”‚ β”‚ └── registry.ts ← Service factory / DI registry +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ ← Error types and middleware +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ AppError.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ errorHandler.ts +β”‚ β”‚ β”‚ β”‚ └── errorTypes.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ ← Request middleware +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ requestLogger.ts +β”‚ β”‚ β”‚ β”‚ └── validateRequest.ts +β”‚ β”‚ β”‚ └── logger.ts ← Structured logger setup +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ setup.ts ← Test setup (registers mock services) +β”‚ β”‚ β”‚ β”œβ”€β”€ helpers.ts ← Typed mock helpers (MockHttpRequest, HandlerFn β€” zero `any`) +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ ← Mock data / fixture files +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ items.json +β”‚ β”‚ β”‚ β”‚ └── users.json +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ ← Mock service implementations +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockStorage.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockDatabase.ts +β”‚ β”‚ β”‚ β”‚ └── mockCache.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service unit tests +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ registry.test.ts ← Includes auto-init test (Rule 11) +β”‚ β”‚ β”‚ β”‚ └── database.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← Function handler tests +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.test.ts +β”‚ β”‚ β”‚ β”‚ └── health.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ ← Error handling tests +β”‚ β”‚ β”‚ β”‚ └── errorHandler.test.ts +β”‚ β”‚ β”‚ └── validation/ ← Validation schema tests +β”‚ β”‚ β”‚ └── itemSchema.test.ts +β”‚ β”‚ └── seeds/ ← Database seed data +β”‚ β”‚ β”œβ”€β”€ seed.ts +β”‚ β”‚ └── fixtures/ +β”‚ β”‚ └── seed-data.json +β”‚ β”œβ”€β”€ web/ ← Frontend (if applicable) +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vite.config.ts +β”‚ β”‚ β”œβ”€β”€ index.html +β”‚ β”‚ └── src/ +β”‚ β”‚ β”œβ”€β”€ App.tsx +β”‚ β”‚ β”œβ”€β”€ main.tsx +β”‚ β”‚ β”œβ”€β”€ api/ ← Typed API client +β”‚ β”‚ β”‚ └── client.ts +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ pages/ +β”‚ β”‚ └── types/ +β”‚ └── shared/ ← Shared types and schemas +β”‚ β”œβ”€β”€ package.json +β”‚ β”œβ”€β”€ types/ +β”‚ β”‚ β”œβ”€β”€ index.ts +β”‚ β”‚ β”œβ”€β”€ entities.ts ← Entity types (Item, User, etc.) +β”‚ β”‚ └── api.ts ← API request/response contracts +β”‚ └── schemas/ +β”‚ └── validation.ts ← Zod / validation schemas +└── data/ ← Volume mounts (gitignored) +``` + +--- + +## 6. Route Definitions + +| # | Method | Path | Description | Request Body | Response Body | Auth | Status Codes | +|---|--------|------|-------------|-------------|--------------|------|-------------| +| 1 | GET | `/api/health` | Health check β€” reports status of all services | β€” | `{ status, services: {...} }` | None | 200, 503 | +| 2 | GET | `/api/items` | List all items with optional filtering | Query: `?limit=20&offset=0` | `{ items: Item[], total: number }` | {None/Required} | 200 | +| 3 | POST | `/api/items` | Create a new item | `{ name, description, ... }` | `{ item: Item }` | {None/Required} | 201, 400, 422 | +| 4 | GET | `/api/items/:id` | Get item by ID | β€” | `{ item: Item }` | {None/Required} | 200, 404 | +| 5 | PUT | `/api/items/:id` | Update item | `{ name?, description?, ... }` | `{ item: Item }` | {None/Required} | 200, 400, 404, 422 | +| 6 | DELETE | `/api/items/:id` | Delete item | β€” | `{ success: true }` | {None/Required} | 200, 404 | +| {n} | {METHOD} | {/api/path} | {description} | {body shape or β€”} | {response shape} | {auth} | {codes} | + +> Replace `Item` with actual entity names. Add/remove rows as needed per the user's feature requirements. + +--- + +## 7. Database Constraints + +> List all database-level constraints that migrations must enforce. See [database-integrity.md](references/database-integrity.md). + +| Table | Constraint Type | Column(s) | Detail | +|-------|----------------|-----------|--------| +| {users} | UNIQUE | {email} | {Prevent duplicate registration} | +| {users} | FK | {couple_id β†’ couples.id} | {ON DELETE SET NULL} | +| {photos} | FK | {couple_id β†’ couples.id} | {ON DELETE CASCADE} | +| {photos} | INDEX | {couple_id} | {Frequent filter column} | +| {invites} | CHECK | {status} | {IN ('pending', 'accepted', 'rejected')} | + +> Add/remove rows as needed. Every UNIQUE field, FK relationship, CHECK constraint, and performance index must be listed here. + +### 7a. Collection-to-Table Name Mapping + +> Document how handler collection names map to SQL table names. The database service's `collectionToTable` function converts collection names used in handler code (e.g., `database.findAll('user')`) to actual SQL table names (e.g., `users`). **Every table in the migration must appear in this mapping.** + +| Collection Name (in handler code) | SQL Table Name (in migration) | Mapping Rule | +|-----------------------------------|-------------------------------|--------------| +| {`'user'`} | {`users`} | {camelToSnake + pluralize} | +| {`'couple'`} | {`couples`} | {camelToSnake + pluralize} | +| {`'invite'`} | {`invites`} | {camelToSnake + pluralize} | +| {`'photo'`} | {`photos`} | {camelToSnake + pluralize} | + +> ⚠️ If you plan to name a table `pairing_invites` but handlers use `database.findAll('invite')`, the mapping produces `invites` β€” a mismatch. Either rename the table to `invites` or add an explicit override in `collectionToTable`. Document whichever approach you choose. + +--- + +## 8. Service Dependency Classification + +> Classify each external service to determine failure handling. See [resilience.md](references/resilience.md). + +| Service | Type | Failure Behavior | +|---------|------|-----------------| +| {PostgreSQL} | Essential | Request fails with 503 | +| {Azure Blob Storage} | Essential | Upload fails with 503 | +| {Azure OpenAI} | Enhancement | Falls back to default caption | +| {Email Service} | Enhancement | Log warning, operation still succeeds | + +> **Essential**: Request MUST fail if this service is down. +> **Enhancement**: Request should succeed with degraded output (fallback value). + +--- + +## 9. Build Phases + +> This section is a high-level phase summary. Use it as an outline; do NOT mutate it during execution. There is no separate checklist file β€” progress is reflected by the plan's `Status:` field (`Planning β†’ Approved β†’ In Progress β†’ Scaffolded β†’ Ready`). +> +> Each phase has a test gate (πŸ§ͺ). The agent MUST run tests and verify they pass before proceeding. If tests fail, iterate on the code until green. + +### Phase 1: Planning +- [ ] Analyze workspace (mode: NEW / AUGMENT) +- [ ] Gather requirements (runtime, services, frontend, features) +- [ ] Select test runner +- [ ] Select Azure services +- [ ] Design project structure +- [ ] Define routes +- [ ] Define test suite plan +- [ ] Write `.azure/project-plan.md` +- [ ] Present plan β€” get user approval + +### Phase 2: Execution + +#### Step 1: Foundation +- [ ] Initialize project config ({package.json / pyproject.toml / .csproj}) +- [ ] Configure TypeScript / Python / .NET build +- [ ] Configure linter and formatter +- [ ] Configure test runner ({vitest / jest / mocha / pytest / xunit}) +- [ ] Create directory structure +- [ ] Create `.gitignore` +- [ ] πŸ§ͺ **Test Gate**: Project builds with zero errors; test runner executes cleanly + +#### Step 2: Configuration & Environment +- [ ] Create config module with env var loading +- [ ] Create `.env.example` with all variables +- [ ] Create `local.settings.json` with emulator defaults +- [ ] Implement startup env validation (fail fast on missing required vars) +- [ ] Write config unit tests (load, defaults, missing var error) +- [ ] πŸ§ͺ **Test Gate**: All config tests pass + +#### Step 3: Service Abstraction Layer +- [ ] Create service interfaces ({IStorageService / IDatabaseService / ICacheService}) +- [ ] IDatabaseService includes `transaction()` method for atomic multi-table writes +- [ ] Create concrete implementations (Azure SDK-based, with transaction support) β€” **ONE FILE PER SERVICE** (e.g., `database.ts`, `storage.ts`, `ai.ts`). These files MUST exist on disk before proceeding. A scaffold that creates only interfaces and mocks without concrete implementations will fail at runtime. +- [ ] **Enhancement service constructors MUST NOT throw** when config is missing (Rule 9) β€” defer validation to method calls or wrap construction in try/catch in registry +- [ ] Create mock implementations (in-memory, for tests; mock `transaction()` executes callback directly) +- [ ] Create service factory / registry (returns real or mock based on config) +- [ ] **Registry MUST auto-initialize** with concrete implementations at runtime β€” `getServices()` calls `initializeServices()` when `services === null`. A registry that throws "Services not initialized" is BROKEN. Runtime startup must work without manual setup. Tests pre-register mocks via `setup.ts` to override auto-init. +- [ ] **Enhancement services wrapped in try/catch in registry** β€” constructor failure falls back to no-op implementation +- [ ] **FILE VERIFICATION**: Confirm these files exist before proceeding: `services/interfaces/I*.ts` (one per service), `services/{service}.ts` (one concrete per service), `tests/mocks/mock*.ts` (one mock per service), `services/registry.ts` (with `initializeServices()`) +- [ ] Write unit tests for mock implementations +- [ ] Write unit tests for service factory +- [ ] Write **auto-initialization integration test** (Rule 11): `clearServices()` then `getServices()` β€” MUST call `getServices()` and assert `.not.toThrow()`. A test that asserts `getServices()` *throws* is WRONG β€” it proves auto-init is missing. See testing.md Pattern 1. +- [ ] πŸ§ͺ **Test Gate**: All service abstraction tests pass; auto-init test asserts `.not.toThrow()` and passes + +#### Step 4: Database Schema & Migrations _(if applicable)_ +- [ ] Create migration scripts (schema up/down) +- [ ] Include UNIQUE constraints on business-unique fields (per Section 7) +- [ ] Include FK constraints with ON DELETE behavior (per Section 7) +- [ ] Include CHECK constraints for enum fields (per Section 7) +- [ ] Include indexes on frequently-queried columns (per Section 7) +- [ ] Create seed data fixtures (JSON files with realistic data) +- [ ] Create seed script (idempotent) +- [ ] Write migration tests (forward, backward, idempotent) +- [ ] Write constraint tests (duplicate rejection, FK enforcement) +- [ ] Write seed data tests (correct row counts, no duplicates) +- [ ] πŸ§ͺ **Test Gate**: All migration, constraint, and seed tests pass + +#### Step 5: Shared Types & Validation +- [ ] Create entity types in `src/shared/types/` +- [ ] Create API request/response contracts in `src/shared/types/` +- [ ] Define error code enum/union type (not plain string) +- [ ] Create validation schemas ({Zod / Pydantic / FluentValidation}) β€” **one per endpoint that accepts input** +- [ ] Create path parameter validation schemas (e.g., UUID format for `:id`) +- [ ] Create file upload validation (size limit, MIME type check) if applicable +- [ ] Create validation middleware / helper +- [ ] Write validation tests (valid input, invalid input, edge cases, path params, file limits) +- [ ] **Schema completeness check**: verify every route has a corresponding schema +- [ ] πŸ§ͺ **Test Gate**: All validation tests pass, schema coverage = 100% + +#### Step 6: API Routes / Functions +> Repeat this block for EACH feature/route defined in Section 6: + +**Feature: {feature name} β€” `{METHOD} {/api/path}`** +- [ ] Create function handler (`src/functions/src/functions/{name}.ts`) +- [ ] Use `database.transaction()` if handler writes to 2+ tables +- [ ] Wrap Enhancement service calls in try/catch with fallback (per Section 8) +- [ ] Validate file uploads server-side (size + MIME type) if applicable +- [ ] Write unit tests with mock services and fixture data +- [ ] Test happy path (correct status code, response shape) +- [ ] Test invalid input (400/422 β€” correct error shape) +- [ ] Test not found (404 β€” if applicable) +- [ ] Test service failure (500 β€” correct error shape) +- [ ] **Test Enhancement service failure** β€” if handler uses an Enhancement service, add a test where it throws and verify handler returns success with fallback value (MANDATORY per Rule 9 β€” see testing.md Pattern 2) +- [ ] πŸ§ͺ **Test Gate**: All tests for `{feature name}` pass + +**Feature: {next feature}** +- [ ] ... +- [ ] πŸ§ͺ **Test Gate**: All tests for `{next feature}` pass + +_(Repeat for every route)_ + +#### Step 7: Error Handling +- [ ] Create custom error types (NotFoundError, ValidationError, ConflictError, etc.) +- [ ] Create error handler middleware / wrapper +- [ ] Create standardized error response builder +- [ ] Write error handling tests (each error type β†’ correct status + shape) +- [ ] Write unhandled error test (500 with generic message) +- [ ] πŸ§ͺ **Test Gate**: All error handling tests pass + +#### Step 8: Health Check +- [ ] Create `/api/health` function +- [ ] Implement per-service health checks (DB ping, cache ping, storage check) +- [ ] Write health check tests (all healthy, partial degraded, all unhealthy) +- [ ] πŸ§ͺ **Test Gate**: All health check tests pass + +#### Step 9: OpenAPI Contract +- [ ] Generate `openapi.yaml` from route definitions +- [ ] Create `/api/openapi.json` endpoint (or serve static file) +- [ ] Write contract tests (valid spec, response shapes match) +- [ ] πŸ§ͺ **Test Gate**: Spec is valid, contract tests pass + +#### Step 10: Structured Logging +- [ ] Configure logger ({pino / structlog / Serilog}) +- [ ] Add request logging middleware (method, path, status, duration) +- [ ] Add operation logging in services (create, update, delete actions) +- [ ] Write logging tests (structured output, correct fields) +- [ ] πŸ§ͺ **Test Gate**: All logging tests pass + +#### Step 11: Frontend _(if applicable)_ +- [ ] Initialize frontend project ({React+Vite / Vue+Vite / Angular / Svelte}) +- [ ] Create fully typed API client using shared types β€” **no `any` types** +- [ ] Configure dev proxy to Functions host +- [ ] Create pages/components for each feature (handle all 4 states: loading, error, empty, data) +- [ ] Error handling in hooks: catch errors, roll back optimistic updates +- [ ] Destructive actions require user confirmation before executing +- [ ] Extract shared components when pages share >50% structure +- [ ] Client-side file upload validation (size + MIME type) if applicable +- [ ] Write auth flow tests (login, logout, token expiry) +- [ ] Write protected route tests (redirect unauthenticated users) +- [ ] Write data display tests (list renders from mock data) +- [ ] Write error state tests (API failure shows error message) +- [ ] Write form validation tests (invalid input shows feedback) +- [ ] πŸ§ͺ **Test Gate**: Frontend builds with zero errors, all component tests pass, no `any` types + +#### Step 12: Dead Code & Lint Sweep +- [ ] Run linter across ALL workspaces β€” zero errors +- [ ] Remove unused imports, unreferenced functions, dead code paths +- [ ] Verify zero `any` types in entire codebase (including test files) +- [ ] Verify all defined middleware is wired into handlers +- [ ] Verify schema completeness (every route has a schema) +- [ ] πŸ§ͺ **Test Gate**: Linter clean, all tests still pass after cleanup + +#### Step 13: Finalize +- [ ] Run full test suite β€” ALL tests must pass +- [ ] Build all workspaces β€” zero errors +- [ ] πŸ§ͺ **Final Test Gate**: Zero failures across entire project +- [ ] Update `.azure/project-plan.md` status to `Ready` + +--- + +## 10. Test Suite Plan + +| # | Test File | Type | Tests | Mock Data Source | Pass Criteria | +|---|-----------|------|-------|-----------------|---------------| +| 1 | `tests/services/config.test.ts` | Unit | Config loading, defaults, missing var errors | Inline env vars | All assertions pass | +| 2 | `tests/services/storage.test.ts` | Unit | Upload, download, list, delete via mock | `tests/fixtures/files.json` | Mock operations match expected calls | +| 3 | `tests/services/database.test.ts` | Unit | CRUD operations via mock | `tests/fixtures/items.json` | Mock operations match expected calls | +| 4 | `tests/validation/itemSchema.test.ts` | Unit | Valid/invalid input variations | Inline test cases | Validation passes/fails correctly | +| 5 | `tests/functions/getItems.test.ts` | Unit | GET /api/items with mock DB | `tests/fixtures/items.json` | Returns 200 + correct items | +| 6 | `tests/functions/createItem.test.ts` | Unit | POST /api/items with valid/invalid body | `tests/fixtures/items.json` | 201 on valid, 400/422 on invalid | +| 7 | `tests/functions/getItemById.test.ts` | Unit | GET /api/items/:id found/not found | `tests/fixtures/items.json` | 200 on found, 404 on missing | +| 8 | `tests/errors/errorHandler.test.ts` | Unit | Error type β†’ status code mapping | Inline error instances | Correct status + response shape | +| 9 | `tests/functions/health.test.ts` | Unit | Health check with mocked services | Mock service health methods | Correct aggregate status | +| {n} | `{test file path}` | {Unit/Integration} | {what it tests} | {fixture file or inline} | {pass criteria} | + +> Add rows for every test file in the project. Each module should have at least one corresponding test file. + +--- + +## 11. Files to Generate + +| File | Action | Description | +|------|--------|-------------| +| `.env.example` | CREATE | Environment variable template with documentation | +| `.gitignore` | CREATE/MODIFY | Runtime-appropriate ignore rules | +| `{project config}` | CREATE | `package.json` / `pyproject.toml` / `.csproj` | +| `{build config}` | CREATE | `tsconfig.json` / build settings | +| `{test config}` | CREATE | `vitest.config.ts` / `jest.config.ts` / `.mocharc.yml` / `pytest.ini` | +| `{lint config}` | CREATE | `.eslintrc.*` / `ruff.toml` / `.editorconfig` | +| `src/functions/host.json` | CREATE | Functions host configuration | +| `src/functions/local.settings.json` | CREATE | Functions local env config | +| `src/functions/src/services/config.ts` | CREATE | Configuration loader + validation | +| `src/functions/src/services/interfaces/*` | CREATE | Service contracts | +| `src/functions/src/services/*.ts` | CREATE | Service implementations | +| `src/functions/src/errors/*` | CREATE | Error types and handler | +| `src/functions/src/middleware/*` | CREATE | Request logging, validation | +| `src/functions/src/functions/*.ts` | CREATE | Function handlers (one per route) | +| `src/functions/openapi.yaml` | CREATE | OpenAPI 3.x specification | +| `src/functions/tests/**` | CREATE | All test files | +| `src/functions/tests/fixtures/*` | CREATE | Mock data fixtures | +| `src/functions/tests/mocks/*` | CREATE | Mock service implementations | +| `src/shared/types/*` | CREATE | Shared entity and API types | +| `src/shared/schemas/*` | CREATE | Validation schemas | +| `src/web/**` | CREATE | Frontend (if applicable) | + +--- + +## 12. Next Steps + +**Current Phase**: {Planning | Execution} + +**When current phase completes:** + +1. **Database provisioning** _(if PostgreSQL or other relational DB is used)_ β€” Before running migrations, the database itself must be created: + ```bash + # Create the database (one-time setup) + createdb {dbname} # e.g., createdb scrapbook + # Or via psql: + psql -c "CREATE DATABASE {dbname}" + # Then run migrations: + npm run db:migrate + # Then seed with sample data: + npm run db:seed + ``` + > _The **local-dev** skill handles this automatically via Docker. This step is only needed when running PostgreSQL natively._ + +2. **Set up local dev environment** β€” Run the **local-dev** skill to add Docker Compose emulators, VS Code F5 debugging, and `docker-compose.yml`. The service abstraction layer generated here is fully compatible. + +3. **Deploy to Azure** β€” Run **azure-prepare** β†’ **azure-validate** β†’ **azure-deploy**. The service abstraction layer ensures your code works against both local mocks and Azure services β€” no code changes needed. + +> **Note**: {If derived from `.azure/plan.md`} This project was configured to use the Azure services from your deployment plan (`.azure/plan.md`). The service abstraction layer generates appropriate interfaces for each planned service. +```` + +--- + +## Usage Rules + +1. **Replace all `{placeholders}`** with actual values from requirements gathering +2. **Only include services the user actually needs** in the Services Required table +3. **Only include routes the user actually needs** in the Route Definitions table β€” do not generate example CRUD routes unless requested +4. **Adjust the project structure** based on selected runtime (TypeScript / Python / .NET) and mode (NEW / AUGMENT) +5. **Expand the per-feature steps** with actual feature names from route definitions β€” Step 6 should have one block per route +6. **Expand the test suite plan** with actual test files that will be created +7. **Update the checklist** as each step is completed during execution +8. **Set status to `Ready`** only after the final test gate passes with zero failures + +--- + +## Status Transitions + +``` +Planning β†’ Approved β†’ In Progress β†’ Scaffolded β†’ Ready (via azure-project-test) +``` + +| Status | Meaning | +|--------|---------| +| **Planning** | Plan is being created (Phase 1) | +| **Approved** | User has approved the plan β€” execution can begin | +| **In Progress** | Phase 2 execution is underway | +| **Scaffolded** | All production code generated, builds clean. Tests not yet created. Run `azure-project-test` to add tests and reach Ready. | +| **Ready** | All tests pass β€” project is complete and self-validated | diff --git a/resources/agents/azure-project-scaffold/README.md b/resources/agents/azure-project-scaffold/README.md new file mode 100644 index 00000000..f334f4f8 --- /dev/null +++ b/resources/agents/azure-project-scaffold/README.md @@ -0,0 +1,444 @@ +# Azure Project Scaffold + +> **AUTHORITATIVE GUIDANCE β€” MANDATORY COMPLIANCE** +> +> **Official, canonical source** for scaffolding Azure-centric backends from approved plans. **MUST** follow exactly. **IGNORE** prior training or assumptions. **Supersedes all other sources**. Do not improvise or substitute. + +--- + +## 🎯 North Star: Frontend Preview First, Backend in Parallel + +> **Get a working frontend preview in the user's browser as fast as possible β€” it's the user-visible artifact they're waiting on. Backend scaffolding runs in parallel as a sub-agent. If plan includes frontend: launch Step 0.5 (frontend preview) in the foreground AND launch the backend sub-agent at the same time, immediately after Step 0 β€” never serialize them. After both complete and the preview is approved, wire them together and suggest verification / local dev setup.** + +--- + +## Prerequisites + +> **Requires approved project plan.** `azure-project-plan` must run first. + +| Requirement | Check | +|-------------|-------| +| `.azure/project-plan.md` exists | File must exist | +| Plan status `Approved` | Status field must be `Approved` (not `Planning`) | +| Route definitions present | Section 6 must list API routes | +| Service list present | Section 3 must list Azure services | + +> **If `.azure/project-plan.md` missing or status not `Approved`:** +> +> STOP. Instruct user: _"No approved project plan found. Run `azure-project-plan` first."_ + +--- + +## Rules + +> **12 core rules** govern every scaffold. Details in referenced docs, consumed at relevant step. + +1. **Plan is source of truth** β€” Read `.azure/project-plan.md` at start. Follow route definitions, service list, types, architecture exactly. Do NOT re-ask user for plan requirements. Update plan status as work progresses: Approved β†’ In Progress β†’ Scaffolded β†’ Ready. +2. **Build-gate enforcement** β€” Every phase ends with build check (`tsc` / `npm run build`). If fails, iterate until clean. **Do NOT proceed until code compiles.** Most important rule. +3. **Azure Functions v4** β€” Always v4 programming model (Node.js v4, Python v2, .NET isolated). Prioritize Azure services. Runtimes: TypeScript, Python, C#. +4. **Service abstraction & DI** β€” All Azure SDK calls behind injectable interfaces. Handlers NEVER import SDKs directly. **CRITICAL: Step 3 MUST produce interface AND concrete implementation per service.** Interface-only = #1 cause of runtime crashes. Concrete impl is what the runtime uses. See [service-abstraction.md](../shared-references/service-abstraction.md). +5. **Modular, one function per file** β€” Each Function own file. Each service own module. Extract shared utilities to `src/utils/` β€” no duplication, no unused stubs. Prefix unused params with `_`. **DRY**: Same helper in 2+ files β†’ extract to `src/functions/src/utils/` and import. **Proactive**: Before writing handlers, identify common patterns (password hashing, entity sanitization, response formatting) and pre-create shared utils. See [architecture.md](../shared-references/architecture.md). +6. **Environment-driven config** β€” Connection strings switch local/Azure via env vars. Validate required vars on startup, fail fast. See [service-abstraction.md](../shared-references/service-abstraction.md). +7. **Input validation & standardized errors** β€” Every endpoint has validation schema (Zod/Pydantic/FluentValidation). Every route returns `{ error: { code, message, details? } }`. Error codes typed union, not strings. See [error-handling.md](../shared-references/error-handling.md). +8. **Resilience classification** β€” Follow plan's Essential/Enhancement classification. Enhancement services wrapped in try/catch with fallback. **Enhancement constructors MUST NOT throw** β€” defer config validation to method calls or wrap in try/catch in registry. Constructor throws crash ALL handlers via `getServices()`. See [resilience.md](../shared-references/resilience.md). +9. **Database integrity** β€” Migrations MUST include UNIQUE, FK (ON DELETE), CHECK, INDEX constraints. Multi-table writes MUST use transactions. Collection-to-table mappings documented and verified. See [database-integrity.md](../shared-references/database-integrity.md). +10. **Wire frontend to real types** β€” If frontend preview generated, replace mock types with shared package imports, replace mock API client with real typed client, verify frontend builds. No `any` types. +11. **Auto-initialization** β€” Registry `getServices()` MUST auto-initialize with concrete implementations when nothing pre-registered. A registry that throws "Services not initialized" is BROKEN. See [service-abstraction.md](../shared-references/service-abstraction.md). +12. **Cross-workspace build safety** β€” When Functions imports `../shared/`, set `rootDir` to `".."` and **compute `main` field from actual `dist/` output after `tsc`** β€” never hardcode. With `rootDir: ".."`, handlers compile to `dist/functions/src/functions/X.js`. After build, list `dist/`, verify `main` matches. **#1 cause of "build passes but app won't start"**. See [architecture.md](../shared-references/architecture.md). + +--- + +## πŸ“¦ Context Management β€” READ THIS FIRST + +> **Do NOT read all reference files upfront.** Total ~250KB. Loading all at once wastes context needed for project code, test output, and fixes. +> +> **Read lazily β€” only when reaching step that needs them.** + +### Step-to-Reference Mapping + +| Step | Read ONLY these files | Skip | +|------|----------------------|------| +| **Step 0** (Read Plan) | `.azure/project-plan.md` | All reference files | +| **Step 0.5** (Frontend Preview) | `references/frontend-patterns.md`, `references/frontend-preview-steps.md` | All other reference files | +| **Sub-Agent Strategy** | `references/sub-agent-strategy.md` | | +| **Step 1** (Foundation) | `../shared-references/architecture.md` | | +| **Step 2** (Config) | `../shared-references/service-abstraction.md` β€” read only the Config Module section | | +| **Step 3** (Services) | `../shared-references/service-abstraction.md` (full), selected runtime file | | +| **Step 4** (Migrations) | `../shared-references/database-integrity.md`, `../shared-references/seed-data.md` | | +| **Step 5** (Types/Validation) | `../shared-references/error-handling.md` β€” read only the Error Code Type Safety section | | +| **Step 6** (Routes) | `../shared-references/resilience.md`, selected runtime file | | +| **Step 7** (Errors) | `../shared-references/error-handling.md` (full) | | +| **Step 8–10** (Health/OpenAPI/Logging) | _(instructions are in README.md)_ | | +| **Step 11** (Wire Frontend) | _(instructions are in README.md β€” uses shared types from Step 5)_ | | +| **Step 12** (Wrap Up) | _(instructions are in README.md)_ | | + +### Runtime-Specific Files β€” Load ONLY ONE + +| Selected Runtime | Load | Do NOT load | +|-----------------|------|-------------| +| TypeScript | `../shared-references/runtimes/typescript.md` | `python.md`, `dotnet.md` | +| Python | `../shared-references/runtimes/python.md` | `typescript.md`, `dotnet.md` | +| C# (.NET) | `../shared-references/runtimes/dotnet.md` | `typescript.md`, `python.md` | + +### Context Release + +> After step checkpoint passes, that step's reference no longer needed. Under context pressure, prioritize current step reference + project source over completed step references. + +--- + +## STEP 0: Read Plan & Validate β€” MANDATORY FIRST ACTION + +**BEFORE starting execution**, read and validate plan: + +| Task | Details | +|------|---------| +| Read `.azure/project-plan.md` | Load complete plan | +| Validate status | Must be `Approved`. If not, STOP β€” instruct user to run `azure-project-plan`. | +| Extract plan details | Routes, services, entity types, frontend framework, runtime, structure | +| Determine frontend needed | Check if plan includes frontend (SPA + API, Full-stack SSR, Static + API). If yes, Step 0.5 generates preview. | +| Update plan status | Set to `In Progress` | + +> **βœ… Checkpoint**: Plan loaded, status valid, status `In Progress`. +> +> **➑️ Immediately after this checkpoint, launch both workstreams in parallel β€” do NOT serialize:** +> +> 1. **(Foreground β€” PRIORITY)** Start **Step 0.5: Frontend Preview**. The user is waiting to see something; this is the visible deliverable. +> 2. **(Background β€” same moment)** Launch the **backend sub-agent** per the Sub-Agent Strategy section (Phase A Contracts β†’ Phase B Backend). It runs concurrently while you drive the preview. +> +> If plan has no frontend (API only / Background worker), skip Step 0.5 and run Phase A β†’ Phase B in the foreground. + +--- + +## Execution Steps + +### Step 0.5: Frontend Preview (If Applicable) β€” PRIORITY WORKSTREAM + +> **Skip** if plan has no frontend ("API only" or "Background worker"). +> +> **This is the priority foreground workstream when a frontend exists.** Launch it the moment Step 0 finishes β€” the user is waiting to see their app. Backend Phase A/B run **in parallel via a sub-agent** (see Sub-Agent Strategy below); do NOT defer the preview to wait on backend work, and do NOT wait for the preview before kicking off the backend sub-agent. + +**Goal**: Standalone frontend with mock data for user to see/interact with before backend is ready. **Preview MUST be auto-authenticated** β€” if app has auth, seed mock auth state so user lands on main view (dashboard, feed), NOT login page. **Auto-open in browser** β€” do NOT prompt. + +**References**: +- [frontend-patterns.md](references/frontend-patterns.md) for patterns and quality bar. +- [frontend-preview-steps.md](references/frontend-preview-steps.md) for sub-steps (F1–F4), working directory rules, approval loop. + +> **βœ… Checkpoint**: +> 1. Frontend builds zero errors (`npx vite build` from `src/web/`) +> 2. No `any` types in `.ts`/`.tsx` +> 3. Auto-authenticated β€” user lands on main content on first load +> 4. Dev server started, preview opened in VS Code Simple Browser +> 5. User approval obtained (or iterating) + +--- + +### Sub-Agent Strategy for Backend Scaffolding + +**Reference**: Read [sub-agent-strategy.md](references/sub-agent-strategy.md) for execution model, Phase A/B details, coordination rules. + +> **Frontend-first pipelining**: Step 0.5 (Frontend Preview) is the priority foreground workstream. Phase A (Contracts) then Phase B (Backend) run in parallel in a backend sub-agent, launched at the **same instant** as the preview β€” not after it. For API-only projects, backend scaffolding runs in the foreground immediately after Step 0. + +> **Synchronization gate**: Step 11 MUST wait for BOTH: (a) frontend preview approved AND (b) Phase B completed. + +### Step 1: Foundation + +**Goal**: Project skeleton compiles/builds with zero errors. + +| Task | Details | +|------|---------| +| Initialize project | `package.json` + `tsconfig.json` (Node.js) / `pyproject.toml` (Python) / `*.csproj` + `*.sln` (.NET) | +| Configure linter/formatter | ESLint + Prettier (Node.js) / Ruff (Python) / dotnet format (.NET) | +| Create `.gitignore` | Runtime-appropriate ignores (node_modules, .env, data/, etc.) | +| Create directory structure | `src/functions/`, `src/functions/src/utils/`, `src/shared/` (do NOT create `src/web/` β€” may exist from frontend preview) | + +**Reference**: [architecture.md](../shared-references/architecture.md) + +> **βœ… Checkpoint**: +> 1. **Build gate**: `npm run build` / `python -m py_compile` / `dotnet build`. Zero errors. +> 2. **Workspace build scripts**: If monorepo, verify every workspace has `build` script. Run in each. If produces `dist/`, verify non-empty. +> 3. **Shared package**: If `src/shared/` exists, verify: (a) `package.json` has `"exports"` or `"main"` pointing to compiled output, (b) `npm run build` produces `dist/` with `.js` and `.d.ts`, (c) other workspaces import without errors. +> 4. **Cross-workspace imports (CRITICAL)**: Run `tsc --noEmit` in every workspace importing shared. If `TS2307: Cannot find module` β†’ exports broken. **Fix before proceeding.** +> 5. **rootDir and main field (CRITICAL)**: After `tsc`, **list actual dist/ contents**, verify `main` glob matches compiled handlers. If `rootDir: ".."`, output nests deeper. Fix `main`. +> ⚠️ **Pitfalls**: (1) Shared packages without build β†’ `ERR_MODULE_NOT_FOUND`. (2) Wildcard exports fail TS resolution. (3) `rootDir: "."` blocks cross-workspace imports β€” use `".."` and update `main`. + +--- + +### Step 2: Configuration & Environment + +**Goal**: Config module that loads env vars with validation and safe defaults. + +| Task | Details | +|------|---------| +| Create `config` module | `services/config.ts` / `services/config.py` / `Services/Config.cs` | +| Create `.env.example` | All required env vars with placeholders and comments | +| Create `local.settings.json` | Azure Functions local settings with emulator defaults | +| Implement env validation | On startup, check required vars set. Fail fast with clear error listing missing. | + +**Reference**: [service-abstraction.md](../shared-references/service-abstraction.md) + +> **βœ… Checkpoint**: Config module loads env vars. `.env.example` documents all variables. + +--- + +### Step 3: Service Abstraction Layer + +**Goal**: One module per Azure service, with injectable interfaces and concrete implementations. + +> ⚠️ **CRITICAL β€” DO NOT SKIP CONCRETE IMPLEMENTATIONS** +> +> MUST produce **two files per service**: interface and concrete implementation. Interface-only scaffolding is #1 cause of runtime failures. The registry has nothing to auto-initialize and crashes at runtime. **Every interface MUST have corresponding concrete implementation before checkpoint.** + +| Task | Details | +|------|---------| +| Create service interface/protocol | Define contract (TS interface / Python Protocol / C# interface). **Document auto-managed fields** (e.g., `updated_at`, `created_at`, `id`) in comments. **IDatabaseService MUST include `transaction()` method** for atomic multi-table writes. | +| Create concrete implementation | Implements interface with Azure SDK. **MUST strip auto-managed fields** from caller data in `update()` and `create()` before building queries. **Transaction MUST use BEGIN/COMMIT/ROLLBACK.** | +| Create service factory/registry | Factory/DI that returns real impl from config. **`getServices()` MUST auto-initialize with concrete implementations when nothing pre-registered** β€” calling without prior `registerServices()` MUST construct instances from config, NOT throw. **MUST use correct import style** β€” ESM uses static imports or `await import()`, NOT `require()`. **Enhancement construction wrapped in try/catch** (Rule 8). | + +**Reference**: [service-abstraction.md](../shared-references/service-abstraction.md) + +> **πŸ“‹ File Verification** β€” Before checkpoint, verify on disk: +> +> For EACH service in plan: +> - [ ] `src/services/interfaces/I{Service}Service.ts` β€” interface +> - [ ] `src/services/{service}.ts` β€” **concrete implementation** (imports SDK, implements interface) +> +> Additionally: +> - [ ] `src/services/registry.ts` β€” `initializeServices()` constructs concrete instances +> - [ ] `getServices()` calls `initializeServices()` when `services === null` (lazy auto-init) +> +> **If any concrete implementation missing, DO NOT proceed.** Auto-init will fail. + +> **βœ… Checkpoint**: All interfaces, concrete implementations, and registry exist. `getServices()` auto-initializes. `tsc` zero errors. + +--- + +### Step 4: Database Schema & Migrations + +**Goal**: Repeatable schema management and seed data with constraints. + +> β›› **MANDATORY for relational databases.** If plan includes PostgreSQL, Azure SQL, or any relational DB, NOT optional. Empty `seeds/` directory = scaffold failure β€” tables don't exist, every handler fails with `relation "X" does not exist`. Mocked tests can't catch this. +> +> β›› **BLOCKING DEPENDENCY**: Step 4 can't complete until Step 6 (API Routes) planned. Migration schema must match handler data access patterns. If Step 6 reveals schema changes, return and update. +> +> β›› **MIGRATION FILES MUST CONTAIN CODE.** Empty migration files do NOT satisfy this step. Each MUST contain complete `up()` with `CREATE TABLE` (all columns, types, constraints, indexes from plan) and `down()` reversing changes. **After creating, list directory and verify non-zero size.** Empty files = NOT complete. +> +> β›› **SEED DATA MUST BE GENERATED.** `seeds/fixtures/seed-data.json` and `seeds/seed.ts` (or equivalent) MUST be created with realistic data. Enables demo-ability and integration testing baseline. + +| Task | Details | +|------|---------| +| Create migration scripts | Knex (Node.js) / Alembic (Python) / EF Core (C#) | +| Add database constraints | UNIQUE on business-unique fields, FK with ON DELETE, CHECK for enums, indexes on queried columns | +| Create seed data scripts | Realistic fixtures in JSON + seed script | +| Create migration runner | Script/function to run migrations forward/backward | +| Verify table names match handlers | Cross-reference every table in migration against handler collection names via `collectionToTable` mapping. Document mapping in plan. | + +> **βœ… Checkpoint**: +> - Migration files exist and non-empty (check count > 0) +> - **List migration files, verify each > 0 bytes.** If directory empty or files empty, **STOP β€” create migrations with full `CREATE TABLE` before continuing.** +> - **Seed data files exist.** `seeds/fixtures/seed-data.json` with valid JSON. `seeds/seed.ts` with idempotent script. If missing, create before proceeding. +> - **File existence**: Every plan table has corresponding migration. +> - **Migration count**: Should match plan's schema section. +> - Seed data exists. Table names match collection-to-table mapping. +> - All pass β†’ proceed + +--- + +### Step 5: Shared Types & Validation Schemas + +**Goal**: Type-safe contracts between frontend and backend, with validation covering every endpoint. + +| Task | Details | +|------|---------| +| Create shared types | Entity types, API request/response contracts in `src/shared/` | +| Create validation schemas | Zod (Node.js) / Pydantic (Python) / FluentValidation (.NET) β€” **one per endpoint accepting input** | +| Create path param schemas | UUID format validation for path params (e.g., `:id`) | +| Create file upload validation | Size limit and MIME type validation for uploads | +| Define error code enum | Typed union of all valid error codes (not plain `string`) | +| Wire validation into handlers | Validate request body/params before processing | + +**Reference**: [error-handling.md](../shared-references/error-handling.md) + +> ⚠️ **Schema Completeness Check** (MANDATORY) +> +> Before marking complete, verify **every route** has: +> - Request body schema (if accepts body) +> - Query param schema (if has query params) +> - Path param schema (if has path params like `:id`) +> - Response type in shared package +> +> Count schemas vs routes. Coverage < 100% = NOT complete. + +> **βœ… Checkpoint**: Every route has a corresponding validation schema. Types build cleanly. + +--- + +### Step 6: API Routes / Functions (Per Feature) + +**Goal**: Implement each route one at a time. Each compiles and matches API contract before starting next. + +> ❌ **CRITICAL**: Implement ONE route at a time. Verify compiles. THEN start next. + +For **each** route in plan: + +| Task | Details | +|------|---------| +| Create function handler | One file per function. **All async calls MUST include `await`**. **`handleError` calls MUST match standardized signature**. | +| Use transactions for multi-table writes | Any handler writing 2+ tables MUST use `database.transaction()` | +| Wrap Enhancement services | External services classified Enhancement MUST have try/catch with fallback (see [resilience.md](../shared-references/resilience.md)) | +| Validate file uploads server-side | Check file size and MIME type before processing | +| Validate path params before DB queries | When auth middleware extracts userId from token, **validate format** (e.g., UUID) before DB query. Malformed ID on typed column causes 500 instead of 401. Most common runtime error mocked tests miss. | +| Verify response shape | `jsonBody` must match Route Definitions | +| Verify collection names | Must map to migration tables (Rule 9) | +| Extract shared utilities | Duplicated helpers β†’ `src/functions/src/utils/` (Rule 5). **After each handler**, grep for helpers in 2+ files, extract immediately. Consider handler wrapper if >8 handlers share try/catch boilerplate. Prefix unused params with `_`. | + +**Reference**: [service-abstraction.md](../shared-references/service-abstraction.md), [resilience.md](../shared-references/resilience.md) + +> **βœ… Checkpoint (per feature)**: Handler compiles. Response shape matches plan contract. + +--- + +### Step 7: Error Handling Middleware + +**Goal**: Global error handler for consistent error responses. + +| Task | Details | +|------|---------| +| Create error types | Custom classes (NotFoundError, ValidationError, etc.) | +| Create error middleware | Catches errors, maps to standardized response | +| Create error response shape | `{ error: { code: string, message: string, details?: any } }` | + +**Reference**: [error-handling.md](../shared-references/error-handling.md) + +> **βœ… Checkpoint**: Error types and middleware exist. Response shape consistent. `tsc` zero errors. + +--- + +### Step 8: Health Check Endpoint + +**Goal**: `/api/health` endpoint that reports status of all configured services. + +| Task | Details | +|------|---------| +| Create health check function | Calls each service's health method, aggregates results | +| Return structured response | `{ status: "healthy" | "degraded" | "unhealthy", services: { ... } }` | + +**Status β†’ HTTP code mapping** (must match tests): + +| Status | HTTP Code | Condition | +|--------|:---------:|-----------| +| `healthy` | 200 | All services healthy | +| `degraded` | 200 | Some services down but app functional | +| `unhealthy` | 503 | All services down or all Essential down | + +> ⚠️ **`degraded` returns 200, NOT 503.** App still serving β€” reduced functionality. Only `unhealthy` returns 503. Tests must match. + +> **βœ… Checkpoint**: Health endpoint exists, returns structured response. Status-to-HTTP mapping correct. + +--- + +### Step 9: OpenAPI / API Contract + +**Goal**: Auto-generated or manually defined OpenAPI 3.x spec from route definitions. + +| Task | Details | +|------|---------| +| Generate OpenAPI spec | From plan route definitions, produce `openapi.yaml` or `.json`. **Prefer inlining as TypeScript object** in handler to avoid dist/ path issues. | +| Add spec endpoint | Serve at `/api/docs` or `/api/openapi.json`. If file-based, verify path resolves from compiled output. | +| Validate responses | Test actual responses match spec shapes | + +> **βœ… Checkpoint**: OpenAPI spec exists and valid. Endpoint wired. + +--- + +### Step 10: Structured Logging + +**Goal**: Consistent, machine-readable logging across handlers and services. + +| Task | Details | +|------|---------| +| Configure logger | pino (Node.js) / structlog (Python) / Serilog (.NET) | +| Add request logging | Log method, path, status, duration per request | +| Add operation logging | Log key operations (create, update, delete) | + +**Reference**: [runtimes/](../shared-references/runtimes/) + +> **βœ… Checkpoint**: Logger configured, wired into handlers. Request logging in place. `tsc` zero errors. + +--- + +### Step 11: Wire Frontend (If Applicable) + +**Goal**: Replace mock data/types in frontend preview with real shared types and typed API client. + +> **Skip** if no frontend or no preview generated. + +| Task | Details | +|------|---------| +| Replace local types | Remove `src/web/src/types/` locals. Import from shared package (e.g., `import type { PublicUser } from '@app/shared'`) | +| Replace mock API client | Remove `src/web/src/mocks/api.ts`. Create typed client in `src/web/src/api/client.ts` calling real endpoints | +| Configure dev proxy | Dev server proxies `/api` to Functions host (e.g., `localhost:7071`) | +| Update hooks and pages | Replace mock imports with real API calls. Maintain 4 data states (loading, error, empty, data) | +| Error handling in hooks | Every async hook catches errors, rolls back optimistic updates on failure | +| Destructive action confirmations | Delete/irreversible actions require user confirmation | +| Client-side upload validation | Files validate size and MIME before sending (server also validates) | +| Correct file extensions | JSX content (``) MUST use `.tsx`. Pure TS (no JSX) uses `.ts`. Includes hooks returning JSX providers. | + +> ⚠️ **No `any` Types** (MANDATORY) +> +> Frontend MUST import and use types from shared package for all entities/responses. +> `useState` or untyped responses found = NOT complete. + +> **βœ… Checkpoint**: +> - Frontend builds zero errors, zero `any` warnings +> - **Dev server starts**: `npx vite` **from `src/web/`** starts without errors. Kill after confirming. Catches `.ts`/`.tsx` extension mismatches that `tsc` doesn't report. +> - Mock data layer removed or no longer imported + +--- + +### Step 12: Wrap Up + +**Goal**: All code compiles, app starts, health check responds. Scaffold complete. + +| Task | Details | +|------|---------| +| Build all workspaces | `npm run build` in every workspace β€” zero errors | +| Update plan status | Set to `Scaffolded` | +| Print completion | List created files, announce: **"Scaffolding complete!"** | +| **Suggest next steps** | **MANDATORY**: Present follow-up via `vscode_askQuestions`. Do NOT auto-invoke. Single question with two options:\n\n**Header**: "Next Step"\n**Question**: "Scaffolding complete! What would you like to do next?"\n**Options** (allowFreeformInput: false):\n- **"Verify project"** ("Add test coverage and runtime validation") β€” recommended\n- **"Set up local dev"** ("Configure Docker emulators, VS Code debugging, and F5 launch")\n\nIf "Verify project" β†’ invoke `azure-project-test`\nIf "Set up local dev" β†’ invoke `azure-localdev` | + +> **βœ… Final Checkpoint**: +> 1. **Build**: `npm run build` every workspace. `dist/` has output. Zero errors. +> 2. **Status**: `.azure/project-plan.md` = `Scaffolded`. +> 3. **Follow-up**: Button prompt presented via `vscode_askQuestions`. + +--- + +## Outputs + +| Artifact | Location | +|----------|----------| +| Frontend preview (if applicable) | `src/web/` (with mock data, local types, pages, components β€” from Step 0.5) | +| Backend (Functions) | `src/functions/` or user-specified path | +| Shared types | `src/shared/` | +| Service abstractions | `src/functions/src/services/` (or equivalent) | +| Function handlers | `src/functions/src/functions/` (or equivalent) | +| Validation schemas | `src/shared/schemas/` or `src/shared/validation/` | +| Error types | `src/functions/src/errors/` (or equivalent) | +| OpenAPI spec | `src/functions/openapi.yaml` or `openapi.json` | +| Environment template | `.env.example` (project root) | +| Functions config | `src/functions/local.settings.json` | +| Seed data | `src/functions/seeds/` or `data/fixtures/` | +| Wired frontend (if applicable) | `src/web/` (with real types + API client β€” from Step 11) | +| **Next step** | Presented via `vscode_askQuestions`: "Verify project" or "Set up local dev" | + +--- + +## Runtime Quick Reference + +| Runtime | Functions Init | Programming Model | Package Manager | +|---------|---------------|-------------------|-----------------| +| TypeScript | `func init --typescript --model V4` | v4 (recommended) | npm / pnpm | +| Python | `func init --python --model V2` | v2 (recommended) | pip / poetry | +| C# (.NET 8) | `func init --dotnet --isolated` | Isolated worker model | dotnet | + +For runtime-specific implementation patterns, see [runtimes/](../shared-references/runtimes/). diff --git a/resources/agents/azure-project-scaffold/references/architecture.md b/resources/agents/azure-project-scaffold/references/architecture.md new file mode 100644 index 00000000..1abb030e --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/architecture.md @@ -0,0 +1,473 @@ +# Project Architecture + +> Best practices for structuring an Azure-centric project with built-in testability. + +--- + +## Core Principles + +1. **Service boundary isolation** β€” Every Azure service interaction lives in a dedicated module behind an interface. Never scatter SDK calls across function handlers. +2. **Dependency injection** β€” Services are injectable. Function handlers receive their dependencies rather than importing singletons. This makes testing trivial β€” swap real services for mocks. +3. **Environment-driven config** β€” The same code runs against local mocks, local emulators, and Azure services, switched only by environment variables. +4. **Monorepo by default** β€” Frontend, backend, and shared types live in one repo with clear directory boundaries. +5. **Contracts first** β€” Shared types/schemas between frontend and backend live in a `shared/` directory. API contracts are defined before implementation. +6. **One function per file** β€” Each Azure Function gets its own file. The file name matches the function name. Each is independently testable. +7. **Tests live next to what they test** β€” Test directory structure mirrors source directory structure. + +--- + +## Canonical Project Structures + +### TypeScript β€” SPA + Azure Functions + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md ← Project plan (source of truth) +β”œβ”€β”€ .env.example ← Connection string template (checked in) +β”œβ”€β”€ .env ← Actual values (gitignored) +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json ← Root workspace config +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json ← Functions env config (gitignored) +β”‚ β”‚ β”œβ”€β”€ package.json ← Backend dependencies +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vitest.config.ts ← Test runner config +β”‚ β”‚ β”œβ”€β”€ openapi.yaml ← API contract +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← Function handlers (one per file) +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItemById.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ updateItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ deleteItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ health.ts +β”‚ β”‚ β”‚ β”‚ └── openapi.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service abstraction layer +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces/ +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IStorageService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IDatabaseService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ └── ICacheService.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ database.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ cache.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.ts ← Config loader + env validation +β”‚ β”‚ β”‚ β”‚ └── registry.ts ← Service factory / DI registry +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ AppError.ts ← Base error class +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ errorTypes.ts ← NotFoundError, ValidationError, etc. +β”‚ β”‚ β”‚ β”‚ └── errorHandler.ts ← Global error handler +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ requestLogger.ts +β”‚ β”‚ β”‚ β”‚ └── validateRequest.ts +β”‚ β”‚ β”‚ └── logger.ts ← Structured logger (pino) +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ ← Mock data (JSON files) +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ items.json +β”‚ β”‚ β”‚ β”‚ └── users.json +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ ← Mock service implementations +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockStorage.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockDatabase.ts +β”‚ β”‚ β”‚ β”‚ └── mockCache.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ database.test.ts +β”‚ β”‚ β”‚ β”‚ └── registry.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItemById.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ health.test.ts +β”‚ β”‚ β”‚ β”‚ └── openapi.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”‚ └── errorHandler.test.ts +β”‚ β”‚ β”‚ └── validation/ +β”‚ β”‚ β”‚ └── itemSchema.test.ts +β”‚ β”‚ └── seeds/ ← Database seed data (if applicable) +β”‚ β”‚ β”œβ”€β”€ seed.ts +β”‚ β”‚ └── fixtures/ +β”‚ β”‚ └── seed-data.json +β”‚ β”œβ”€β”€ web/ ← Frontend application +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vite.config.ts ← Dev proxy to Functions +β”‚ β”‚ β”œβ”€β”€ index.html +β”‚ β”‚ └── src/ +β”‚ β”‚ β”œβ”€β”€ App.tsx +β”‚ β”‚ β”œβ”€β”€ main.tsx +β”‚ β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ β”‚ └── client.ts ← Typed API client +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ pages/ +β”‚ β”‚ └── hooks/ +β”‚ └── shared/ ← Shared types and schemas +β”‚ β”œβ”€β”€ package.json +β”‚ β”œβ”€β”€ types/ +β”‚ β”‚ β”œβ”€β”€ index.ts +β”‚ β”‚ β”œβ”€β”€ entities.ts ← Entity types (shared FE + BE) +β”‚ β”‚ └── api.ts ← Request/response contracts +β”‚ └── schemas/ +β”‚ └── validation.ts ← Zod schemas +└── data/ ← Docker volume mounts (gitignored) +``` + +### TypeScript β€” API Only + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vitest.config.ts +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”‚ └── logger.ts +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ └── errors/ +β”‚ β”‚ └── seeds/ +β”‚ └── shared/ +β”‚ β”œβ”€β”€ types/ +β”‚ └── schemas/ +└── data/ +``` + +### Python β€” SPA + Azure Functions + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions Python project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ pyproject.toml ← Python project config +β”‚ β”‚ β”œβ”€β”€ pytest.ini ← Test config +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ function_app.py ← Function registration +β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces.py ← ABC / Protocol definitions +β”‚ β”‚ β”‚ β”œβ”€β”€ storage.py +β”‚ β”‚ β”‚ β”œβ”€β”€ database.py +β”‚ β”‚ β”‚ β”œβ”€β”€ cache.py +β”‚ β”‚ β”‚ β”œβ”€β”€ config.py ← Config loader + validation +β”‚ β”‚ β”‚ └── registry.py ← Service factory +β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”‚ β”œβ”€β”€ app_error.py +β”‚ β”‚ β”‚ β”œβ”€β”€ error_types.py +β”‚ β”‚ β”‚ └── error_handler.py +β”‚ β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”‚ β”œβ”€β”€ request_logger.py +β”‚ β”‚ β”‚ └── validate_request.py +β”‚ β”‚ β”œβ”€β”€ logger.py ← structlog setup +β”‚ β”‚ └── tests/ +β”‚ β”‚ β”œβ”€β”€ conftest.py ← Pytest fixtures (mock services) +β”‚ β”‚ β”œβ”€β”€ fixtures/ +β”‚ β”‚ β”‚ β”œβ”€β”€ items.json +β”‚ β”‚ β”‚ └── users.json +β”‚ β”‚ β”œβ”€β”€ test_config.py +β”‚ β”‚ β”œβ”€β”€ test_storage.py +β”‚ β”‚ β”œβ”€β”€ test_database.py +β”‚ β”‚ β”œβ”€β”€ test_get_items.py +β”‚ β”‚ β”œβ”€β”€ test_create_item.py +β”‚ β”‚ β”œβ”€β”€ test_error_handler.py +β”‚ β”‚ β”œβ”€β”€ test_health.py +β”‚ β”‚ └── test_validation.py +β”‚ β”œβ”€β”€ web/ ← Frontend +β”‚ β”‚ └── (same as TypeScript) +β”‚ └── shared/ +β”‚ β”œβ”€β”€ types.py ← Pydantic models +β”‚ └── validation.py ← Validation schemas +└── data/ +``` + +### C# (.NET 8) β€” SPA + Azure Functions + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ ProjectName.sln +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ Functions/ ← Azure Functions isolated worker +β”‚ β”‚ β”œβ”€β”€ Functions.csproj +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ Program.cs ← DI registration + startup +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ Functions/ ← Function handlers +β”‚ β”‚ β”‚ β”œβ”€β”€ GetItems.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ CreateItem.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ GetItemById.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ Health.cs +β”‚ β”‚ β”‚ └── OpenApi.cs +β”‚ β”‚ β”œβ”€β”€ Services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ Interfaces/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IStorageService.cs +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IDatabaseService.cs +β”‚ β”‚ β”‚ β”‚ └── ICacheService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ StorageService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ DatabaseService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ CacheService.cs +β”‚ β”‚ β”‚ └── Config.cs +β”‚ β”‚ β”œβ”€β”€ Errors/ +β”‚ β”‚ β”‚ β”œβ”€β”€ AppException.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ ErrorTypes.cs +β”‚ β”‚ β”‚ └── ErrorHandler.cs +β”‚ β”‚ β”œβ”€β”€ Middleware/ +β”‚ β”‚ β”‚ β”œβ”€β”€ RequestLogger.cs +β”‚ β”‚ β”‚ └── ValidateRequest.cs +β”‚ β”‚ └── Seeds/ +β”‚ β”‚ └── SeedData.cs +β”‚ β”œβ”€β”€ Functions.Tests/ ← xUnit test project +β”‚ β”‚ β”œβ”€β”€ Functions.Tests.csproj +β”‚ β”‚ β”œβ”€β”€ Fixtures/ +β”‚ β”‚ β”‚ └── ItemFixtures.cs +β”‚ β”‚ β”œβ”€β”€ Mocks/ +β”‚ β”‚ β”‚ β”œβ”€β”€ MockStorageService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ MockDatabaseService.cs +β”‚ β”‚ β”‚ └── MockCacheService.cs +β”‚ β”‚ β”œβ”€β”€ Services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ ConfigTests.cs +β”‚ β”‚ β”‚ └── StorageTests.cs +β”‚ β”‚ β”œβ”€β”€ Functions/ +β”‚ β”‚ β”‚ β”œβ”€β”€ GetItemsTests.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ CreateItemTests.cs +β”‚ β”‚ β”‚ └── HealthTests.cs +β”‚ β”‚ β”œβ”€β”€ Errors/ +β”‚ β”‚ β”‚ └── ErrorHandlerTests.cs +β”‚ β”‚ └── Validation/ +β”‚ β”‚ └── ItemValidatorTests.cs +β”‚ β”œβ”€β”€ Shared/ +β”‚ β”‚ β”œβ”€β”€ Shared.csproj +β”‚ β”‚ β”œβ”€β”€ Models/ +β”‚ β”‚ β”‚ β”œβ”€β”€ Item.cs +β”‚ β”‚ β”‚ └── ApiContracts.cs +β”‚ β”‚ └── Validators/ +β”‚ β”‚ └── ItemValidator.cs ← FluentValidation +β”‚ └── Web/ ← Frontend +β”‚ └── (same as TypeScript) +└── data/ +``` + +--- + +## Service Abstraction Layer β€” Structure + +The `services/` directory is the **critical architectural component** for testability. Each file wraps one Azure service behind an interface. + +### Interface Pattern + +Every service follows this pattern: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Function Handler β”‚ +β”‚ (receives services via DI β€” no SDK imports) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Service Interface β”‚ +β”‚ IStorageService β”‚ IDatabaseService β”‚ ICacheService +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Real Impl β”‚ Mock Impl β”‚ +β”‚ (Azure SDK) β”‚ (in-memory Map/Dict/List) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Environment (local or Azure) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +| File | Azure Service | Purpose | +|------|---------------|---------| +| `interfaces/IStorageService` | Blob / Queue / Table | Define upload, download, list, delete | +| `interfaces/IDatabaseService` | CosmosDB / PostgreSQL | Define query, insert, update, delete | +| `interfaces/ICacheService` | Redis | Define get, set, delete, clear | +| `storage` | Blob Storage | Concrete impl using `@azure/storage-blob` / `azure-storage-blob` / `Azure.Storage.Blobs` | +| `database` | PostgreSQL / CosmosDB | Concrete impl using `pg` / `psycopg2` / `Npgsql` | +| `cache` | Redis | Concrete impl using `ioredis` / `redis-py` / `StackExchange.Redis` | +| `config` | β€” | Config loader + env validation | +| `registry` | β€” | Factory that returns real or mock services | + +> See [service-abstraction.md](service-abstraction.md) for implementation patterns per runtime. + +--- + +## Function Organization + +### One Function Per File (Required) + +``` +src/functions/src/functions/ +β”œβ”€β”€ getItems.ts ← HTTP GET /api/items +β”œβ”€β”€ createItem.ts ← HTTP POST /api/items +β”œβ”€β”€ getItemById.ts ← HTTP GET /api/items/{id} +β”œβ”€β”€ updateItem.ts ← HTTP PUT /api/items/{id} +β”œβ”€β”€ deleteItem.ts ← HTTP DELETE /api/items/{id} +β”œβ”€β”€ health.ts ← HTTP GET /api/health +└── openapi.ts ← HTTP GET /api/openapi.json +``` + +Each function receives its dependencies via the service registry: + +```typescript +// Example: clean handler with injected services +import { app } from "@azure/functions"; +import { getServices } from "../services/registry"; + +app.http("getItems", { + methods: ["GET"], + authLevel: "anonymous", + route: "items", + handler: async (request, context) => { + const { database } = getServices(); + const items = await database.findAll("items"); + return { jsonBody: { items } }; + } +}); +``` + +--- + +## Frontend Proxy Configuration + +When a frontend is included, the dev server must proxy `/api` requests to the Functions host: + +### Vite (React, Vue, Svelte) + +```typescript +// vite.config.ts +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:7071', + changeOrigin: true + } + } + } +}); +``` + +### Angular + +```json +// proxy.conf.json +{ + "/api": { + "target": "http://localhost:7071", + "secure": false + } +} +``` + +--- + +## Monorepo Package Management + +### npm Workspaces (TypeScript) + +```json +{ + "private": true, + "workspaces": ["src/functions", "src/web", "src/shared"], + "scripts": { + "test": "npm test --workspaces", + "test:functions": "cd src/functions && npm test", + "test:web": "cd src/web && npm test", + "build": "npm run build --workspaces" + } +} +``` + +### Python (Poetry) + +```toml +# pyproject.toml at project root +[tool.poetry] +packages = [ + { include = "services", from = "src/functions" }, + { include = "shared", from = "src" }, +] +``` + +### .NET (Solution) + +```xml + + + + +``` + +--- + +## .gitignore Additions + +```gitignore +# Environment +.env +local.settings.json + +# Data volumes +data/ + +# Build output +dist/ +bin/ +obj/ +.vite/ + +# Runtime +node_modules/ +__pycache__/ +.python_packages/ + +# Test output +coverage/ +.pytest_cache/ +TestResults/ + +# IDE +.vs/ +``` + +--- + +## Port Allocation Convention + +| Service | Port | Notes | +|---------|------|-------| +| Azure Functions host | 7071 | Default `func start` port | +| Frontend dev server (Vite) | 5173 | Default Vite port | +| Frontend dev server (Angular) | 4200 | Default Angular port | +| Azurite Blob | 10000 | | +| Azurite Queue | 10001 | | +| Azurite Table | 10002 | | +| PostgreSQL | 5432 | | +| CosmosDB Emulator | 8081 | | +| Redis | 6379 | | +| Azure SQL Edge | 1433 | | diff --git a/resources/agents/azure-project-scaffold/references/database-integrity.md b/resources/agents/azure-project-scaffold/references/database-integrity.md new file mode 100644 index 00000000..b3c5b5eb --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/database-integrity.md @@ -0,0 +1,304 @@ +# Database Integrity Patterns + +> Schema constraints, transactions, and indexes β€” the database is the last line of defense against data corruption. + +--- + +## Core Principle + +**Application-level checks are necessary but insufficient.** The database schema must enforce correctness even under concurrent access. Race conditions, partial failures, and unexpected input can bypass application logic β€” the database constraints must catch what the code misses. + +--- + +## Rule: Constraints Are Mandatory in Migrations + +Every migration MUST include appropriate constraints. Do not rely solely on application-level validation. + +### UNIQUE Constraints + +Any field that must be unique across the table (email, username, slug, invite token) MUST have a database-level UNIQUE constraint. + +```sql +-- Application-level check alone is NOT sufficient (race condition under concurrent requests) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, -- ← REQUIRED: prevents duplicate registration race condition + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + couple_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Why application-level checks fail:** +``` +Request A: SELECT WHERE email = 'alice@test.com' β†’ not found β†’ INSERT +Request B: SELECT WHERE email = 'alice@test.com' β†’ not found β†’ INSERT ← Both succeed! +``` + +With a UNIQUE constraint, the second INSERT fails with a constraint violation, which the error handler maps to a 409 Conflict. + +### Foreign Key Constraints + +Any field referencing another table MUST have an FK constraint with an explicit ON DELETE behavior. + +```sql +CREATE TABLE photos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, + uploaded_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blob_url TEXT NOT NULL, + thumbnail_url TEXT, + caption TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +| ON DELETE Behavior | When to Use | +|-------------------|-------------| +| `CASCADE` | Child records should be deleted when parent is deleted (photos when couple deleted) | +| `SET NULL` | Child should remain but lose the reference (user.couple_id when couple dissolved) | +| `RESTRICT` | Prevent parent deletion if children exist (user can't be deleted if they own photos) | + +### CHECK Constraints + +Business rules expressible as column constraints should be enforced at the database level: + +```sql +ALTER TABLE pairing_invites + ADD CONSTRAINT valid_status CHECK (status IN ('pending', 'accepted', 'rejected')); + +ALTER TABLE users + ADD CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'); +``` + +### NOT NULL + +Default to `NOT NULL`. Use `NULL` only when the absence of a value is a meaningful business state: + +| βœ… Nullable (meaningful absence) | ❌ Should be NOT NULL | +|----------------------------------|----------------------| +| `user.couple_id` (unpaired user) | `user.email` | +| `photo.thumbnail_url` (not yet generated) | `photo.blob_url` | + +--- + +## Rule: Transactions for Multi-Table Writes + +Any operation that writes to 2+ tables MUST use a database transaction. Without a transaction, a failure mid-sequence leaves the database in an inconsistent state. + +### IDatabaseService Transaction Method + +Add to the database service interface: + +#### TypeScript + +```typescript +// services/interfaces/IDatabaseService.ts +export interface IDatabaseService { + findAll(collection: string, options?: QueryOptions): Promise; + findById(collection: string, id: string): Promise; + findOne(collection: string, filter: Record): Promise; + create(collection: string, data: T): Promise; + update(collection: string, id: string, data: Partial): Promise; + delete(collection: string, id: string): Promise; + count(collection: string, filter?: Record): Promise; + healthCheck(): Promise; + + // Execute multiple operations atomically β€” all succeed or all rollback + transaction(fn: (trx: IDatabaseService) => Promise): Promise; +} +``` + +#### Python + +```python +# services/interfaces/database_service.py +from typing import Protocol + +class IDatabaseService(Protocol): + # ... existing methods ... + + async def transaction(self, fn) -> any: + """Execute fn within a database transaction. fn receives a transactional + IDatabaseService instance. If fn throws, all changes are rolled back.""" + ... +``` + +#### C# + +```csharp +// Services/Interfaces/IDatabaseService.cs +public interface IDatabaseService +{ + // ... existing methods ... + + Task TransactionAsync(Func> fn); +} +``` + +### Concrete Implementation (PostgreSQL) + +```typescript +// services/database.ts +async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + // Create a transaction-scoped service that uses this client instead of the pool + const trxService = new TransactionDatabaseService(client); + const result = await fn(trxService); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} +``` + +### Mock Implementation + +```typescript +// tests/mocks/mockDatabase.ts +async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + // Mock transactions execute the callback directly against in-memory state. + // For most tests this is sufficient β€” the transaction boundary is tested + // via integration tests with a real database. + return fn(this); +} +``` + +### Usage in Handlers + +```typescript +// BAD β€” 4 sequential writes with no atomicity +const couple = await database.create('couples', { ... }); +await database.update('users', user1Id, { coupleId }); +await database.update('users', user2Id, { coupleId }); +await database.update('pairing_invites', inviteId, { status: 'accepted' }); + +// GOOD β€” all-or-nothing transaction +const couple = await database.transaction(async (trx) => { + const couple = await trx.create('couples', { + id: uuid(), + user1Id: invite.fromUserId, + user2Id: userId, + createdAt: new Date().toISOString(), + }); + await trx.update('users', invite.fromUserId, { coupleId: couple.id }); + await trx.update('users', userId, { coupleId: couple.id }); + await trx.update('pairing_invites', inviteId, { status: 'accepted' }); + return couple; +}); +``` + +--- + +## Rule: Indexes on Frequently Queried Columns + +Migrations should include indexes for columns used in: +- `WHERE` clauses (filter queries) +- `JOIN` conditions +- `ORDER BY` clauses +- Foreign keys + +```sql +-- Foreign keys used in WHERE/JOIN +CREATE INDEX idx_photos_couple_id ON photos(couple_id); +CREATE INDEX idx_invites_to_email ON pairing_invites(to_email); +CREATE INDEX idx_invites_from_user ON pairing_invites(from_user_id); + +-- Composite indexes for common query patterns +CREATE INDEX idx_invites_lookup ON pairing_invites(to_email, status); +``` + +--- + +## Rule: Handle Constraint Violations in Error Handler + +When the database rejects an operation due to a constraint violation, map it to the appropriate HTTP error: + +### TypeScript + +```typescript +// In errorHandler.ts β€” add constraint violation handling +if (error instanceof Error && error.message?.includes('duplicate key')) { + return { + status: 409, + jsonBody: { + error: { + code: 'CONFLICT', + message: 'A record with this value already exists', + details: null, + }, + }, + }; +} + +if (error instanceof Error && error.message?.includes('violates foreign key')) { + return { + status: 400, + jsonBody: { + error: { + code: 'BAD_REQUEST', + message: 'Referenced record does not exist', + details: null, + }, + }, + }; +} +``` + +--- + +## Planning Checkpoint + +During Phase 1 planning, the project plan MUST include a **Database Constraints** section: + +```markdown +## Database Constraints + +| Table | Constraint Type | Column(s) | Detail | +|-------|----------------|-----------|--------| +| users | UNIQUE | email | Prevent duplicate registration | +| users | FK | couple_id β†’ couples.id | ON DELETE SET NULL | +| photos | FK | couple_id β†’ couples.id | ON DELETE CASCADE | +| photos | FK | uploaded_by_user_id β†’ users.id | ON DELETE CASCADE | +| photos | INDEX | couple_id | Filter photos by couple | +| pairing_invites | CHECK | status | IN ('pending', 'accepted', 'rejected') | +| pairing_invites | FK | from_user_id β†’ users.id | ON DELETE CASCADE | +| pairing_invites | INDEX | to_email, status | Invite lookup | +``` + +--- + +## Testing Database Integrity + +```typescript +describe('database constraints', () => { + it('should reject duplicate email registration', async () => { + // First registration succeeds + await database.create('users', { id: uuid(), email: 'alice@test.com', ... }); + + // Second registration with same email should fail + await expect( + database.create('users', { id: uuid(), email: 'alice@test.com', ... }) + ).rejects.toThrow(); + }); + + it('should cascade delete photos when couple is deleted', async () => { + const photos = await database.findAll('photos', { filter: { coupleId } }); + expect(photos).toHaveLength(3); + + await database.delete('couples', coupleId); + + const remaining = await database.findAll('photos', { filter: { coupleId } }); + expect(remaining).toHaveLength(0); + }); +}); +``` diff --git a/resources/agents/azure-project-scaffold/references/error-handling.md b/resources/agents/azure-project-scaffold/references/error-handling.md new file mode 100644 index 00000000..4e2a1698 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/error-handling.md @@ -0,0 +1,659 @@ +# Error Handling + +> Standardized error response patterns, error types, and middleware for consistent error handling across all routes. + +--- + +## Core Principle + +**Every route returns errors in a consistent shape.** Clients can rely on a single error format for all endpoints. Error paths are tested just as thoroughly as happy paths. + +--- + +## Standardized Error Response Shape + +All error responses follow this shape: + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "Item with ID 'xyz' was not found", + "details": null + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `error.code` | `string` | Machine-readable error code (e.g., `VALIDATION_ERROR`, `NOT_FOUND`) | +| `error.message` | `string` | Human-readable description of the error | +| `error.details` | `any?` | Optional additional details (validation errors, field-level issues). **Omitted in production** for security-sensitive errors. | + +--- + +## Error Code β†’ HTTP Status Mapping + +| Error Code | HTTP Status | When | +|------------|-------------|------| +| `VALIDATION_ERROR` | 422 | Request body fails validation (Zod/Pydantic/FluentValidation) | +| `BAD_REQUEST` | 400 | Malformed request (missing params, wrong content type) | +| `NOT_FOUND` | 404 | Resource doesn't exist | +| `CONFLICT` | 409 | Duplicate resource or state conflict | +| `UNAUTHORIZED` | 401 | Missing or invalid auth token | +| `FORBIDDEN` | 403 | Valid auth but insufficient permissions | +| `INTERNAL_ERROR` | 500 | Unhandled exception or service failure | + +--- + +## Error Code Type Safety + +Error codes MUST be defined as a typed union in the shared types package, not as arbitrary strings. This enables frontend consumers to switch on error codes with exhaustiveness checking. + +### TypeScript + +```typescript +// shared/types/errors.ts +export type ErrorCode = + | 'VALIDATION_ERROR' + | 'BAD_REQUEST' + | 'NOT_FOUND' + | 'CONFLICT' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'INTERNAL_ERROR'; + +export interface ErrorResponse { + error: { + code: ErrorCode; // ← typed union, not string + message: string; + details: Record | null; + }; +} +``` + +### Python + +```python +# shared/types.py +from enum import Enum + +class ErrorCode(str, Enum): + VALIDATION_ERROR = "VALIDATION_ERROR" + BAD_REQUEST = "BAD_REQUEST" + NOT_FOUND = "NOT_FOUND" + CONFLICT = "CONFLICT" + UNAUTHORIZED = "UNAUTHORIZED" + FORBIDDEN = "FORBIDDEN" + INTERNAL_ERROR = "INTERNAL_ERROR" +``` + +### C# + +```csharp +// Shared/ErrorCode.cs +public static class ErrorCodes +{ + public const string ValidationError = "VALIDATION_ERROR"; + public const string BadRequest = "BAD_REQUEST"; + public const string NotFound = "NOT_FOUND"; + public const string Conflict = "CONFLICT"; + public const string Unauthorized = "UNAUTHORIZED"; + public const string Forbidden = "FORBIDDEN"; + public const string InternalError = "INTERNAL_ERROR"; +} +``` + +### Frontend Usage + +With typed error codes, the frontend can handle specific error types: + +```typescript +import type { ErrorCode } from 'app-shared'; + +function handleApiError(code: ErrorCode, message: string) { + switch (code) { + case 'UNAUTHORIZED': + // Redirect to login + break; + case 'CONFLICT': + // Show "already exists" message + break; + case 'VALIDATION_ERROR': + // Show field-level errors + break; + default: + // Show generic error + break; + } +} +``` + +--- + +## TypeScript Implementation + +### Error Types + +```typescript +// errors/AppError.ts +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.details = details; + this.name = this.constructor.name; + } +} +``` + +```typescript +// errors/errorTypes.ts +import { AppError } from './AppError'; + +export class NotFoundError extends AppError { + constructor(resource: string, id: string) { + super(404, 'NOT_FOUND', `${resource} with ID '${id}' was not found`); + } +} + +export class ValidationError extends AppError { + constructor(message: string, details?: unknown) { + super(422, 'VALIDATION_ERROR', message, details); + } +} + +export class BadRequestError extends AppError { + constructor(message: string) { + super(400, 'BAD_REQUEST', message); + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super(409, 'CONFLICT', message); + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = 'Authentication required') { + super(401, 'UNAUTHORIZED', message); + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'Insufficient permissions') { + super(403, 'FORBIDDEN', message); + } +} +``` + +### Error Handler + +```typescript +// errors/errorHandler.ts +import { HttpResponseInit, InvocationContext } from '@azure/functions'; +import { AppError } from './AppError'; +import { ZodError } from 'zod'; +import { getLogger } from '../logger'; + +const logger = getLogger(); + +export function handleError(error: unknown, context: InvocationContext): HttpResponseInit { + // Known application errors + if (error instanceof AppError) { + logger.warn({ err: error, code: error.code }, error.message); + return { + status: error.statusCode, + jsonBody: { + error: { + code: error.code, + message: error.message, + details: error.details ?? null, + }, + }, + }; + } + + // Zod validation errors β†’ map to ValidationError shape + if (error instanceof ZodError) { + const details = error.errors.map(e => ({ + field: e.path.join('.'), + message: e.message, + })); + logger.warn({ err: error, details }, 'Validation failed'); + return { + status: 422, + jsonBody: { + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details, + }, + }, + }; + } + + // Unknown errors β†’ 500 + const err = error instanceof Error ? error : new Error(String(error)); + logger.error({ err }, 'Unhandled error'); + + return { + status: 500, + jsonBody: { + error: { + code: 'INTERNAL_ERROR', + message: process.env.NODE_ENV === 'production' + ? 'An internal error occurred' + : err.message, + details: null, + }, + }, + }; +} +``` + +### Request Validation Middleware + +```typescript +// middleware/validateRequest.ts +import { HttpRequest } from '@azure/functions'; +import { ZodSchema, ZodError } from 'zod'; +import { ValidationError } from '../errors/errorTypes'; + +export async function validateBody(request: HttpRequest, schema: ZodSchema): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + throw new ValidationError('Request body must be valid JSON'); + } + + const result = schema.safeParse(body); + if (!result.success) { + const details = result.error.errors.map(e => ({ + field: e.path.join('.'), + message: e.message, + })); + throw new ValidationError('Request validation failed', details); + } + return result.data; +} + +export function validateParams(params: Record, required: string[]): void { + const missing = required.filter(key => !params[key]); + if (missing.length > 0) { + throw new ValidationError(`Missing required parameters: ${missing.join(', ')}`); + } +} +``` + +### Usage in Function Handlers + +```typescript +// functions/createItem.ts +import { app } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { validateBody } from '../middleware/validateRequest'; +import { createItemSchema } from '../../shared/schemas/validation'; +import { v4 as uuid } from 'uuid'; + +app.http('createItem', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'items', + handler: async (request, context) => { + try { + const body = await validateBody(request, createItemSchema); + const { database } = getServices(); + + const item = { + id: uuid(), + ...body, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const created = await database.create('items', item); + return { status: 201, jsonBody: { item: created } }; + } catch (error) { + return handleError(error, context); + } + } +}); +``` + +--- + +## Python Implementation + +### Error Types + +```python +# errors/app_error.py +class AppError(Exception): + def __init__(self, status_code: int, code: str, message: str, details=None): + super().__init__(message) + self.status_code = status_code + self.code = code + self.details = details +``` + +```python +# errors/error_types.py +from .app_error import AppError + +class NotFoundError(AppError): + def __init__(self, resource: str, id: str): + super().__init__(404, "NOT_FOUND", f"{resource} with ID '{id}' was not found") + +class ValidationError(AppError): + def __init__(self, message: str, details=None): + super().__init__(422, "VALIDATION_ERROR", message, details) + +class BadRequestError(AppError): + def __init__(self, message: str): + super().__init__(400, "BAD_REQUEST", message) + +class ConflictError(AppError): + def __init__(self, message: str): + super().__init__(409, "CONFLICT", message) +``` + +### Error Handler + +```python +# errors/error_handler.py +import json +import os +import logging +from azure.functions import HttpResponse +from .app_error import AppError +from pydantic import ValidationError as PydanticValidationError + +logger = logging.getLogger(__name__) + +def handle_error(error: Exception) -> HttpResponse: + if isinstance(error, AppError): + logger.warning(f"{error.code}: {error}") + body = { + "error": { + "code": error.code, + "message": str(error), + "details": error.details, + } + } + return HttpResponse( + json.dumps(body), + status_code=error.status_code, + mimetype="application/json", + ) + + if isinstance(error, PydanticValidationError): + details = [ + {"field": ".".join(str(loc) for loc in e["loc"]), "message": e["msg"]} + for e in error.errors() + ] + logger.warning(f"Validation failed: {details}") + body = { + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "details": details, + } + } + return HttpResponse( + json.dumps(body), status_code=422, mimetype="application/json" + ) + + logger.error(f"Unhandled error: {error}", exc_info=True) + message = str(error) if os.environ.get("ENVIRONMENT") != "production" else "An internal error occurred" + body = { + "error": { + "code": "INTERNAL_ERROR", + "message": message, + "details": None, + } + } + return HttpResponse( + json.dumps(body), status_code=500, mimetype="application/json" + ) +``` + +--- + +## C# (.NET) Implementation + +### Error Types + +```csharp +// Errors/AppException.cs +public class AppException : Exception +{ + public int StatusCode { get; } + public string Code { get; } + public object? Details { get; } + + public AppException(int statusCode, string code, string message, object? details = null) + : base(message) + { + StatusCode = statusCode; + Code = code; + Details = details; + } +} + +// Errors/ErrorTypes.cs +public class NotFoundException : AppException +{ + public NotFoundException(string resource, string id) + : base(404, "NOT_FOUND", $"{resource} with ID '{id}' was not found") { } +} + +public class ValidationException : AppException +{ + public ValidationException(string message, object? details = null) + : base(422, "VALIDATION_ERROR", message, details) { } +} + +public class BadRequestException : AppException +{ + public BadRequestException(string message) + : base(400, "BAD_REQUEST", message) { } +} +``` + +### Error Handler + +```csharp +// Errors/ErrorHandler.cs +using FluentValidation; + +public static class ErrorHandler +{ + public static HttpResponseData HandleError(Exception error, HttpRequestData req, ILogger logger) + { + if (error is AppException appError) + { + logger.LogWarning("{Code}: {Message}", appError.Code, appError.Message); + var response = req.CreateResponse((HttpStatusCode)appError.StatusCode); + response.WriteAsJsonAsync(new + { + error = new { code = appError.Code, message = appError.Message, details = appError.Details } + }); + return response; + } + + if (error is FluentValidation.ValidationException validationError) + { + var details = validationError.Errors.Select(e => new + { + field = e.PropertyName, + message = e.ErrorMessage + }); + var response = req.CreateResponse(HttpStatusCode.UnprocessableEntity); + response.WriteAsJsonAsync(new + { + error = new { code = "VALIDATION_ERROR", message = "Request validation failed", details } + }); + return response; + } + + logger.LogError(error, "Unhandled error"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + errorResponse.WriteAsJsonAsync(new + { + error = new { code = "INTERNAL_ERROR", message = "An internal error occurred", details = (object?)null } + }); + return errorResponse; + } +} +``` + +--- + +## Testing Error Handling + +### TypeScript Tests + +```typescript +// tests/errors/errorHandler.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { handleError } from '../../src/errors/errorHandler'; +import { NotFoundError, ValidationError, BadRequestError } from '../../src/errors/errorTypes'; +import { InvocationContext } from '@azure/functions'; + +const mockContext = { log: vi.fn() } as unknown as InvocationContext; + +describe('errorHandler', () => { + it('should return 404 for NotFoundError', () => { + const error = new NotFoundError('Item', 'abc-123'); + const response = handleError(error, mockContext); + + expect(response.status).toBe(404); + expect(response.jsonBody).toEqual({ + error: { + code: 'NOT_FOUND', + message: "Item with ID 'abc-123' was not found", + details: null, + }, + }); + }); + + it('should return 422 for ValidationError', () => { + const details = [{ field: 'name', message: 'Required' }]; + const error = new ValidationError('Validation failed', details); + const response = handleError(error, mockContext); + + expect(response.status).toBe(422); + expect(response.jsonBody.error.code).toBe('VALIDATION_ERROR'); + expect(response.jsonBody.error.details).toEqual(details); + }); + + it('should return 400 for BadRequestError', () => { + const error = new BadRequestError('Missing content type'); + const response = handleError(error, mockContext); + + expect(response.status).toBe(400); + expect(response.jsonBody.error.code).toBe('BAD_REQUEST'); + }); + + it('should return 500 for unknown errors', () => { + const error = new Error('Something broke'); + const response = handleError(error, mockContext); + + expect(response.status).toBe(500); + expect(response.jsonBody.error.code).toBe('INTERNAL_ERROR'); + }); + + it('should return consistent error shape for all error types', () => { + const errors = [ + new NotFoundError('Item', '1'), + new ValidationError('Bad input'), + new BadRequestError('Bad request'), + new Error('Unknown'), + ]; + + for (const error of errors) { + const response = handleError(error, mockContext); + expect(response.jsonBody).toHaveProperty('error'); + expect(response.jsonBody.error).toHaveProperty('code'); + expect(response.jsonBody.error).toHaveProperty('message'); + expect(response.jsonBody.error).toHaveProperty('details'); + } + }); +}); +``` + +### Validation Schema Tests + +```typescript +// tests/validation/itemSchema.test.ts +import { describe, it, expect } from 'vitest'; +import { createItemSchema } from '../../src/shared/schemas/validation'; + +describe('createItemSchema', () => { + it('should pass with valid input', () => { + const result = createItemSchema.safeParse({ + name: 'Widget', + description: 'A nice widget', + price: 29.99, + category: 'widgets', + }); + expect(result.success).toBe(true); + }); + + it('should fail when name is empty', () => { + const result = createItemSchema.safeParse({ + name: '', + description: 'A widget', + price: 29.99, + category: 'widgets', + }); + expect(result.success).toBe(false); + }); + + it('should fail when price is negative', () => { + const result = createItemSchema.safeParse({ + name: 'Widget', + description: 'A widget', + price: -5, + category: 'widgets', + }); + expect(result.success).toBe(false); + }); + + it('should fail when required fields are missing', () => { + const result = createItemSchema.safeParse({ + description: 'Just a description', + }); + expect(result.success).toBe(false); + }); + + it('should fail when name is not a string', () => { + const result = createItemSchema.safeParse({ + name: 123, + description: 'A widget', + price: 29.99, + category: 'widgets', + }); + expect(result.success).toBe(false); + }); +}); +``` + +--- + +## Validation Library Quick Reference + +| Runtime | Library | Schema Example | +|---------|---------|---------------| +| TypeScript | **Zod** | `z.object({ name: z.string().min(1), price: z.number().positive() })` | +| Python | **Pydantic** | `class CreateItem(BaseModel): name: str = Field(min_length=1); price: float = Field(gt=0)` | +| C# | **FluentValidation** | `RuleFor(x => x.Name).NotEmpty(); RuleFor(x => x.Price).GreaterThan(0);` | diff --git a/resources/agents/azure-project-scaffold/references/frontend-patterns.md b/resources/agents/azure-project-scaffold/references/frontend-patterns.md new file mode 100644 index 00000000..7bf81b3a --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/frontend-patterns.md @@ -0,0 +1,406 @@ +# Frontend Architecture Patterns + +> Frontend has same quality bar as backend: typed, tested, structured. + +--- + +## Core Principle + +**Frontend is not second-class.** Consumes shared types package, has own test gate, follows consistent patterns for data fetching, error handling, component structure. + +--- + +## Rule: Consume Shared Types β€” No `any` + +Shared package exists so frontend doesn't reinvent types. Every entity, request, response MUST use shared type. + +```typescript +// ❌ BAD β€” defeats the purpose of the shared types package +const [user, setUser] = useState(null); +const [photos, setPhotos] = useState([]); +const [error, setError] = useState(null); + +// βœ… GOOD β€” imports from shared package +import type { PublicUser, Photo } from 'scrapbook-shared'; + +const [user, setUser] = useState(null); +const [photos, setPhotos] = useState([]); +const [error, setError] = useState(null); +``` + +**Enforcement**: If shared package defines a type for an entity or API response, frontend MUST import and use it. No inline `any` or ad-hoc interfaces duplicating shared definitions. + +--- + +## Rule: API Client Must Be Fully Typed + +API client module MUST use shared request/response types for every endpoint. Client is contract boundary between frontend and backend. + +```typescript +// api/client.ts +import type { + AuthResponse, + MeResponse, + LoginRequest, + RegisterRequest, + ListPhotosResponse, + PhotoResponse, + ErrorResponse, +} from 'app-shared'; + +async function request(path: string, options: RequestInit = {}): Promise { + const token = localStorage.getItem('token'); + const headers: Record = { + ...((options.headers as Record) || {}), + }; + + if (token) headers['Authorization'] = `Bearer ${token}`; + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`/api${path}`, { ...options, headers }); + + if (!res.ok) { + const error: ErrorResponse = await res.json(); + throw new ApiError(res.status, error.error.code, error.error.message); + } + + return res.json() as Promise; +} + +// Every endpoint is fully typed β€” no `any` +export const api = { + login: (data: LoginRequest) => + request('POST', '/auth/login', { body: JSON.stringify(data) }), + + register: (data: RegisterRequest) => + request('POST', '/auth/register', { body: JSON.stringify(data) }), + + getMe: () => + request('GET', '/auth/me'), + + listPhotos: (limit = 20, offset = 0) => + request('GET', `/photos?limit=${limit}&offset=${offset}`), + + uploadPhoto: (file: File) => { + const form = new FormData(); + form.append('file', file); + return request('POST', '/photos', { body: form }); + }, + + deletePhoto: (id: string) => + request<{ success: true }>('DELETE', `/photos/${id}`), +}; +``` + +--- + +## Rule: Error Handling in Custom Hooks + +Every async op in custom hook MUST catch errors and update error state. Optimistic updates MUST roll back on failure. + +```typescript +// ❌ BAD β€” if API call fails, UI state is inconsistent +const deletePhoto = useCallback(async (id: string) => { + setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic removal + await api.deletePhoto(id); // if this throws, photo is gone from UI but not from DB +}, []); + +// βœ… GOOD β€” rollback on failure +const deletePhoto = useCallback(async (id: string) => { + const previousPhotos = photos; + setPhotos(prev => prev.filter(p => p.id !== id)); // optimistic + try { + await api.deletePhoto(id); + } catch (err) { + setPhotos(previousPhotos); // rollback + setError(err instanceof Error ? err.message : 'Failed to delete photo'); + } +}, [photos]); +``` + +### Silent Error Swallowing is a Bug + +```typescript +// ❌ BAD β€” catches ALL errors including network failures, 500s +useEffect(() => { + api.getCouple().then(setCouple).catch(() => { + // "Not paired yet β€” that's fine" + }); +}, []); + +// βœ… GOOD β€” only ignore expected "not found" errors +useEffect(() => { + api.getCouple() + .then(setCouple) + .catch((err) => { + if (err instanceof ApiError && err.status === 404) { + // Not paired yet β€” expected state + return; + } + setError('Failed to load couple info'); + logger.error('Unexpected error loading couple', err); + }); +}, []); +``` + +--- + +## Rule: No Destructive Actions Without Confirmation + +Any action that permanently deletes or irreversibly modifies data MUST require user confirmation before executing. + +```typescript +// ❌ BAD β€” one mis-click deletes a photo permanently + + +// βœ… GOOD β€” confirmation required + +``` + +For better UX, consider custom confirmation dialog instead of `window.confirm`. + +--- + +## Pattern: Extract Shared Form Components + +When 2+ pages share >50% structure, extract shared component. Common with auth forms. + +```typescript +// ❌ BAD β€” LoginPage and RegisterPage are 90% identical +// LoginPage.tsx: 80 lines of form, state, error handling, submit +// RegisterPage.tsx: 85 lines of nearly identical code + +// βœ… GOOD β€” shared AuthForm component +interface AuthFormProps { + title: string; + fields: { name: string; type: string; label: string; required?: boolean }[]; + onSubmit: (data: Record) => Promise; + submitLabel: string; + altLink: { text: string; to: string }; +} + +function AuthForm({ title, fields, onSubmit, submitLabel, altLink }: AuthFormProps) { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const formData = new FormData(e.target as HTMLFormElement); + const data = Object.fromEntries(formData) as Record; + await onSubmit(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + return ( +
+

{title}

+ {error &&

{error}

} +
+ {fields.map(field => ( + + ))} + +
+ {altLink.text} +
+ ); +} +``` + +--- + +## Pattern: File Upload Validation (Client-Side) + +File uploads MUST validate client-side before sending to server. Provides immediate feedback, prevents wasted bandwidth. + +```typescript +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + +function validateFile(file: File): string | null { + if (!ALLOWED_TYPES.includes(file.type)) { + return `Invalid file type: ${file.type}. Allowed: JPEG, PNG, WebP, GIF`; + } + if (file.size > MAX_FILE_SIZE) { + return `File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`; + } + return null; // valid +} + +// Usage in upload handler +const handleUpload = async (file: File) => { + const validationError = validateFile(file); + if (validationError) { + setError(validationError); + return; + } + // ... proceed with upload +}; +``` + +**Server MUST also validate** β€” client-side validation is for UX, not security. Backend upload handler MUST independently check file size and type. + +--- + +## Pattern: Four-State Data Pages + +Every page fetching data MUST handle exactly four states: + +```typescript +function PhotoGallery() { + const { photos, loading, error } = usePhotos(); + + // 1. Loading + if (loading) { + return
Loading your memories...
; + } + + // 2. Error (with retry) + if (error) { + return ( +
+

Something went wrong: {error}

+ +
+ ); + } + + // 3. Empty (with helpful CTA) + if (photos.length === 0) { + return ( +
+

No photos yet!

+ Upload your first memory +
+ ); + } + + // 4. Data + return ( +
+ {photos.map(photo => )} +
+ ); +} +``` + +--- + +## Pattern: Consistent Styling Approach + +Choose ONE styling approach and use it consistently. Do not mix inline styles with CSS classes. + +| Approach | When to Use | How | +|----------|------------|-----| +| **CSS Modules** | Component-scoped styles, medium-large apps | `import styles from './Button.module.css'` | +| **Global CSS + BEM** | Small apps, rapid prototyping | `className="photo-card__caption"` | +| **CSS-in-JS** (styled-components, Emotion) | Dynamic themes, complex state-based styling | `const Button = styled.button\`...\`` | +| **Tailwind** | Utility-first, design-system projects | `className="flex items-center gap-2"` | + +❌ Never mix inline `style={{ }}` props with CSS classes in the same codebase. + +--- + +## Frontend Test Requirements + +The frontend test gate (Step 11) requires the following minimum tests: + +### Minimum Test Coverage + +| Category | What to Test | Example | +|----------|-------------|---------| +| **Auth flow** | Login success, login failure, logout, token expiry redirect | Mock fetch β†’ verify state changes | +| **Protected routes** | Unauthenticated user redirects to /login | Render route without token β†’ expect redirect | +| **Data display** | List renders items from mock API data | Mock fetch β†’ verify items in DOM | +| **Error states** | Error message shown when API returns error | Mock fetch 500 β†’ verify error message | +| **Form validation** | Invalid input shows feedback | Submit empty form β†’ verify error text | +| **Destructive actions** | Delete shows confirmation before executing | Click delete β†’ verify confirm dialog | + +### Test Setup Pattern (React + Vitest) + +```typescript +// tests/setup.ts +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock fetch globally for all tests +global.fetch = vi.fn(); + +// Helper to mock successful API responses +export function mockFetchSuccess(body: unknown) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => body, + }); +} + +// Helper to mock API errors +export function mockFetchError(status: number, error: { code: string; message: string }) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status, + json: async () => ({ error: { ...error, details: null } }), + }); +} +``` + +### Component Test Example + +```typescript +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { mockFetchSuccess, mockFetchError } from '../setup'; + +describe('ScrapbookPage', () => { + it('renders photo list from API', async () => { + const mockPhotos = [ + { id: '1', caption: 'Beach sunset', blobUrl: '/photo1.jpg', createdAt: '2026-01-01' }, + ]; + mockFetchSuccess({ photos: mockPhotos, total: 1 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Beach sunset')).toBeInTheDocument(); + }); + }); + + it('shows error when API fails', async () => { + mockFetchError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); + }); + + it('shows empty state when no photos', async () => { + mockFetchSuccess({ photos: [], total: 0 }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/no photos yet/i)).toBeInTheDocument(); + }); + }); +}); +``` diff --git a/resources/agents/azure-project-scaffold/references/frontend-preview-steps.md b/resources/agents/azure-project-scaffold/references/frontend-preview-steps.md new file mode 100644 index 00000000..b5d7afaf --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/frontend-preview-steps.md @@ -0,0 +1,78 @@ +# Frontend Preview Steps + +> Detailed sub-steps for standalone frontend preview. Read during **Step 0.5** (Frontend Preview). + +--- + +## Sub-step F1: Initialize Frontend Project + +| Task | Details | +|------|---------| +| Initialize frontend project | React + Vite / Vue + Vite / Angular / Svelte (per plan) | +| Create `src/web/` directory | Standard structure matching plan's frontend framework | +| Create local type definitions | Define entity types locally in `src/web/src/types/` β€” standalone mock types for now | + +--- + +## Sub-step F2: Create Mock Data Layer + +| Task | Details | +|------|---------| +| Create mock data files | `src/web/src/mocks/data.ts` β€” realistic sample data matching plan entities | +| Create mock API client | `src/web/src/mocks/api.ts` β€” returns mock data with simulated delays | +| **Auto-seed auth state** | If app has auth, auth context/provider MUST auto-login with mock credentials on first load (no token in storage). Preview boots directly into authenticated view so user sees main app content β€” NOT a login page. Login/register/logout MUST still work if user manually logs out. | +| Handle all 4 data states | Loading (skeleton/spinner), Error (retry button), Empty (call-to-action), Data (populated) | + +--- + +## Sub-step F3: Create Pages & Components + +| Task | Details | +|------|---------| +| Create pages | One page per major feature, wired to mock API client | +| Create shared components | Reusable UI components (layout, nav, forms, cards) | +| Error handling in hooks | Every async hook catches errors, handles loading/error states | +| Destructive action confirmations | Delete and irreversible actions require user confirmation | +| Auth context auto-login | If app has auth, AuthProvider/auth context MUST auto-login on mount when no token exists, so preview opens to main authenticated content | +| Use correct file extensions | `.tsx` for JSX, `.ts` for pure TypeScript | + +--- + +## Sub-step F4: Build, Auto-Open & Approval Loop + +> ⚠️ **PARALLEL STEP**: Step 0.5 runs **concurrently** with Phase A (Contracts) and Phase B (Backend). Backend derives from **plan's route definitions and entity types**, not frontend preview β€” independent work streams. Phase A and Phase B may begin immediately after Step 0 (plan validation) while frontend preview is generated and reviewed. +> +> **Step 11 (Wire Frontend) is synchronization gate** β€” requires BOTH: +> - (a) Frontend preview approved by user +> - (b) Phase B backend agent completed +> +> **Why safe**: Entity types, route definitions, service interfaces all come from approved plan. Frontend preview uses standalone mock types (`src/web/src/types/`) independent of `src/shared/`. Frontend UI changes (layout, styling, components) don't affect backend contracts. Only Step 11 merges both streams by replacing mock types with shared imports. + +> ⚠️ **WORKING DIRECTORY**: All frontend build/dev-server commands (`npx vite build`, `npx vite --host`, `npm run dev`, etc.) MUST run with `cwd` set to frontend project directory (e.g., `src/web/`), **NOT workspace root**. Running from root produces blank white page because Vite cannot locate `index.html`. + +### Approval Loop Procedure + +1. Frontend builds with zero errors (`npx vite build` from `src/web/`, or equivalent). **cwd MUST be frontend directory β€” NOT project root.** +2. No `any` types in `.ts`/`.tsx` files +3. Preview is auto-authenticated β€” if app has login/auth, user lands on main content (not login page) on first load +4. Start dev server: `cd src/web && npx vite --host` (async/detach β€” must keep running). **cwd MUST be `src/web/` β€” running from project root serves blank white page.** +5. **Open preview in VS Code's Simple Browser** using `simpleBrowser.show` command: + - Use `run_vscode_command` tool: `simpleBrowser.show` with argument `"http://localhost:{port}/"` + - Opens embedded browser tab inside VS Code β€” no external browser needed +6. **Ask user for approval** (use `ask_user`): _"Your frontend preview is live in your browser. Do you approve this UI, or would you like changes?"_ +7. If user requests changes β†’ make changes, rebuild, ask again (loop) +8. If user approves β†’ stop dev server, proceed to Step 11 (Wire Frontend) once Phase B also completes + +> **CRITICAL**: Do NOT prompt "Would you like to preview?" β€” always auto-open in VS Code's Simple Browser via `simpleBrowser.show`. User explicitly opted into this workflow by approving a plan with frontend. Frontend preview is user's first chance to validate app direction β€” but backend builds in parallel since it depends only on plan. + +--- + +## Frontend Quality Bar + +Even in preview mode, frontend MUST meet these standards: +- No `any` types (use local type definitions in `src/web/src/types/`) +- Hooks catch errors and handle loading/error states +- Destructive actions (delete, etc.) require `window.confirm()` before executing +- `.tsx` for files containing JSX, `.ts` for pure TypeScript +- All 4 data states handled: loading, error, empty, data +- **Auto-authenticated preview**: If app has auth, preview MUST auto-login on first load so user sees main content immediately (not login page) diff --git a/resources/agents/azure-project-scaffold/references/plan-template.md b/resources/agents/azure-project-scaffold/references/plan-template.md new file mode 100644 index 00000000..98b337c1 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/plan-template.md @@ -0,0 +1,500 @@ +# Project Plan Template + +> Template for `.azure/project-plan.md` β€” the source of truth for the azure-project-scaffold skill workflow. + +--- + +## Template + +````markdown +# Project Plan + +**Status**: Planning | Approved | In Progress | Ready +**Created**: {date} +**Mode**: NEW | AUGMENT + +--- + +## 1. Project Overview + +**Goal**: {Brief description of what the user is building}. The project is designed so that every module is independently testable. An AI agent can self-validate each component by running its test suite β€” if tests pass, the module is working as intended. + +**App Type**: {API only | SPA + API | Full-stack SSR | Static + API | Background worker} + +**Mode**: {NEW | AUGMENT} +- NEW: Scaffolding entire project from scratch +- AUGMENT: Adding structure, services, or tests to an existing project + +**Deployment Plan**: {`.azure/plan.md` found β€” services derived from deployment plan | No deployment plan found β€” services determined from workspace analysis and user input} + +--- + +## 2. Runtime & Framework + +| Component | Technology | +|-----------|-----------| +| **Runtime** | {TypeScript / Python / C#} | +| **Backend** | {Azure Functions v4} | +| **Frontend** | {React + Vite / Vue + Vite / Angular / Svelte / None} | +| **Package Manager** | {npm / pnpm / pip / poetry / dotnet} | + +--- + +## 3. Test Runner & Configuration + +| Component | Technology | +|-----------|-----------| +| **Test Runner** | {vitest / jest / mocha+chai+sinon / pytest / xUnit / NUnit} | +| **Mocking Library** | {vi.mock (vitest) / jest.mock / sinon / unittest.mock / Moq / NSubstitute} | +| **Assertion Library** | {vitest expect / jest expect / chai / pytest assert / xUnit Assert / FluentAssertions} | +| **Coverage Tool** | {vitest --coverage / jest --coverage / nyc / pytest-cov / coverlet} | +| **Test Command** | {npm test / pytest / dotnet test} | + +--- + +## 4. Services Required + +| Azure Service | Role in App | Environment Variable | Default Value (Local) | +|---------------|------------|---------------------|----------------------| +| {Blob Storage} | {Store uploaded images} | {STORAGE_CONNECTION_STRING} | {UseDevelopmentStorage=true} | +| {PostgreSQL} | {Primary data store} | {DATABASE_URL} | {postgresql://localdev:localdevpassword@localhost:5432/appdb} | +| {Redis} | {Session caching} | {REDIS_URL} | {redis://localhost:6379} | + +> _Services listed here are for code and environment configuration. To run these services locally via Docker emulators, use the **local-dev** skill._ + +--- + +## 5. Design System & UI + +> Only populate this section when the app has a frontend (Frontend β‰  None in Section 2). For API-only or Background-worker plans, leave the **Component Library** field blank β€” the plan view will hide the card automatically. Renumbering of later sections does NOT depend on whether this section has content; keep it stable. + +**Component Library**: {Fluent UI / shadcn/ui / Material UI (MUI) / Tailwind CSS / Chakra UI / Custom CSS / β€”} +**Typography**: {e.g. Inter, system-ui fallback} +**Style Direction**: {1–2 sentences describing visual tone β€” "clean and content-forward, plenty of whitespace, light mode default".} + +### Color Palette + +| Token | Color | +|---------|----------| +| Primary | {#0078D4} | +| Accent | {#FFB900} | +| Neutral | {#323130} | +| Surface | {#FAF9F8} | +| Success | {#107C10} | +| Warning | {#F7630C} | +| Danger | {#D13438} | + +> The plan view detects this table by the `#xxxxxx` hex column and renders each row as a color swatch chip. + +### Pages + +| Page | Route | Layout | +|---------------|----------------|-----------------------------------------------------| +| {Home} | {`/`} | {header, hero, card-list, footer} | +| {Item Detail} | {`/items/:id`} | {header, two-column(image+meta), action-bar, footer} | +| {Settings} | {`/settings`} | {header, form, footer} | + +> The plan view detects this table by the `Layout` header and renders each row as a small "screen" wireframe β€” one stacked block per region token. +> +> **Supported region tokens** (use these; unknown tokens render as a generic labeled gray block): +> `header`, `nav`, `sidebar`, `hero`, `main`, `list`, `card-list`, `grid`, `form`, `table`, `actions`, `action-bar`, `tabs`, `modal`, `footer`, `two-column(a+b)`, `split(a|b)`. +> +> Pages and routes are **not** 1-to-1 β€” list user-facing pages here, not API endpoints. Auth pages (Login, Register) belong here even if scaffold auto-authenticates the preview. + +--- + +## 6. Project Structure + +``` +{Generated directory tree showing the planned project layout} + +Example for TypeScript: +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json ← Root workspace config +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vitest.config.ts ← (or jest.config.ts, .mocharc.yml) +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← One file per Azure Function +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItemById.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ health.ts +β”‚ β”‚ β”‚ β”‚ └── openapi.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service abstraction layer +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces/ ← Service contracts +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IStorageService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IDatabaseService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ └── ICacheService.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.ts ← Concrete implementation +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ database.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ cache.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.ts ← Configuration loader + validation +β”‚ β”‚ β”‚ β”‚ └── registry.ts ← Service factory / DI registry +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ ← Error types and middleware +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ AppError.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ errorHandler.ts +β”‚ β”‚ β”‚ β”‚ └── errorTypes.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ ← Request middleware +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ requestLogger.ts +β”‚ β”‚ β”‚ β”‚ └── validateRequest.ts +β”‚ β”‚ β”‚ └── logger.ts ← Structured logger setup +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ ← Mock data / fixture files +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ items.json +β”‚ β”‚ β”‚ β”‚ └── users.json +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ ← Mock service implementations +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockStorage.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockDatabase.ts +β”‚ β”‚ β”‚ β”‚ └── mockCache.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service unit tests +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.test.ts +β”‚ β”‚ β”‚ β”‚ └── database.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← Function handler tests +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.test.ts +β”‚ β”‚ β”‚ β”‚ └── health.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ ← Error handling tests +β”‚ β”‚ β”‚ β”‚ └── errorHandler.test.ts +β”‚ β”‚ β”‚ └── validation/ ← Validation schema tests +β”‚ β”‚ β”‚ └── itemSchema.test.ts +β”‚ β”‚ └── seeds/ ← Database seed data +β”‚ β”‚ β”œβ”€β”€ seed.ts +β”‚ β”‚ └── fixtures/ +β”‚ β”‚ └── seed-data.json +β”‚ β”œβ”€β”€ web/ ← Frontend (if applicable) +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vite.config.ts +β”‚ β”‚ β”œβ”€β”€ index.html +β”‚ β”‚ └── src/ +β”‚ β”‚ β”œβ”€β”€ App.tsx +β”‚ β”‚ β”œβ”€β”€ main.tsx +β”‚ β”‚ β”œβ”€β”€ api/ ← Typed API client +β”‚ β”‚ β”‚ └── client.ts +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ pages/ +β”‚ β”‚ └── types/ +β”‚ └── shared/ ← Shared types and schemas +β”‚ β”œβ”€β”€ package.json +β”‚ β”œβ”€β”€ types/ +β”‚ β”‚ β”œβ”€β”€ index.ts +β”‚ β”‚ β”œβ”€β”€ entities.ts ← Entity types (Item, User, etc.) +β”‚ β”‚ └── api.ts ← API request/response contracts +β”‚ └── schemas/ +β”‚ └── validation.ts ← Zod / validation schemas +└── data/ ← Volume mounts (gitignored) +``` + +--- + +## 7. Route Definitions + +| # | Method | Path | Description | Request Body | Response Body | Auth | Status Codes | +|---|--------|------|-------------|-------------|--------------|------|-------------| +| 1 | GET | `/api/health` | Health check β€” reports status of all services | β€” | `{ status, services: {...} }` | None | 200, 503 | +| 2 | GET | `/api/items` | List all items with optional filtering | Query: `?limit=20&offset=0` | `{ items: Item[], total: number }` | {None/Required} | 200 | +| 3 | POST | `/api/items` | Create a new item | `{ name, description, ... }` | `{ item: Item }` | {None/Required} | 201, 400, 422 | +| 4 | GET | `/api/items/:id` | Get item by ID | β€” | `{ item: Item }` | {None/Required} | 200, 404 | +| 5 | PUT | `/api/items/:id` | Update item | `{ name?, description?, ... }` | `{ item: Item }` | {None/Required} | 200, 400, 404, 422 | +| 6 | DELETE | `/api/items/:id` | Delete item | β€” | `{ success: true }` | {None/Required} | 200, 404 | +| {n} | {METHOD} | {/api/path} | {description} | {body shape or β€”} | {response shape} | {auth} | {codes} | + +> Replace `Item` with actual entity names. Add/remove rows as needed per the user's feature requirements. + +--- + +## 8. Database Constraints + +> List all database-level constraints that migrations must enforce. See [database-integrity.md](references/database-integrity.md). + +| Table | Constraint Type | Column(s) | Detail | +|-------|----------------|-----------|--------| +| {users} | UNIQUE | {email} | {Prevent duplicate registration} | +| {users} | FK | {couple_id β†’ couples.id} | {ON DELETE SET NULL} | +| {photos} | FK | {couple_id β†’ couples.id} | {ON DELETE CASCADE} | +| {photos} | INDEX | {couple_id} | {Frequent filter column} | +| {invites} | CHECK | {status} | {IN ('pending', 'accepted', 'rejected')} | + +> Add/remove rows as needed. Every UNIQUE field, FK relationship, CHECK constraint, and performance index must be listed here. + +### 8a. Collection-to-Table Name Mapping + +> Document how handler collection names map to SQL table names. The database service's `collectionToTable` function converts collection names used in handler code (e.g., `database.findAll('user')`) to actual SQL table names (e.g., `users`). **Every table in the migration must appear in this mapping.** + +| Collection Name (in handler code) | SQL Table Name (in migration) | Mapping Rule | +|-----------------------------------|-------------------------------|--------------| +| {`'user'`} | {`users`} | {camelToSnake + pluralize} | +| {`'couple'`} | {`couples`} | {camelToSnake + pluralize} | +| {`'invite'`} | {`invites`} | {camelToSnake + pluralize} | +| {`'photo'`} | {`photos`} | {camelToSnake + pluralize} | + +> ⚠️ If you plan to name a table `pairing_invites` but handlers use `database.findAll('invite')`, the mapping produces `invites` β€” a mismatch. Either rename the table to `invites` or add an explicit override in `collectionToTable`. Document whichever approach you choose. + +--- + +## 9. Service Dependency Classification + +> Classify each external service to determine failure handling. See [resilience.md](references/resilience.md). + +| Service | Type | Failure Behavior | +|---------|------|-----------------| +| {PostgreSQL} | Essential | Request fails with 503 | +| {Azure Blob Storage} | Essential | Upload fails with 503 | +| {Azure OpenAI} | Enhancement | Falls back to default caption | +| {Email Service} | Enhancement | Log warning, operation still succeeds | + +> **Essential**: Request MUST fail if this service is down. +> **Enhancement**: Request should succeed with degraded output (fallback value). + +--- + +## 10. Build Phases + +> This section is a high-level phase summary. Use it as an outline; do NOT mutate it during execution. There is no separate checklist file β€” progress is reflected by the plan's `Status:` field (`Planning β†’ Approved β†’ In Progress β†’ Scaffolded β†’ Ready`). +> +> Each phase has a test gate (πŸ§ͺ). The agent MUST run tests and verify they pass before proceeding. If tests fail, iterate on the code until green. + +### Phase 1: Planning +- [ ] Analyze workspace (mode: NEW / AUGMENT) +- [ ] Gather requirements (runtime, services, frontend, features) +- [ ] Select test runner +- [ ] Select Azure services +- [ ] Design project structure +- [ ] Define routes +- [ ] Define test suite plan +- [ ] Write `.azure/project-plan.md` +- [ ] Present plan β€” get user approval + +### Phase 2: Execution + +#### Step 1: Foundation +- [ ] Initialize project config ({package.json / pyproject.toml / .csproj}) +- [ ] Configure TypeScript / Python / .NET build +- [ ] Configure linter and formatter +- [ ] Configure test runner ({vitest / jest / mocha / pytest / xunit}) +- [ ] Create directory structure +- [ ] Create `.gitignore` +- [ ] πŸ§ͺ **Test Gate**: Project builds with zero errors; test runner executes cleanly5 + +#### Step 2: Configuration & Environment +- [ ] Create config module with env var loading +- [ ] Create `.env.example` with all variables +- [ ] Create `local.settings.json` with emulator defaults +- [ ] Implement startup env validation (fail fast on missing required vars) +- [ ] Write config unit tests (load, defaults, missing var error) +- [ ] πŸ§ͺ **Test Gate**: All config tests pass + +#### Step 3: Service Abstraction Layer +- [ ] Create service interfaces ({IStorageService / IDatabaseService / ICacheService}) +- [ ] IDatabaseService includes `transaction()` method for atomic multi-table writes +- [ ] Create concrete implementations (Azure SDK-based, with transaction support) +- [ ] Create mock implementations (in-memory, for tests; mock `transaction()` executes callback directly) +- [ ] Create service factory / registry (returns real or mock based on config) +- [ ] **Registry MUST auto-initialize** with concrete implementations at runtime β€” runtime startup must work without manual setup. Tests pre-register mocks via `setup.ts` to override auto-init. +- [ ] Write unit tests for mock implementations +- [ ] Write unit tests for service factory +- [ ] πŸ§ͺ **Test Gate**: All service abstraction tests pass; auto-init test asserts `.not.toThrow()` and passes + +#### Step 4: Database Schema & Migrations _(if applicable)_ +- [ ] Create migration scripts (schema up/down) +- [ ] Include UNIQUE constraints on business-unique fields (per Section 8) +- [ ] Include FK constraints with ON DELETE behavior (per Section 8) +- [ ] Include CHECK constraints for enum fields (per Section 8) +- [ ] Include indexes on frequently-queried columns (per Section 8) +- [ ] Create seed data fixtures (JSON files with realistic data) +- [ ] Create seed script (idempotent) +- [ ] Write migration tests (forward, backward, idempotent) +- [ ] Write constraint tests (duplicate rejection, FK enforcement) +- [ ] Write seed data tests (correct row counts, no duplicates) +- [ ] πŸ§ͺ **Test Gate**: All migration, constraint, and seed tests pass + +#### Step 5: Shared Types & Validation +- [ ] Create entity types in `src/shared/types/` +- [ ] Create API request/response contracts in `src/shared/types/` +- [ ] Define error code enum/union type (not plain string) +- [ ] Create validation schemas ({Zod / Pydantic / FluentValidation}) β€” **one per endpoint that accepts input** +- [ ] Create path parameter validation schemas (e.g., UUID format for `:id`) +- [ ] Create file upload validation (size limit, MIME type check) if applicable +- [ ] Create validation middleware / helper +- [ ] Write validation tests (valid input, invalid input, edge cases, path params, file limits) +- [ ] **Schema completeness check**: verify every route has a corresponding schema +- [ ] πŸ§ͺ **Test Gate**: All validation tests pass, schema coverage = 100% + +#### Step 6: API Routes / Functions +> Repeat this block for EACH feature/route defined in Section 7: + +**Feature: {feature name} β€” `{METHOD} {/api/path}`** +- [ ] Create function handler (`src/functions/src/functions/{name}.ts`) +- [ ] Use `database.transaction()` if handler writes to 2+ tables +- [ ] Wrap Enhancement service calls in try/catch with fallback (per Section 9) +- [ ] Validate file uploads server-side (size + MIME type) if applicable +- [ ] Write unit tests with mock services and fixture data +- [ ] Test happy path (correct status code, response shape) +- [ ] Test invalid input (400/422 β€” correct error shape) +- [ ] Test not found (404 β€” if applicable) +- [ ] Test service failure (500 β€” correct error shape) +- [ ] Test Enhancement service failure (handler succeeds with fallback) +- [ ] πŸ§ͺ **Test Gate**: All tests for `{feature name}` pass + +**Feature: {next feature}** +- [ ] ... +- [ ] πŸ§ͺ **Test Gate**: All tests for `{next feature}` pass + +_(Repeat for every route)_ + +#### Step 7: Error Handling +- [ ] Create custom error types (NotFoundError, ValidationError, ConflictError, etc.) +- [ ] Create error handler middleware / wrapper +- [ ] Create standardized error response builder +- [ ] Write error handling tests (each error type β†’ correct status + shape) +- [ ] Write unhandled error test (500 with generic message) +- [ ] πŸ§ͺ **Test Gate**: All error handling tests pass + +#### Step 8: Health Check +- [ ] Create `/api/health` function +- [ ] Implement per-service health checks (DB ping, cache ping, storage check) +- [ ] Write health check tests (all healthy, partial degraded, all unhealthy) +- [ ] πŸ§ͺ **Test Gate**: All health check tests pass + +#### Step 9: OpenAPI Contract +- [ ] Generate `openapi.yaml` from route definitions +- [ ] Create `/api/openapi.json` endpoint (or serve static file) +- [ ] Write contract tests (valid spec, response shapes match) +- [ ] πŸ§ͺ **Test Gate**: Spec is valid, contract tests pass + +#### Step 10: Structured Logging +- [ ] Configure logger ({pino / structlog / Serilog}) +- [ ] Add request logging middleware (method, path, status, duration) +- [ ] Add operation logging in services (create, update, delete actions) +- [ ] Write logging tests (structured output, correct fields) +- [ ] πŸ§ͺ **Test Gate**: All logging tests pass + +#### Step 11: Frontend _(if applicable)_ +- [ ] Use Section 5 (Design System & UI) as the source of truth for component library, color palette, typography, and per-page layout tokens β€” pre-install the chosen component library and apply the palette in the global stylesheet. +- [ ] Initialize frontend project ({React+Vite / Vue+Vite / Angular / Svelte}) +- [ ] Create fully typed API client using shared types β€” **no `any` types** +- [ ] Configure dev proxy to Functions host +- [ ] Create pages/components for each feature (handle all 4 states: loading, error, empty, data) +- [ ] Error handling in hooks: catch errors, roll back optimistic updates +- [ ] Destructive actions require user confirmation before executing +- [ ] Extract shared components when pages share >50% structure +- [ ] Client-side file upload validation (size + MIME type) if applicable +- [ ] Write auth flow tests (login, logout, token expiry) +- [ ] Write protected route tests (redirect unauthenticated users) +- [ ] Write data display tests (list renders from mock data) +- [ ] Write error state tests (API failure shows error message) +- [ ] Write form validation tests (invalid input shows feedback) +- [ ] πŸ§ͺ **Test Gate**: Frontend builds with zero errors, all component tests pass, no `any` types + +#### Step 12: Dead Code & Lint Sweep +- [ ] Run linter across entire project β€” zero errors +- [ ] Remove unused imports, unreferenced functions, dead code paths +- [ ] Verify all defined middleware is wired into handlers +- [ ] Verify schema completeness (every route has a schema) +- [ ] πŸ§ͺ **Test Gate**: Linter clean, all tests still pass after cleanup + +#### Step 13: Finalize +- [ ] Run full test suite β€” ALL tests must pass +- [ ] πŸ§ͺ **Final Test Gate**: Zero failures across entire project +- [ ] Update `.azure/project-plan.md` status to `Ready` + +--- + +## 11. Test Suite Plan + +| # | Test File | Type | Tests | Mock Data Source | Pass Criteria | +|---|-----------|------|-------|-----------------|---------------| +| 1 | `tests/services/config.test.ts` | Unit | Config loading, defaults, missing var errors | Inline env vars | All assertions pass | +| 2 | `tests/services/storage.test.ts` | Unit | Upload, download, list, delete via mock | `tests/fixtures/files.json` | Mock operations match expected calls | +| 3 | `tests/services/database.test.ts` | Unit | CRUD operations via mock | `tests/fixtures/items.json` | Mock operations match expected calls | +| 4 | `tests/validation/itemSchema.test.ts` | Unit | Valid/invalid input variations | Inline test cases | Validation passes/fails correctly | +| 5 | `tests/functions/getItems.test.ts` | Unit | GET /api/items with mock DB | `tests/fixtures/items.json` | Returns 200 + correct items | +| 6 | `tests/functions/createItem.test.ts` | Unit | POST /api/items with valid/invalid body | `tests/fixtures/items.json` | 201 on valid, 400/422 on invalid | +| 7 | `tests/functions/getItemById.test.ts` | Unit | GET /api/items/:id found/not found | `tests/fixtures/items.json` | 200 on found, 404 on missing | +| 8 | `tests/errors/errorHandler.test.ts` | Unit | Error type β†’ status code mapping | Inline error instances | Correct status + response shape | +| 9 | `tests/functions/health.test.ts` | Unit | Health check with mocked services | Mock service health methods | Correct aggregate status | +| {n} | `{test file path}` | {Unit/Integration} | {what it tests} | {fixture file or inline} | {pass criteria} | + +> Add rows for every test file in the project. Each module should have at least one corresponding test file. + +--- + +## 12. Files to Generate + +| File | Action | Description | +|------|--------|-------------| +| `.env.example` | CREATE | Environment variable template with documentation | +| `.gitignore` | CREATE/MODIFY | Runtime-appropriate ignore rules | +| `{project config}` | CREATE | `package.json` / `pyproject.toml` / `.csproj` | +| `{build config}` | CREATE | `tsconfig.json` / build settings | +| `{test config}` | CREATE | `vitest.config.ts` / `jest.config.ts` / `.mocharc.yml` / `pytest.ini` | +| `{lint config}` | CREATE | `.eslintrc.*` / `ruff.toml` / `.editorconfig` | +| `src/functions/host.json` | CREATE | Functions host configuration | +| `src/functions/local.settings.json` | CREATE | Functions local env config | +| `src/functions/src/services/config.ts` | CREATE | Configuration loader + validation | +| `src/functions/src/services/interfaces/*` | CREATE | Service contracts | +| `src/functions/src/services/*.ts` | CREATE | Service implementations | +| `src/functions/src/errors/*` | CREATE | Error types and handler | +| `src/functions/src/middleware/*` | CREATE | Request logging, validation | +| `src/functions/src/functions/*.ts` | CREATE | Function handlers (one per route) | +| `src/functions/openapi.yaml` | CREATE | OpenAPI 3.x specification | +| `src/functions/tests/**` | CREATE | All test files | +| `src/functions/tests/fixtures/*` | CREATE | Mock data fixtures | +| `src/functions/tests/mocks/*` | CREATE | Mock service implementations | +| `src/shared/types/*` | CREATE | Shared entity and API types | +| `src/shared/schemas/*` | CREATE | Validation schemas | +| `src/web/**` | CREATE | Frontend (if applicable) | + +--- + +## 13. Next Steps + +**Current Phase**: {Planning | Execution} + +**When current phase completes:** + +1. **Set up local dev environment** β€” Run the **local-dev** skill to add Docker Compose emulators, VS Code F5 debugging, and `docker-compose.yml`. The service abstraction layer generated here is fully compatible. + +2. **Deploy to Azure** β€” Run **azure-prepare** β†’ **azure-validate** β†’ **azure-deploy**. The service abstraction layer ensures your code works against both local mocks and Azure services β€” no code changes needed. + +> **Note**: {If derived from `.azure/plan.md`} This project was configured to use the Azure services from your deployment plan (`.azure/plan.md`). The service abstraction layer generates appropriate interfaces for each planned service. +```` + +--- + +## Usage Rules + +1. **Replace all `{placeholders}`** with actual values from requirements gathering +2. **Only include services the user actually needs** in the Services Required table +3. **Only include routes the user actually needs** in the Route Definitions table β€” do not generate example CRUD routes unless requested +4. **Adjust the project structure** based on selected runtime (TypeScript / Python / .NET) and mode (NEW / AUGMENT) +5. **Expand the per-feature steps** with actual feature names from route definitions β€” Step 6 should have one block per route +6. **Expand the test suite plan** with actual test files that will be created +7. **Update the checklist** as each step is completed during execution +8. **Set status to `Ready`** only after the final test gate passes with zero failures + +--- + +## Status Transitions + +``` +Planning β†’ Approved β†’ In Progress β†’ Ready +``` + +| Status | Meaning | +|--------|---------| +| **Planning** | Plan is being created (Phase 1) | +| **Approved** | User has approved the plan β€” execution can begin | +| **In Progress** | Phase 2 execution is underway | +| **Ready** | All tests pass β€” project is complete and self-validated | diff --git a/resources/agents/azure-project-scaffold/references/resilience.md b/resources/agents/azure-project-scaffold/references/resilience.md new file mode 100644 index 00000000..4b7aa1c6 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/resilience.md @@ -0,0 +1,296 @@ +# Resilience & Graceful Degradation + +> Patterns for handling external service failures without crashing requests. Every external call must have a plan for when it fails. + +--- + +## Core Principle + +**External service failures must never crash the request unless the service is essential to the operation.** Every external dependency gets classified as **Essential** or **Enhancement**, and the code must handle each accordingly. + +--- + +## Service Dependency Classification + +During Phase 1 planning, classify every external service: + +| Type | Definition | Failure Behavior | Example | +|------|-----------|-----------------|---------| +| **Essential** | The request cannot produce a meaningful result without this service | Propagate error to client (4xx/5xx) | Database, auth provider | +| **Enhancement** | The request can succeed with degraded output if this service is unavailable | Catch error, use fallback, log warning | AI captions, email notifications, thumbnail generation, analytics | + +This classification **MUST appear in the project plan** under "Service Dependency Classification". + +--- + +## Pattern: Try/Fallback Wrapper + +When a feature depends on an Enhancement service, wrap the call in try/catch with a sensible default. + +### TypeScript + +```typescript +// BAD β€” AI failure crashes the entire photo upload +const caption = await aiCaption.generateCaption(buffer, mimeType); + +// GOOD β€” AI failure degrades gracefully +let caption: string; +try { + caption = await aiCaption.generateCaption(buffer, mimeType); +} catch (err) { + logger.warn({ err }, 'Caption generation failed, using default'); + caption = 'A special moment πŸ“Έ'; +} +``` + +### Python + +```python +# BAD +caption = await ai_caption.generate_caption(image_buffer, mime_type) + +# GOOD +try: + caption = await ai_caption.generate_caption(image_buffer, mime_type) +except Exception as e: + logger.warning("Caption generation failed, using default", error=str(e)) + caption = "A special moment πŸ“Έ" +``` + +### C# + +```csharp +// BAD +var caption = await _aiCaption.GenerateCaptionAsync(buffer, mimeType); + +// GOOD +string caption; +try +{ + caption = await _aiCaption.GenerateCaptionAsync(buffer, mimeType); +} +catch (Exception ex) +{ + _logger.LogWarning(ex, "Caption generation failed, using default"); + caption = "A special moment πŸ“Έ"; +} +``` + +--- + +## Pattern: Timeouts + +Every external HTTP/SDK call should have a timeout to prevent hanging requests. + +### TypeScript + +```typescript +// Using AbortController (built-in) +async function withTimeout(fn: () => Promise, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fn(); + } finally { + clearTimeout(timeout); + } +} + +// Usage +const caption = await withTimeout( + () => aiCaption.generateCaption(buffer, mimeType), + 10_000 // 10 seconds +); +``` + +### Python + +```python +import asyncio + +async def with_timeout(coro, timeout_seconds: float): + return await asyncio.wait_for(coro, timeout=timeout_seconds) + +# Usage +caption = await with_timeout( + ai_caption.generate_caption(buffer, mime_type), + timeout_seconds=10.0 +) +``` + +### C# + +```csharp +// Using CancellationTokenSource +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); +var caption = await _aiCaption.GenerateCaptionAsync(buffer, mimeType, cts.Token); +``` + +--- + +## Pattern: Retry with Exponential Backoff + +For transient failures (429 Too Many Requests, 503 Service Unavailable, network timeouts), retry with increasing delays. + +### TypeScript + +```typescript +async function withRetry( + fn: () => Promise, + options: { maxRetries?: number; baseDelayMs?: number; retryableErrors?: string[] } = {} +): Promise { + const { maxRetries = 3, baseDelayMs = 500 } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + if (attempt === maxRetries) throw err; + + const isRetryable = err instanceof Error && ( + err.message.includes('ECONNRESET') || + err.message.includes('ETIMEDOUT') || + err.message.includes('429') || + err.message.includes('503') + ); + + if (!isRetryable) throw err; + + const delay = baseDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * delay * 0.1; + await new Promise(r => setTimeout(r, delay + jitter)); + } + } + throw new Error('Unreachable'); +} +``` + +### Python + +```python +import asyncio +import random + +async def with_retry(fn, max_retries=3, base_delay=0.5): + for attempt in range(max_retries + 1): + try: + return await fn() + except Exception as e: + if attempt == max_retries: + raise + delay = base_delay * (2 ** attempt) + jitter = random.uniform(0, delay * 0.1) + await asyncio.sleep(delay + jitter) +``` + +### C# + +```csharp +public static async Task WithRetryAsync( + Func> fn, + int maxRetries = 3, + int baseDelayMs = 500) +{ + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + return await fn(); + } + catch (Exception) when (attempt < maxRetries) + { + var delay = baseDelayMs * Math.Pow(2, attempt); + var jitter = Random.Shared.NextDouble() * delay * 0.1; + await Task.Delay((int)(delay + jitter)); + } + } + throw new InvalidOperationException("Unreachable"); +} +``` + +--- + +## Pattern: Parallel Independent Calls + +When multiple independent external calls are needed for a single request, run them in parallel. Combine with try/fallback for Enhancement services. + +### TypeScript + +```typescript +// BAD β€” sequential (slow, and second call waits for first) +const blobUrl = await storage.upload('photos', blobName, buffer, file.type); +const caption = await aiCaption.generateCaption(buffer, file.type); + +// GOOD β€” parallel, with fallback on enhancement service +const [blobUrl, caption] = await Promise.all([ + storage.upload('photos', blobName, buffer, file.type), // Essential β€” let it throw + aiCaption.generateCaption(buffer, file.type) // Enhancement β€” catch + .catch((err) => { + logger.warn({ err }, 'Caption generation failed'); + return 'A special moment πŸ“Έ'; + }), +]); +``` + +### Python + +```python +import asyncio + +# Parallel with fallback +async def safe_caption(buffer, mime_type): + try: + return await ai_caption.generate_caption(buffer, mime_type) + except Exception as e: + logger.warning("Caption failed", error=str(e)) + return "A special moment πŸ“Έ" + +blob_url, caption = await asyncio.gather( + storage.upload("photos", blob_name, buffer, file_type), + safe_caption(buffer, file_type), +) +``` + +--- + +## Testing Resilience + +Every Enhancement service wrapper must have tests verifying graceful degradation: + +```typescript +describe('uploadPhoto', () => { + it('should succeed with default caption when AI service fails', async () => { + // Arrange: Make AI service throw + mockAICaption.generateCaption = vi.fn().mockRejectedValue(new Error('AI down')); + + // Act: Upload should still succeed + const response = await handler(uploadRequest, mockContext); + + // Assert: Photo created with default caption + expect(response.status).toBe(201); + expect(response.jsonBody.photo.caption).toBe('A special moment πŸ“Έ'); + }); + + it('should fail when storage service fails', async () => { + // Arrange: Make essential service throw + mockStorage.upload = vi.fn().mockRejectedValue(new Error('Storage down')); + + // Act & Assert: Upload should fail + const response = await handler(uploadRequest, mockContext); + expect(response.status).toBe(500); + }); +}); +``` + +--- + +## Checklist + +When implementing a function handler that calls external services: + +- [ ] Classify each service call as Essential or Enhancement +- [ ] Enhancement services are wrapped in try/catch with fallback +- [ ] Essential services propagate errors to the error handler +- [ ] Independent calls are parallelized with `Promise.all` / `asyncio.gather` / `Task.WhenAll` +- [ ] Tests verify the handler succeeds when Enhancement services fail +- [ ] Tests verify the handler fails correctly when Essential services fail diff --git a/resources/agents/azure-project-scaffold/references/runtimes/dotnet.md b/resources/agents/azure-project-scaffold/references/runtimes/dotnet.md new file mode 100644 index 00000000..241dd0e5 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/runtimes/dotnet.md @@ -0,0 +1,844 @@ +# .NET (C# 8) Runtime Reference + +> Azure Functions isolated worker model with .NET 8. xUnit setup, FluentValidation, Serilog logging, and built-in DI patterns. + +--- + +## Azure Functions Isolated Worker Setup + +### Initialization + +```bash +func init src/Functions --dotnet --isolated +cd src/Functions +dotnet add package Microsoft.Azure.Functions.Worker +dotnet add package Microsoft.Azure.Functions.Worker.Sdk +dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http +``` + +### host.json + +```json +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} +``` + +### local.settings.json + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "ASPNETCORE_ENVIRONMENT": "Development", + "STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "DATABASE_URL": "Host=localhost;Port=5432;Database=appdb;Username=localdev;Password=localdevpassword", + "REDIS_URL": "localhost:6379" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} +``` + +### Functions.csproj + +```xml + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + +``` + +### Program.cs (DI Registration) + +```csharp +// Program.cs +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +// Configure Serilog +Log.Logger = new LoggerConfiguration() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .Enrich.FromLogContext() + .CreateLogger(); + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices((context, services) => + { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + + // Register services via DI + var config = AppConfig.Load(); + services.AddSingleton(config); + + // Register service implementations + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register validators + services.AddValidatorsFromAssemblyContaining(); + + // Validate environment on startup + config.Validate(); + }) + .Build(); + +host.Run(); +``` + +--- + +## Function Handler Pattern + +### HTTP Function (Isolated Worker) + +```csharp +// Functions/GetItems.cs +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +public class GetItems +{ + private readonly IDatabaseService _database; + private readonly ILogger _logger; + + public GetItems(IDatabaseService database, ILogger logger) + { + _database = database; + _logger = logger; + } + + [Function("GetItems")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "items")] HttpRequestData req) + { + try + { + var limit = int.TryParse(req.Query["limit"], out var l) ? l : 20; + var offset = int.TryParse(req.Query["offset"], out var o) ? o : 0; + + var items = await _database.FindAllAsync("items", new QueryOptions + { + Limit = limit, + Offset = offset + }); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { items, total = items.Count }); + return response; + } + catch (Exception ex) + { + return ErrorHandler.HandleError(ex, req, _logger); + } + } +} +``` + +### POST with Validation + +```csharp +// Functions/CreateItem.cs +using FluentValidation; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +public class CreateItem +{ + private readonly IDatabaseService _database; + private readonly IValidator _validator; + private readonly ILogger _logger; + + public CreateItem( + IDatabaseService database, + IValidator validator, + ILogger logger) + { + _database = database; + _validator = validator; + _logger = logger; + } + + [Function("CreateItem")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "items")] HttpRequestData req) + { + try + { + var body = await req.ReadFromJsonAsync(); + if (body == null) + throw new BadRequestException("Request body is required"); + + var validationResult = await _validator.ValidateAsync(body); + if (!validationResult.IsValid) + throw new FluentValidation.ValidationException(validationResult.Errors); + + var item = new Item + { + Id = Guid.NewGuid().ToString(), + Name = body.Name, + Description = body.Description ?? "", + Price = body.Price, + Category = body.Category, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + var created = await _database.CreateAsync("items", item); + + var response = req.CreateResponse(HttpStatusCode.Created); + await response.WriteAsJsonAsync(new { item = created }); + return response; + } + catch (Exception ex) + { + return ErrorHandler.HandleError(ex, req, _logger); + } + } +} +``` + +### GET by ID with 404 + +```csharp +// Functions/GetItemById.cs +public class GetItemById +{ + private readonly IDatabaseService _database; + private readonly ILogger _logger; + + public GetItemById(IDatabaseService database, ILogger logger) + { + _database = database; + _logger = logger; + } + + [Function("GetItemById")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "items/{id}")] HttpRequestData req, + string id) + { + try + { + var item = await _database.FindByIdAsync("items", id); + if (item == null) + throw new NotFoundException("Item", id); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { item }); + return response; + } + catch (Exception ex) + { + return ErrorHandler.HandleError(ex, req, _logger); + } + } +} +``` + +### Health Check + +```csharp +// Functions/Health.cs +public class Health +{ + private readonly IDatabaseService _database; + private readonly IStorageService _storage; + private readonly ICacheService _cache; + + public Health(IDatabaseService database, IStorageService storage, ICacheService cache) + { + _database = database; + _storage = storage; + _cache = cache; + } + + [Function("Health")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequestData req) + { + var checks = new Dictionary(); + + try { checks["database"] = await _database.HealthCheckAsync(); } catch { checks["database"] = false; } + try { checks["storage"] = await _storage.HealthCheckAsync(); } catch { checks["storage"] = false; } + try { checks["cache"] = await _cache.HealthCheckAsync(); } catch { checks["cache"] = false; } + + var allHealthy = checks.Values.All(v => v); + var anyHealthy = checks.Values.Any(v => v); + var status = allHealthy ? "healthy" : anyHealthy ? "degraded" : "unhealthy"; + + var response = req.CreateResponse(allHealthy ? HttpStatusCode.OK : HttpStatusCode.ServiceUnavailable); + await response.WriteAsJsonAsync(new { status, services = checks }); + return response; + } +} +``` + +--- + +## Test Project Setup + +### Functions.Tests.csproj + +```xml + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + +``` + +### Fixture Classes + +```csharp +// Fixtures/ItemFixtures.cs +public static class ItemFixtures +{ + public static Item CreateValidItem(string? id = null) => new() + { + Id = id ?? Guid.NewGuid().ToString(), + Name = "Test Widget", + Description = "A test widget for unit testing", + Price = 29.99m, + Category = "widgets", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + public static CreateItemRequest CreateValidRequest() => new() + { + Name = "New Widget", + Description = "A brand new widget", + Price = 19.99m, + Category = "widgets", + }; + + public static CreateItemRequest CreateInvalidRequest_EmptyName() => new() + { + Name = "", + Description = "Missing name", + Price = 19.99m, + Category = "widgets", + }; + + public static CreateItemRequest CreateInvalidRequest_NegativePrice() => new() + { + Name = "Widget", + Description = "Negative price", + Price = -5.00m, + Category = "widgets", + }; + + public static List CreateItemList(int count = 5) => + Enumerable.Range(1, count) + .Select(i => CreateValidItem($"item-{i:D3}")) + .ToList(); +} +``` + +### Mock Service + +```csharp +// Mocks/MockDatabaseService.cs +public class MockDatabaseService : IDatabaseService +{ + private readonly Dictionary> _stores = new(); + + public MockDatabaseService(Dictionary>? initialData = null) + { + if (initialData != null) + { + foreach (var (collection, items) in initialData) + { + _stores[collection] = new Dictionary(); + foreach (dynamic item in items) + { + _stores[collection][(string)item.Id] = item; + } + } + } + } + + public Task> FindAllAsync(string collection, QueryOptions? options = null) + { + if (!_stores.ContainsKey(collection)) + return Task.FromResult(new List()); + + var items = _stores[collection].Values.Cast().ToList(); + if (options != null) + { + items = items.Skip(options.Offset).Take(options.Limit).ToList(); + } + return Task.FromResult(items); + } + + public Task FindByIdAsync(string collection, string id) + { + if (!_stores.ContainsKey(collection) || !_stores[collection].ContainsKey(id)) + return Task.FromResult(default); + return Task.FromResult((T?)_stores[collection][id]); + } + + public Task CreateAsync(string collection, T data) + { + if (!_stores.ContainsKey(collection)) + _stores[collection] = new Dictionary(); + + dynamic item = data!; + _stores[collection][(string)item.Id] = data!; + return Task.FromResult(data); + } + + public Task UpdateAsync(string collection, string id, object data) + { + if (!_stores.ContainsKey(collection) || !_stores[collection].ContainsKey(id)) + return Task.FromResult(default); + + // Simplified: replace entire object + _stores[collection][id] = data; + return Task.FromResult((T?)data); + } + + public Task DeleteAsync(string collection, string id) + { + if (!_stores.ContainsKey(collection)) + return Task.FromResult(false); + return Task.FromResult(_stores[collection].Remove(id)); + } + + public Task HealthCheckAsync() => Task.FromResult(true); +} +``` + +### Test Examples + +```csharp +// Functions/GetItemsTests.cs +using Moq; +using FluentAssertions; + +public class GetItemsTests +{ + private readonly Mock _mockDb; + private readonly Mock> _mockLogger; + + public GetItemsTests() + { + _mockDb = new Mock(); + _mockLogger = new Mock>(); + } + + [Fact] + public async Task GetItems_ReturnsAllItems() + { + var items = ItemFixtures.CreateItemList(3); + _mockDb.Setup(db => db.FindAllAsync("items", It.IsAny())) + .ReturnsAsync(items); + + // Note: In a real test, you'd use WebApplicationFactory or construct + // the function class directly and invoke the handler + var result = await _mockDb.Object.FindAllAsync("items"); + + result.Should().HaveCount(3); + } + + [Fact] + public async Task GetItems_ReturnsEmptyList_WhenNoItems() + { + _mockDb.Setup(db => db.FindAllAsync("items", It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _mockDb.Object.FindAllAsync("items"); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetItemById_ReturnsItem_WhenExists() + { + var item = ItemFixtures.CreateValidItem("test-001"); + _mockDb.Setup(db => db.FindByIdAsync("items", "test-001")) + .ReturnsAsync(item); + + var result = await _mockDb.Object.FindByIdAsync("items", "test-001"); + + result.Should().NotBeNull(); + result!.Id.Should().Be("test-001"); + } + + [Fact] + public async Task GetItemById_ReturnsNull_WhenNotFound() + { + _mockDb.Setup(db => db.FindByIdAsync("items", "nonexistent")) + .ReturnsAsync((Item?)null); + + var result = await _mockDb.Object.FindByIdAsync("items", "nonexistent"); + + result.Should().BeNull(); + } +} +``` + +```csharp +// Validation/CreateItemValidatorTests.cs +using FluentValidation.TestHelper; + +public class CreateItemValidatorTests +{ + private readonly CreateItemValidator _validator = new(); + + [Fact] + public void Valid_Input_Passes() + { + var request = ItemFixtures.CreateValidRequest(); + var result = _validator.TestValidate(request); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Empty_Name_Fails() + { + var request = ItemFixtures.CreateInvalidRequest_EmptyName(); + var result = _validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Negative_Price_Fails() + { + var request = ItemFixtures.CreateInvalidRequest_NegativePrice(); + var result = _validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Price); + } + + [Fact] + public void Missing_Category_Fails() + { + var request = new CreateItemRequest + { + Name = "Widget", + Price = 29.99m, + Category = "" + }; + var result = _validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Category); + } +} +``` + +```csharp +// Errors/ErrorHandlerTests.cs +public class ErrorHandlerTests +{ + [Fact] + public void NotFound_Returns_404() + { + var error = new NotFoundException("Item", "abc-123"); + error.StatusCode.Should().Be(404); + error.Code.Should().Be("NOT_FOUND"); + error.Message.Should().Contain("abc-123"); + } + + [Fact] + public void ValidationException_Returns_422() + { + var error = new Errors.ValidationException("Bad input"); + error.StatusCode.Should().Be(422); + error.Code.Should().Be("VALIDATION_ERROR"); + } + + [Fact] + public void BadRequest_Returns_400() + { + var error = new BadRequestException("Missing field"); + error.StatusCode.Should().Be(400); + error.Code.Should().Be("BAD_REQUEST"); + } + + [Fact] + public void All_Errors_Have_Consistent_Shape() + { + var errors = new AppException[] + { + new NotFoundException("Item", "1"), + new Errors.ValidationException("Bad input"), + new BadRequestException("Bad request"), + }; + + foreach (var error in errors) + { + error.StatusCode.Should().BeGreaterThan(0); + error.Code.Should().NotBeNullOrEmpty(); + error.Message.Should().NotBeNullOrEmpty(); + } + } +} +``` + +--- + +## Validation β€” FluentValidation + +### Validator Definition + +```csharp +// Shared/Validators/CreateItemValidator.cs +using FluentValidation; + +public class CreateItemValidator : AbstractValidator +{ + public CreateItemValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(255); + + RuleFor(x => x.Price) + .GreaterThan(0).WithMessage("Price must be positive"); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(100); + } +} + +public class UpdateItemValidator : AbstractValidator +{ + public UpdateItemValidator() + { + RuleFor(x => x.Name) + .MaximumLength(255) + .When(x => x.Name != null); + + RuleFor(x => x.Price) + .GreaterThan(0) + .When(x => x.Price.HasValue); + + RuleFor(x => x.Category) + .MaximumLength(100) + .When(x => x.Category != null); + } +} +``` + +--- + +## Structured Logging β€” Serilog + +### Logger Setup + +```csharp +// Already configured in Program.cs (see above) +// Usage in functions via ILogger injection: + +public class CreateItem +{ + private readonly ILogger _logger; + + public CreateItem(ILogger logger) + { + _logger = logger; + } + + // In handler: + _logger.LogInformation("Creating item {ItemName} in category {Category}", + body.Name, body.Category); +} +``` + +--- + +## Shared Types + +```csharp +// Shared/Models/Item.cs +public class Item +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public decimal Price { get; set; } + public string Category { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +// Shared/Models/ApiContracts.cs +public class CreateItemRequest +{ + public string Name { get; set; } = ""; + public string? Description { get; set; } + public decimal Price { get; set; } + public string Category { get; set; } = ""; +} + +public class UpdateItemRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public decimal? Price { get; set; } + public string? Category { get; set; } +} + +public class ListItemsResponse +{ + public List Items { get; set; } = new(); + public int Total { get; set; } +} + +public class ErrorResponse +{ + public ErrorDetail Error { get; set; } = new(); +} + +public class ErrorDetail +{ + public string Code { get; set; } = ""; + public string Message { get; set; } = ""; + public object? Details { get; set; } +} + +public class HealthResponse +{ + public string Status { get; set; } = ""; + public Dictionary Services { get; set; } = new(); +} +``` + +--- + +## Config with Validation + +```csharp +// Services/Config.cs +public class AppConfig +{ + public string StorageConnectionString { get; set; } = ""; + public string DatabaseUrl { get; set; } = ""; + public string RedisUrl { get; set; } = ""; + public bool IsDevelopment { get; set; } + + public static AppConfig Load() + { + return new AppConfig + { + StorageConnectionString = Environment.GetEnvironmentVariable("STORAGE_CONNECTION_STRING") + ?? "UseDevelopmentStorage=true", + DatabaseUrl = Environment.GetEnvironmentVariable("DATABASE_URL") + ?? "Host=localhost;Port=5432;Database=appdb;Username=localdev;Password=localdevpassword", + RedisUrl = Environment.GetEnvironmentVariable("REDIS_URL") + ?? "localhost:6379", + IsDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development", + }; + } + + public void Validate() + { + var missing = new List(); + // Add required var checks here based on services used + // Example: + // if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DATABASE_URL"))) + // missing.Add("DATABASE_URL β€” PostgreSQL connection string"); + + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Missing required environment variables:\n{string.Join("\n", missing.Select(m => $" - {m}"))}\n\nCopy .env.example to .env and fill in the values."); + } + } +} +``` + +--- + +## Dependencies Quick Reference + +### Core NuGet Packages + +| Package | Purpose | +|---------|---------| +| `Microsoft.Azure.Functions.Worker` | Functions runtime | +| `Microsoft.Azure.Functions.Worker.Extensions.Http` | HTTP trigger | +| `FluentValidation` | Input validation | +| `Serilog` + `Serilog.Sinks.Console` | Structured logging | + +### Per Service + +| Service | Package | +|---------|---------| +| Blob Storage | `Azure.Storage.Blobs` | +| PostgreSQL | `Npgsql` | +| CosmosDB | `Microsoft.Azure.Cosmos` | +| Redis | `StackExchange.Redis` | +| Migrations | `Microsoft.EntityFrameworkCore`, `Npgsql.EntityFrameworkCore.PostgreSQL` | + +### Test Packages + +| Package | Purpose | +|---------|---------| +| `xunit` + `xunit.runner.visualstudio` | Test runner | +| `Microsoft.NET.Test.Sdk` | Test SDK | +| `Moq` | Mocking | +| `FluentAssertions` | Readable assertions | +| `coverlet.collector` | Code coverage | +| `FluentValidation.TestHelper` | Validation test helpers | diff --git a/resources/agents/azure-project-scaffold/references/runtimes/python.md b/resources/agents/azure-project-scaffold/references/runtimes/python.md new file mode 100644 index 00000000..2e993122 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/runtimes/python.md @@ -0,0 +1,577 @@ +# Python Runtime Reference + +> Azure Functions v2 programming model with Python. pytest setup, Pydantic validation, structlog logging, and DI patterns. + +--- + +## Azure Functions v2 Setup + +### Initialization + +```bash +func init src/functions --python --model V2 +cd src/functions +pip install -r requirements.txt +``` + +### host.json + +```json +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} +``` + +### local.settings.json + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "ENVIRONMENT": "development", + "STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "DATABASE_URL": "postgresql://localdev:localdevpassword@localhost:5432/appdb", + "REDIS_URL": "redis://localhost:6379" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} +``` + +### pyproject.toml + +```toml +[project] +name = "functions" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = [ + "azure-functions>=1.17.0", + "pydantic>=2.0.0", + "structlog>=23.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "ruff>=0.1.0", + "httpx>=0.25.0", +] + +# Add per-service dependencies as needed: +# "azure-storage-blob>=12.0.0", +# "psycopg2-binary>=2.9.0", +# "redis>=5.0.0", + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +``` + +--- + +## Function Handler Pattern + +### function_app.py (Registration) + +```python +# function_app.py +import azure.functions as func +from services.registry import initialize_services + +# Initialize services on cold start +initialize_services() + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +# Import function handlers β€” they register themselves via decorators +import functions.get_items # noqa: F401 +import functions.create_item # noqa: F401 +import functions.get_item_by_id # noqa: F401 +import functions.health # noqa: F401 +``` + +### HTTP Function (v2 Model) + +```python +# functions/get_items.py +import azure.functions as func +import json +from services.registry import get_services +from errors.error_handler import handle_error +from function_app import app + +@app.route(route="items", methods=["GET"]) +async def get_items(req: func.HttpRequest) -> func.HttpResponse: + try: + services = get_services() + limit = int(req.params.get("limit", "20")) + offset = int(req.params.get("offset", "0")) + + items = await services.database.find_all("items", limit=limit, offset=offset) + + return func.HttpResponse( + json.dumps({"items": items, "total": len(items)}), + status_code=200, + mimetype="application/json", + ) + except Exception as e: + return handle_error(e) +``` + +### POST with Validation + +```python +# functions/create_item.py +import azure.functions as func +import json +import uuid +from datetime import datetime, timezone +from services.registry import get_services +from errors.error_handler import handle_error +from shared.validation import CreateItemRequest +from function_app import app + +@app.route(route="items", methods=["POST"]) +async def create_item(req: func.HttpRequest) -> func.HttpResponse: + try: + # Validate input + body = req.get_json() + validated = CreateItemRequest(**body) + + services = get_services() + now = datetime.now(timezone.utc).isoformat() + + item = { + "id": str(uuid.uuid4()), + **validated.model_dump(), + "created_at": now, + "updated_at": now, + } + + created = await services.database.create("items", item) + + return func.HttpResponse( + json.dumps({"item": created}), + status_code=201, + mimetype="application/json", + ) + except Exception as e: + return handle_error(e) +``` + +### GET by ID with 404 + +```python +# functions/get_item_by_id.py +import azure.functions as func +import json +from services.registry import get_services +from errors.error_handler import handle_error +from errors.error_types import NotFoundError +from function_app import app + +@app.route(route="items/{id}", methods=["GET"]) +async def get_item_by_id(req: func.HttpRequest) -> func.HttpResponse: + try: + services = get_services() + item_id = req.route_params.get("id") + + item = await services.database.find_by_id("items", item_id) + if item is None: + raise NotFoundError("Item", item_id) + + return func.HttpResponse( + json.dumps({"item": item}), + status_code=200, + mimetype="application/json", + ) + except Exception as e: + return handle_error(e) +``` + +### Health Check + +```python +# functions/health.py +import azure.functions as func +import json +from services.registry import get_services +from function_app import app + +@app.route(route="health", methods=["GET"]) +async def health(req: func.HttpRequest) -> func.HttpResponse: + services = get_services() + checks = {} + + try: + checks["database"] = await services.database.health_check() + except Exception: + checks["database"] = False + + try: + checks["storage"] = await services.storage.health_check() + except Exception: + checks["storage"] = False + + try: + checks["cache"] = await services.cache.health_check() + except Exception: + checks["cache"] = False + + all_healthy = all(checks.values()) + any_healthy = any(checks.values()) + status = "healthy" if all_healthy else ("degraded" if any_healthy else "unhealthy") + + return func.HttpResponse( + json.dumps({"status": status, "services": checks}), + status_code=200 if all_healthy else 503, + mimetype="application/json", + ) +``` + +--- + +## pytest Configuration + +### conftest.py (Global Test Setup) + +```python +# tests/conftest.py +import pytest +import json +from pathlib import Path +from services.registry import register_services, clear_services, ServiceRegistry +from tests.mocks.mock_database import MockDatabaseService +from tests.mocks.mock_storage import MockStorageService +from tests.mocks.mock_cache import MockCacheService + +@pytest.fixture(autouse=True) +def setup_mock_services(item_fixtures): + """Register mock services before each test, clear after.""" + register_services( + ServiceRegistry( + database=MockDatabaseService({"items": item_fixtures["validItems"]}), + storage=MockStorageService(), + cache=MockCacheService(), + ) + ) + yield + clear_services() + +@pytest.fixture +def item_fixtures(): + fixture_path = Path(__file__).parent / "fixtures" / "items.json" + with open(fixture_path) as f: + return json.load(f) + +@pytest.fixture +def valid_item(item_fixtures): + return item_fixtures["validItems"][0] + +@pytest.fixture +def invalid_items(item_fixtures): + return item_fixtures["invalidItems"] + +@pytest.fixture +def mock_database(item_fixtures): + return MockDatabaseService({"items": item_fixtures["validItems"]}) +``` + +### Test Examples + +```python +# tests/test_get_items.py +import pytest +from unittest.mock import AsyncMock +from services.registry import get_services + +async def test_get_items_returns_all_items(item_fixtures): + services = get_services() + items = await services.database.find_all("items") + assert len(items) == len(item_fixtures["validItems"]) + +async def test_get_items_returns_empty_when_no_data(): + from services.registry import register_services, ServiceRegistry + from tests.mocks.mock_database import MockDatabaseService + from tests.mocks.mock_storage import MockStorageService + from tests.mocks.mock_cache import MockCacheService + + register_services( + ServiceRegistry( + database=MockDatabaseService(), + storage=MockStorageService(), + cache=MockCacheService(), + ) + ) + services = get_services() + items = await services.database.find_all("items") + assert items == [] + +async def test_get_item_by_id_returns_item(valid_item): + services = get_services() + item = await services.database.find_by_id("items", valid_item["id"]) + assert item is not None + assert item["id"] == valid_item["id"] + +async def test_get_item_by_id_returns_none_for_missing(): + services = get_services() + item = await services.database.find_by_id("items", "nonexistent-id") + assert item is None +``` + +```python +# tests/test_validation.py +import pytest +from pydantic import ValidationError +from shared.validation import CreateItemRequest + +def test_valid_create_item(): + item = CreateItemRequest( + name="Widget", description="A widget", price=29.99, category="widgets" + ) + assert item.name == "Widget" + assert item.price == 29.99 + +def test_empty_name_fails(): + with pytest.raises(ValidationError): + CreateItemRequest(name="", description="A widget", price=29.99, category="widgets") + +def test_negative_price_fails(): + with pytest.raises(ValidationError): + CreateItemRequest(name="Widget", description="A widget", price=-5.0, category="widgets") + +def test_missing_required_fields(): + with pytest.raises(ValidationError): + CreateItemRequest(description="Just a description") +``` + +```python +# tests/test_error_handler.py +import json +from errors.error_types import NotFoundError, ValidationError, BadRequestError +from errors.error_handler import handle_error + +def test_not_found_error_returns_404(): + error = NotFoundError("Item", "abc-123") + response = handle_error(error) + body = json.loads(response.get_body()) + + assert response.status_code == 404 + assert body["error"]["code"] == "NOT_FOUND" + assert "abc-123" in body["error"]["message"] + +def test_validation_error_returns_422(): + error = ValidationError("Bad input", details=[{"field": "name", "message": "Required"}]) + response = handle_error(error) + body = json.loads(response.get_body()) + + assert response.status_code == 422 + assert body["error"]["code"] == "VALIDATION_ERROR" + +def test_unknown_error_returns_500(): + error = RuntimeError("Something broke") + response = handle_error(error) + body = json.loads(response.get_body()) + + assert response.status_code == 500 + assert body["error"]["code"] == "INTERNAL_ERROR" + +def test_error_response_shape_is_consistent(): + errors = [ + NotFoundError("Item", "1"), + ValidationError("Bad input"), + BadRequestError("Bad request"), + RuntimeError("Unknown"), + ] + for error in errors: + response = handle_error(error) + body = json.loads(response.get_body()) + assert "error" in body + assert "code" in body["error"] + assert "message" in body["error"] +``` + +--- + +## Validation β€” Pydantic + +### Schema Definition + +```python +# shared/validation.py +from pydantic import BaseModel, Field +from typing import Optional + +class CreateItemRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + description: Optional[str] = "" + price: float = Field(gt=0) + category: str = Field(min_length=1, max_length=100) + +class UpdateItemRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + category: Optional[str] = Field(None, min_length=1, max_length=100) + +class PaginationParams(BaseModel): + limit: int = Field(20, ge=1, le=100) + offset: int = Field(0, ge=0) +``` + +--- + +## Structured Logging β€” structlog + +### Logger Setup + +```python +# logger.py +import structlog +import os +import logging + +def setup_logging(): + log_level = os.environ.get("LOG_LEVEL", "INFO").upper() + + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer() + if os.environ.get("ENVIRONMENT") == "development" + else structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, log_level, logging.INFO) + ), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + +def get_logger(name: str = "app"): + return structlog.get_logger(name) +``` + +### Request Logging + +```python +# middleware/request_logger.py +import time +from logger import get_logger + +logger = get_logger("http") + +def log_request(method: str, path: str, status_code: int, start_time: float): + duration_ms = round((time.time() - start_time) * 1000, 2) + logger.info( + "request_completed", + method=method, + path=path, + status=status_code, + duration_ms=duration_ms, + ) +``` + +--- + +## Shared Types + +```python +# shared/types.py +from pydantic import BaseModel +from typing import Optional, Any +from datetime import datetime + +class Item(BaseModel): + id: str + name: str + description: str + price: float + category: str + created_at: datetime + updated_at: datetime + +class ListItemsResponse(BaseModel): + items: list[Item] + total: int + +class SingleItemResponse(BaseModel): + item: Item + +class ErrorDetail(BaseModel): + code: str + message: str + details: Optional[Any] = None + +class ErrorResponse(BaseModel): + error: ErrorDetail + +class HealthResponse(BaseModel): + status: str # "healthy" | "degraded" | "unhealthy" + services: dict[str, bool] +``` + +--- + +## Dependencies Quick Reference + +### Core Dependencies + +| Package | Purpose | +|---------|---------| +| `azure-functions` | Azure Functions v2 runtime | +| `pydantic` | Input validation & shared types | +| `structlog` | Structured logging | + +### Per Service + +| Service | Package | +|---------|---------| +| Blob Storage | `azure-storage-blob` | +| PostgreSQL | `psycopg2-binary` | +| CosmosDB | `azure-cosmos` | +| Redis | `redis` | +| Migrations | `alembic`, `sqlalchemy` | + +### Dev Dependencies + +| Package | Purpose | +|---------|---------| +| `pytest` | Test runner | +| `pytest-asyncio` | Async test support | +| `pytest-cov` | Coverage reporting | +| `ruff` | Linting + formatting | +| `httpx` | HTTP client for request-level tests | diff --git a/resources/agents/azure-project-scaffold/references/runtimes/typescript.md b/resources/agents/azure-project-scaffold/references/runtimes/typescript.md new file mode 100644 index 00000000..2b509b86 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/runtimes/typescript.md @@ -0,0 +1,522 @@ +# TypeScript (Node.js) Runtime Reference + +> Azure Functions v4 programming model with TypeScript. Test runner setup, validation, logging, and DI patterns. + +--- + +## Azure Functions v4 Setup + +### Initialization + +```bash +func init src/functions --typescript --model V4 +cd src/functions +npm install +``` + +### host.json + +```json +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} +``` + +### local.settings.json + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node", + "NODE_ENV": "development", + "STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "DATABASE_URL": "postgresql://localdev:localdevpassword@localhost:5432/appdb", + "REDIS_URL": "redis://localhost:6379" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} +``` + +### package.json (backend) + +```json +{ + "name": "functions", + "version": "1.0.0", + "private": true, + "main": "dist/src/functions/*.js", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "start": "func start", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src/ tests/", + "db:migrate": "knex migrate:latest", + "db:seed": "tsx seeds/seed.ts" + }, + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^1.0.0" + } +} +``` + +> Adjust `scripts.test` for the user's selected test runner. + +--- + +## Function Handler Pattern + +### HTTP Function (v4 Model) + +```typescript +// src/functions/getItems.ts +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { Item } from '../../shared/types/entities'; + +app.http('getItems', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'items', + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + try { + const { database } = getServices(); + const limit = Number(request.query.get('limit')) || 20; + const offset = Number(request.query.get('offset')) || 0; + + const items = await database.findAll('items', { limit, offset }); + + return { + status: 200, + jsonBody: { items, total: items.length }, + }; + } catch (error) { + return handleError(error, context); + } + }, +}); +``` + +### POST with Validation + +```typescript +// src/functions/createItem.ts +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { validateBody } from '../middleware/validateRequest'; +import { createItemSchema } from '../../shared/schemas/validation'; +import { v4 as uuid } from 'uuid'; + +app.http('createItem', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'items', + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + try { + const body = await validateBody(request, createItemSchema); + const { database } = getServices(); + + const item = { + id: uuid(), + ...body, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const created = await database.create('items', item); + return { status: 201, jsonBody: { item: created } }; + } catch (error) { + return handleError(error, context); + } + }, +}); +``` + +### GET by ID with 404 Handling + +```typescript +// src/functions/getItemById.ts +import { app } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { NotFoundError } from '../errors/errorTypes'; +import { Item } from '../../shared/types/entities'; + +app.http('getItemById', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'items/{id}', + handler: async (request, context) => { + try { + const { database } = getServices(); + const id = request.params.id; + + const item = await database.findById('items', id); + if (!item) { + throw new NotFoundError('Item', id); + } + + return { jsonBody: { item } }; + } catch (error) { + return handleError(error, context); + } + }, +}); +``` + +### Health Check + +```typescript +// src/functions/health.ts +import { app } from '@azure/functions'; +import { getServices } from '../services/registry'; + +app.http('health', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'health', + handler: async (request, context) => { + const services = getServices(); + + const checks: Record = {}; + + // Check each service + try { checks.database = await services.database.healthCheck(); } catch { checks.database = false; } + try { checks.storage = await services.storage.healthCheck(); } catch { checks.storage = false; } + try { checks.cache = await services.cache.healthCheck(); } catch { checks.cache = false; } + + const allHealthy = Object.values(checks).every(v => v); + const anyHealthy = Object.values(checks).some(v => v); + + const status = allHealthy ? 'healthy' : anyHealthy ? 'degraded' : 'unhealthy'; + + return { + status: allHealthy ? 200 : 503, + jsonBody: { status, services: checks }, + }; + }, +}); +``` + +--- + +## Test Runner Configurations + +### vitest + +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/interfaces/**', 'src/functions/*.ts'], + }, + }, +}); +``` + +```typescript +// tests/setup.ts +import { registerServices, clearServices } from '../src/services/registry'; +import { MockDatabaseService } from './mocks/mockDatabase'; +import { MockStorageService } from './mocks/mockStorage'; +import { MockCacheService } from './mocks/mockCache'; +import itemFixtures from './fixtures/items.json'; + +beforeEach(() => { + registerServices({ + database: new MockDatabaseService({ items: itemFixtures.validItems }), + storage: new MockStorageService(), + cache: new MockCacheService(), + }); +}); + +afterEach(() => { + clearServices(); +}); +``` + +### jest + +```typescript +// jest.config.ts +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + setupFilesAfterSetup: ['/tests/setup.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/interfaces/**', + ], +}; +``` + +### mocha + chai + sinon + +```yaml +# .mocharc.yml +require: + - tsx + - tests/setup.ts +spec: 'tests/**/*.test.ts' +recursive: true +timeout: 10000 +``` + +```typescript +// tests/setup.ts (mocha version) +import { registerServices, clearServices } from '../src/services/registry'; +import { MockDatabaseService } from './mocks/mockDatabase'; +import { MockStorageService } from './mocks/mockStorage'; +import { MockCacheService } from './mocks/mockCache'; +import itemFixtures from './fixtures/items.json'; + +beforeEach(() => { + registerServices({ + database: new MockDatabaseService({ items: itemFixtures.validItems }), + storage: new MockStorageService(), + cache: new MockCacheService(), + }); +}); + +afterEach(() => { + clearServices(); +}); +``` + +--- + +## Validation β€” Zod + +### Schema Definition + +```typescript +// shared/schemas/validation.ts +import { z } from 'zod'; + +export const createItemSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + description: z.string().optional().default(''), + price: z.number().positive('Price must be positive'), + category: z.string().min(1, 'Category is required').max(100), +}); + +export const updateItemSchema = createItemSchema.partial(); + +export const paginationSchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type CreateItemRequest = z.infer; +export type UpdateItemRequest = z.infer; +``` + +--- + +## Structured Logging β€” pino + +### Logger Setup + +```typescript +// src/logger.ts +import pino from 'pino'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: process.env.NODE_ENV === 'development' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +export function getLogger(name?: string) { + return name ? logger.child({ module: name }) : logger; +} +``` + +### Request Logging Middleware + +```typescript +// middleware/requestLogger.ts +import { HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getLogger } from '../logger'; + +const logger = getLogger('http'); + +export function logRequest( + request: HttpRequest, + response: HttpResponseInit, + context: InvocationContext, + durationMs: number +): void { + logger.info({ + method: request.method, + path: request.url, + status: response.status || 200, + durationMs, + functionName: context.functionName, + }, `${request.method} ${request.url} ${response.status || 200} ${durationMs}ms`); +} +``` + +--- + +## Shared Types + +```typescript +// shared/types/entities.ts +export interface Item { + id: string; + name: string; + description: string; + price: number; + category: string; + createdAt: string; + updatedAt: string; +} +``` + +```typescript +// shared/types/api.ts +import { Item } from './entities'; + +// Response contracts +export interface ListItemsResponse { + items: Item[]; + total: number; +} + +export interface SingleItemResponse { + item: Item; +} + +export interface ErrorResponse { + error: { + code: string; + message: string; + details: unknown | null; + }; +} + +export interface HealthResponse { + status: 'healthy' | 'degraded' | 'unhealthy'; + services: Record; +} +``` + +--- + +## ESLint Configuration + +```json +// .eslintrc.json +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "env": { + "node": true, + "es2022": true + } +} +``` + +--- + +## Dependencies Quick Reference + +### Core Dependencies + +| Package | Purpose | +|---------|---------| +| `@azure/functions` | Azure Functions v4 runtime | +| `zod` | Input validation | +| `pino` | Structured logging | +| `uuid` | ID generation | + +### Per Service + +| Service | Package | +|---------|---------| +| Blob Storage | `@azure/storage-blob` | +| PostgreSQL | `pg`, `@types/pg` | +| CosmosDB | `@azure/cosmos` | +| Redis | `ioredis` | +| Migrations | `knex` | + +### Dev Dependencies + +| Package | Purpose | +|---------|---------| +| `typescript` | TypeScript compiler | +| `vitest` / `jest` / `mocha` | Test runner (user's choice) | +| `eslint` + `@typescript-eslint/*` | Linting | +| `prettier` | Formatting | +| `tsx` | TypeScript execution (for scripts) | +| `pino-pretty` | Dev log formatting | diff --git a/resources/agents/azure-project-scaffold/references/seed-data.md b/resources/agents/azure-project-scaffold/references/seed-data.md new file mode 100644 index 00000000..d1df841b --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/seed-data.md @@ -0,0 +1,447 @@ +# Seed Data & Migrations + +> Database schema management and realistic test data seeding patterns. + +--- + +## Core Principle + +**Repeatable, idempotent schema and data management.** Migrations run forward and backward cleanly. Seed data is realistic and can be used in both development and tests. Running seed twice does not duplicate data. + +--- + +## When This Applies + +This reference is used **only when the project includes a database service** (PostgreSQL, CosmosDB, Azure SQL). If the project only uses Blob Storage, Queue Storage, or Redis, skip this reference. + +--- + +## Migration Patterns + +### TypeScript β€” Knex Migrations + +#### Setup + +```bash +npm install knex pg +npm install -D @types/pg +``` + +```typescript +// knexfile.ts +import { loadConfig } from './src/services/config'; + +const config = loadConfig(); + +export default { + client: 'pg', + connection: config.database.url, + migrations: { + directory: './seeds/migrations', + extension: 'ts', + }, + seeds: { + directory: './seeds/data', + extension: 'ts', + }, +}; +``` + +#### Migration File + +```typescript +// seeds/migrations/20260101000000_create_items.ts +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('items', (table) => { + table.uuid('id').primary().defaultTo(knex.fn.uuid()); + table.string('name').notNullable(); + table.text('description'); + table.decimal('price', 10, 2).notNullable(); + table.string('category').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // Add indexes + await knex.schema.alterTable('items', (table) => { + table.index('category'); + table.index('created_at'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('items'); +} +``` + +#### Seed Script + +```typescript +// seeds/seed.ts +import knex from 'knex'; +import knexConfig from '../knexfile'; +import seedData from './fixtures/seed-data.json'; + +async function seed() { + const db = knex(knexConfig); + + try { + // Run migrations first + await db.migrate.latest(); + console.log('Migrations applied.'); + + // Seed data β€” idempotent (upsert) + for (const item of seedData.items) { + await db('items') + .insert(item) + .onConflict('id') + .merge(); + } + console.log(`Seeded ${seedData.items.length} items.`); + } finally { + await db.destroy(); + } +} + +seed().catch(console.error); +``` + +#### Seed Data Fixture + +```json +// seeds/fixtures/seed-data.json +{ + "items": [ + { + "id": "seed-001", + "name": "Sample Widget", + "description": "A sample widget for development", + "price": 29.99, + "category": "widgets", + "created_at": "2026-01-15T10:00:00.000Z", + "updated_at": "2026-01-15T10:00:00.000Z" + }, + { + "id": "seed-002", + "name": "Demo Gadget", + "description": "A demo gadget for development", + "price": 49.99, + "category": "gadgets", + "created_at": "2026-02-01T14:00:00.000Z", + "updated_at": "2026-02-01T14:00:00.000Z" + }, + { + "id": "seed-003", + "name": "Test Doohickey", + "description": "A test doohickey for development", + "price": 9.99, + "category": "doohickeys", + "created_at": "2026-03-01T08:00:00.000Z", + "updated_at": "2026-03-01T08:00:00.000Z" + } + ] +} +``` + +#### Package.json Scripts + +```json +{ + "scripts": { + "db:migrate": "knex migrate:latest", + "db:migrate:rollback": "knex migrate:rollback", + "db:seed": "tsx seeds/seed.ts", + "db:reset": "knex migrate:rollback --all && knex migrate:latest && npm run db:seed" + } +} +``` + +--- + +### Python β€” Alembic Migrations + +#### Setup + +```bash +pip install alembic psycopg2-binary sqlalchemy +``` + +```ini +# alembic.ini (key settings) +[alembic] +script_location = seeds/migrations +sqlalchemy.url = postgresql://localdev:localdevpassword@localhost:5432/appdb +``` + +#### Migration File + +```python +# seeds/migrations/versions/001_create_items.py +"""create items table""" +from alembic import op +import sqlalchemy as sa + +revision = '001' +down_revision = None + +def upgrade(): + op.create_table( + 'items', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('description', sa.Text), + sa.Column('price', sa.Numeric(10, 2), nullable=False), + sa.Column('category', sa.String(100), nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now()), + ) + op.create_index('ix_items_category', 'items', ['category']) + op.create_index('ix_items_created_at', 'items', ['created_at']) + +def downgrade(): + op.drop_table('items') +``` + +#### Seed Script + +```python +# seeds/seed.py +import json +from pathlib import Path +from services.config import load_config +import psycopg2 +from psycopg2.extras import execute_values + +def seed(): + config = load_config() + fixture_path = Path(__file__).parent / "fixtures" / "seed-data.json" + + with open(fixture_path) as f: + data = json.load(f) + + conn = psycopg2.connect(config.database_url) + try: + with conn.cursor() as cur: + for item in data["items"]: + cur.execute( + """ + INSERT INTO items (id, name, description, price, category, created_at, updated_at) + VALUES (%(id)s, %(name)s, %(description)s, %(price)s, %(category)s, %(created_at)s, %(updated_at)s) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + price = EXCLUDED.price, + category = EXCLUDED.category, + updated_at = EXCLUDED.updated_at + """, + item, + ) + conn.commit() + print(f"Seeded {len(data['items'])} items.") + finally: + conn.close() + +if __name__ == "__main__": + seed() +``` + +--- + +### C# β€” Entity Framework Core Migrations + +#### Setup + +```bash +dotnet add package Microsoft.EntityFrameworkCore +dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL +dotnet add package Microsoft.EntityFrameworkCore.Design +dotnet tool install --global dotnet-ef +``` + +#### DbContext + +```csharp +// Data/AppDbContext.cs +public class AppDbContext : DbContext +{ + public DbSet Items => Set(); + + public AppDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(255); + entity.Property(e => e.Price).IsRequired().HasPrecision(10, 2); + entity.Property(e => e.Category).IsRequired().HasMaxLength(100); + entity.HasIndex(e => e.Category); + entity.HasIndex(e => e.CreatedAt); + }); + } +} +``` + +#### Seed Data + +```csharp +// Seeds/SeedData.cs +public static class SeedData +{ + public static async Task SeedAsync(AppDbContext context) + { + if (await context.Items.AnyAsync()) return; // Idempotent + + var items = new List + { + new() { Id = "seed-001", Name = "Sample Widget", Description = "A sample widget", Price = 29.99m, Category = "widgets" }, + new() { Id = "seed-002", Name = "Demo Gadget", Description = "A demo gadget", Price = 49.99m, Category = "gadgets" }, + new() { Id = "seed-003", Name = "Test Doohickey", Description = "A test doohickey", Price = 9.99m, Category = "doohickeys" }, + }; + + context.Items.AddRange(items); + await context.SaveChangesAsync(); + } +} +``` + +#### Commands + +```bash +dotnet ef migrations add CreateItems +dotnet ef database update +dotnet ef database update 0 # rollback all +``` + +--- + +## Testing Migrations & Seed Data + +### TypeScript Tests + +```typescript +// tests/seeds/migration.test.ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import knex, { Knex } from 'knex'; +import knexConfig from '../../knexfile'; + +// These tests require a running database (integration tests) +// Skip if DATABASE_URL is not set +const shouldRun = !!process.env.DATABASE_URL; + +describe.skipIf(!shouldRun)('Database Migrations', () => { + let db: Knex; + + beforeAll(() => { + db = knex(knexConfig); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it('should run migrations forward', async () => { + await db.migrate.latest(); + const tables = await db.raw( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" + ); + const tableNames = tables.rows.map((r: any) => r.table_name); + expect(tableNames).toContain('items'); + }); + + it('should run migrations backward', async () => { + await db.migrate.rollback(undefined, true); + const tables = await db.raw( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" + ); + const tableNames = tables.rows.map((r: any) => r.table_name); + expect(tableNames).not.toContain('items'); + }); + + it('should be idempotent (run forward twice without error)', async () => { + await db.migrate.latest(); + await db.migrate.latest(); // Should not throw + }); +}); +``` + +```typescript +// tests/seeds/seedData.test.ts +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import knex, { Knex } from 'knex'; +import knexConfig from '../../knexfile'; +import seedData from '../../seeds/fixtures/seed-data.json'; + +const shouldRun = !!process.env.DATABASE_URL; + +describe.skipIf(!shouldRun)('Seed Data', () => { + let db: Knex; + + beforeAll(async () => { + db = knex(knexConfig); + await db.migrate.latest(); + }); + + beforeEach(async () => { + await db('items').del(); // Clean slate + }); + + afterAll(async () => { + await db.migrate.rollback(undefined, true); + await db.destroy(); + }); + + it('should seed correct number of rows', async () => { + for (const item of seedData.items) { + await db('items').insert(item).onConflict('id').merge(); + } + const count = await db('items').count('* as total').first(); + expect(Number(count?.total)).toBe(seedData.items.length); + }); + + it('should be idempotent (seeding twice produces same row count)', async () => { + // Seed once + for (const item of seedData.items) { + await db('items').insert(item).onConflict('id').merge(); + } + // Seed again + for (const item of seedData.items) { + await db('items').insert(item).onConflict('id').merge(); + } + const count = await db('items').count('* as total').first(); + expect(Number(count?.total)).toBe(seedData.items.length); + }); +}); +``` + +--- + +## Fixture Data Guidelines + +1. **Use realistic data** β€” Names, descriptions, and values should look like real data, not "test1", "test2" +2. **Include edge cases** β€” Empty strings (where valid), long strings, special characters, Unicode, boundary numbers (0, negative, max) +3. **Use stable IDs** β€” Seed data should have predictable IDs (e.g., `seed-001`) so tests can reference specific records +4. **Keep fixtures in JSON** β€” Shared between seed scripts and test fixtures. Easy to read and modify. +5. **Separate seed data from test fixtures** β€” Seed data populates the dev database. Test fixtures drive unit test assertions. They may overlap but serve different purposes. +6. **Document the fixture schema** β€” Add a comment block or README explaining what each fixture covers + +### Example Fixture Structure + +``` +seeds/ +β”œβ”€β”€ seed.ts ← Seed script (runs against real DB) +β”œβ”€β”€ migrations/ +β”‚ └── 20260101_create_items.ts +└── fixtures/ + └── seed-data.json ← Seed data (realistic dev data) + +tests/ +β”œβ”€β”€ fixtures/ +β”‚ β”œβ”€β”€ items.json ← Test data (valid + invalid variations) +β”‚ └── users.json ← Test data for another entity +└── mocks/ + └── mockDatabase.ts ← Mock service pre-loaded with fixture data +``` diff --git a/resources/agents/azure-project-scaffold/references/service-abstraction.md b/resources/agents/azure-project-scaffold/references/service-abstraction.md new file mode 100644 index 00000000..11231332 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/service-abstraction.md @@ -0,0 +1,777 @@ +# Service Abstraction Layer + +> Patterns for writing testable code that works against both local mocks and live Azure services with zero code changes. + +--- + +## Core Principle + +**The same application code runs against mocks (tests), local emulators (dev), and Azure services (production).** The only difference is which implementation is injected: + +- **Tests**: In-memory mock implementation (pre-registered via `setup.ts` / `conftest.py`) +- **Local dev**: Real SDK pointing to emulator (via local-dev skill's docker-compose) +- **Azure**: Real SDK pointing to Azure services (via managed identity) + +Function handlers NEVER import Azure SDKs directly. They receive services via dependency injection. + +> ⚠️ **Auto-initialization requirement**: The service registry's `getServices()` MUST auto-initialize with concrete implementations at runtime. The user should be able to run `func start` immediately after `npm run build` β€” no manual `registerServices()` call, no startup script. Tests override this by calling `registerServices()` with mocks before each test. + +> ⚠️ **camelCase↔snake_case conversion requirement**: TypeScript entities use camelCase (`displayName`, `coupleId`) but PostgreSQL columns use snake_case (`display_name`, `couple_id`). The concrete database service MUST handle this conversion automatically β€” converting keys to snake_case for outbound SQL queries and converting row keys to camelCase for inbound results. **The mock database does NOT enforce this** (it uses plain Maps), so this mismatch will only surface at runtime against a real database. The conversion must be built into the concrete implementation, not left as an exercise. + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Function Handler β”‚ +β”‚ (receives services β€” no SDK imports) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Service Interface β”‚ +β”‚ IStorageService β”‚ IDatabaseService β”‚ ICacheService +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Real Impl β”‚ Mock Impl β”‚ +β”‚ (Azure SDK) β”‚ (in-memory Map/Dict/List) β”‚ +β”‚ ↓ β”‚ ↓ β”‚ +β”‚ Azurite/Azure β”‚ No external deps β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## TypeScript Patterns + +### Service Interface + +```typescript +// services/interfaces/IDatabaseService.ts +export interface IDatabaseService { + findAll(collection: string, options?: QueryOptions): Promise; + findById(collection: string, id: string): Promise; + findOne(collection: string, filter: Record): Promise; + create(collection: string, data: T): Promise; + update(collection: string, id: string, data: Partial): Promise; + delete(collection: string, id: string): Promise; + count(collection: string, filter?: Record): Promise; + healthCheck(): Promise; + + // Execute multiple operations atomically β€” all succeed or all rollback. + // The callback receives a transactional IDatabaseService scoped to the transaction. + transaction(fn: (trx: IDatabaseService) => Promise): Promise; +} + +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: string; + orderDirection?: 'asc' | 'desc'; + filter?: Record; +} +``` + +```typescript +// services/interfaces/IStorageService.ts +export interface IStorageService { + upload(container: string, name: string, data: Buffer, contentType?: string): Promise; + download(container: string, name: string): Promise; + list(container: string): Promise; + delete(container: string, name: string): Promise; + healthCheck(): Promise; +} +``` + +```typescript +// services/interfaces/ICacheService.ts +export interface ICacheService { + get(key: string): Promise; + set(key: string, value: T, ttlSeconds?: number): Promise; + delete(key: string): Promise; + clear(pattern: string): Promise; + healthCheck(): Promise; +} +``` + +### Config Module with Environment Validation + +```typescript +// services/config.ts +export interface AppConfig { + storage: { connectionString: string }; + database: { url: string }; + cache: { url: string }; + isDevelopment: boolean; +} + +const REQUIRED_VARS: { key: string; envVar: string; description: string }[] = [ + // Add required vars here based on selected services +]; + +export function validateEnvironment(): string[] { + const missing: string[] = []; + for (const { key, envVar, description } of REQUIRED_VARS) { + if (!process.env[envVar]) { + missing.push(`${envVar} β€” ${description}`); + } + } + return missing; +} + +export function loadConfig(): AppConfig { + const missing = validateEnvironment(); + if (missing.length > 0) { + throw new Error( + `Missing required environment variables:\n${missing.map(m => ` - ${m}`).join('\n')}\n\nCopy .env.example to .env and fill in the values.` + ); + } + + return { + storage: { + connectionString: process.env.STORAGE_CONNECTION_STRING || 'UseDevelopmentStorage=true', + }, + database: { + url: process.env.DATABASE_URL || 'postgresql://localdev:localdevpassword@localhost:5432/appdb', + }, + cache: { + url: process.env.REDIS_URL || 'redis://localhost:6379', + }, + isDevelopment: process.env.NODE_ENV !== 'production', + }; +} +``` + +### Concrete Implementation (PostgreSQL Example) + +> **Important**: Includes camelCase↔snake_case key conversion. TypeScript entities use camelCase but PostgreSQL columns are snake_case. The conversion is handled transparently β€” function handlers never deal with snake_case. + +```typescript +// services/database.ts +import { Pool } from 'pg'; +import { IDatabaseService, QueryOptions } from './interfaces/IDatabaseService'; +import { loadConfig } from './config'; + +// --- camelCase ↔ snake_case conversion utilities --- + +/** Convert camelCase to snake_case (e.g. displayName β†’ display_name) */ +function toSnake(str: string): string { + return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** Convert snake_case to camelCase (e.g. display_name β†’ displayName) */ +function toCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** Convert all keys in an object from camelCase to snake_case */ +function keysToSnake(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[toSnake(key)] = value; + } + return result; +} + +/** Convert all keys in an object from snake_case to camelCase */ +function keysToCamel(obj: Record): T { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[toCamel(key)] = value; + } + return result as T; +} + +/** Convert an array of rows from snake_case to camelCase */ +function rowsToCamel(rows: Record[]): T[] { + return rows.map(row => keysToCamel(row)); +} + +// --- Database service implementation --- + +export class PostgresDatabaseService implements IDatabaseService { + private pool: Pool; + + constructor(connectionString?: string) { + const config = loadConfig(); + this.pool = new Pool({ + connectionString: connectionString || config.database.url, + max: 20, + idleTimeoutMillis: 30000, + }); + } + + async findAll(collection: string, options?: QueryOptions): Promise { + const limit = options?.limit || 100; + const offset = options?.offset || 0; + const orderBy = toSnake(options?.orderBy || 'createdAt'); + const direction = options?.orderDirection || 'desc'; + + const result = await this.pool.query( + `SELECT * FROM ${collection} ORDER BY ${orderBy} ${direction} LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return rowsToCamel(result.rows); + } + + async findById(collection: string, id: string): Promise { + const result = await this.pool.query( + `SELECT * FROM ${collection} WHERE id = $1`, + [id] + ); + return result.rows[0] ? keysToCamel(result.rows[0]) : null; + } + + async create(collection: string, data: T): Promise { + const record = data as Record; + const { createdAt: _ca, updatedAt: _ua, ...cleanData } = record; + const snakeData = keysToSnake(cleanData); + const keys = Object.keys(snakeData); + const values = Object.values(snakeData); + const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); + const columns = keys.join(', '); + + const result = await this.pool.query( + `INSERT INTO ${collection} (${columns}) VALUES (${placeholders}) RETURNING *`, + values + ); + return keysToCamel(result.rows[0]); + } + + async update(collection: string, id: string, data: Partial): Promise { + const record = data as Record; + const { id: _id, createdAt: _ca, updatedAt: _ua, ...cleanData } = record; + const snakeData = keysToSnake(cleanData); + const entries = Object.entries(snakeData); + const sets = entries.map(([key], i) => `${key} = $${i + 1}`).join(', '); + const values = [...entries.map(([, val]) => val), id]; + + const result = await this.pool.query( + `UPDATE ${collection} SET ${sets}, updated_at = NOW() WHERE id = $${values.length} RETURNING *`, + values + ); + return (result.rows[0] as T) || null; + } + + async delete(collection: string, id: string): Promise { + const result = await this.pool.query( + `DELETE FROM ${collection} WHERE id = $1`, + [id] + ); + return (result.rowCount ?? 0) > 0; + } + + async healthCheck(): Promise { + try { + await this.pool.query('SELECT 1'); + return true; + } catch { + return false; + } + } + + async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const trxService = new PostgresDatabaseService(); // shares pool reference + // Override pool methods to use this client for the transaction scope + (trxService as any).pool = { query: (...args: any[]) => client.query(...args) }; + const result = await fn(trxService); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } +} +``` + +### Mock Implementation (For Tests) + +```typescript +// tests/mocks/mockDatabase.ts +import { IDatabaseService, QueryOptions } from '../../src/services/interfaces/IDatabaseService'; + +export class MockDatabaseService implements IDatabaseService { + private stores: Map> = new Map(); + + constructor(initialData?: Record) { + if (initialData) { + for (const [collection, items] of Object.entries(initialData)) { + const store = new Map(); + items.forEach((item: any) => store.set(item.id, item)); + this.stores.set(collection, store); + } + } + } + + private getStore(collection: string): Map { + if (!this.stores.has(collection)) { + this.stores.set(collection, new Map()); + } + return this.stores.get(collection)!; + } + + async findAll(collection: string, options?: QueryOptions): Promise { + const store = this.getStore(collection); + let items = Array.from(store.values()) as T[]; + if (options?.limit) { + const offset = options.offset || 0; + items = items.slice(offset, offset + options.limit); + } + return items; + } + + async findById(collection: string, id: string): Promise { + const store = this.getStore(collection); + return (store.get(id) as T) || null; + } + + async create(collection: string, data: T): Promise { + const store = this.getStore(collection); + const item = data as any; + store.set(item.id, item); + return data; + } + + async update(collection: string, id: string, data: Partial): Promise { + const store = this.getStore(collection); + const existing = store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data, updatedAt: new Date().toISOString() }; + store.set(id, updated); + return updated as T; + } + + async delete(collection: string, id: string): Promise { + const store = this.getStore(collection); + return store.delete(id); + } + + async healthCheck(): Promise { + return true; + } + + async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + // Mock transactions execute the callback directly against in-memory state. + // For unit tests this is sufficient β€” transaction atomicity is validated + // via integration tests against a real database. + return fn(this); + } +} +``` + +### Service Registry (DI) + +> **Critical**: The registry MUST auto-initialize with concrete implementations at runtime. `func start` must work without any manual `registerServices()` call. Tests pre-register mocks via `setup.ts`, which overrides auto-initialization. + +```typescript +// services/registry.ts +import { IDatabaseService } from './interfaces/IDatabaseService'; +import { IStorageService } from './interfaces/IStorageService'; +import { ICacheService } from './interfaces/ICacheService'; + +export interface ServiceRegistry { + database: IDatabaseService; + storage: IStorageService; + cache: ICacheService; +} + +let services: ServiceRegistry | null = null; + +export function registerServices(registry: ServiceRegistry): void { + services = registry; +} + +/** + * Returns the service registry. In tests, services are pre-registered via setup.ts. + * At runtime, auto-initializes with concrete implementations on first call. + */ +export function getServices(): ServiceRegistry { + if (!services) { + initializeServices(); + } + return services!; +} + +export function clearServices(): void { + services = null; +} + +/** Lazy-load concrete implementations at runtime */ +function initializeServices(): void { + // Use require() for synchronous lazy loading. + // Replace with the actual concrete service classes for the project. + const { PostgresDatabaseService } = require('./database'); + const { BlobStorageService } = require('./storage'); + const { RedisCacheService } = require('./cache'); + + services = { + database: new PostgresDatabaseService(), + storage: new BlobStorageService(), + cache: new RedisCacheService(), + }; +} +``` + +### Usage in Function Handlers + +```typescript +// functions/getItems.ts +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { Item } from '../../shared/types/entities'; + +app.http('getItems', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'items', + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + try { + const { database } = getServices(); + const limit = Number(request.query.get('limit')) || 20; + const offset = Number(request.query.get('offset')) || 0; + + const items = await database.findAll('items', { limit, offset }); + return { jsonBody: { items, total: items.length } }; + } catch (error) { + return handleError(error, context); + } + } +}); +``` + +--- + +## Python Patterns + +### Service Interface (Protocol) + +```python +# services/interfaces.py +from typing import Protocol, TypeVar, Optional, Any +from dataclasses import dataclass + +T = TypeVar('T') + +@dataclass +class QueryOptions: + limit: int = 100 + offset: int = 0 + order_by: str = 'created_at' + order_direction: str = 'desc' + +class IDatabaseService(Protocol): + async def find_all(self, collection: str, options: Optional[QueryOptions] = None) -> list[dict]: ... + async def find_by_id(self, collection: str, id: str) -> Optional[dict]: ... + async def create(self, collection: str, data: dict) -> dict: ... + async def update(self, collection: str, id: str, data: dict) -> Optional[dict]: ... + async def delete(self, collection: str, id: str) -> bool: ... + async def health_check(self) -> bool: ... + +class IStorageService(Protocol): + async def upload(self, container: str, name: str, data: bytes, content_type: Optional[str] = None) -> str: ... + async def download(self, container: str, name: str) -> bytes: ... + async def list(self, container: str) -> list[str]: ... + async def delete(self, container: str, name: str) -> None: ... + async def health_check(self) -> bool: ... + +class ICacheService(Protocol): + async def get(self, key: str) -> Optional[Any]: ... + async def set(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None: ... + async def delete(self, key: str) -> None: ... + async def health_check(self) -> bool: ... +``` + +### Config with Validation + +```python +# services/config.py +import os +from dataclasses import dataclass + +REQUIRED_VARS = [ + # ("ENV_VAR_NAME", "description") +] + +def validate_environment() -> list[str]: + missing = [] + for var_name, description in REQUIRED_VARS: + if not os.environ.get(var_name): + missing.append(f"{var_name} β€” {description}") + return missing + +@dataclass +class AppConfig: + storage_connection_string: str + database_url: str + redis_url: str + is_development: bool + +def load_config() -> AppConfig: + missing = validate_environment() + if missing: + raise RuntimeError( + "Missing required environment variables:\n" + + "\n".join(f" - {m}" for m in missing) + + "\n\nCopy .env.example to .env and fill in the values." + ) + + return AppConfig( + storage_connection_string=os.environ.get( + "STORAGE_CONNECTION_STRING", "UseDevelopmentStorage=true" + ), + database_url=os.environ.get( + "DATABASE_URL", "postgresql://localdev:localdevpassword@localhost:5432/appdb" + ), + redis_url=os.environ.get("REDIS_URL", "redis://localhost:6379"), + is_development=os.environ.get("ENVIRONMENT", "development") != "production", + ) +``` + +### Mock Implementation + +```python +# tests/mocks/mock_database.py +from typing import Optional +from services.interfaces import QueryOptions + +class MockDatabaseService: + def __init__(self, initial_data: Optional[dict[str, list[dict]]] = None): + self._stores: dict[str, dict[str, dict]] = {} + if initial_data: + for collection, items in initial_data.items(): + self._stores[collection] = {item["id"]: item for item in items} + + def _get_store(self, collection: str) -> dict[str, dict]: + if collection not in self._stores: + self._stores[collection] = {} + return self._stores[collection] + + async def find_all(self, collection: str, options: Optional[QueryOptions] = None) -> list[dict]: + store = self._get_store(collection) + items = list(store.values()) + if options: + items = items[options.offset:options.offset + options.limit] + return items + + async def find_by_id(self, collection: str, id: str) -> Optional[dict]: + store = self._get_store(collection) + return store.get(id) + + async def create(self, collection: str, data: dict) -> dict: + store = self._get_store(collection) + store[data["id"]] = data + return data + + async def update(self, collection: str, id: str, data: dict) -> Optional[dict]: + store = self._get_store(collection) + if id not in store: + return None + store[id] = {**store[id], **data} + return store[id] + + async def delete(self, collection: str, id: str) -> bool: + store = self._get_store(collection) + return store.pop(id, None) is not None + + async def health_check(self) -> bool: + return True +``` + +### Service Registry + +> **Critical**: The registry MUST auto-initialize with concrete implementations at runtime. `func start` must work without any manual `register_services()` call. Tests pre-register mocks via `conftest.py`, which overrides auto-initialization. + +```python +# services/registry.py +from typing import Optional +from services.interfaces import IDatabaseService, IStorageService, ICacheService +from dataclasses import dataclass + +@dataclass +class ServiceRegistry: + database: IDatabaseService + storage: IStorageService + cache: ICacheService + +_services: Optional[ServiceRegistry] = None + +def register_services(registry: ServiceRegistry) -> None: + global _services + _services = registry + +def get_services() -> ServiceRegistry: + """Returns the service registry. Auto-initializes with concrete implementations at runtime.""" + global _services + if _services is None: + _initialize_services() + return _services # type: ignore + +def clear_services() -> None: + global _services + _services = None + +def _initialize_services() -> None: + """Lazy-load concrete implementations at runtime.""" + global _services + from services.database import PostgresDatabaseService + from services.storage import BlobStorageService + from services.cache import RedisCacheService + + _services = ServiceRegistry( + database=PostgresDatabaseService(), + storage=BlobStorageService(), + cache=RedisCacheService(), + ) +``` + +--- + +## C# (.NET) Patterns + +### Service Interface + +```csharp +// Services/Interfaces/IDatabaseService.cs +public interface IDatabaseService +{ + Task> FindAllAsync(string collection, QueryOptions? options = null); + Task FindByIdAsync(string collection, string id); + Task CreateAsync(string collection, T data); + Task UpdateAsync(string collection, string id, object data); + Task DeleteAsync(string collection, string id); + Task HealthCheckAsync(); +} +``` + +### DI Registration (Program.cs) + +```csharp +// Program.cs +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices((context, services) => + { + // Register services β€” swap implementations via config + if (context.HostingEnvironment.IsDevelopment()) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + +host.Run(); +``` + +### Mock Implementation + +```csharp +// Tests/Mocks/MockDatabaseService.cs +public class MockDatabaseService : IDatabaseService +{ + private readonly Dictionary> _stores = new(); + + public MockDatabaseService(Dictionary>? initialData = null) + { + if (initialData != null) + { + foreach (var (collection, items) in initialData) + { + _stores[collection] = new Dictionary(); + foreach (dynamic item in items) + { + _stores[collection][item.Id] = item; + } + } + } + } + + public Task> FindAllAsync(string collection, QueryOptions? options = null) + { + if (!_stores.ContainsKey(collection)) + return Task.FromResult(new List()); + return Task.FromResult(_stores[collection].Values.Cast().ToList()); + } + + public Task FindByIdAsync(string collection, string id) + { + if (!_stores.ContainsKey(collection) || !_stores[collection].ContainsKey(id)) + return Task.FromResult(default); + return Task.FromResult((T?)_stores[collection][id]); + } + + // ... create, update, delete implementations follow same pattern + + public Task HealthCheckAsync() => Task.FromResult(true); +} +``` + +--- + +## Testing Service Abstractions + +Every service implementation (real and mock) should be tested: + +```typescript +// tests/services/registry.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { registerServices, getServices, clearServices } from '../../src/services/registry'; +import { MockDatabaseService } from '../mocks/mockDatabase'; +import { MockStorageService } from '../mocks/mockStorage'; +import { MockCacheService } from '../mocks/mockCache'; + +describe('ServiceRegistry', () => { + beforeEach(() => { + clearServices(); + }); + + afterEach(() => { + clearServices(); + }); + + it('should return registered mock services (not auto-initialized ones)', () => { + const registry = { + database: new MockDatabaseService(), + storage: new MockStorageService(), + cache: new MockCacheService(), + }; + registerServices(registry); + + const services = getServices(); + expect(services.database).toBe(registry.database); + expect(services.storage).toBe(registry.storage); + expect(services.cache).toBe(registry.cache); + }); + + it('should allow re-registration after clearServices', () => { + const first = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() }; + const second = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() }; + + registerServices(first); + clearServices(); + registerServices(second); + + expect(getServices().database).toBe(second.database); + }); + + it('pre-registered mocks take priority over auto-initialization', () => { + const mock = new MockDatabaseService(); + registerServices({ + database: mock, + storage: new MockStorageService(), + cache: new MockCacheService(), + }); + expect(getServices().database).toBe(mock); + }); +}); +``` diff --git a/resources/agents/azure-project-scaffold/references/sub-agent-strategy.md b/resources/agents/azure-project-scaffold/references/sub-agent-strategy.md new file mode 100644 index 00000000..3512eaf5 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/sub-agent-strategy.md @@ -0,0 +1,68 @@ +# Sub-Agent Strategy for Backend Scaffolding + +> Parallelization strategy for backend scaffold execution. The backend sub-agent is launched **at the same instant as Step 0.5 (Frontend Preview)** β€” never serialized after it. The main agent stays in the foreground driving the preview; backend completion is awaited only at the Step 11 synchronization gate. + +--- + +## Execution Model + +> ⚠️ **FRONTEND-FIRST PIPELINING**: After Step 0 (plan validation), the **frontend preview (Step 0.5) is the priority foreground workstream** β€” it's what the user sees first. Phase A (Contracts) then Phase B (Backend) run **in parallel in a backend sub-agent**, started at the **same instant** as the preview. The user must not wait on backend work to see their app. For API-only projects (no frontend), backend scaffolding runs in the foreground immediately after Step 0. +> +> **Execution timeline for SPA + API projects:** +> ``` +> Step 0 (Plan Validated) +> β”‚ +> β”œβ”€β”€ Step 0.5: Frontend Preview (FOREGROUND β€” PRIORITY) +> β”‚ └─> Build mock UI ─> Auto-open in browser ─> User approves ──┐ +> β”‚ β”‚ +> └── Backend sub-agent (BACKGROUND β€” launched at SAME time) +> └─> Phase A: Contracts ─> Phase B: Backend ─────────────────── +> β–Ό +> Step 11: Wire Frontend +> Step 12: Wrap Up +> ``` + +--- + +## Phase A: Contracts First (BLOCKING β€” Sequential, No Parallelism) + +Create sequentially β€” dependencies for everything else: +1. Shared types (`src/shared/types/`) +2. Validation schemas (`src/shared/schemas/`) +3. Service interfaces (`src/functions/src/services/interfaces/`) +4. Error types (`src/functions/src/errors/`) +5. Config module (`src/functions/src/services/config.ts`) + +Build shared package to produce `dist/`. Verify cross-workspace imports resolve. + +--- + +## Phase B: Parallel Implementation via Sub-Agents + +Once contracts exist on disk, launch backend sub-agent: + +| Sub-Agent | Responsibility | Scope | +|-----------|---------------|-------| +| **Backend API Agent** (general-purpose) | Concrete service implementations, service registry, function handlers, migrations, seed data, OpenAPI spec, structured logging | Steps 3–10 implementation files | + +> **NOTE**: Testing is NOT part of scaffold phase. Test infrastructure, mocks, fixtures, and unit tests are generated by `azure-project-test`, invoked after scaffolding completes (Step 12). This separation ensures: +> 1. Scaffold focuses on correct, buildable production code +> 2. Verify skill has clean baseline to generate and validate tests against +> 3. User sees test results as distinct verification step, not buried in scaffold output + +--- + +## Coordination Rules + +- Agent receives full project plan and contracts created in Phase A as context +- After agent completes, run final build gate (`npm run build` in all workspaces) +- **Synchronization gate**: Step 11 (Wire Frontend) MUST wait for BOTH: (a) frontend preview approved by user AND (b) Phase B backend agent completed. If backend finishes first, wait for frontend approval. If frontend approved first, wait for backend completion. +- Then proceed to Step 11 (Wire Frontend) and Step 12 (Wrap Up) + +--- + +## Key Contract Rules + +- Agent MUST use same `AppConfig` shape (flat structure β€” see [../../shared-references/service-abstraction.md](../../shared-references/service-abstraction.md)) +- Agent MUST use same collection names (`'user'`, `'couple'`, etc.) mapping to SQL table names +- Agent MUST use same validation schema names exported from `src/shared/schemas/validation.ts` diff --git a/resources/agents/azure-project-scaffold/references/testing.md b/resources/agents/azure-project-scaffold/references/testing.md new file mode 100644 index 00000000..94e4ec74 --- /dev/null +++ b/resources/agents/azure-project-scaffold/references/testing.md @@ -0,0 +1,652 @@ +# Testing Patterns + +> Core reference for self-testable north star. Every module ships with tests. Every phase has test gate. + +--- + +## Core Principle + +**Project is not done until tests say it is.** + +Agent runs tests after every module. If tests fail, agent iterates until they pass. No module complete until tests green. Not a suggestion β€” it's workflow. + +--- + +## Test Pyramid + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ E2E / β”‚ ← Full request-response cycle + β”‚ Integration β”‚ with real/mock services + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ β”‚ + β”‚ Unit β”‚ ← Fast, isolated, mocked deps + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +| Layer | What It Tests | Dependencies | Speed | When | +|-------|---------------|-------------|-------|------| +| **Unit** | Single function/class in isolation | All deps mocked | Fast (ms) | Every module, always | +| **Integration** | Request β†’ handler β†’ service β†’ response cycle | Mock services injected | Fast (ms) | Every route, always | +| **E2E** | Full stack with real emulators | Running emulators (via local-dev) | Slower (s) | When emulators are available | + +### What Each Layer Covers + +**Unit Tests** (mandatory for every module): +- Service abstraction methods (with mock storage/DB/cache) +- Config loading (env vars present, missing, defaults) +- Validation schemas (valid input, invalid input, edge cases) +- Error types and error handler (mapping to HTTP status codes) +- Individual function handler logic (with injected mock services) +- Utility functions and helpers + +**Integration Tests** (mandatory for every route): +- HTTP request β†’ function handler β†’ mock service β†’ HTTP response +- Correct status codes (200, 201, 400, 404, 422, 500) +- Correct response body shapes +- Request validation (bad input rejected) +- Error handling (service failures produce correct error responses) + +**E2E Tests** (when emulators available via local-dev): +- Full DB round-trip (create β†’ read β†’ verify data) +- File upload β†’ storage β†’ retrieval +- Cache set β†’ get β†’ verify +- Health check with live services + +--- + +## Test Runner Quick Reference + +### Node.js (TypeScript) + +| Runner | Setup | Config File | Test Command | Mock Library | Assertion Library | +|--------|-------|-------------|-------------|-------------|------------------| +| **vitest** | `npm i -D vitest` | `vitest.config.ts` | `npx vitest run` | Built-in `vi.mock()` | Built-in `expect` | +| **jest** | `npm i -D jest ts-jest @types/jest` | `jest.config.ts` | `npx jest` | Built-in `jest.mock()` | Built-in `expect` | +| **mocha+chai+sinon** | `npm i -D mocha chai sinon @types/mocha @types/chai @types/sinon tsx` | `.mocharc.yml` | `npx mocha` | sinon | chai `expect` | + +#### vitest config example +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/interfaces/**'] + } + } +}); +``` + +#### jest config example +```typescript +// jest.config.ts +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/interfaces/**'] +}; +``` + +#### mocha config example +```yaml +# .mocharc.yml +require: + - tsx +spec: 'tests/**/*.test.ts' +recursive: true +timeout: 5000 +``` + +### Python + +| Runner | Setup | Config | Test Command | Mock Library | Assertion | +|--------|-------|--------|-------------|-------------|-----------| +| **pytest** | `pip install pytest pytest-cov pytest-asyncio` | `pytest.ini` or `pyproject.toml` | `pytest` | `unittest.mock` | Built-in `assert` | + +```ini +# pytest.ini +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +asyncio_mode = auto +``` + +### .NET + +| Runner | Setup | Config | Test Command | Mock Library | Assertion | +|--------|-------|--------|-------------|-------------|-----------| +| **xUnit** | NuGet: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk` | `.csproj` | `dotnet test` | Moq or NSubstitute | xUnit `Assert` or FluentAssertions | +| **NUnit** | NuGet: `NUnit`, `NUnit3TestAdapter`, `Microsoft.NET.Test.Sdk` | `.csproj` | `dotnet test` | Moq or NSubstitute | NUnit `Assert` or FluentAssertions | + +--- + +## Mock Data Patterns + +### Fixture Files (JSON) + +Store realistic mock data in `tests/fixtures/`: +```json +// tests/fixtures/items.json +{ + "validItems": [ + { + "id": "item-001", + "name": "Widget Alpha", + "description": "A high-quality widget for testing", + "price": 29.99, + "category": "widgets", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "item-002", + "name": "Gadget Beta", + "description": "An innovative gadget", + "price": 49.99, + "category": "gadgets", + "createdAt": "2026-02-20T14:00:00.000Z", + "updatedAt": "2026-03-01T09:15:00.000Z" + } + ], + "invalidItems": [ + { "name": "", "description": "Missing name" }, + { "name": "X", "price": -5, "description": "Invalid price" }, + { "description": "No name field at all" } + ], + "createItemRequest": { + "name": "New Widget", + "description": "A brand new widget", + "price": 19.99, + "category": "widgets" + } +} +``` + +### Factory Functions + +For dynamic mock data, use factory functions: +```typescript +// tests/fixtures/itemFactory.ts +import { Item, CreateItemRequest } from '../../src/shared/types/entities'; + +let counter = 0; + +export function createMockItem(overrides?: Partial): Item { + counter++; + return { + id: `item-${counter.toString().padStart(3, '0')}`, + name: `Test Item ${counter}`, + description: `Description for test item ${counter}`, + price: 9.99 + counter, + category: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides + }; +} + +export function createMockCreateRequest(overrides?: Partial): CreateItemRequest { + counter++; + return { + name: `New Item ${counter}`, + description: `New item description ${counter}`, + price: 19.99, + category: 'test', + ...overrides + }; +} +``` + +### Python Fixtures (pytest) + +```python +# tests/conftest.py +import pytest +import json +from pathlib import Path + +@pytest.fixture +def item_fixtures(): + fixture_path = Path(__file__).parent / "fixtures" / "items.json" + with open(fixture_path) as f: + return json.load(f) + +@pytest.fixture +def valid_item(item_fixtures): + return item_fixtures["validItems"][0] + +@pytest.fixture +def invalid_item(item_fixtures): + return item_fixtures["invalidItems"][0] + +@pytest.fixture +def mock_database(): + """Returns mock database service with pre-loaded data.""" + from services.interfaces import IDatabaseService + from unittest.mock import MagicMock + + db = MagicMock(spec=IDatabaseService) + # Configure default returns + db.find_all.return_value = [] + db.find_by_id.return_value = None + return db +``` + +### C# Fixtures + +```csharp +// Fixtures/ItemFixtures.cs +public static class ItemFixtures +{ + public static Item CreateValidItem(string? id = null) => new() + { + Id = id ?? Guid.NewGuid().ToString(), + Name = "Test Widget", + Description = "A test widget", + Price = 29.99m, + Category = "widgets", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + public static CreateItemRequest CreateValidRequest() => new() + { + Name = "New Widget", + Description = "A brand new widget", + Price = 19.99m, + Category = "widgets" + }; + + public static List CreateItemList(int count = 5) => + Enumerable.Range(1, count) + .Select(i => CreateValidItem($"item-{i:D3}")) + .ToList(); +} +``` + +--- + +## Service Mocking Patterns + +### TypeScript β€” vitest + +```typescript +// tests/mocks/mockDatabase.ts +import { IDatabaseService } from '../../src/services/interfaces/IDatabaseService'; +import { Item } from '../../src/shared/types/entities'; + +export function createMockDatabase(initialData: Item[] = []): IDatabaseService { + const store = new Map(); + initialData.forEach(item => store.set(item.id, item)); + + return { + findAll: async () => Array.from(store.values()), + findById: async (id: string) => store.get(id) ?? null, + create: async (item: Item) => { store.set(item.id, item); return item; }, + update: async (id: string, data: Partial) => { + const existing = store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data, updatedAt: new Date().toISOString() }; + store.set(id, updated); + return updated; + }, + delete: async (id: string) => { return store.delete(id); }, + healthCheck: async () => true + }; +} +``` + +```typescript +// tests/functions/getItems.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { createMockDatabase } from '../mocks/mockDatabase'; +import { createMockItem } from '../fixtures/itemFactory'; + +describe('getItems', () => { + let mockDb: IDatabaseService; + const testItems = [createMockItem(), createMockItem()]; + + beforeEach(() => { + mockDb = createMockDatabase(testItems); + }); + + it('should return all items', async () => { + const items = await mockDb.findAll(); + expect(items).toHaveLength(2); + }); + + it('should return empty array when no items', async () => { + mockDb = createMockDatabase([]); + const items = await mockDb.findAll(); + expect(items).toEqual([]); + }); +}); +``` + +### TypeScript β€” mocha + chai + sinon + +```typescript +// tests/functions/getItems.test.ts +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createMockDatabase } from '../mocks/mockDatabase'; +import { createMockItem } from '../fixtures/itemFactory'; + +describe('getItems', () => { + let mockDb: IDatabaseService; + const testItems = [createMockItem(), createMockItem()]; + + beforeEach(() => { + mockDb = createMockDatabase(testItems); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return all items', async () => { + const items = await mockDb.findAll(); + expect(items).to.have.lengthOf(2); + }); + + it('should return empty array when no items', async () => { + mockDb = createMockDatabase([]); + const items = await mockDb.findAll(); + expect(items).to.deep.equal([]); + }); +}); +``` + +### Python β€” pytest + unittest.mock + +```python +# tests/test_get_items.py +import pytest +from unittest.mock import AsyncMock, MagicMock +from services.interfaces import IDatabaseService + +@pytest.fixture +def mock_database(item_fixtures): + db = MagicMock(spec=IDatabaseService) + db.find_all = AsyncMock(return_value=item_fixtures["validItems"]) + db.find_by_id = AsyncMock(return_value=item_fixtures["validItems"][0]) + return db + +async def test_get_items_returns_all(mock_database, item_fixtures): + items = await mock_database.find_all() + assert len(items) == len(item_fixtures["validItems"]) + +async def test_get_items_empty(mock_database): + mock_database.find_all = AsyncMock(return_value=[]) + items = await mock_database.find_all() + assert items == [] +``` + +### C# β€” xUnit + Moq + +```csharp +// Functions/GetItemsTests.cs +public class GetItemsTests +{ + private readonly Mock _mockDb; + + public GetItemsTests() + { + _mockDb = new Mock(); + } + + [Fact] + public async Task GetItems_ReturnsAllItems() + { + var items = ItemFixtures.CreateItemList(3); + _mockDb.Setup(db => db.FindAllAsync()).ReturnsAsync(items); + + var result = await _mockDb.Object.FindAllAsync(); + + Assert.Equal(3, result.Count); + } + + [Fact] + public async Task GetItems_ReturnsEmptyList() + { + _mockDb.Setup(db => db.FindAllAsync()).ReturnsAsync(new List()); + + var result = await _mockDb.Object.FindAllAsync(); + + Assert.Empty(result); + } +} +``` + +--- + +## Test Gate Enforcement + +Agent MUST follow this workflow at every test gate: + +### 1. Run Tests + +```bash +# TypeScript +npm test +# or: npx vitest run +# or: npx jest +# or: npx mocha + +# Python +pytest + +# .NET +dotnet test +``` + +### 2. Parse Output + +Look for: +- **Pass**: All tests passed, zero failures β†’ proceed to next phase +- **Fail**: One or more tests failed β†’ DO NOT proceed +### 3. If Tests Fail + +1. Read failure output β€” identify which test failed and why +2. Determine if issue is in **code** or **test** +3. Fix issue +4. Re-run tests +5. Repeat until ALL tests pass + +### 4. If Tests Pass + +1. Mark current phase's checklist items as complete in `.azure/project-plan.md` +2. Proceed to next phase + +### Decision Tree + +``` +Run tests + β”‚ + β”œβ”€β”€ ALL PASS ──→ Mark phase complete β†’ Proceed to next phase + β”‚ + └── ANY FAIL ──→ Read failure output + β”‚ + β”œβ”€β”€ Code bug ──→ Fix code β†’ Re-run tests + β”‚ + β”œβ”€β”€ Test bug ──→ Fix test β†’ Re-run tests + β”‚ + └── Missing dep ──→ Install dep β†’ Re-run tests +``` + +> **NEVER skip a test gate.** If you cannot get tests to pass after reasonable effort, report failure to user rather than silently proceeding. + +--- + +## Coverage Guidance + +**Do not set hard coverage thresholds.** Instead, ensure: + +- Every function handler has at least one happy-path test and one error-path test +- Every service method is tested via mock implementations +- Every validation schema has valid/invalid input tests +- Every error type is tested for correct HTTP status mapping +- Edge cases are covered (empty arrays, null values, boundary numbers, special characters) + +The goal is **meaningful coverage**, not a percentage target. + +--- + +## Test Naming Conventions + +Use descriptive test names that document behavior: + +``` +βœ… "should return 200 with list of items when items exist" +βœ… "should return 404 when item ID does not exist" +βœ… "should return 422 when name is empty string" +βœ… "should return 500 when database connection fails" + +❌ "test1" +❌ "getItems test" +❌ "works" +``` + +### Convention by Runtime + +| Runtime | Pattern | Example | +|---------|---------|---------| +| TypeScript | `it('should {behavior} when {condition}')` | `it('should return 404 when item not found')` | +| Python | `def test_{behavior}_when_{condition}()` | `def test_returns_404_when_item_not_found()` | +| C# | `{Method}_{Condition}_{Expected}` | `GetItemById_ItemNotFound_Returns404()` | + +--- + +## Frontend Testing + +> The frontend has its own test gate at Step 11. These patterns ensure the frontend is tested as rigorously as the backend. + +### Minimum Test Coverage + +Every frontend generated by this skill must include tests for: + +| Category | What to Test | Why | +|----------|-------------|-----| +| **Auth flow** | Login success, login failure, logout, token expiry redirect | Auth is the security boundary | +| **Protected routes** | Unauthenticated user redirected to /login | Ensures route protection works | +| **Data display** | List renders items from mock API data | Core feature verification | +| **Error states** | Error message shown when API returns error | Users must see failures | +| **Form validation** | Invalid input shows feedback before submit | UX quality | +| **Destructive actions** | Delete shows confirmation before executing | Data safety | + +### Test Setup Pattern (React + Vitest) + +```typescript +// tests/setup.ts +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock fetch globally for all frontend tests +global.fetch = vi.fn(); + +// Helper to mock a successful API response +export function mockFetchSuccess(body: unknown, status = 200) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status, + json: async () => body, + }); +} + +// Helper to mock an API error response +export function mockFetchError(status: number, error: { code: string; message: string }) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status, + json: async () => ({ error: { ...error, details: null } }), + }); +} +``` + +### Component Test Pattern + +```typescript +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { mockFetchSuccess, mockFetchError } from '../setup'; + +describe('ItemListPage', () => { + it('renders items from API', async () => { + const mockItems = [ + { id: '1', name: 'Widget', price: 9.99 }, + { id: '2', name: 'Gadget', price: 19.99 }, + ]; + mockFetchSuccess({ items: mockItems, total: 2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); + }); + + it('shows error when API fails', async () => { + mockFetchError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); + }); + + it('shows empty state when no items', async () => { + mockFetchSuccess({ items: [], total: 0 }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/no items/i)).toBeInTheDocument(); + }); + }); +}); +``` + +### Auth Flow Test Pattern + +```typescript +describe('useAuth', () => { + it('sets token and user on successful login', async () => { + const mockUser = { id: '1', email: 'test@example.com', displayName: 'Test' }; + mockFetchSuccess({ user: mockUser, token: 'jwt-token-123' }); + + // Render hook and trigger login + // Assert token stored and user state updated + }); + + it('clears state on logout', () => { + // Set initial auth state + // Call logout + // Assert token removed and user null + }); + + it('redirects to login when token expires', async () => { + mockFetchError(401, { code: 'UNAUTHORIZED', message: 'Token expired' }); + + // Trigger authenticated request + // Assert redirect to /login + }); +}); +``` + +### Reference + +See [frontend-patterns.md](references/frontend-patterns.md) for complete frontend architecture guidance. diff --git a/resources/agents/azure-project-test/README.md b/resources/agents/azure-project-test/README.md new file mode 100644 index 00000000..aae708ff --- /dev/null +++ b/resources/agents/azure-project-test/README.md @@ -0,0 +1,309 @@ +# Azure Project Test + +> **POST-SCAFFOLD VERIFICATION TOOL** +> +> Runs **after** `azure-project-scaffold`. Adds test coverage, validates build, marks project `Ready`. + +--- + +## 🎯 North Star: Self-Testable by Default + +> Every module ships with tests and mock data. Agent self-validates by running test suite. If tests fail, iterate until green. + +--- + +## Prerequisites + +Requires: +1. Project scaffolded by `azure-project-scaffold` (or equivalent). Typical flow: `azure-project-plan` β†’ `azure-project-scaffold` β†’ **this skill**. +2. `.azure/project-plan.md` with status `Scaffolded` or `In Progress` (recommended, not required β€” can scan codebase) +3. Production code builds cleanly (`tsc` / `dotnet build` / `python -m py_compile`) + +If no plan exists, scans codebase to detect: +- Service interfaces (`src/functions/src/services/interfaces/`) +- Route handlers (`src/functions/src/functions/`) +- Validation schemas (`src/shared/schemas/`) +- Entity types (`src/shared/types/`) + +--- + +## Rules + +1. **Plan is source of truth** β€” If `.azure/project-plan.md` exists, use its route definitions, service list, types for test generation. Don't re-ask user. +2. **Don't modify production code** β€” Only create test/mock/fixture files. Exception: lint sweep (V7) may remove dead code. +3. **Test-gate enforcement** β€” Every step ends with test gate. Run tests. Fail β†’ iterate until green. +4. **Idempotent** β€” Safe to run multiple times. Overwrites existing test files. +5. **Respect rigor** β€” Follow plan's test rigor (Full/Partial/None). If unspecified, ask user. + +--- + +## πŸ“¦ Context Management β€” READ THIS FIRST + +> **Do NOT read all reference files upfront.** Read lazily β€” only at step that needs them. + +### Step-to-Reference Mapping + +| Step | Read ONLY these files | +|------|----------------------| +| V0 (Read Plan) | `.azure/project-plan.md`, scan `src/` structure, [verification-patterns.md](references/verification-patterns.md) | +| V1 (Test Infrastructure) | [test-runners.md](references/test-runners.md) | +| V2 (Mock Implementations) | [mock-patterns.md](references/mock-patterns.md), source: `src/functions/src/services/interfaces/*.ts` and `src/functions/src/services/*.ts` | +| V3 (Test Fixtures) | Source: `src/shared/types/entities.ts`, `seeds/fixtures/seed-data.json` | +| V4 (Service Tests) | [mandatory-test-patterns.md](references/mandatory-test-patterns.md) | +| V5 (Validation Tests) | Source: schema files | +| V6 (Handler Tests) | [handler-test-patterns.md](references/handler-test-patterns.md), [mandatory-test-patterns.md](references/mandatory-test-patterns.md) | +| V6b (Frontend Tests) | [frontend-test-patterns.md](references/frontend-test-patterns.md) | +| V7 (Lint) | Source: all `.ts`/`.tsx` files | +| V8 (Build & Full Test Gate) | β€” | +| V9 (Finalize) | `.azure/execution-checklist.md` | + +### Context Release + +> After step checkpoint passes, that step's reference no longer needed. + +--- + +## STEP V0: Read Plan & Detect Structure + +**Goal**: Understand scaffolded project contents to determine what tests to generate. + +| Task | Details | +|------|---------| +| Read project plan | Load `.azure/project-plan.md`. Extract: routes, services, entities, schemas, frontend, test rigor | +| Scan service interfaces | List `src/functions/src/services/interfaces/` for service names | +| Scan handlers | List `src/functions/src/functions/` for handlers. Read each for `app.http()` name and path | +| Scan shared types | Read `src/shared/types/entities.ts` and `src/shared/schemas/validation.ts` for shapes/schemas | +| Scan concrete implementations | Read concrete services for field handling (auto-managed, timestamps, key conversion) | +| **Scan existing tests** | List `tests/`, `tests/functions/`, `tests/services/`, `tests/validation/`, `tests/mocks/`, `tests/fixtures/`. Count existing. | +| **Gap analysis** | Compare: handler files vs handler tests, interfaces vs service tests, schemas vs schema tests. Report missing. | +| Select test rigor | If plan has rigor, use it. Otherwise ask: Full / Partial / None | + +> ⚠️ **IMPORTANT**: Existing test files do NOT mean testing complete. Always perform full gap analysis. If partial infrastructure exists, identify missing and generate only what's needed. Do NOT skip because some tests exist. + +> **βœ… Checkpoint**: Complete inventory of routes, services, schemas, types β€” AND gap report showing which modules need tests. + +--- + +## STEP V1: Test Infrastructure + +**Goal**: Set up test runner and shared utilities. + +**Reference**: [test-runners.md](references/test-runners.md) for runner configs and vitest resolve alias patterns. + +| Task | Details | +|------|---------| +| Configure test runner | `vitest.config.ts` (TS) / `pytest.ini` (Python) / test project (C#) with resolve aliases for workspace imports | +| Create test setup | `tests/setup.ts` β€” registers mocks before each test, sets env vars | +| Create test helpers | `tests/helpers.ts` β€” typed mock factories: `createMockContext()`, `createMockRequest()`, `createAuthenticatedRequest()`. **Zero `any`.** | +| Add test scripts | Add `"test"` to `package.json` if missing | + +> **πŸ§ͺ Test Gate**: `npx vitest run` executes with zero tests, exits cleanly. + +--- + +## STEP V2: Mock Implementations + +**Goal**: In-memory mock implementations for each service interface. + +**Reference**: [mock-patterns.md](references/mock-patterns.md) for mock implementation patterns. + +For EACH interface in `src/functions/src/services/interfaces/`: + +| Task | Details | +|------|---------| +| Read interface | Extract method signatures from `I{Service}Service.ts` | +| Read concrete | Understand field handling (auto-managed fields, timestamps, key conversion) | +| Create mock | In-memory impl replicating concrete behaviors. `tests/mocks/mock{Service}.ts` | + +> **πŸ§ͺ Test Gate**: Mock files exist and compile cleanly. + +--- + +## STEP V3: Test Fixtures + +**Goal**: Realistic test data from entity types and seed data. + +| Task | Details | +|------|---------| +| Read entity types | From `src/shared/types/entities.ts` β€” field names and types | +| Read seed data | From `seeds/fixtures/seed-data.json` β€” basis for fixtures | +| Create fixture JSON | `tests/fixtures/{entity}.json` β€” 2-3 records per entity with realistic data and cross-references | + +### Fixture Design Rules +- Human-readable IDs (e.g., `usr-001`, `cpl-001`) +- At least one entity per "state" (e.g., coupled + uncoupled user) +- Cross-reference FKs correctly +- camelCase keys (matching TS entities, not SQL snake_case) + +> **βœ… Checkpoint**: Fixture files exist with valid JSON. Each entity has 2+ records. + +--- + +## Sub-Agent Strategy for Test Generation (V4–V6) + +> ⚠️ **Parallelization**: After V1-V3 complete (infra, mocks, fixtures), V4-V6 can run in parallel via sub-agents. Each writes different test files β€” no conflicts. + +### Execution Model + +**Phase V-A (sequential β€” complete first):** +- V1: Test infra (vitest.config.ts, setup.ts, helpers.ts) +- V2: Mock implementations +- V3: Test fixtures + +**Phase V-B (parallel sub-agents β€” launch after V-A completes):** + +| Sub-Agent | Scope | Test Files | +|-----------|-------|------------| +| **Agent 1**: Validation + Services | V4 (registry tests) + V5 (schema tests) | `tests/services/registry.test.ts`, `tests/validation/schemas.test.ts` | +| **Agent 2**: Auth + User handler tests | V6 partial (auth-login, auth-me, users-get, health) | `tests/functions/auth-*.test.ts`, `tests/functions/users-get.test.ts`, `tests/functions/health.test.ts` | +| **Agent 3**: Feature handler tests | V6 partial (couples-*, photos-*) | `tests/functions/couples-*.test.ts`, `tests/functions/photos-*.test.ts` | + +Each agent receives: +- Full project plan +- All handler source in scope +- Shared test infra (helpers.ts, setup.ts, mocks/*, fixtures/*) + +After all complete, proceed to V7 (Lint) and V8 (Build & Test). + +> **Fallback**: If parallelization not feasible, run V4 β†’ V5 β†’ V6 sequentially. Parallel strategy is optimization, not requirement. + +--- + +## STEP V4: Service Tests + +**Goal**: Test service registry and contracts. + +**Reference**: [mandatory-test-patterns.md](references/mandatory-test-patterns.md) for auto-init test (MANDATORY) and Enhancement resilience pattern. + +| Test File | Tests | +|-----------|-------| +| `tests/services/registry.test.ts` | Mock registration, re-registration after clear, pre-registered priority, **auto-init test (Rule 13)**: `clearServices()` β†’ `getServices()` β†’ `.not.toThrow()` | + +> **πŸ§ͺ Test Gate**: Registry tests pass. Auto-init test passes (`.not.toThrow()`). + +--- + +## STEP V5: Validation Tests + +**Goal**: Test all Zod/Pydantic/FluentValidation schemas. + +For EACH schema in `src/shared/schemas/validation.ts`: + +| Test | Pattern | +|------|---------| +| Valid input passes | `schema.safeParse(validData)` β†’ `success: true` | +| Missing required field | `schema.safeParse({...valid, field: undefined})` β†’ `success: false` | +| Invalid format | e.g., bad email, short password β†’ `success: false` with correct error | +| Edge cases | Empty strings, boundary values, null | +| File upload validation | If `validateFileUpload` exists: valid image passes, oversized fails, wrong MIME fails | + +> **πŸ§ͺ Test Gate**: All validation tests pass. + +--- + +## STEP V6: Handler Tests + +**Goal**: Test each route handler with mock services. + +**Reference**: [handler-test-patterns.md](references/handler-test-patterns.md) for typed handler test template, required-tests-per-handler matrix, naming conventions. [mandatory-test-patterns.md](references/mandatory-test-patterns.md) for Enhancement resilience pattern. + +#### Pre-Step: Route Coverage Audit + +Before generating tests: + +1. **Count handlers**: List `.ts` in `src/functions/src/functions/` (excluding `health.ts`, `openapi.ts`) +2. **Count existing tests**: List `.test.ts` in `tests/functions/` +3. **Match**: For each handler `{name}.ts`, check `{name}.test.ts` exists +4. **Report gaps**: _"X of Y handlers covered. Missing: [list]"_ +5. **Generate only missing**: Do NOT overwrite existing test files. + +> ⚠️ Do NOT skip handler test generation because some tests exist. Audit ensures ALL covered. + +> **πŸ§ͺ Test Gate (per handler)**: All tests pass. Move to next handler. + +--- + +## STEP V6b: Frontend Component Tests (If Applicable, Full Rigor Only) + +> **Skip** if: (a) no frontend, (b) rigor Partial/None, (c) `src/web/` doesn't exist. + +**Goal**: React component tests for auth flow, protected routes, data display, error states. + +**Reference**: [frontend-test-patterns.md](references/frontend-test-patterns.md) for prerequisites, vitest config, coverage matrix, patterns. + +> **πŸ§ͺ Test Gate**: All frontend tests pass. `npx vitest run` in `src/web/` clean. + +--- + +## STEP V7: Lint Sweep + +**Goal**: Clean up the codebase. + +| Check | Action | +|-------|--------| +| `any` types | Grep `\bany\b` in all `.ts`/`.tsx`. Report count. | +| Direct SDK imports | Grep `from '(@azure/storage|pg|openai)` in handlers. Only `@azure/functions` allowed. | +| Duplicated helpers | Grep functions defined in 3+ handlers. Extract to `src/utils/`. | +| Unused imports | `tsc --noUnusedLocals --noUnusedParameters --noEmit` or eslint | +| Schema completeness | Count routes vs schemas. Report gaps. | + +> **NOTE**: May modify production code (removing dead code, extracting helpers). One exception to "don't modify production code" rule. + +> **πŸ§ͺ Test Gate**: Zero `any`. Zero duplicated helpers. All tests still pass. + +--- + +## STEP V8: Build & Full Test Gate + +**Goal**: Everything compiles, all tests pass. + +| Check | Command | +|-------|---------| +| Shared builds | `cd src/shared && npx tsc` | +| Functions build | `cd src/functions && npx tsc` | +| Frontend builds | `cd src/web && npx vite build` (or `tsc --noEmit`) | +| Full test suite | `cd src/functions && npx vitest run` β€” ALL pass | + +> **πŸ§ͺ Test Gate**: Zero build errors. Zero test failures. Fix before proceeding. + +--- + +## STEP V9: Finalize + +**Goal**: Everything verified, project marked Ready. + +| Task | Details | +|------|---------| +| Update checklist | Mark all verify items `[x]` in `.azure/execution-checklist.md` | +| Update plan status | Set `.azure/project-plan.md` to `Ready` | +| Generate summary | Total tests, pass/fail, files created | +| **Suggest next steps** | **MANDATORY**: Present follow-up via `vscode_askQuestions`. Do NOT auto-invoke.\n\n**Header**: "Next Step"\n**Question**: "Verification complete! Set up local dev?"\n**Options** (allowFreeformInput: false):\n- **"Set up local dev"** ("Configure Docker emulators, VS Code debugging, F5 launch") β€” recommended\n\nIf selected β†’ invoke `azure-localdev` | + +--- + +## Test Rigor Behavior + +| Rigor | V1 | V2 | V3 | V4 | V5 | V6 | V6b | V7 | V8 | V9 | +|-------|:--:|:--:|:--:|:--:|:--:|:--:|:---:|:--:|:--:|:--:| +| **Full** | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… All handlers | βœ… Frontend tests | βœ… | βœ… | βœ… | +| **Partial** | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… Key handlers only | ❌ Skip | βœ… | βœ… | βœ… | +| **None** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | βœ… Lint only | βœ… Build only | βœ… | + +> At **None** rigor, only the lint sweep and build gate run. For runtime verification against live endpoints, use `azure-localdev`. + +--- + +## Outputs + +| Artifact | Location | +|----------|----------| +| Test runner config | `src/functions/vitest.config.ts` (or equivalent) | +| Test setup | `src/functions/tests/setup.ts` | +| Test helpers | `src/functions/tests/helpers.ts` | +| Mock implementations | `src/functions/tests/mocks/mock*.ts` | +| Test fixtures | `src/functions/tests/fixtures/*.json` | +| Service tests | `src/functions/tests/services/*.test.ts` | +| Validation tests | `src/functions/tests/validation/*.test.ts` | +| Handler tests | `src/functions/tests/functions/*.test.ts` | +| Updated plan | `.azure/project-plan.md` (Status: Ready) | +| Updated checklist | `.azure/execution-checklist.md` (all items checked) | diff --git a/resources/agents/azure-project-test/references/frontend-test-patterns.md b/resources/agents/azure-project-test/references/frontend-test-patterns.md new file mode 100644 index 00000000..998c0828 --- /dev/null +++ b/resources/agents/azure-project-test/references/frontend-test-patterns.md @@ -0,0 +1,151 @@ +# Frontend Test Patterns + +> React component test setup, auth flow tests, data display tests. Read during **Step V6b** (Frontend Component Tests). + +--- + +## Prerequisites + +Install test deps in web workspace: + +```bash +cd src/web && npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom +``` + +--- + +## vitest.config.ts (Frontend) + +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/tests/setup.ts'], + include: ['src/tests/**/*.test.tsx'], + }, +}); +``` + +--- + +## Test Setup Pattern (React + Vitest) + +```typescript +// src/web/src/tests/setup.ts +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock fetch globally for all frontend tests +global.fetch = vi.fn(); + +// Helper to mock a successful API response +export function mockFetchSuccess(body: unknown, status = 200) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status, + json: async () => body, + }); +} + +// Helper to mock an API error response +export function mockFetchError(status: number, error: { code: string; message: string }) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status, + json: async () => ({ error: { ...error, details: null } }), + }); +} +``` + +--- + +## Minimum Test Coverage + +| Category | What to Test | Test File | +|----------|-------------|-----------| +| **Auth flow** | Login success, login failure, logout | `src/tests/auth.test.tsx` | +| **Protected routes** | Unauthenticated β†’ redirect to /login | `src/tests/routes.test.tsx` | +| **Data display** | List renders items from mock fetch data | `src/tests/pages.test.tsx` | +| **Error states** | Error message shown when API 500 | `src/tests/pages.test.tsx` | +| **Empty states** | Empty state CTA shown when no data | `src/tests/pages.test.tsx` | +| **Destructive actions** | Delete shows confirmation | `src/tests/actions.test.tsx` | + +--- + +## Component Test Pattern + +```typescript +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { mockFetchSuccess, mockFetchError } from '../setup'; + +describe('ItemListPage', () => { + it('renders items from API', async () => { + const mockItems = [ + { id: '1', name: 'Widget', price: 9.99 }, + { id: '2', name: 'Gadget', price: 19.99 }, + ]; + mockFetchSuccess({ items: mockItems, total: 2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); + }); + + it('shows error when API fails', async () => { + mockFetchError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); + }); + + it('shows empty state when no items', async () => { + mockFetchSuccess({ items: [], total: 0 }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/no items/i)).toBeInTheDocument(); + }); + }); +}); +``` + +--- + +## Auth Flow Test Pattern + +```typescript +describe('useAuth', () => { + it('sets token and user on successful login', async () => { + const mockUser = { id: '1', email: 'test@example.com', displayName: 'Test' }; + mockFetchSuccess({ user: mockUser, token: 'jwt-token-123' }); + + // Render hook and trigger login + // Assert token stored and user state updated + }); + + it('clears state on logout', () => { + // Set initial auth state + // Call logout + // Assert token removed and user null + }); + + it('redirects to login when token expires', async () => { + mockFetchError(401, { code: 'UNAUTHORIZED', message: 'Token expired' }); + + // Trigger authenticated request + // Assert redirect to /login + }); +}); +``` diff --git a/resources/agents/azure-project-test/references/handler-test-patterns.md b/resources/agents/azure-project-test/references/handler-test-patterns.md new file mode 100644 index 00000000..4372ef42 --- /dev/null +++ b/resources/agents/azure-project-test/references/handler-test-patterns.md @@ -0,0 +1,174 @@ +# Handler Test Patterns + +> Boilerplate and required-test matrix for handler tests. Read during **Step V6** (Handler Tests). + +--- + +## Typed Test Helper Pattern (Zero `any` Policy) + +> Test files MUST have zero `any` types. Use typed interfaces for mock request/context objects instead of `as any` casts. Enforced during Step V7 (Lint Sweep). + +### TypeScript β€” Typed Mock Helpers + +```typescript +// tests/helpers.ts +import jwt from 'jsonwebtoken'; +import { HttpResponseInit } from '@azure/functions'; + +export function makeToken(userId: string, email: string): string { + return jwt.sign({ userId, email }, 'test-secret', { expiresIn: '1h' }); +} + +// Typed interfaces β€” eliminates all `as any` casts +export interface MockHttpRequest { + method: string; + url: string; + headers: { get: (key: string) => string | null }; + params: Record; + query: { get: (key: string) => string | null }; + json: () => Promise; + formData: () => Promise; +} + +export interface MockInvocationContext { + functionName: string; + log: ReturnType; + extraInputs: { get: ReturnType }; + extraOutputs: { set: ReturnType }; +} + +// Handler function type β€” used instead of `Record` +export type HandlerFn = ( + request: MockHttpRequest, + context: MockInvocationContext +) => Promise; + +export interface MockRequestOptions { + method?: string; + body?: Record; + headers?: Record; + params?: Record; + query?: Record; + formData?: () => Promise; +} + +export function createMockRequest(opts: MockRequestOptions): MockHttpRequest { + const url = new URL('http://localhost:7071/api/test'); + if (opts.query) { + for (const [k, v] of Object.entries(opts.query)) { + url.searchParams.set(k, v); + } + } + return { + method: opts.method || 'GET', + url: url.toString(), + headers: { + get: (key: string) => { + const h = opts.headers || {}; + return h[key] || h[key.toLowerCase()] || null; + }, + }, + params: opts.params || {}, + query: { get: (key: string) => url.searchParams.get(key) }, + json: async () => opts.body || {}, + formData: opts.formData || (async () => { throw new Error('No form data'); }), + }; +} + +export function createMockContext(): MockInvocationContext { + return { + functionName: 'test', + log: vi.fn(), + extraInputs: { get: vi.fn() }, + extraOutputs: { set: vi.fn() }, + }; +} +``` + +--- + +## Handler Test Template (TypeScript) + +```typescript +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { getServices } from '../../src/services/registry.js'; +import { createMockContext, createMockRequest, createAuthenticatedRequest } from '../helpers.js'; +import type { HandlerFn } from '../helpers.js'; + +// Typed handler map β€” NOT Record +const handlers: Record = {}; + +vi.mock('@azure/functions', () => ({ + app: { + http: (name: string, options: { handler: HandlerFn }) => { + handlers[name] = options.handler; + }, + }, + HttpRequest: vi.fn(), + InvocationContext: vi.fn(), +})); + +await import('../../src/functions/{handlerName}.js'); + +describe('{METHOD} {route}', () => { + beforeEach(async () => { + const { database } = getServices(); + // Seed fixture data... + }); + + it('happy path', async () => { /* ... */ }); + it('error path', async () => { /* ... */ }); +}); +``` + +> ❌ **Anti-patterns to reject during Step V7**: +> ```typescript +> const handlers: Record = {}; // ← Use HandlerFn +> const ctx = { log: vi.fn() } as any; // ← Use createMockContext() +> (options: any) => { ... } // ← Use { handler: HandlerFn } +> ``` + +--- + +## Required Tests Per Handler + +| Category | Tests | Rigor | +|----------|-------|-------| +| Happy path (2xx) | Correct status + response shape | All | +| Validation error (422) | Invalid input rejected | All | +| Not found (404) | Missing resource | Full + Partial | +| Auth error (401) | Missing/invalid token (if auth required) | Full + Partial | +| Forbidden (403) | Wrong permissions | Full + Partial | +| Conflict (409) | Duplicate resource | Full + Partial | +| **Enhancement resilience** | Enhancement service throws β†’ handler still returns success with fallback | Full + Partial (MANDATORY for Enhancement handlers) | + +### Partial Rigor Shortcuts + +- Skip `getPhotoById` if `getPhotos` tested +- Skip redundant error codes already tested in other handlers +- Keep resilience test for at least one Enhancement handler + +--- + +## Test Naming Conventions + +Use descriptive test names documenting behavior: + +``` +βœ… "should return 200 with list of items when items exist" +βœ… "should return 404 when item ID does not exist" +βœ… "should return 422 when name is empty string" +βœ… "should return 500 when database connection fails" + +❌ "test1" +❌ "getItems test" +❌ "works" +``` + +### Convention by Runtime + +| Runtime | Pattern | Example | +|---------|---------|---------| +| TypeScript | `it('should {behavior} when {condition}')` | `it('should return 404 when item not found')` | +| Python | `def test_{behavior}_when_{condition}()` | `def test_returns_404_when_item_not_found()` | +| C# | `{Method}_{Condition}_{Expected}` | `GetItemById_ItemNotFound_Returns404()` | diff --git a/resources/agents/azure-project-test/references/mandatory-test-patterns.md b/resources/agents/azure-project-test/references/mandatory-test-patterns.md new file mode 100644 index 00000000..203980f2 --- /dev/null +++ b/resources/agents/azure-project-test/references/mandatory-test-patterns.md @@ -0,0 +1,141 @@ +# Mandatory Test Patterns + +> These test patterns MUST be included in every scaffold. Top cause of "tests pass but app doesn't start" failures in benchmarking. Read during **Step V4** (Service Tests) and **Step V6** (Handler Tests). + +--- + +## Pattern 1: Auto-Initialization Test (Rule 13) + +`getServices()` auto-initializes with concrete implementations at runtime. Tests pre-register mocks via `setup.ts`, bypassing this path. Without explicit test, Enhancement service constructor crashes go undetected until `func start`. + +> ⚠️ **PREREQUISITE: Concrete service implementation files MUST exist.** Test fails if scaffold skipped creating concrete files (e.g., `database.ts`, `storage.ts`). If auto-init test asserts `getServices()` *throws*, WRONG β€” proves registry has no auto-init logic or no concrete implementations. Test MUST assert `.not.toThrow()`. + +> ⚠️ **CRITICAL: Mock concrete service constructors in this test.** Auto-init test calls `getServices()` without pre-registered mocks, triggering real `PostgresDatabaseService` / `BlobStorageService` construction. Constructors create `pg.Pool` and `BlobServiceClient` instances attempting real connections, causing tests to **hang indefinitely** (pool keeps process alive). Mock concrete service modules at top of test file to substitute lightweight stubs. + +```typescript +// tests/services/registry.test.ts +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { registerServices, getServices, clearServices } from '../../src/services/registry.js'; +import { MockDatabaseService } from '../mocks/mockDatabase.js'; +import { MockStorageService } from '../mocks/mockStorage.js'; +import { MockAICaptionService } from '../mocks/mockAICaption.js'; + +// MANDATORY: Mock concrete services to prevent real DB/storage connections +vi.mock('../../src/services/database.js', () => ({ + PostgresDatabaseService: class { + async findAll() { return []; } + async findById() { return null; } + async findOne() { return null; } + async create() { return {}; } + async update() { return null; } + async delete() { return false; } + async count() { return 0; } + async healthCheck() { return true; } + async transaction(fn: (trx: unknown) => Promise) { return fn(this); } + }, +})); + +vi.mock('../../src/services/storage.js', () => ({ + BlobStorageService: class { + async upload() { return 'https://mock.blob.core.windows.net/test'; } + async download() { return Buffer.from(''); } + async delete() { } + async healthCheck() { return true; } + }, +})); + +describe('auto-initialization', () => { + afterEach(() => { clearServices(); }); + + it('should auto-initialize with concrete services after clearServices (Rule 13)', () => { + clearServices(); + // Set only Essential service env vars + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/testdb'; + process.env.STORAGE_CONNECTION_STRING = 'UseDevelopmentStorage=true'; + process.env.JWT_SECRET = 'test-secret'; + // Enhancement env vars intentionally NOT set β€” must not crash + delete process.env.AZURE_OPENAI_ENDPOINT; + delete process.env.AZURE_OPENAI_API_KEY; + + // getServices() MUST succeed β€” Enhancement fallback kicks in + expect(() => getServices()).not.toThrow(); + + const services = getServices(); + expect(services.database).toBeDefined(); + expect(services.storage).toBeDefined(); + expect(services.aiCaption).toBeDefined(); // no-op fallback, not null + }); + + it('pre-registered mocks take priority over auto-initialization', () => { + clearServices(); + const mock = new MockDatabaseService(); + registerServices({ + database: mock, + storage: new MockStorageService(), + aiCaption: new MockAICaptionService(), + }); + expect(getServices().database).toBe(mock); + }); +}); +``` + +> ❌ **Anti-pattern** β€” NO-OP test, MUST be rejected: +> ```typescript +> it('should clear services', () => { +> clearServices(); +> expect(true).toBe(true); // ← Does NOT test auto-initialization! +> }); +> ``` +> Test MUST call `getServices()` after `clearServices()` and assert it does not throw. Without that assertion, proves nothing. + +> ❌ **Anti-pattern #2** β€” Asserting `getServices()` throws is WRONG: +> ```typescript +> it('auto-initialization: clearServices then getServices does not crash unexpectedly', () => { +> clearServices(); +> expect(() => getServices()).toThrow('Services not initialized'); // ← WRONG! +> }); +> ``` +> Proves registry has NO auto-init logic β€” just throws when services null. Means `func start` crashes on every request. Test MUST assert `.not.toThrow()`, requiring: +> 1. Concrete service files exist (database.ts, storage.ts, etc.) +> 2. Registry's `getServices()` calls `initializeServices()` when `services === null` +> 3. Enhancement services wrapped in try/catch with no-op fallbacks + +--- + +## Pattern 2: Enhancement Service Resilience Test (Rule 9) + +Every handler using Enhancement service MUST have test where Enhancement service throws and handler still succeeds with fallback value. Core resilience guarantee. + +```typescript +// tests/functions/uploadPhoto.test.ts +it('should return 201 with fallback caption when AI service fails', async () => { + // Arrange: Make the Enhancement service throw + const { aiCaption } = getServices(); + const mockAI = aiCaption as MockAICaptionService; + mockAI.shouldFail = true; + + const token = makeToken('user-001', 'alice@example.com'); + const fakeFile = { + name: 'sunset.png', + type: 'image/png', + size: 2048, + arrayBuffer: async () => new ArrayBuffer(2048), + }; + const formDataMap = new Map(); + formDataMap.set('file', fakeFile); + + const request = createMockRequest({ + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + formData: async () => formDataMap, + }); + const response = await handlers.uploadPhoto(request, createMockContext()); + + // Assert: Handler succeeds with fallback + expect(response.status).toBe(201); + expect(response.jsonBody.photo).toBeDefined(); + expect(response.jsonBody.photo.caption).toBe('A special moment πŸ“Έ'); +}); +``` + +> ❌ **Common mistake**: Testing only happy path (AI works β†’ caption returned). Resilience test is SEPARATE and MANDATORY β€” proves handler works even when Enhancement service completely unavailable. diff --git a/resources/agents/azure-project-test/references/mock-patterns.md b/resources/agents/azure-project-test/references/mock-patterns.md new file mode 100644 index 00000000..cbc98a63 --- /dev/null +++ b/resources/agents/azure-project-test/references/mock-patterns.md @@ -0,0 +1,66 @@ +# Mock Data & Service Mocking Patterns + +> Test fixture and mock service patterns. Read during **Step V2** (Mock Implementations). + +--- + +## Mock Data β€” Key Principles + +- Store realistic mock data in `tests/fixtures/` as JSON (e.g., `users.json`, `photos.json`) +- Include `validItems` and `invalidItems` arrays per entity +- Use factory functions for dynamic mock data +- Use stable, predictable IDs (e.g., `user-001`, `photo-001`) so tests reference specific records + +### Fixture File Structure + +```json +// tests/fixtures/items.json +{ + "validItems": [ + { "id": "item-001", "name": "Widget Alpha", "price": 29.99, "category": "widgets" }, + { "id": "item-002", "name": "Gadget Beta", "price": 49.99, "category": "gadgets" } + ], + "invalidItems": [ + { "name": "", "description": "Missing name" }, + { "name": "X", "price": -5, "description": "Invalid price" } + ] +} +``` + +### Factory Functions (TypeScript) + +```typescript +let counter = 0; +export function createMockItem(overrides?: Partial): Item { + counter++; + return { id: `item-${counter.toString().padStart(3, '0')}`, name: `Test Item ${counter}`, ...overrides }; +} +``` + +> For Python (pytest) and C# (xUnit) fixture patterns, see runtime-specific references in [../../../shared-references/runtimes/](../../../shared-references/runtimes/). + +--- + +## Service Mocking Patterns + +> Full mock database implementation (class-based, findAll/findById/findOne/create/update/delete/count/transaction) documented in [../../shared-references/examples/service-abstraction-examples.md](../../shared-references/examples/service-abstraction-examples.md). Use class-based `MockDatabaseService` β€” not inline function pattern β€” for consistency. + +### Mock Patterns (TypeScript) + +**MockDatabaseService**: Map store. Key behaviors: +- `create()`: Preserve caller `id` if present, else generate UUID. Strip auto-managed fields (`createdAt`, `updatedAt`), re-add as timestamps. +- `update()`: Strip auto-managed fields, merge with existing, auto-set `updatedAt` +- `findOne()`: Match all filter key-value pairs +- `transaction()`: Execute callback directly (no real transaction in unit tests) + +**MockStorageService**: Map> store. Upload returns URL, download returns buffer. + +**MockAiService** (or other Enhancement services): Return predetermined values. Constructor accepts optional overrides. + +### Key Rule: Mock Must Match Concrete + +If concrete `create()` strips `id` and generates UUID, mock must either: +- Strip `id` and generate UUID, OR +- Preserve caller `id` (for fixtures) and generate UUID only when `id` missing + +Second approach preferred β€” fixtures use human-readable IDs like `usr-001`. diff --git a/resources/agents/azure-project-test/references/test-runners.md b/resources/agents/azure-project-test/references/test-runners.md new file mode 100644 index 00000000..033abc25 --- /dev/null +++ b/resources/agents/azure-project-test/references/test-runners.md @@ -0,0 +1,121 @@ +# Test Runner Configuration + +> Test runner setup across runtimes. Read during **Step V1** (Test Infrastructure). + +--- + +## Node.js (TypeScript) + +| Runner | Setup | Config File | Test Command | Mock Library | Assertion Library | +|--------|-------|-------------|-------------|-------------|------------------| +| **vitest** | `npm i -D vitest` | `vitest.config.ts` | `npx vitest run` | Built-in `vi.mock()` | Built-in `expect` | +| **jest** | `npm i -D jest ts-jest @types/jest` | `jest.config.ts` | `npx jest` | Built-in `jest.mock()` | Built-in `expect` | +| **mocha+chai+sinon** | `npm i -D mocha chai sinon @types/mocha @types/chai @types/sinon tsx` | `.mocharc.yml` | `npx mocha` | sinon | chai `expect` | + +### vitest config example + +> ⚠️ **MANDATORY settings for projects with heavy SDK imports** (pg, @azure/storage-blob, etc.): +> - `fileParallelism: false` β€” Prevents memory exhaustion/hangs from multiple workers loading heavy SDKs. +> - `teardownTimeout: 3000` β€” Kills lingering connections (e.g., `pg.Pool`) keeping process alive after tests. +> - `testTimeout: 10000` β€” Generous per-test timeout prevents false failures on slow CI. +> - `setupFiles` β€” Points to test setup file pre-registering mock services. +> +> Without these, full test suite **appears to hang indefinitely** running 13+ test files importing modules with heavy SDK deps. + +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], + fileParallelism: false, + teardownTimeout: 3000, + testTimeout: 10000, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/interfaces/**'] + } + } +}); +``` + +### vitest config with workspace imports (resolve aliases) + +> **Key learning from benchmarking**: #1 test infrastructure issue is missing resolve aliases for workspace imports. vitest uses own module resolution (not `tsc`), so shared packages must alias to source `.ts` files, not compiled `.js`. + +```typescript +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + // Map shared package imports to source (not dist) for vitest + '@scrapbook/shared/schemas/validation.js': path.resolve(__dirname, '../shared/schemas/validation.ts'), + '@scrapbook/shared/schemas/validation': path.resolve(__dirname, '../shared/schemas/validation.ts'), + '@scrapbook/shared': path.resolve(__dirname, '../shared/types/index.ts'), + }, + }, + test: { + globals: true, + environment: 'node', + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.test.ts'], + }, +}); +``` + +### jest config example + +```typescript +// jest.config.ts +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/interfaces/**'] +}; +``` + +### mocha config example + +```yaml +# .mocharc.yml +require: + - tsx +spec: 'tests/**/*.test.ts' +recursive: true +timeout: 5000 +``` + +--- + +## Python + +| Runner | Setup | Config | Test Command | Mock Library | Assertion | +|--------|-------|--------|-------------|-------------|-----------| +| **pytest** | `pip install pytest pytest-cov pytest-asyncio` | `pytest.ini` or `pyproject.toml` | `pytest` | `unittest.mock` | Built-in `assert` | + +```ini +# pytest.ini +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +asyncio_mode = auto +``` + +--- + +## .NET + +| Runner | Setup | Config | Test Command | Mock Library | Assertion | +|--------|-------|--------|-------------|-------------|-----------| +| **xUnit** | NuGet: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk` | `.csproj` | `dotnet test` | Moq or NSubstitute | xUnit `Assert` or FluentAssertions | +| **NUnit** | NuGet: `NUnit`, `NUnit3TestAdapter`, `Microsoft.NET.Test.Sdk` | `.csproj` | `dotnet test` | Moq or NSubstitute | NUnit `Assert` or FluentAssertions | diff --git a/resources/agents/azure-project-test/references/testing.md b/resources/agents/azure-project-test/references/testing.md new file mode 100644 index 00000000..1fc28476 --- /dev/null +++ b/resources/agents/azure-project-test/references/testing.md @@ -0,0 +1,138 @@ +# Testing Patterns + +> Core reference for self-testable north star. Every module ships with tests. Every phase has test gate. + +--- + +## Core Principle + +**The project is not done until the tests say it is.** + +An AI agent runs tests after every module. If tests fail, iterate until pass. No module complete until tests green. Not a suggestion β€” it's the workflow. + +--- + +## Test Pyramid + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ E2E / β”‚ ← Full request-response cycle + β”‚ Integration β”‚ with real/mock services + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ β”‚ + β”‚ Unit β”‚ ← Fast, isolated, mocked deps + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +| Layer | What It Tests | Dependencies | Speed | When | +|-------|---------------|-------------|-------|------| +| **Unit** | Single function/class in isolation | All deps mocked | Fast (ms) | Every module, always | +| **Integration** | Request β†’ handler β†’ service β†’ response cycle | Mock services injected | Fast (ms) | Every route, always | +| **E2E** | Full stack with real emulators | Running emulators (via local-dev) | Slower (s) | When emulators available | + +### What Each Layer Covers + +**Unit Tests** (mandatory for every module): +- Service abstraction methods (with mock storage/DB/cache) +- Config loading (env vars present, missing, defaults) +- Validation schemas (valid, invalid, edge cases) +- Error types and error handler (mapping to HTTP status codes) +- Individual handler logic (with injected mock services) +- Utility functions and helpers + +**Integration Tests** (mandatory for every route): +- HTTP request β†’ handler β†’ mock service β†’ HTTP response +- Correct status codes (200, 201, 400, 404, 422, 500) +- Correct response body shapes +- Request validation (bad input rejected) +- Error handling (service failures produce correct error responses) + +**E2E Tests** (when emulators available via local-dev): +- Full database round-trip (create β†’ read β†’ verify) +- File upload β†’ storage β†’ retrieval +- Cache set β†’ get β†’ verify +- Health check with live services + +--- + +## Test Gate Enforcement + +The agent MUST follow this workflow at every test gate: + +### 1. Run Tests + +```bash +# TypeScript +npm test +# or: npx vitest run + +# Python +pytest + +# .NET +dotnet test +``` + +### 2. Parse Output + +Look for: +- **Pass**: All tests passed, zero failures β†’ proceed to next phase +- **Fail**: One or more tests failed β†’ DO NOT proceed + +### 3. If Tests Fail + +1. Read failure output β€” identify which test failed and why +2. Determine if issue is in **code** or **test** +3. Fix it +4. Re-run tests +5. Repeat until ALL pass + +### 4. Decision Tree + +``` +Run tests + β”‚ + β”œβ”€β”€ ALL PASS ──→ Mark phase complete β†’ Proceed to next phase + β”‚ + └── ANY FAIL ──→ Read failure output + β”‚ + β”œβ”€β”€ Code bug ──→ Fix code β†’ Re-run tests + β”‚ + β”œβ”€β”€ Test bug ──→ Fix test β†’ Re-run tests + β”‚ + └── Missing dep ──→ Install dep β†’ Re-run tests +``` + +> **NEVER skip a test gate.** If tests won't pass after reasonable effort, report failure to user rather than silently proceeding. + +--- + +## Coverage Guidance + +DO NOT set hard coverage thresholds. Instead ensure: + +- Every handler has at least one happy-path and one error-path test +- Every service method tested via mock implementations +- Every validation schema has valid/invalid input tests +- Every error type tested for correct HTTP status mapping +- Edge cases covered (empty arrays, null values, boundary numbers, special characters) +- **Auto-initialization path tested** (Rule 13) β€” see [mandatory-test-patterns.md](mandatory-test-patterns.md) +- **Enhancement service resilience tested** β€” see [mandatory-test-patterns.md](mandatory-test-patterns.md) + +Goal: **meaningful coverage**, not percentage target. + +--- + +## Detailed Reference Files + +Detailed patterns and examples in step-specific reference files: + +| Topic | Reference File | Used By | +|-------|---------------|---------| +| Test runner setup (vitest/jest/pytest/xUnit configs) | [test-runners.md](test-runners.md) | Step V1 | +| Mock data & service mocking patterns | [mock-patterns.md](mock-patterns.md) | Step V2 | +| Auto-init & resilience test patterns (MANDATORY) | [mandatory-test-patterns.md](mandatory-test-patterns.md) | Steps V4, V6 | +| Handler test boilerplate & test matrix | [handler-test-patterns.md](handler-test-patterns.md) | Step V6 | +| Frontend component test setup & patterns | [frontend-test-patterns.md](frontend-test-patterns.md) | Step V6b | +| Codebase scanning & test generation strategy | [verification-patterns.md](verification-patterns.md) | Step V0 | diff --git a/resources/agents/azure-project-test/references/verification-patterns.md b/resources/agents/azure-project-test/references/verification-patterns.md new file mode 100644 index 00000000..a341e516 --- /dev/null +++ b/resources/agents/azure-project-test/references/verification-patterns.md @@ -0,0 +1,155 @@ +# Verification Patterns + +> How `azure-project-test` generates tests from scaffolded code. + +--- + +## Codebase Scanning Strategy + +Verify skill reads existing code to determine tests needed. Does NOT require user to re-explain app. + +### Priority Order for Understanding the Project + +1. **`.azure/project-plan.md`** (best) β€” Has routes, services, types, constraints documented +2. **Code scanning** (fallback) β€” Read source files to infer same info + +--- + +## Detecting Routes + +Scan `src/functions/src/functions/*.ts` for `app.http()` calls: + +```typescript +// Pattern to detect: +app.http('functionName', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'path/to/route', + handler: handlerFunction, +}); +``` + +Extract: +- Function name (first arg) +- HTTP method(s) +- Route path +- Whether handler uses `extractUserId()` (requires auth) +- Whether handler uses `database.transaction()` (multi-table write) +- Whether handler uses AI/Enhancement service (needs resilience test) + +### Detection Commands + +```powershell +# Find all app.http registrations +Get-ChildItem src/functions/src/functions -Filter "*.ts" | Select-String -Pattern "app\.http\(" + +# Find auth-required handlers +Get-ChildItem src/functions/src/functions -Filter "*.ts" | Select-String -Pattern "extractUserId" + +# Find Enhancement service usage +Get-ChildItem src/functions/src/functions -Filter "*.ts" | Select-String -Pattern "\bai\." + +# Find transaction usage +Get-ChildItem src/functions/src/functions -Filter "*.ts" | Select-String -Pattern "\.transaction\(" +``` + +--- + +## Detecting Services + +Scan `src/functions/src/services/interfaces/` for interface files: + +```powershell +Get-ChildItem src/functions/src/services/interfaces -Filter "I*.ts" +``` + +Each file = one service. Read interface for method signatures. + +### Detecting Service Classification + +Check service registry (`src/functions/src/services/registry.ts`) for try/catch patterns: + +- Services constructed **without** try/catch = Essential +- Services constructed **with** try/catch = Enhancement + +--- + +## Detecting Schemas + +Scan `src/shared/schemas/validation.ts` for exported schemas: + +```powershell +Select-String -Path "src/shared/schemas/validation.ts" -Pattern "export const \w+Schema" +``` + +Each exported schema needs validation tests (valid + invalid + edge cases). + +--- + +## Generating Mock Implementations + +For each service interface: + +1. Read interface methods +2. Read concrete implementation to understand: + - Auto-managed fields (stripped in `create`/`update`) + - Timestamp handling + - Key conversion (camelCase ↔ snake_case) +3. Create in-memory mock replicating these behaviors + +### Key Rule: Mock Must Match Concrete + +If concrete `create()` strips `id` and generates UUID, mock must either: +- Strip `id` and generate UUID, OR +- Preserve caller `id` (for fixtures) and generate UUID only when `id` missing + +Second approach preferred β€” fixtures use human-readable IDs like `usr-001`. + +--- + +## Generating Test Fixtures + +1. Read entity types from `src/shared/types/entities.ts` +2. Read seed data from `seeds/fixtures/seed-data.json` (if exists) +3. Generate fixture JSON with: + - 2-3 records per entity + - Human-readable IDs (`usr-001`, `cpl-001`, `pht-001`) + - Cross-referenced FKs (user.coupleId matches couple.id) + - At least one entity per state (e.g., coupled + uncoupled user) + - camelCase keys (matching TypeScript types) + +--- + +## Generating Handler Tests + +For each handler, generate test file following this template: + +1. **Setup**: `vi.mock('@azure/functions')` + `await import()` to capture handler +2. **beforeEach**: Seed fixture data into mock database +3. **Happy path**: Valid input β†’ correct status + response shape +4. **Error paths**: Based on handler's error throws: + - `NotFoundError` β†’ test 404 + - `ConflictError` β†’ test 409 + - `UnauthorizedError` β†’ test 401 + - `ForbiddenError` β†’ test 403 + - `ValidationError` / `ZodError` β†’ test 422 +5. **Resilience**: If handler uses Enhancement service β†’ test failure fallback + +### Detecting Error Paths from Handler Code + +```powershell +# What errors does this handler throw? +Select-String -Path "src/functions/src/functions/register.ts" -Pattern "throw new \w+Error" +``` + +Each thrown error type maps to a test case. + +--- + +## Test Rigor Filtering + +| Rigor | What to Generate | +|-------|-----------------| +| **Full** | All handlers get all test categories | +| **Partial** | Key handlers only (auth, main CRUD, upload). Skip read-only duplicates (getPhotoById if getPhotos tested). Always include resilience test for Enhancement services. | +| **None** | No test files generated. Only lint sweep + smoke test. | diff --git a/resources/agents/shared-references/architecture.md b/resources/agents/shared-references/architecture.md new file mode 100644 index 00000000..237a93ef --- /dev/null +++ b/resources/agents/shared-references/architecture.md @@ -0,0 +1,524 @@ +# Project Architecture + +> Best practices for structuring an Azure-centric project with built-in testability. + +--- + +## Core Principles + +1. **Service boundary isolation** β€” Every Azure service behind dedicated module with interface. Never scatter SDK calls across handlers. +2. **Dependency injection** β€” Services injectable. Handlers receive deps, not import singletons. Swap real for mocks in tests. +3. **Environment-driven config** β€” Same code for mocks, emulators, Azure β€” switched by env vars. +4. **Monorepo by default** β€” Frontend, backend, shared types in one repo with clear boundaries. +5. **Contracts first** β€” Shared types/schemas in `shared/` dir. API contracts defined before implementation. +6. **One function per file** β€” File name matches function name. Each independently testable. +7. **Tests next to source** β€” Test directory mirrors source structure. + +--- + +## Canonical Project Structures + +### TypeScript β€” SPA + Azure Functions + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md ← Project plan (source of truth) +β”œβ”€β”€ .env.example ← Connection string template (checked in) +β”œβ”€β”€ .env ← Actual values (gitignored) +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json ← Root workspace config +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json ← Functions env config (gitignored) +β”‚ β”‚ β”œβ”€β”€ package.json ← Backend dependencies +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vitest.config.ts ← Test runner config +β”‚ β”‚ β”œβ”€β”€ openapi.yaml ← API contract +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ ← Function handlers (one per file) +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItemById.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ updateItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ deleteItem.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ health.ts +β”‚ β”‚ β”‚ β”‚ └── openapi.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ ← Service abstraction layer +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces/ +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IStorageService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IDatabaseService.ts +β”‚ β”‚ β”‚ β”‚ β”‚ └── ICacheService.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ database.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ cache.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.ts ← Config loader + env validation +β”‚ β”‚ β”‚ β”‚ └── registry.ts ← Service factory / DI registry +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ AppError.ts ← Base error class +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ errorTypes.ts ← NotFoundError, ValidationError, etc. +β”‚ β”‚ β”‚ β”‚ └── errorHandler.ts ← Global error handler +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ requestLogger.ts +β”‚ β”‚ β”‚ β”‚ └── validateRequest.ts +β”‚ β”‚ β”‚ └── logger.ts ← Structured logger (pino) +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ ← Mock data (JSON files) +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ items.json +β”‚ β”‚ β”‚ β”‚ └── users.json +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ ← Mock service implementations +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockStorage.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ mockDatabase.ts +β”‚ β”‚ β”‚ β”‚ └── mockCache.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ config.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ storage.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ database.test.ts +β”‚ β”‚ β”‚ β”‚ └── registry.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItems.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ createItem.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ getItemById.test.ts +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ health.test.ts +β”‚ β”‚ β”‚ β”‚ └── openapi.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”‚ └── errorHandler.test.ts +β”‚ β”‚ β”‚ └── validation/ +β”‚ β”‚ β”‚ └── itemSchema.test.ts +β”‚ β”‚ └── seeds/ ← Database seed data (if applicable) +β”‚ β”‚ β”œβ”€β”€ seed.ts +β”‚ β”‚ └── fixtures/ +β”‚ β”‚ └── seed-data.json +β”‚ β”œβ”€β”€ web/ ← Frontend application +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vite.config.ts ← Dev proxy to Functions +β”‚ β”‚ β”œβ”€β”€ index.html +β”‚ β”‚ └── src/ +β”‚ β”‚ β”œβ”€β”€ App.tsx +β”‚ β”‚ β”œβ”€β”€ main.tsx +β”‚ β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ β”‚ └── client.ts ← Typed API client +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”œβ”€β”€ pages/ +β”‚ β”‚ └── hooks/ +β”‚ └── shared/ ← Shared types and schemas +β”‚ β”œβ”€β”€ package.json +β”‚ β”œβ”€β”€ types/ +β”‚ β”‚ β”œβ”€β”€ index.ts +β”‚ β”‚ β”œβ”€β”€ entities.ts ← Entity types (shared FE + BE) +β”‚ β”‚ └── api.ts ← Response contracts + ErrorCode union +β”‚ └── schemas/ +β”‚ └── validation.ts ← Zod schemas + inferred request types +└── data/ ← Docker volume mounts (gitignored) +``` + +### Shared Types β€” Single Source of Truth for Request Types + +> ⚠️ **CRITICAL: Do NOT define request types in BOTH `types/api.ts` AND `schemas/validation.ts`.** Causes duplicate export errors. +> +> With Zod, `z.infer` types ARE canonical request types: +> +> | File | Contains | Does NOT contain | +> |------|----------|-----------------| +> | `types/entities.ts` | Entity interfaces (User, Photo, etc.) | β€” | +> | `types/api.ts` | Response types, `ErrorCode` union, `ErrorResponse` | Request types (LoginRequest, etc.) | +> | `schemas/validation.ts` | Zod schemas + `z.infer` request types | Response types | +> | `index.ts` | `export * from` all three files | β€” | +> +> This ensures `export * from './types/api.js'` and `export * from './schemas/validation.js'` never export the same name. + +### TypeScript β€” API Only + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ package.json +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ package.json +β”‚ β”‚ β”œβ”€β”€ tsconfig.json +β”‚ β”‚ β”œβ”€β”€ vitest.config.ts +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”‚ └── logger.ts +β”‚ β”‚ β”œβ”€β”€ tests/ +β”‚ β”‚ β”‚ β”œβ”€β”€ fixtures/ +β”‚ β”‚ β”‚ β”œβ”€β”€ mocks/ +β”‚ β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ functions/ +β”‚ β”‚ β”‚ └── errors/ +β”‚ β”‚ └── seeds/ +β”‚ └── shared/ +β”‚ β”œβ”€β”€ types/ +β”‚ └── schemas/ +└── data/ +``` + +### Python β€” SPA + Azure Functions + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ functions/ ← Azure Functions Python project +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ pyproject.toml ← Python project config +β”‚ β”‚ β”œβ”€β”€ pytest.ini ← Test config +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ function_app.py ← Function registration +β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”‚ β”œβ”€β”€ interfaces.py ← ABC / Protocol definitions +β”‚ β”‚ β”‚ β”œβ”€β”€ storage.py +β”‚ β”‚ β”‚ β”œβ”€β”€ database.py +β”‚ β”‚ β”‚ β”œβ”€β”€ cache.py +β”‚ β”‚ β”‚ β”œβ”€β”€ config.py ← Config loader + validation +β”‚ β”‚ β”‚ └── registry.py ← Service factory +β”‚ β”‚ β”œβ”€β”€ errors/ +β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”‚ β”œβ”€β”€ app_error.py +β”‚ β”‚ β”‚ β”œβ”€β”€ error_types.py +β”‚ β”‚ β”‚ └── error_handler.py +β”‚ β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”‚ β”œβ”€β”€ request_logger.py +β”‚ β”‚ β”‚ └── validate_request.py +β”‚ β”‚ β”œβ”€β”€ logger.py ← structlog setup +β”‚ β”‚ └── tests/ +β”‚ β”‚ β”œβ”€β”€ conftest.py ← Pytest fixtures (mock services) +β”‚ β”‚ β”œβ”€β”€ fixtures/ +β”‚ β”‚ β”‚ β”œβ”€β”€ items.json +β”‚ β”‚ β”‚ └── users.json +β”‚ β”‚ β”œβ”€β”€ test_config.py +β”‚ β”‚ β”œβ”€β”€ test_storage.py +β”‚ β”‚ β”œβ”€β”€ test_database.py +β”‚ β”‚ β”œβ”€β”€ test_get_items.py +β”‚ β”‚ β”œβ”€β”€ test_create_item.py +β”‚ β”‚ β”œβ”€β”€ test_error_handler.py +β”‚ β”‚ β”œβ”€β”€ test_health.py +β”‚ β”‚ └── test_validation.py +β”‚ β”œβ”€β”€ web/ ← Frontend +β”‚ β”‚ └── (same as TypeScript) +β”‚ └── shared/ +β”‚ β”œβ”€β”€ types.py ← Pydantic models +β”‚ └── validation.py ← Validation schemas +└── data/ +``` + +### C# (.NET 8) β€” SPA + Azure Functions + +``` +project-root/ +β”œβ”€β”€ .azure/ +β”‚ └── project-plan.md +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ ProjectName.sln +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ Functions/ ← Azure Functions isolated worker +β”‚ β”‚ β”œβ”€β”€ Functions.csproj +β”‚ β”‚ β”œβ”€β”€ host.json +β”‚ β”‚ β”œβ”€β”€ local.settings.json +β”‚ β”‚ β”œβ”€β”€ Program.cs ← DI registration + startup +β”‚ β”‚ β”œβ”€β”€ openapi.yaml +β”‚ β”‚ β”œβ”€β”€ Functions/ ← Function handlers +β”‚ β”‚ β”‚ β”œβ”€β”€ GetItems.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ CreateItem.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ GetItemById.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ Health.cs +β”‚ β”‚ β”‚ └── OpenApi.cs +β”‚ β”‚ β”œβ”€β”€ Services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ Interfaces/ +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IStorageService.cs +β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ IDatabaseService.cs +β”‚ β”‚ β”‚ β”‚ └── ICacheService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ StorageService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ DatabaseService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ CacheService.cs +β”‚ β”‚ β”‚ └── Config.cs +β”‚ β”‚ β”œβ”€β”€ Errors/ +β”‚ β”‚ β”‚ β”œβ”€β”€ AppException.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ ErrorTypes.cs +β”‚ β”‚ β”‚ └── ErrorHandler.cs +β”‚ β”‚ β”œβ”€β”€ Middleware/ +β”‚ β”‚ β”‚ β”œβ”€β”€ RequestLogger.cs +β”‚ β”‚ β”‚ └── ValidateRequest.cs +β”‚ β”‚ └── Seeds/ +β”‚ β”‚ └── SeedData.cs +β”‚ β”œβ”€β”€ Functions.Tests/ ← xUnit test project +β”‚ β”‚ β”œβ”€β”€ Functions.Tests.csproj +β”‚ β”‚ β”œβ”€β”€ Fixtures/ +β”‚ β”‚ β”‚ └── ItemFixtures.cs +β”‚ β”‚ β”œβ”€β”€ Mocks/ +β”‚ β”‚ β”‚ β”œβ”€β”€ MockStorageService.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ MockDatabaseService.cs +β”‚ β”‚ β”‚ └── MockCacheService.cs +β”‚ β”‚ β”œβ”€β”€ Services/ +β”‚ β”‚ β”‚ β”œβ”€β”€ ConfigTests.cs +β”‚ β”‚ β”‚ └── StorageTests.cs +β”‚ β”‚ β”œβ”€β”€ Functions/ +β”‚ β”‚ β”‚ β”œβ”€β”€ GetItemsTests.cs +β”‚ β”‚ β”‚ β”œβ”€β”€ CreateItemTests.cs +β”‚ β”‚ β”‚ └── HealthTests.cs +β”‚ β”‚ β”œβ”€β”€ Errors/ +β”‚ β”‚ β”‚ └── ErrorHandlerTests.cs +β”‚ β”‚ └── Validation/ +β”‚ β”‚ └── ItemValidatorTests.cs +β”‚ β”œβ”€β”€ Shared/ +β”‚ β”‚ β”œβ”€β”€ Shared.csproj +β”‚ β”‚ β”œβ”€β”€ Models/ +β”‚ β”‚ β”‚ β”œβ”€β”€ Item.cs +β”‚ β”‚ β”‚ └── ApiContracts.cs +β”‚ β”‚ └── Validators/ +β”‚ β”‚ └── ItemValidator.cs ← FluentValidation +β”‚ └── Web/ ← Frontend +β”‚ └── (same as TypeScript) +└── data/ +``` + +--- + +## Service Abstraction Layer + +The `services/` directory is the **critical architectural component** for testability. Each file wraps one Azure service behind interface. Handlers receive services via DI β€” never import SDKs directly. + +> Full service abstraction architecture: see [service-abstraction.md](service-abstraction.md). + +--- + +## Function Organization + +### One Function Per File (Required) + +``` +src/functions/src/functions/ +β”œβ”€β”€ getItems.ts ← HTTP GET /api/items +β”œβ”€β”€ createItem.ts ← HTTP POST /api/items +β”œβ”€β”€ getItemById.ts ← HTTP GET /api/items/{id} +β”œβ”€β”€ updateItem.ts ← HTTP PUT /api/items/{id} +β”œβ”€β”€ deleteItem.ts ← HTTP DELETE /api/items/{id} +β”œβ”€β”€ health.ts ← HTTP GET /api/health +└── openapi.ts ← HTTP GET /api/openapi.json +``` + +Each function receives deps via service registry: + +```typescript +// Example: clean handler with injected services +import { app } from "@azure/functions"; +import { getServices } from "../services/registry"; + +app.http("getItems", { + methods: ["GET"], + authLevel: "anonymous", + route: "items", + handler: async (request, context) => { + const { database } = getServices(); + const items = await database.findAll("items"); + return { jsonBody: { items } }; + } +}); +``` + +### Shared Handler Utilities (Required β€” DRY Enforcement) + +When same helper needed in 3+ handlers, extract to `src/functions/src/utils/` β€” do NOT duplicate inline. + +**Common examples:** + +```typescript +// src/functions/src/utils/toPublicUser.ts +import type { User, PublicUser } from '../../../shared/types/entities.js'; + +export function toPublicUser(user: User): PublicUser { + return { + id: user.id, + email: user.email, + displayName: user.displayName, + coupleId: user.coupleId, + createdAt: user.createdAt, + }; +} +``` + +```typescript +// Usage in handler β€” import, don't redefine +import { toPublicUser } from '../utils/toPublicUser.js'; +``` + +**Detection**: After Step 6, grep for repeated helper names across handlers. If 3+ files, extract. + +**Enforcement**: Step 12 MUST check for duplicated helpers and extract before finalization. + +--- + +## Frontend Proxy Configuration + +When frontend included, dev server must proxy `/api` to Functions host: + +### Vite (React, Vue, Svelte) + +```typescript +// vite.config.ts +export default defineConfig({ + server: { + proxy: { + '/api': { + target: 'http://localhost:7071', + changeOrigin: true + } + } + } +}); +``` + +### Angular + +```json +// proxy.conf.json +{ + "/api": { + "target": "http://localhost:7071", + "secure": false + } +} +``` + +--- + +## Monorepo Package Management + +### npm Workspaces (TypeScript) + +```json +{ + "private": true, + "workspaces": ["src/functions", "src/web", "src/shared"], + "scripts": { + "test": "npm test --workspaces", + "test:functions": "cd src/functions && npm test", + "test:web": "cd src/web && npm test", + "build": "npm run build --workspaces" + } +} +``` + +### TypeScript Cross-Workspace Import Configuration + +When Functions imports from `../shared/`, `tsconfig.json` must set `rootDir` to reach outside workspace: + +```jsonc +// src/functions/tsconfig.json +{ + "compilerOptions": { + "rootDir": "..", // ← Parent of functions dir (i.e., src/) + "outDir": "dist", + // ... other options + }, + "include": ["src/**/*.ts", "../shared/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} +``` + +> ⚠️ **Build output nesting β€” `main` MUST match actual dist/ output (Rule 14)** +> +> When `rootDir` is parent dir, `tsc` mirrors full structure under `dist/`. `main` in `package.json` MUST be computed from actual output β€” never hardcoded. +> +> | `rootDir` value | `src/functions/src/functions/register.ts` compiles to | Correct `main` field | +> |-----------------|-------------------------------------------------------|---------------------| +> | `"."` | `dist/src/functions/register.js` | `"dist/src/functions/*.js"` | +> | `".."` (= `src/`) | `dist/functions/src/functions/register.js` | `"dist/functions/src/functions/*.js"` | +> | `"../.."` (= project root) | `dist/src/functions/src/functions/register.js` | `"dist/src/functions/src/functions/*.js"` | +> +> **Verification (MANDATORY after every `tsc` build):** +> 1. Run `tsc` in functions workspace +> 2. List `dist/` β€” find compiled handler `.js` files +> 3. Construct matching glob +> 4. Set `main` to that glob +> 5. `func start` β€” verify functions register. "Found zero files" = wrong `main`. +> +> **#1 cause of "tests pass but app won't start".** Tests use vitest/ts-node (transpile on fly, never read `main`). Only `func start` uses `main` to discover handlers. + +### Python (Poetry) + +```toml +# pyproject.toml at project root +[tool.poetry] +packages = [ + { include = "services", from = "src/functions" }, + { include = "shared", from = "src" }, +] +``` + +### .NET (Solution) + +```xml + + + + +``` + +--- + +## .gitignore Additions + +```gitignore +# Environment +.env +local.settings.json + +# Data volumes +data/ + +# Build output +dist/ +bin/ +obj/ +.vite/ + +# Runtime +node_modules/ +__pycache__/ +.python_packages/ + +# Test output +coverage/ +.pytest_cache/ +TestResults/ + +# IDE +.vs/ +``` + +--- + +## Port Allocation Convention + +| Service | Port | Notes | +|---------|------|-------| +| Azure Functions host | 7071 | Default `func start` port | +| Frontend dev server (Vite) | 5173 | Default Vite port | +| Frontend dev server (Angular) | 4200 | Default Angular port | +| Azurite Blob | 10000 | | +| Azurite Queue | 10001 | | +| Azurite Table | 10002 | | +| PostgreSQL | 5432 | | +| CosmosDB Emulator | 8081 | | +| Redis | 6379 | | +| Azure SQL Edge | 1433 | | diff --git a/resources/agents/shared-references/database-integrity.md b/resources/agents/shared-references/database-integrity.md new file mode 100644 index 00000000..a19dbbe8 --- /dev/null +++ b/resources/agents/shared-references/database-integrity.md @@ -0,0 +1,316 @@ +# Database Integrity Patterns + +> Schema constraints, transactions, and indexes β€” database is last line of defense against data corruption. + +--- + +## Core Principle + +**Application-level checks are necessary but insufficient.** Database schema must enforce correctness even under concurrent access. Race conditions, partial failures, and unexpected input can bypass application logic β€” database constraints must catch what code misses. + +--- + +## Rule: Constraints Are Mandatory in Migrations + +Every migration MUST include appropriate constraints. Do not rely solely on application-level validation. + +### UNIQUE Constraints + +Any field that must be unique across the table (email, username, slug, invite token) MUST have database-level UNIQUE constraint. + +```sql +-- Application-level check alone is NOT sufficient (race condition under concurrent requests) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, -- ← REQUIRED: prevents duplicate registration race condition + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + couple_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Why application-level checks fail:** +``` +Request A: SELECT WHERE email = 'alice@test.com' β†’ not found β†’ INSERT +Request B: SELECT WHERE email = 'alice@test.com' β†’ not found β†’ INSERT ← Both succeed! +``` + +With UNIQUE constraint, second INSERT fails with constraint violation, which error handler maps to 409 Conflict. + +### Foreign Key Constraints + +Any field referencing another table MUST have FK constraint with explicit ON DELETE behavior. + +```sql +CREATE TABLE photos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, + uploaded_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blob_url TEXT NOT NULL, + thumbnail_url TEXT, + caption TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +| ON DELETE Behavior | When to Use | +|-------------------|-------------| +| `CASCADE` | Child records deleted when parent deleted (photos when couple deleted) | +| `SET NULL` | Child remains but loses reference (user.couple_id when couple dissolved) | +| `RESTRICT` | Prevent parent deletion if children exist (user can't be deleted if they own photos) | + +### CHECK Constraints + +Business rules expressible as column constraints should be enforced at database level: + +```sql +ALTER TABLE pairing_invites + ADD CONSTRAINT valid_status CHECK (status IN ('pending', 'accepted', 'rejected')); + +ALTER TABLE users + ADD CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'); +``` + +### Partial UNIQUE Constraints + +When UNIQUE constraint should only apply to rows matching a condition (e.g., prevent duplicate *pending* invites but allow multiple *rejected* ones), use PostgreSQL **partial unique index**: + +```sql +-- Prevent duplicate pending invites from the same user to the same email +CREATE UNIQUE INDEX idx_unique_pending_invite + ON invites(from_user_id, to_email) WHERE status = 'pending'; +``` + +> ⚠️ **Application-level checks are NOT sufficient** for partial uniqueness β€” concurrent requests can both pass `findOne` check and both INSERT. Database constraint is defense-in-depth layer preventing this race condition. Always include partial UNIQUE indexes in migrations when plan specifies them. + +### NOT NULL + +Default to `NOT NULL`. Use `NULL` only when absence of value is a meaningful business state: + +| βœ… Nullable (meaningful absence) | ❌ Should be NOT NULL | +|----------------------------------|----------------------| +| `user.couple_id` (unpaired user) | `user.email` | +| `photo.thumbnail_url` (not yet generated) | `photo.blob_url` | + +--- + +## Rule: Transactions for Multi-Table Writes + +Any operation that writes to 2+ tables MUST use database transaction. Without transaction, failure mid-sequence leaves database in inconsistent state. + +### IDatabaseService Transaction Method + +Add to database service interface: + +#### TypeScript + +```typescript +// services/interfaces/IDatabaseService.ts +export interface IDatabaseService { + findAll(collection: string, options?: QueryOptions): Promise; + findById(collection: string, id: string): Promise; + findOne(collection: string, filter: Record): Promise; + create(collection: string, data: T): Promise; + update(collection: string, id: string, data: Partial): Promise; + delete(collection: string, id: string): Promise; + count(collection: string, filter?: Record): Promise; + healthCheck(): Promise; + + // Execute multiple operations atomically β€” all succeed or all rollback + transaction(fn: (trx: IDatabaseService) => Promise): Promise; +} +``` + +#### Python + +```python +# services/interfaces/database_service.py +from typing import Protocol + +class IDatabaseService(Protocol): + # ... existing methods ... + + async def transaction(self, fn) -> any: + """Execute fn within a database transaction. fn receives a transactional + IDatabaseService instance. If fn throws, all changes are rolled back.""" + ... +``` + +#### C# + +```csharp +// Services/Interfaces/IDatabaseService.cs +public interface IDatabaseService +{ + // ... existing methods ... + + Task TransactionAsync(Func> fn); +} +``` + +### Concrete Implementation (PostgreSQL) + +```typescript +// services/database.ts +async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + // Create a transaction-scoped service that uses this client instead of the pool + const trxService = new TransactionDatabaseService(client); + const result = await fn(trxService); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} +``` + +### Mock Implementation + +```typescript +// tests/mocks/mockDatabase.ts +async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + // Mock transactions execute callback directly against in-memory state. + // For most tests this is sufficient β€” transaction boundary tested + // via integration tests with real database. + return fn(this); +} +``` + +### Usage in Handlers + +```typescript +// BAD β€” 4 sequential writes with no atomicity +const couple = await database.create('couples', { ... }); +await database.update('users', user1Id, { coupleId }); +await database.update('users', user2Id, { coupleId }); +await database.update('pairing_invites', inviteId, { status: 'accepted' }); + +// GOOD β€” all-or-nothing transaction +const couple = await database.transaction(async (trx) => { + const couple = await trx.create('couples', { + id: uuid(), + user1Id: invite.fromUserId, + user2Id: userId, + createdAt: new Date().toISOString(), + }); + await trx.update('users', invite.fromUserId, { coupleId: couple.id }); + await trx.update('users', userId, { coupleId: couple.id }); + await trx.update('pairing_invites', inviteId, { status: 'accepted' }); + return couple; +}); +``` + +--- + +## Rule: Indexes on Frequently Queried Columns + +Migrations should include indexes for columns used in: +- `WHERE` clauses (filter queries) +- `JOIN` conditions +- `ORDER BY` clauses +- Foreign keys + +```sql +-- Foreign keys used in WHERE/JOIN +CREATE INDEX idx_photos_couple_id ON photos(couple_id); +CREATE INDEX idx_invites_to_email ON pairing_invites(to_email); +CREATE INDEX idx_invites_from_user ON pairing_invites(from_user_id); + +-- Composite indexes for common query patterns +CREATE INDEX idx_invites_lookup ON pairing_invites(to_email, status); +``` + +--- + +## Rule: Handle Constraint Violations in Error Handler + +When database rejects operation due to constraint violation, map to appropriate HTTP error: + +### TypeScript + +```typescript +// In errorHandler.ts β€” add constraint violation handling +if (error instanceof Error && error.message?.includes('duplicate key')) { + return { + status: 409, + jsonBody: { + error: { + code: 'CONFLICT', + message: 'A record with this value already exists', + details: null, + }, + }, + }; +} + +if (error instanceof Error && error.message?.includes('violates foreign key')) { + return { + status: 400, + jsonBody: { + error: { + code: 'BAD_REQUEST', + message: 'Referenced record does not exist', + details: null, + }, + }, + }; +} +``` + +--- + +## Planning Checkpoint + +During Phase 1 planning, project plan MUST include **Database Constraints** section: + +```markdown +## Database Constraints + +| Table | Constraint Type | Column(s) | Detail | +|-------|----------------|-----------|--------| +| users | UNIQUE | email | Prevent duplicate registration | +| users | FK | couple_id β†’ couples.id | ON DELETE SET NULL | +| photos | FK | couple_id β†’ couples.id | ON DELETE CASCADE | +| photos | FK | uploaded_by_user_id β†’ users.id | ON DELETE CASCADE | +| photos | INDEX | couple_id | Filter photos by couple | +| pairing_invites | CHECK | status | IN ('pending', 'accepted', 'rejected') | +| pairing_invites | FK | from_user_id β†’ users.id | ON DELETE CASCADE | +| pairing_invites | INDEX | to_email, status | Invite lookup | +``` + +--- + +## Testing Database Integrity + +```typescript +describe('database constraints', () => { + it('should reject duplicate email registration', async () => { + // First registration succeeds + await database.create('users', { id: uuid(), email: 'alice@test.com', ... }); + + // Second registration with same email should fail + await expect( + database.create('users', { id: uuid(), email: 'alice@test.com', ... }) + ).rejects.toThrow(); + }); + + it('should cascade delete photos when couple is deleted', async () => { + const photos = await database.findAll('photos', { filter: { coupleId } }); + expect(photos).toHaveLength(3); + + await database.delete('couples', coupleId); + + const remaining = await database.findAll('photos', { filter: { coupleId } }); + expect(remaining).toHaveLength(0); + }); +}); +``` diff --git a/resources/agents/shared-references/error-handling.md b/resources/agents/shared-references/error-handling.md new file mode 100644 index 00000000..05f5b41c --- /dev/null +++ b/resources/agents/shared-references/error-handling.md @@ -0,0 +1,476 @@ +# Error Handling + +> Standardized error responses, error types, and middleware for consistent error handling across all routes. + +--- + +## Core Principle + +**Every route returns errors in a consistent shape.** Clients rely on single error format for all endpoints. Error paths tested as thoroughly as happy paths. + +--- + +## Standardized Error Response Shape + +All error responses follow this shape: + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "Item with ID 'xyz' was not found", + "details": null + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `error.code` | `string` | Machine-readable error code (e.g., `VALIDATION_ERROR`, `NOT_FOUND`) | +| `error.message` | `string` | Human-readable error description | +| `error.details` | `any?` | Optional extra details (validation errors, field-level issues). **Omitted in production** for security-sensitive errors. | + +--- + +## Error Code β†’ HTTP Status Mapping + +| Error Code | HTTP Status | When | +|------------|-------------|------| +| `VALIDATION_ERROR` | 422 | Request body fails validation (Zod/Pydantic/FluentValidation) | +| `BAD_REQUEST` | 400 | Malformed request (missing params, wrong content type) | +| `NOT_FOUND` | 404 | Resource doesn't exist | +| `CONFLICT` | 409 | Duplicate resource or state conflict | +| `UNAUTHORIZED` | 401 | Missing or invalid auth token | +| `FORBIDDEN` | 403 | Valid auth but insufficient permissions | +| `INTERNAL_ERROR` | 500 | Unhandled exception or service failure | + +--- + +## Error Code Type Safety + +Error codes MUST be defined as typed union in shared types package, not arbitrary strings. Enables frontend consumers to switch on error codes with exhaustiveness checking. + +### TypeScript + +```typescript +// shared/types/errors.ts +export type ErrorCode = + | 'VALIDATION_ERROR' + | 'BAD_REQUEST' + | 'NOT_FOUND' + | 'CONFLICT' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'INTERNAL_ERROR'; + +export interface ErrorResponse { + error: { + code: ErrorCode; // ← typed union, not string + message: string; + details: Record | null; + }; +} +``` + +### Python + +```python +# shared/types.py +from enum import Enum + +class ErrorCode(str, Enum): + VALIDATION_ERROR = "VALIDATION_ERROR" + BAD_REQUEST = "BAD_REQUEST" + NOT_FOUND = "NOT_FOUND" + CONFLICT = "CONFLICT" + UNAUTHORIZED = "UNAUTHORIZED" + FORBIDDEN = "FORBIDDEN" + INTERNAL_ERROR = "INTERNAL_ERROR" +``` + +### C# + +```csharp +// Shared/ErrorCode.cs +public static class ErrorCodes +{ + public const string ValidationError = "VALIDATION_ERROR"; + public const string BadRequest = "BAD_REQUEST"; + public const string NotFound = "NOT_FOUND"; + public const string Conflict = "CONFLICT"; + public const string Unauthorized = "UNAUTHORIZED"; + public const string Forbidden = "FORBIDDEN"; + public const string InternalError = "INTERNAL_ERROR"; +} +``` + +### Frontend Usage + +With typed error codes, frontend can handle specific error types: + +```typescript +import type { ErrorCode } from 'app-shared'; + +function handleApiError(code: ErrorCode, message: string) { + switch (code) { + case 'UNAUTHORIZED': + // Redirect to login + break; + case 'CONFLICT': + // Show "already exists" message + break; + case 'VALIDATION_ERROR': + // Show field-level errors + break; + default: + // Show generic error + break; + } +} +``` + +--- + +## TypeScript Implementation + +### Error Types + +```typescript +// errors/AppError.ts +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.details = details; + this.name = this.constructor.name; + } +} +``` + +```typescript +// errors/errorTypes.ts +import { AppError } from './AppError'; + +export class NotFoundError extends AppError { + constructor(resource: string, id: string) { + super(404, 'NOT_FOUND', `${resource} with ID '${id}' was not found`); + } +} + +export class ValidationError extends AppError { + constructor(message: string, details?: unknown) { + super(422, 'VALIDATION_ERROR', message, details); + } +} + +export class BadRequestError extends AppError { + constructor(message: string) { + super(400, 'BAD_REQUEST', message); + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super(409, 'CONFLICT', message); + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = 'Authentication required') { + super(401, 'UNAUTHORIZED', message); + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'Insufficient permissions') { + super(403, 'FORBIDDEN', message); + } +} +``` + +### Error Handler + +```typescript +// errors/errorHandler.ts +import { HttpResponseInit, InvocationContext } from '@azure/functions'; +import { AppError } from './AppError'; +import { ZodError } from 'zod'; +import { getLogger } from '../logger'; + +const logger = getLogger(); + +export function handleError(error: unknown, context: InvocationContext): HttpResponseInit { + // Known application errors + if (error instanceof AppError) { + logger.warn({ err: error, code: error.code }, error.message); + return { + status: error.statusCode, + jsonBody: { + error: { + code: error.code, + message: error.message, + details: error.details ?? null, + }, + }, + }; + } + + // Zod validation errors β†’ map to ValidationError shape + if (error instanceof ZodError) { + const details = error.errors.map(e => ({ + field: e.path.join('.'), + message: e.message, + })); + logger.warn({ err: error, details }, 'Validation failed'); + return { + status: 422, + jsonBody: { + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details, + }, + }, + }; + } + + // Unknown errors β†’ 500 + const err = error instanceof Error ? error : new Error(String(error)); + logger.error({ err }, 'Unhandled error'); + + return { + status: 500, + jsonBody: { + error: { + code: 'INTERNAL_ERROR', + message: process.env.NODE_ENV === 'production' + ? 'An internal error occurred' + : err.message, + details: null, + }, + }, + }; +} +``` + +### Request Validation Middleware + +```typescript +// middleware/validateRequest.ts +import { HttpRequest } from '@azure/functions'; +import { ZodSchema, ZodError } from 'zod'; +import { ValidationError } from '../errors/errorTypes'; + +export async function validateBody(request: HttpRequest, schema: ZodSchema): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + throw new ValidationError('Request body must be valid JSON'); + } + + const result = schema.safeParse(body); + if (!result.success) { + const details = result.error.errors.map(e => ({ + field: e.path.join('.'), + message: e.message, + })); + throw new ValidationError('Request validation failed', details); + } + return result.data; +} + +export function validateParams(params: Record, required: string[]): void { + const missing = required.filter(key => !params[key]); + if (missing.length > 0) { + throw new ValidationError(`Missing required parameters: ${missing.join(', ')}`); + } +} +``` + +### Usage in Function Handlers + +```typescript +// functions/createItem.ts +import { app } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { validateBody } from '../middleware/validateRequest'; +import { createItemSchema } from '../../shared/schemas/validation'; +import { v4 as uuid } from 'uuid'; + +app.http('createItem', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'items', + handler: async (request, context) => { + try { + const body = await validateBody(request, createItemSchema); + const { database } = getServices(); + + const item = { + id: uuid(), + ...body, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const created = await database.create('items', item); + return { status: 201, jsonBody: { item: created } }; + } catch (error) { + return handleError(error, context); + } + } +}); +``` + +--- + +For Python error handling, see [runtimes/python.md](runtimes/python.md). For C#, see [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Testing Error Handling + +### TypeScript Tests + +```typescript +// tests/errors/errorHandler.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { handleError } from '../../src/errors/errorHandler'; +import { NotFoundError, ValidationError, BadRequestError } from '../../src/errors/errorTypes'; +import { InvocationContext } from '@azure/functions'; + +const mockContext = { log: vi.fn() } as unknown as InvocationContext; + +describe('errorHandler', () => { + it('should return 404 for NotFoundError', () => { + const error = new NotFoundError('Item', 'abc-123'); + const response = handleError(error, mockContext); + + expect(response.status).toBe(404); + expect(response.jsonBody).toEqual({ + error: { + code: 'NOT_FOUND', + message: "Item with ID 'abc-123' was not found", + details: null, + }, + }); + }); + + it('should return 422 for ValidationError', () => { + const details = [{ field: 'name', message: 'Required' }]; + const error = new ValidationError('Validation failed', details); + const response = handleError(error, mockContext); + + expect(response.status).toBe(422); + expect(response.jsonBody.error.code).toBe('VALIDATION_ERROR'); + expect(response.jsonBody.error.details).toEqual(details); + }); + + it('should return 400 for BadRequestError', () => { + const error = new BadRequestError('Missing content type'); + const response = handleError(error, mockContext); + + expect(response.status).toBe(400); + expect(response.jsonBody.error.code).toBe('BAD_REQUEST'); + }); + + it('should return 500 for unknown errors', () => { + const error = new Error('Something broke'); + const response = handleError(error, mockContext); + + expect(response.status).toBe(500); + expect(response.jsonBody.error.code).toBe('INTERNAL_ERROR'); + }); + + it('should return consistent error shape for all error types', () => { + const errors = [ + new NotFoundError('Item', '1'), + new ValidationError('Bad input'), + new BadRequestError('Bad request'), + new Error('Unknown'), + ]; + + for (const error of errors) { + const response = handleError(error, mockContext); + expect(response.jsonBody).toHaveProperty('error'); + expect(response.jsonBody.error).toHaveProperty('code'); + expect(response.jsonBody.error).toHaveProperty('message'); + expect(response.jsonBody.error).toHaveProperty('details'); + } + }); +}); +``` + +### Validation Schema Tests + +```typescript +// tests/validation/itemSchema.test.ts +import { describe, it, expect } from 'vitest'; +import { createItemSchema } from '../../src/shared/schemas/validation'; + +describe('createItemSchema', () => { + it('should pass with valid input', () => { + const result = createItemSchema.safeParse({ + name: 'Widget', + description: 'A nice widget', + price: 29.99, + category: 'widgets', + }); + expect(result.success).toBe(true); + }); + + it('should fail when name is empty', () => { + const result = createItemSchema.safeParse({ + name: '', + description: 'A widget', + price: 29.99, + category: 'widgets', + }); + expect(result.success).toBe(false); + }); + + it('should fail when price is negative', () => { + const result = createItemSchema.safeParse({ + name: 'Widget', + description: 'A widget', + price: -5, + category: 'widgets', + }); + expect(result.success).toBe(false); + }); + + it('should fail when required fields are missing', () => { + const result = createItemSchema.safeParse({ + description: 'Just a description', + }); + expect(result.success).toBe(false); + }); + + it('should fail when name is not a string', () => { + const result = createItemSchema.safeParse({ + name: 123, + description: 'A widget', + price: 29.99, + category: 'widgets', + }); + expect(result.success).toBe(false); + }); +}); +``` + +--- + +## Validation Library Quick Reference + +| Runtime | Library | Schema Example | +|---------|---------|---------------| +| TypeScript | **Zod** | `z.object({ name: z.string().min(1), price: z.number().positive() })` | +| Python | **Pydantic** | `class CreateItem(BaseModel): name: str = Field(min_length=1); price: float = Field(gt=0)` | +| C# | **FluentValidation** | `RuleFor(x => x.Name).NotEmpty(); RuleFor(x => x.Price).GreaterThan(0);` | diff --git a/resources/agents/shared-references/examples/service-abstraction-examples.md b/resources/agents/shared-references/examples/service-abstraction-examples.md new file mode 100644 index 00000000..47a2777b --- /dev/null +++ b/resources/agents/shared-references/examples/service-abstraction-examples.md @@ -0,0 +1,301 @@ +# Service Abstraction β€” Full Code Examples + +> Complete implementation examples referenced by [service-abstraction.md](../service-abstraction.md). Read this file ONLY during Step 3 (Service Abstraction Layer). + +--- + +## Concrete Implementation (PostgreSQL) + +> Includes camelCase↔snake_case key conversion. TypeScript entities use camelCase but PostgreSQL columns are snake_case. Conversion handled transparently β€” function handlers never deal with snake_case. + +```typescript +// services/database.ts +import { Pool } from 'pg'; +import { IDatabaseService, QueryOptions } from './interfaces/IDatabaseService'; +import { loadConfig } from './config'; + +// --- camelCase ↔ snake_case conversion utilities --- + +function toSnake(str: string): string { + return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +function toCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function keysToSnake(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[toSnake(key)] = value; + } + return result; +} + +function keysToCamel(obj: Record): T { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[toCamel(key)] = value; + } + return result as T; +} + +function rowsToCamel(rows: Record[]): T[] { + return rows.map(row => keysToCamel(row)); +} + +// --- Collection name β†’ SQL table name mapping --- + +function collectionToTable(collection: string): string { + const map: Record = { + // Add all entity β†’ table mappings here + // e.g., user: 'users', couple: 'couples', photo: 'photos' + }; + return map[collection] ?? `${collection}s`; +} + +// --- Database service implementation --- + +export class PostgresDatabaseService implements IDatabaseService { + private pool: Pool; + + constructor(connectionString?: string) { + const config = loadConfig(); + this.pool = new Pool({ + connectionString: connectionString || config.databaseUrl, + max: 20, + idleTimeoutMillis: 30000, + }); + } + + async findAll(collection: string, options?: QueryOptions): Promise { + const table = collectionToTable(collection); + const limit = options?.limit || 100; + const offset = options?.offset || 0; + const orderBy = toSnake(options?.orderBy || 'createdAt'); + const direction = options?.orderDirection || 'desc'; + + const result = await this.pool.query( + `SELECT * FROM ${table} ORDER BY ${orderBy} ${direction} LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return rowsToCamel(result.rows); + } + + async findById(collection: string, id: string): Promise { + const table = collectionToTable(collection); + const result = await this.pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]); + return result.rows[0] ? keysToCamel(result.rows[0]) : null; + } + + async findOne(collection: string, filter: Record): Promise { + const table = collectionToTable(collection); + const snakeFilter = keysToSnake(filter); + const entries = Object.entries(snakeFilter); + const conditions = entries.map(([key], i) => `${key} = $${i + 1}`); + const values = entries.map(([, val]) => val); + const result = await this.pool.query( + `SELECT * FROM ${table} WHERE ${conditions.join(' AND ')} LIMIT 1`, values + ); + return result.rows[0] ? keysToCamel(result.rows[0]) : null; + } + + async create(collection: string, data: T): Promise { + const table = collectionToTable(collection); + const record = data as Record; + const { createdAt: _ca, updatedAt: _ua, ...cleanData } = record; + const snakeData = keysToSnake(cleanData); + const keys = Object.keys(snakeData); + const values = Object.values(snakeData); + const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); + const result = await this.pool.query( + `INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders}) RETURNING *`, values + ); + return keysToCamel(result.rows[0]); + } + + async update(collection: string, id: string, data: Partial): Promise { + const table = collectionToTable(collection); + const record = data as Record; + const { id: _id, createdAt: _ca, updatedAt: _ua, ...cleanData } = record; + const snakeData = keysToSnake(cleanData); + const entries = Object.entries(snakeData); + const sets = entries.map(([key], i) => `${key} = $${i + 1}`).join(', '); + const values = [...entries.map(([, val]) => val), id]; + const result = await this.pool.query( + `UPDATE ${table} SET ${sets}, updated_at = NOW() WHERE id = $${values.length} RETURNING *`, values + ); + return result.rows[0] ? keysToCamel(result.rows[0]) : null; + } + + async delete(collection: string, id: string): Promise { + const table = collectionToTable(collection); + const result = await this.pool.query(`DELETE FROM ${table} WHERE id = $1`, [id]); + return (result.rowCount ?? 0) > 0; + } + + async count(collection: string, filter?: Record): Promise { + const table = collectionToTable(collection); + // ... filter handling similar to findAll + const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM ${table}`); + return result.rows[0].count; + } + + async healthCheck(): Promise { + try { await this.pool.query('SELECT 1'); return true; } + catch { return false; } + } + + async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + // Create a transaction-scoped service using this client + const trxService = createTransactionService(client); + const result = await fn(trxService); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } +} +``` + +--- + +## Mock Implementation (For Tests) + +```typescript +// tests/mocks/mockDatabase.ts +import { IDatabaseService, QueryOptions } from '../../src/services/interfaces/IDatabaseService'; + +export class MockDatabaseService implements IDatabaseService { + private stores: Map> = new Map(); + + constructor(initialData?: Record) { + if (initialData) { + for (const [collection, items] of Object.entries(initialData)) { + const store = new Map(); + items.forEach((item: any) => store.set(item.id, item)); + this.stores.set(collection, store); + } + } + } + + private getStore(collection: string): Map { + if (!this.stores.has(collection)) this.stores.set(collection, new Map()); + return this.stores.get(collection)!; + } + + async findAll(collection: string, options?: QueryOptions): Promise { + const store = this.getStore(collection); + let items = Array.from(store.values()) as T[]; + if (options?.limit) { + const offset = options.offset || 0; + items = items.slice(offset, offset + options.limit); + } + return items; + } + + async findById(collection: string, id: string): Promise { + return (this.getStore(collection).get(id) as T) || null; + } + + async findOne(collection: string, filter: Record): Promise { + for (const item of this.getStore(collection).values()) { + const record = item as Record; + if (Object.entries(filter).every(([k, v]) => record[k] === v)) return item as T; + } + return null; + } + + async create(collection: string, data: T): Promise { + const item = data as Record; + this.getStore(collection).set(item.id as string, item); + return data; + } + + async update(collection: string, id: string, data: Partial): Promise { + const store = this.getStore(collection); + const existing = store.get(id); + if (!existing) return null; + const updated = { ...existing, ...data, updatedAt: new Date().toISOString() }; + store.set(id, updated); + return updated as T; + } + + async delete(collection: string, id: string): Promise { + return this.getStore(collection).delete(id); + } + + async count(collection: string, filter?: Record): Promise { + if (!filter) return this.getStore(collection).size; + return (await this.findAll(collection)).length; + } + + async healthCheck(): Promise { return true; } + + async transaction(fn: (trx: IDatabaseService) => Promise): Promise { + return fn(this); // Mock: execute directly, no rollback + } +} +``` + +--- + +## Service Registry (DI) + +> **Critical**: Auto-initializes at runtime. Enhancement services wrapped in try/catch. + +```typescript +// services/registry.ts +import { IDatabaseService } from './interfaces/IDatabaseService'; +import { IStorageService } from './interfaces/IStorageService'; +// ... other interfaces + +import { PostgresDatabaseService } from './database'; +import { BlobStorageService } from './storage'; +// ... other concrete implementations + +export interface ServiceRegistry { + database: IDatabaseService; + storage: IStorageService; + // ... other services +} + +let services: ServiceRegistry | null = null; + +export function registerServices(registry: ServiceRegistry): void { + services = registry; +} + +export function getServices(): ServiceRegistry { + if (!services) initializeServices(); + return services!; +} + +export function clearServices(): void { + services = null; +} + +function initializeServices(): void { + // Essential services β€” let them throw + const database = new PostgresDatabaseService(); + const storage = new BlobStorageService(); + + // Enhancement services β€” wrapped in try/catch with no-op fallback + let aiCaption: IAICaptionService; + try { + aiCaption = new AzureAICaptionService(); + } catch (err) { + logger.warn({ err }, 'AI caption service unavailable, using no-op fallback'); + aiCaption = { generateCaption: async () => 'A special moment πŸ“Έ', healthCheck: async () => false }; + } + + services = { database, storage, aiCaption }; +} +``` diff --git a/resources/agents/shared-references/resilience.md b/resources/agents/shared-references/resilience.md new file mode 100644 index 00000000..77c3a572 --- /dev/null +++ b/resources/agents/shared-references/resilience.md @@ -0,0 +1,286 @@ +# Resilience & Graceful Degradation + +> Patterns for handling external service failures without crashing requests. Every external call must have a failure plan. + +--- + +## Core Principle + +**External service failures MUST NEVER crash the request unless the service is essential to the operation.** Every external dependency gets classified as **Essential** or **Enhancement**, and code must handle each accordingly. + +--- + +## Service Dependency Classification + +During Phase 1 planning, classify every external service: + +| Type | Definition | Failure Behavior | Example | +|------|-----------|-----------------|---------| +| **Essential** | Request cannot produce meaningful result without this service | Propagate error to client (4xx/5xx) | Database, auth provider | +| **Enhancement** | Request can succeed with degraded output if unavailable | Catch error, use fallback, log warning | AI captions, email notifications, thumbnail generation, analytics | + +This classification **MUST appear in the project plan** under "Service Dependency Classification". + +--- + +## Rule: Enhancement Service Constructors Must Not Throw + +> ⚠️ **#1 cause of "all tests pass but app doesn't start" failures** (identified across multiple benchmark runs). + +Enhancement services must be safe to instantiate even when config is missing. Service registry's `initializeServices()` constructs ALL services at once β€” if any constructor throws, it cascades to crash every handler. + +### The Problem + +```typescript +// ❌ BAD β€” constructor validates config and throws when AZURE_OPENAI_ENDPOINT is empty +class AzureAICaptionService implements IAICaptionService { + private endpoint: string; + constructor() { + const config = loadConfig(); + if (!config.openai.endpoint) { + throw new Error('AZURE_OPENAI_ENDPOINT is required'); // πŸ’₯ Crashes initializeServices() + } + this.endpoint = config.openai.endpoint; + } +} +``` + +When `initializeServices()` calls `new AzureAICaptionService()`, the throw prevents registry from initializing. Every subsequent `getServices()` call fails β€” crashing ALL handlers, even those that never use AI service. + +Tests don't catch this because they call `registerServices()` with mocks, bypassing `initializeServices()` entirely. + +### Solution A: Defer validation to method calls (Recommended) + +```typescript +// βœ… GOOD β€” constructor never throws; validation happens at call time +class AzureAICaptionService implements IAICaptionService { + private config: AppConfig; + constructor() { + this.config = loadConfig(); + // Do NOT validate β€” Enhancement services must survive missing config + } + + async generateCaption(buffer: Buffer, mimeType: string): Promise { + if (!this.config.openai.endpoint || !this.config.openai.apiKey) { + throw new Error('AI caption service not configured'); + // Handler's try/catch catches this and uses fallback caption + } + // ... actual implementation + } + + async healthCheck(): Promise { + return !!(this.config.openai.endpoint && this.config.openai.apiKey); + } +} +``` + +### Solution B: Wrap construction in registry + +```typescript +// βœ… GOOD β€” registry catches Enhancement service construction failures +function initializeServices(): void { + let aiCaption: IAICaptionService; + try { + aiCaption = new AzureOpenAICaptionService(); + } catch (err) { + logger.warn({ err }, 'AI caption service unavailable, using no-op fallback'); + aiCaption = { + generateCaption: async () => 'A special moment', + healthCheck: async () => false, + }; + } + services = { + database: new PostgresDatabaseService(), // Essential β€” let it throw + storage: new BlobStorageService(), // Essential β€” let it throw + aiCaption, // Enhancement β€” caught above + }; +} +``` + +### Testing: Auto-initialization Test (Mandatory) + +Every test suite MUST include a test verifying auto-initialization works without pre-registered mocks: + +```typescript +describe('auto-initialization', () => { + it('should survive missing Enhancement service config', () => { + clearServices(); + // Only set Essential service env vars + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/testdb'; + process.env.STORAGE_CONNECTION_STRING = 'UseDevelopmentStorage=true'; + // Enhancement env vars intentionally NOT set + delete process.env.AZURE_OPENAI_ENDPOINT; + delete process.env.AZURE_OPENAI_API_KEY; + // getServices() must NOT throw + expect(() => getServices()).not.toThrow(); + }); +}); +``` + +--- + +## Pattern: Try/Fallback Wrapper + +When a feature depends on Enhancement service, wrap call in try/catch with sensible default. + +### TypeScript + +```typescript +// BAD β€” AI failure crashes the entire photo upload +const caption = await aiCaption.generateCaption(buffer, mimeType); + +// GOOD β€” AI failure degrades gracefully +let caption: string; +try { + caption = await aiCaption.generateCaption(buffer, mimeType); +} catch (err) { + logger.warn({ err }, 'Caption generation failed, using default'); + caption = 'A special moment πŸ“Έ'; +} +``` + +For Python and C# try/fallback patterns, see [runtimes/python.md](runtimes/python.md) and [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Pattern: Timeouts + +Every external HTTP/SDK call should have timeout to prevent hanging requests. + +### TypeScript + +```typescript +// Using AbortController (built-in) +async function withTimeout(fn: () => Promise, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fn(); + } finally { + clearTimeout(timeout); + } +} + +// Usage +const caption = await withTimeout( + () => aiCaption.generateCaption(buffer, mimeType), + 10_000 // 10 seconds +); +``` + +For Python and C# timeout patterns, see [runtimes/python.md](runtimes/python.md) and [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Pattern: Retry with Exponential Backoff + +For transient failures (429 Too Many Requests, 503 Service Unavailable, network timeouts), retry with increasing delays. + +### TypeScript + +```typescript +async function withRetry( + fn: () => Promise, + options: { maxRetries?: number; baseDelayMs?: number; retryableErrors?: string[] } = {} +): Promise { + const { maxRetries = 3, baseDelayMs = 500 } = options; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + if (attempt === maxRetries) throw err; + + const isRetryable = err instanceof Error && ( + err.message.includes('ECONNRESET') || + err.message.includes('ETIMEDOUT') || + err.message.includes('429') || + err.message.includes('503') + ); + + if (!isRetryable) throw err; + + const delay = baseDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * delay * 0.1; + await new Promise(r => setTimeout(r, delay + jitter)); + } + } + throw new Error('Unreachable'); +} +``` + +For Python and C# retry patterns, see [runtimes/python.md](runtimes/python.md) and [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Pattern: Parallel Independent Calls + +When multiple independent external calls are needed for a single request, run them in parallel. Combine with try/fallback for Enhancement services. + +### TypeScript + +```typescript +// BAD β€” sequential (slow, and second call waits for first) +const blobUrl = await storage.upload('photos', blobName, buffer, file.type); +const caption = await aiCaption.generateCaption(buffer, file.type); + +// GOOD β€” parallel, with fallback on enhancement service +const [blobUrl, caption] = await Promise.all([ + storage.upload('photos', blobName, buffer, file.type), // Essential β€” let it throw + aiCaption.generateCaption(buffer, file.type) // Enhancement β€” catch + .catch((err) => { + logger.warn({ err }, 'Caption generation failed'); + return 'A special moment πŸ“Έ'; + }), +]); +``` + +For Python and C# parallel call patterns, see [runtimes/python.md](runtimes/python.md) and [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Testing Resilience + +> ⚠️ **MANDATORY for every handler using Enhancement service.** Absence consistently flagged during scaffold benchmarking. See [testing.md](testing.md) β†’ "Mandatory Test Patterns β†’ Pattern 2" for complete typed test pattern. + +Every Enhancement service wrapper must have tests verifying graceful degradation: + +```typescript +describe('uploadPhoto', () => { + it('should succeed with default caption when AI service fails', async () => { + // Arrange: Make AI service throw + const { aiCaption } = getServices(); + const mockAI = aiCaption as MockAICaptionService; + mockAI.shouldFail = true; + + // Act: Upload should still succeed + const response = await handlers.uploadPhoto(uploadRequest, createMockContext()); + + // Assert: Photo created with default caption + expect(response.status).toBe(201); + expect(response.jsonBody.photo.caption).toBe('A special moment πŸ“Έ'); + }); + + it('should fail when storage service fails', async () => { + // Arrange: Make essential service throw + mockStorage.upload = vi.fn().mockRejectedValue(new Error('Storage down')); + + // Act & Assert: Upload should fail + const response = await handlers.uploadPhoto(uploadRequest, createMockContext()); + expect(response.status).toBe(500); + }); +}); +``` + +--- + +## Checklist + +When implementing a function handler that calls external services: + +- [ ] Classify each service call as Essential or Enhancement +- [ ] Enhancement services wrapped in try/catch with fallback +- [ ] Essential services propagate errors to error handler +- [ ] Independent calls parallelized with `Promise.all` / `asyncio.gather` / `Task.WhenAll` +- [ ] **Tests verify handler succeeds when Enhancement services fail** (MANDATORY β€” see Pattern 2 in testing.md) +- [ ] Tests verify handler fails correctly when Essential services fail diff --git a/resources/agents/shared-references/runtimes/dotnet.md b/resources/agents/shared-references/runtimes/dotnet.md new file mode 100644 index 00000000..d59c4108 --- /dev/null +++ b/resources/agents/shared-references/runtimes/dotnet.md @@ -0,0 +1,844 @@ +# .NET (C# 8) Runtime Reference + +> Azure Functions isolated worker, .NET 8. xUnit, FluentValidation, Serilog, built-in DI. + +--- + +## Azure Functions Isolated Worker Setup + +### Initialization + +```bash +func init src/Functions --dotnet --isolated +cd src/Functions +dotnet add package Microsoft.Azure.Functions.Worker +dotnet add package Microsoft.Azure.Functions.Worker.Sdk +dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http +``` + +### host.json + +```json +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} +``` + +### local.settings.json + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "ASPNETCORE_ENVIRONMENT": "Development", + "STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "DATABASE_URL": "Host=localhost;Port=5432;Database=appdb;Username=localdev;Password=localdevpassword", + "REDIS_URL": "localhost:6379" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} +``` + +### Functions.csproj + +```xml + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + +``` + +### Program.cs (DI Registration) + +```csharp +// Program.cs +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +// Configure Serilog +Log.Logger = new LoggerConfiguration() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .Enrich.FromLogContext() + .CreateLogger(); + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices((context, services) => + { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + + // Register services via DI + var config = AppConfig.Load(); + services.AddSingleton(config); + + // Register service implementations + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register validators + services.AddValidatorsFromAssemblyContaining(); + + // Validate environment on startup + config.Validate(); + }) + .Build(); + +host.Run(); +``` + +--- + +## Function Handler Pattern + +### HTTP Function (Isolated Worker) + +```csharp +// Functions/GetItems.cs +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +public class GetItems +{ + private readonly IDatabaseService _database; + private readonly ILogger _logger; + + public GetItems(IDatabaseService database, ILogger logger) + { + _database = database; + _logger = logger; + } + + [Function("GetItems")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "items")] HttpRequestData req) + { + try + { + var limit = int.TryParse(req.Query["limit"], out var l) ? l : 20; + var offset = int.TryParse(req.Query["offset"], out var o) ? o : 0; + + var items = await _database.FindAllAsync("items", new QueryOptions + { + Limit = limit, + Offset = offset + }); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { items, total = items.Count }); + return response; + } + catch (Exception ex) + { + return ErrorHandler.HandleError(ex, req, _logger); + } + } +} +``` + +### POST with Validation + +```csharp +// Functions/CreateItem.cs +using FluentValidation; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +public class CreateItem +{ + private readonly IDatabaseService _database; + private readonly IValidator _validator; + private readonly ILogger _logger; + + public CreateItem( + IDatabaseService database, + IValidator validator, + ILogger logger) + { + _database = database; + _validator = validator; + _logger = logger; + } + + [Function("CreateItem")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "items")] HttpRequestData req) + { + try + { + var body = await req.ReadFromJsonAsync(); + if (body == null) + throw new BadRequestException("Request body is required"); + + var validationResult = await _validator.ValidateAsync(body); + if (!validationResult.IsValid) + throw new FluentValidation.ValidationException(validationResult.Errors); + + var item = new Item + { + Id = Guid.NewGuid().ToString(), + Name = body.Name, + Description = body.Description ?? "", + Price = body.Price, + Category = body.Category, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + var created = await _database.CreateAsync("items", item); + + var response = req.CreateResponse(HttpStatusCode.Created); + await response.WriteAsJsonAsync(new { item = created }); + return response; + } + catch (Exception ex) + { + return ErrorHandler.HandleError(ex, req, _logger); + } + } +} +``` + +### GET by ID with 404 + +```csharp +// Functions/GetItemById.cs +public class GetItemById +{ + private readonly IDatabaseService _database; + private readonly ILogger _logger; + + public GetItemById(IDatabaseService database, ILogger logger) + { + _database = database; + _logger = logger; + } + + [Function("GetItemById")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "items/{id}")] HttpRequestData req, + string id) + { + try + { + var item = await _database.FindByIdAsync("items", id); + if (item == null) + throw new NotFoundException("Item", id); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { item }); + return response; + } + catch (Exception ex) + { + return ErrorHandler.HandleError(ex, req, _logger); + } + } +} +``` + +### Health Check + +```csharp +// Functions/Health.cs +public class Health +{ + private readonly IDatabaseService _database; + private readonly IStorageService _storage; + private readonly ICacheService _cache; + + public Health(IDatabaseService database, IStorageService storage, ICacheService cache) + { + _database = database; + _storage = storage; + _cache = cache; + } + + [Function("Health")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")] HttpRequestData req) + { + var checks = new Dictionary(); + + try { checks["database"] = await _database.HealthCheckAsync(); } catch { checks["database"] = false; } + try { checks["storage"] = await _storage.HealthCheckAsync(); } catch { checks["storage"] = false; } + try { checks["cache"] = await _cache.HealthCheckAsync(); } catch { checks["cache"] = false; } + + var allHealthy = checks.Values.All(v => v); + var anyHealthy = checks.Values.Any(v => v); + var status = allHealthy ? "healthy" : anyHealthy ? "degraded" : "unhealthy"; + + var response = req.CreateResponse(allHealthy ? HttpStatusCode.OK : HttpStatusCode.ServiceUnavailable); + await response.WriteAsJsonAsync(new { status, services = checks }); + return response; + } +} +``` + +--- + +## Test Project Setup + +### Functions.Tests.csproj + +```xml + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + +``` + +### Fixture Classes + +```csharp +// Fixtures/ItemFixtures.cs +public static class ItemFixtures +{ + public static Item CreateValidItem(string? id = null) => new() + { + Id = id ?? Guid.NewGuid().ToString(), + Name = "Test Widget", + Description = "A test widget for unit testing", + Price = 29.99m, + Category = "widgets", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + public static CreateItemRequest CreateValidRequest() => new() + { + Name = "New Widget", + Description = "A brand new widget", + Price = 19.99m, + Category = "widgets", + }; + + public static CreateItemRequest CreateInvalidRequest_EmptyName() => new() + { + Name = "", + Description = "Missing name", + Price = 19.99m, + Category = "widgets", + }; + + public static CreateItemRequest CreateInvalidRequest_NegativePrice() => new() + { + Name = "Widget", + Description = "Negative price", + Price = -5.00m, + Category = "widgets", + }; + + public static List CreateItemList(int count = 5) => + Enumerable.Range(1, count) + .Select(i => CreateValidItem($"item-{i:D3}")) + .ToList(); +} +``` + +### Mock Service + +```csharp +// Mocks/MockDatabaseService.cs +public class MockDatabaseService : IDatabaseService +{ + private readonly Dictionary> _stores = new(); + + public MockDatabaseService(Dictionary>? initialData = null) + { + if (initialData != null) + { + foreach (var (collection, items) in initialData) + { + _stores[collection] = new Dictionary(); + foreach (dynamic item in items) + { + _stores[collection][(string)item.Id] = item; + } + } + } + } + + public Task> FindAllAsync(string collection, QueryOptions? options = null) + { + if (!_stores.ContainsKey(collection)) + return Task.FromResult(new List()); + + var items = _stores[collection].Values.Cast().ToList(); + if (options != null) + { + items = items.Skip(options.Offset).Take(options.Limit).ToList(); + } + return Task.FromResult(items); + } + + public Task FindByIdAsync(string collection, string id) + { + if (!_stores.ContainsKey(collection) || !_stores[collection].ContainsKey(id)) + return Task.FromResult(default); + return Task.FromResult((T?)_stores[collection][id]); + } + + public Task CreateAsync(string collection, T data) + { + if (!_stores.ContainsKey(collection)) + _stores[collection] = new Dictionary(); + + dynamic item = data!; + _stores[collection][(string)item.Id] = data!; + return Task.FromResult(data); + } + + public Task UpdateAsync(string collection, string id, object data) + { + if (!_stores.ContainsKey(collection) || !_stores[collection].ContainsKey(id)) + return Task.FromResult(default); + + // Simplified: replace entire object + _stores[collection][id] = data; + return Task.FromResult((T?)data); + } + + public Task DeleteAsync(string collection, string id) + { + if (!_stores.ContainsKey(collection)) + return Task.FromResult(false); + return Task.FromResult(_stores[collection].Remove(id)); + } + + public Task HealthCheckAsync() => Task.FromResult(true); +} +``` + +### Test Examples + +```csharp +// Functions/GetItemsTests.cs +using Moq; +using FluentAssertions; + +public class GetItemsTests +{ + private readonly Mock _mockDb; + private readonly Mock> _mockLogger; + + public GetItemsTests() + { + _mockDb = new Mock(); + _mockLogger = new Mock>(); + } + + [Fact] + public async Task GetItems_ReturnsAllItems() + { + var items = ItemFixtures.CreateItemList(3); + _mockDb.Setup(db => db.FindAllAsync("items", It.IsAny())) + .ReturnsAsync(items); + + // Note: In a real test, you'd use WebApplicationFactory or construct + // the function class directly and invoke the handler + var result = await _mockDb.Object.FindAllAsync("items"); + + result.Should().HaveCount(3); + } + + [Fact] + public async Task GetItems_ReturnsEmptyList_WhenNoItems() + { + _mockDb.Setup(db => db.FindAllAsync("items", It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _mockDb.Object.FindAllAsync("items"); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetItemById_ReturnsItem_WhenExists() + { + var item = ItemFixtures.CreateValidItem("test-001"); + _mockDb.Setup(db => db.FindByIdAsync("items", "test-001")) + .ReturnsAsync(item); + + var result = await _mockDb.Object.FindByIdAsync("items", "test-001"); + + result.Should().NotBeNull(); + result!.Id.Should().Be("test-001"); + } + + [Fact] + public async Task GetItemById_ReturnsNull_WhenNotFound() + { + _mockDb.Setup(db => db.FindByIdAsync("items", "nonexistent")) + .ReturnsAsync((Item?)null); + + var result = await _mockDb.Object.FindByIdAsync("items", "nonexistent"); + + result.Should().BeNull(); + } +} +``` + +```csharp +// Validation/CreateItemValidatorTests.cs +using FluentValidation.TestHelper; + +public class CreateItemValidatorTests +{ + private readonly CreateItemValidator _validator = new(); + + [Fact] + public void Valid_Input_Passes() + { + var request = ItemFixtures.CreateValidRequest(); + var result = _validator.TestValidate(request); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Empty_Name_Fails() + { + var request = ItemFixtures.CreateInvalidRequest_EmptyName(); + var result = _validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Negative_Price_Fails() + { + var request = ItemFixtures.CreateInvalidRequest_NegativePrice(); + var result = _validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Price); + } + + [Fact] + public void Missing_Category_Fails() + { + var request = new CreateItemRequest + { + Name = "Widget", + Price = 29.99m, + Category = "" + }; + var result = _validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Category); + } +} +``` + +```csharp +// Errors/ErrorHandlerTests.cs +public class ErrorHandlerTests +{ + [Fact] + public void NotFound_Returns_404() + { + var error = new NotFoundException("Item", "abc-123"); + error.StatusCode.Should().Be(404); + error.Code.Should().Be("NOT_FOUND"); + error.Message.Should().Contain("abc-123"); + } + + [Fact] + public void ValidationException_Returns_422() + { + var error = new Errors.ValidationException("Bad input"); + error.StatusCode.Should().Be(422); + error.Code.Should().Be("VALIDATION_ERROR"); + } + + [Fact] + public void BadRequest_Returns_400() + { + var error = new BadRequestException("Missing field"); + error.StatusCode.Should().Be(400); + error.Code.Should().Be("BAD_REQUEST"); + } + + [Fact] + public void All_Errors_Have_Consistent_Shape() + { + var errors = new AppException[] + { + new NotFoundException("Item", "1"), + new Errors.ValidationException("Bad input"), + new BadRequestException("Bad request"), + }; + + foreach (var error in errors) + { + error.StatusCode.Should().BeGreaterThan(0); + error.Code.Should().NotBeNullOrEmpty(); + error.Message.Should().NotBeNullOrEmpty(); + } + } +} +``` + +--- + +## Validation β€” FluentValidation + +### Validator Definition + +```csharp +// Shared/Validators/CreateItemValidator.cs +using FluentValidation; + +public class CreateItemValidator : AbstractValidator +{ + public CreateItemValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(255); + + RuleFor(x => x.Price) + .GreaterThan(0).WithMessage("Price must be positive"); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(100); + } +} + +public class UpdateItemValidator : AbstractValidator +{ + public UpdateItemValidator() + { + RuleFor(x => x.Name) + .MaximumLength(255) + .When(x => x.Name != null); + + RuleFor(x => x.Price) + .GreaterThan(0) + .When(x => x.Price.HasValue); + + RuleFor(x => x.Category) + .MaximumLength(100) + .When(x => x.Category != null); + } +} +``` + +--- + +## Structured Logging β€” Serilog + +### Logger Setup + +```csharp +// Already configured in Program.cs (see above) +// Usage in functions via ILogger injection: + +public class CreateItem +{ + private readonly ILogger _logger; + + public CreateItem(ILogger logger) + { + _logger = logger; + } + + // In handler: + _logger.LogInformation("Creating item {ItemName} in category {Category}", + body.Name, body.Category); +} +``` + +--- + +## Shared Types + +```csharp +// Shared/Models/Item.cs +public class Item +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public decimal Price { get; set; } + public string Category { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +// Shared/Models/ApiContracts.cs +public class CreateItemRequest +{ + public string Name { get; set; } = ""; + public string? Description { get; set; } + public decimal Price { get; set; } + public string Category { get; set; } = ""; +} + +public class UpdateItemRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public decimal? Price { get; set; } + public string? Category { get; set; } +} + +public class ListItemsResponse +{ + public List Items { get; set; } = new(); + public int Total { get; set; } +} + +public class ErrorResponse +{ + public ErrorDetail Error { get; set; } = new(); +} + +public class ErrorDetail +{ + public string Code { get; set; } = ""; + public string Message { get; set; } = ""; + public object? Details { get; set; } +} + +public class HealthResponse +{ + public string Status { get; set; } = ""; + public Dictionary Services { get; set; } = new(); +} +``` + +--- + +## Config with Validation + +```csharp +// Services/Config.cs +public class AppConfig +{ + public string StorageConnectionString { get; set; } = ""; + public string DatabaseUrl { get; set; } = ""; + public string RedisUrl { get; set; } = ""; + public bool IsDevelopment { get; set; } + + public static AppConfig Load() + { + return new AppConfig + { + StorageConnectionString = Environment.GetEnvironmentVariable("STORAGE_CONNECTION_STRING") + ?? "UseDevelopmentStorage=true", + DatabaseUrl = Environment.GetEnvironmentVariable("DATABASE_URL") + ?? "Host=localhost;Port=5432;Database=appdb;Username=localdev;Password=localdevpassword", + RedisUrl = Environment.GetEnvironmentVariable("REDIS_URL") + ?? "localhost:6379", + IsDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development", + }; + } + + public void Validate() + { + var missing = new List(); + // Add required var checks here based on services used + // Example: + // if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DATABASE_URL"))) + // missing.Add("DATABASE_URL β€” PostgreSQL connection string"); + + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Missing required environment variables:\n{string.Join("\n", missing.Select(m => $" - {m}"))}\n\nCopy .env.example to .env and fill in the values."); + } + } +} +``` + +--- + +## Dependencies Quick Reference + +### Core NuGet Packages + +| Package | Purpose | +|---------|---------| +| `Microsoft.Azure.Functions.Worker` | Functions runtime | +| `Microsoft.Azure.Functions.Worker.Extensions.Http` | HTTP trigger | +| `FluentValidation` | Input validation | +| `Serilog` + `Serilog.Sinks.Console` | Structured logging | + +### Per Service + +| Service | Package | +|---------|---------| +| Blob Storage | `Azure.Storage.Blobs` | +| PostgreSQL | `Npgsql` | +| CosmosDB | `Microsoft.Azure.Cosmos` | +| Redis | `StackExchange.Redis` | +| Migrations | `Microsoft.EntityFrameworkCore`, `Npgsql.EntityFrameworkCore.PostgreSQL` | + +### Test Packages + +| Package | Purpose | +|---------|---------| +| `xunit` + `xunit.runner.visualstudio` | Test runner | +| `Microsoft.NET.Test.Sdk` | Test SDK | +| `Moq` | Mocking | +| `FluentAssertions` | Readable assertions | +| `coverlet.collector` | Code coverage | +| `FluentValidation.TestHelper` | Validation test helpers | diff --git a/resources/agents/shared-references/runtimes/python.md b/resources/agents/shared-references/runtimes/python.md new file mode 100644 index 00000000..4d5b57ad --- /dev/null +++ b/resources/agents/shared-references/runtimes/python.md @@ -0,0 +1,577 @@ +# Python Runtime Reference + +> Azure Functions v2 model, Python. pytest, Pydantic validation, structlog, DI patterns. + +--- + +## Azure Functions v2 Setup + +### Initialization + +```bash +func init src/functions --python --model V2 +cd src/functions +pip install -r requirements.txt +``` + +### host.json + +```json +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} +``` + +### local.settings.json + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "ENVIRONMENT": "development", + "STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "DATABASE_URL": "postgresql://localdev:localdevpassword@localhost:5432/appdb", + "REDIS_URL": "redis://localhost:6379" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} +``` + +### pyproject.toml + +```toml +[project] +name = "functions" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = [ + "azure-functions>=1.17.0", + "pydantic>=2.0.0", + "structlog>=23.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "ruff>=0.1.0", + "httpx>=0.25.0", +] + +# Add per-service dependencies as needed: +# "azure-storage-blob>=12.0.0", +# "psycopg2-binary>=2.9.0", +# "redis>=5.0.0", + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +``` + +--- + +## Function Handler Pattern + +### function_app.py (Registration) + +```python +# function_app.py +import azure.functions as func +from services.registry import initialize_services + +# Initialize services on cold start +initialize_services() + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +# Import function handlers β€” they register themselves via decorators +import functions.get_items # noqa: F401 +import functions.create_item # noqa: F401 +import functions.get_item_by_id # noqa: F401 +import functions.health # noqa: F401 +``` + +### HTTP Function (v2 Model) + +```python +# functions/get_items.py +import azure.functions as func +import json +from services.registry import get_services +from errors.error_handler import handle_error +from function_app import app + +@app.route(route="items", methods=["GET"]) +async def get_items(req: func.HttpRequest) -> func.HttpResponse: + try: + services = get_services() + limit = int(req.params.get("limit", "20")) + offset = int(req.params.get("offset", "0")) + + items = await services.database.find_all("items", limit=limit, offset=offset) + + return func.HttpResponse( + json.dumps({"items": items, "total": len(items)}), + status_code=200, + mimetype="application/json", + ) + except Exception as e: + return handle_error(e) +``` + +### POST with Validation + +```python +# functions/create_item.py +import azure.functions as func +import json +import uuid +from datetime import datetime, timezone +from services.registry import get_services +from errors.error_handler import handle_error +from shared.validation import CreateItemRequest +from function_app import app + +@app.route(route="items", methods=["POST"]) +async def create_item(req: func.HttpRequest) -> func.HttpResponse: + try: + # Validate input + body = req.get_json() + validated = CreateItemRequest(**body) + + services = get_services() + now = datetime.now(timezone.utc).isoformat() + + item = { + "id": str(uuid.uuid4()), + **validated.model_dump(), + "created_at": now, + "updated_at": now, + } + + created = await services.database.create("items", item) + + return func.HttpResponse( + json.dumps({"item": created}), + status_code=201, + mimetype="application/json", + ) + except Exception as e: + return handle_error(e) +``` + +### GET by ID with 404 + +```python +# functions/get_item_by_id.py +import azure.functions as func +import json +from services.registry import get_services +from errors.error_handler import handle_error +from errors.error_types import NotFoundError +from function_app import app + +@app.route(route="items/{id}", methods=["GET"]) +async def get_item_by_id(req: func.HttpRequest) -> func.HttpResponse: + try: + services = get_services() + item_id = req.route_params.get("id") + + item = await services.database.find_by_id("items", item_id) + if item is None: + raise NotFoundError("Item", item_id) + + return func.HttpResponse( + json.dumps({"item": item}), + status_code=200, + mimetype="application/json", + ) + except Exception as e: + return handle_error(e) +``` + +### Health Check + +```python +# functions/health.py +import azure.functions as func +import json +from services.registry import get_services +from function_app import app + +@app.route(route="health", methods=["GET"]) +async def health(req: func.HttpRequest) -> func.HttpResponse: + services = get_services() + checks = {} + + try: + checks["database"] = await services.database.health_check() + except Exception: + checks["database"] = False + + try: + checks["storage"] = await services.storage.health_check() + except Exception: + checks["storage"] = False + + try: + checks["cache"] = await services.cache.health_check() + except Exception: + checks["cache"] = False + + all_healthy = all(checks.values()) + any_healthy = any(checks.values()) + status = "healthy" if all_healthy else ("degraded" if any_healthy else "unhealthy") + + return func.HttpResponse( + json.dumps({"status": status, "services": checks}), + status_code=200 if all_healthy else 503, + mimetype="application/json", + ) +``` + +--- + +## pytest Configuration + +### conftest.py (Global Test Setup) + +```python +# tests/conftest.py +import pytest +import json +from pathlib import Path +from services.registry import register_services, clear_services, ServiceRegistry +from tests.mocks.mock_database import MockDatabaseService +from tests.mocks.mock_storage import MockStorageService +from tests.mocks.mock_cache import MockCacheService + +@pytest.fixture(autouse=True) +def setup_mock_services(item_fixtures): + """Register mock services before each test, clear after.""" + register_services( + ServiceRegistry( + database=MockDatabaseService({"items": item_fixtures["validItems"]}), + storage=MockStorageService(), + cache=MockCacheService(), + ) + ) + yield + clear_services() + +@pytest.fixture +def item_fixtures(): + fixture_path = Path(__file__).parent / "fixtures" / "items.json" + with open(fixture_path) as f: + return json.load(f) + +@pytest.fixture +def valid_item(item_fixtures): + return item_fixtures["validItems"][0] + +@pytest.fixture +def invalid_items(item_fixtures): + return item_fixtures["invalidItems"] + +@pytest.fixture +def mock_database(item_fixtures): + return MockDatabaseService({"items": item_fixtures["validItems"]}) +``` + +### Test Examples + +```python +# tests/test_get_items.py +import pytest +from unittest.mock import AsyncMock +from services.registry import get_services + +async def test_get_items_returns_all_items(item_fixtures): + services = get_services() + items = await services.database.find_all("items") + assert len(items) == len(item_fixtures["validItems"]) + +async def test_get_items_returns_empty_when_no_data(): + from services.registry import register_services, ServiceRegistry + from tests.mocks.mock_database import MockDatabaseService + from tests.mocks.mock_storage import MockStorageService + from tests.mocks.mock_cache import MockCacheService + + register_services( + ServiceRegistry( + database=MockDatabaseService(), + storage=MockStorageService(), + cache=MockCacheService(), + ) + ) + services = get_services() + items = await services.database.find_all("items") + assert items == [] + +async def test_get_item_by_id_returns_item(valid_item): + services = get_services() + item = await services.database.find_by_id("items", valid_item["id"]) + assert item is not None + assert item["id"] == valid_item["id"] + +async def test_get_item_by_id_returns_none_for_missing(): + services = get_services() + item = await services.database.find_by_id("items", "nonexistent-id") + assert item is None +``` + +```python +# tests/test_validation.py +import pytest +from pydantic import ValidationError +from shared.validation import CreateItemRequest + +def test_valid_create_item(): + item = CreateItemRequest( + name="Widget", description="A widget", price=29.99, category="widgets" + ) + assert item.name == "Widget" + assert item.price == 29.99 + +def test_empty_name_fails(): + with pytest.raises(ValidationError): + CreateItemRequest(name="", description="A widget", price=29.99, category="widgets") + +def test_negative_price_fails(): + with pytest.raises(ValidationError): + CreateItemRequest(name="Widget", description="A widget", price=-5.0, category="widgets") + +def test_missing_required_fields(): + with pytest.raises(ValidationError): + CreateItemRequest(description="Just a description") +``` + +```python +# tests/test_error_handler.py +import json +from errors.error_types import NotFoundError, ValidationError, BadRequestError +from errors.error_handler import handle_error + +def test_not_found_error_returns_404(): + error = NotFoundError("Item", "abc-123") + response = handle_error(error) + body = json.loads(response.get_body()) + + assert response.status_code == 404 + assert body["error"]["code"] == "NOT_FOUND" + assert "abc-123" in body["error"]["message"] + +def test_validation_error_returns_422(): + error = ValidationError("Bad input", details=[{"field": "name", "message": "Required"}]) + response = handle_error(error) + body = json.loads(response.get_body()) + + assert response.status_code == 422 + assert body["error"]["code"] == "VALIDATION_ERROR" + +def test_unknown_error_returns_500(): + error = RuntimeError("Something broke") + response = handle_error(error) + body = json.loads(response.get_body()) + + assert response.status_code == 500 + assert body["error"]["code"] == "INTERNAL_ERROR" + +def test_error_response_shape_is_consistent(): + errors = [ + NotFoundError("Item", "1"), + ValidationError("Bad input"), + BadRequestError("Bad request"), + RuntimeError("Unknown"), + ] + for error in errors: + response = handle_error(error) + body = json.loads(response.get_body()) + assert "error" in body + assert "code" in body["error"] + assert "message" in body["error"] +``` + +--- + +## Validation β€” Pydantic + +### Schema Definition + +```python +# shared/validation.py +from pydantic import BaseModel, Field +from typing import Optional + +class CreateItemRequest(BaseModel): + name: str = Field(min_length=1, max_length=255) + description: Optional[str] = "" + price: float = Field(gt=0) + category: str = Field(min_length=1, max_length=100) + +class UpdateItemRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + category: Optional[str] = Field(None, min_length=1, max_length=100) + +class PaginationParams(BaseModel): + limit: int = Field(20, ge=1, le=100) + offset: int = Field(0, ge=0) +``` + +--- + +## Structured Logging β€” structlog + +### Logger Setup + +```python +# logger.py +import structlog +import os +import logging + +def setup_logging(): + log_level = os.environ.get("LOG_LEVEL", "INFO").upper() + + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer() + if os.environ.get("ENVIRONMENT") == "development" + else structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, log_level, logging.INFO) + ), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + +def get_logger(name: str = "app"): + return structlog.get_logger(name) +``` + +### Request Logging + +```python +# middleware/request_logger.py +import time +from logger import get_logger + +logger = get_logger("http") + +def log_request(method: str, path: str, status_code: int, start_time: float): + duration_ms = round((time.time() - start_time) * 1000, 2) + logger.info( + "request_completed", + method=method, + path=path, + status=status_code, + duration_ms=duration_ms, + ) +``` + +--- + +## Shared Types + +```python +# shared/types.py +from pydantic import BaseModel +from typing import Optional, Any +from datetime import datetime + +class Item(BaseModel): + id: str + name: str + description: str + price: float + category: str + created_at: datetime + updated_at: datetime + +class ListItemsResponse(BaseModel): + items: list[Item] + total: int + +class SingleItemResponse(BaseModel): + item: Item + +class ErrorDetail(BaseModel): + code: str + message: str + details: Optional[Any] = None + +class ErrorResponse(BaseModel): + error: ErrorDetail + +class HealthResponse(BaseModel): + status: str # "healthy" | "degraded" | "unhealthy" + services: dict[str, bool] +``` + +--- + +## Dependencies Quick Reference + +### Core Dependencies + +| Package | Purpose | +|---------|---------| +| `azure-functions` | Azure Functions v2 runtime | +| `pydantic` | Input validation & shared types | +| `structlog` | Structured logging | + +### Per Service + +| Service | Package | +|---------|---------| +| Blob Storage | `azure-storage-blob` | +| PostgreSQL | `psycopg2-binary` | +| CosmosDB | `azure-cosmos` | +| Redis | `redis` | +| Migrations | `alembic`, `sqlalchemy` | + +### Dev Dependencies + +| Package | Purpose | +|---------|---------| +| `pytest` | Test runner | +| `pytest-asyncio` | Async test support | +| `pytest-cov` | Coverage reporting | +| `ruff` | Linting + formatting | +| `httpx` | HTTP client for request-level tests | diff --git a/resources/agents/shared-references/runtimes/typescript.md b/resources/agents/shared-references/runtimes/typescript.md new file mode 100644 index 00000000..91c458aa --- /dev/null +++ b/resources/agents/shared-references/runtimes/typescript.md @@ -0,0 +1,538 @@ +# TypeScript (Node.js) Runtime Reference + +> Azure Functions v4 model, TypeScript. Test runner setup, validation, logging, DI patterns. + +--- + +## Azure Functions v4 Setup + +### Initialization + +```bash +func init src/functions --typescript --model V4 +cd src/functions +npm install +``` + +### host.json + +```json +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} +``` + +### local.settings.json + +```json +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node", + "NODE_ENV": "development", + "STORAGE_CONNECTION_STRING": "UseDevelopmentStorage=true", + "DATABASE_URL": "postgresql://localdev:localdevpassword@localhost:5432/appdb", + "REDIS_URL": "redis://localhost:6379" + }, + "Host": { + "CORS": "*", + "CORSCredentials": false + } +} +``` + +### tsconfig.json + +> ⚠️ When importing from `../shared/`, `rootDir` must be `".."` for cross-workspace imports. Changes `dist/` output β€” see `main` field below. + +```json +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "..", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts", "../shared/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} +``` + +### package.json (backend) + +> ⚠️ **`main` field MUST match actual `dist/` output.** With `rootDir: ".."`, handlers compile to `dist/functions/src/functions/*.js` (not `dist/src/functions/*.js`). After `tsc` build, verify `main` resolves via `dist/` contents. + +```json +{ + "name": "functions", + "version": "1.0.0", + "private": true, + "main": "dist/functions/src/functions/*.js", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "start": "func start", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src/ tests/", + "db:migrate": "knex migrate:latest", + "db:seed": "tsx seeds/seed.ts" + }, + "dependencies": { + "@azure/functions": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^1.0.0" + } +} +``` + +> Adjust `scripts.test` per user's test runner. + +--- + +## Function Handler Pattern + +> ⚠️ **Always set `status` explicitly**, even for 200. Do NOT omit and rely on default. Explicit status keeps test assertions consistent and prevents `undefined` vs `200` confusion. + +### HTTP Function (v4 Model) + +```typescript +// src/functions/getItems.ts +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { Item } from '../../shared/types/entities'; + +app.http('getItems', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'items', + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + try { + const { database } = getServices(); + const limit = Number(request.query.get('limit')) || 20; + const offset = Number(request.query.get('offset')) || 0; + + const items = await database.findAll('items', { limit, offset }); + + return { + status: 200, + jsonBody: { items, total: items.length }, + }; + } catch (error) { + return handleError(error, context); + } + }, +}); +``` + +### POST with Validation + +```typescript +// src/functions/createItem.ts +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { validateBody } from '../middleware/validateRequest'; +import { createItemSchema } from '../../shared/schemas/validation'; +import { v4 as uuid } from 'uuid'; + +app.http('createItem', { + methods: ['POST'], + authLevel: 'anonymous', + route: 'items', + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + try { + const body = await validateBody(request, createItemSchema); + const { database } = getServices(); + + const item = { + id: uuid(), + ...body, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const created = await database.create('items', item); + return { status: 201, jsonBody: { item: created } }; + } catch (error) { + return handleError(error, context); + } + }, +}); +``` + +### GET by ID with 404 Handling + +```typescript +// src/functions/getItemById.ts +import { app } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { NotFoundError } from '../errors/errorTypes'; +import { Item } from '../../shared/types/entities'; + +app.http('getItemById', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'items/{id}', + handler: async (request, context) => { + try { + const { database } = getServices(); + const id = request.params.id; + + const item = await database.findById('items', id); + if (!item) { + throw new NotFoundError('Item', id); + } + + return { jsonBody: { item } }; + } catch (error) { + return handleError(error, context); + } + }, +}); +``` + +### Health Check + +```typescript +// src/functions/health.ts +import { app } from '@azure/functions'; +import { getServices } from '../services/registry'; + +app.http('health', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'health', + handler: async (request, context) => { + const services = getServices(); + + const checks: Record = {}; + + // Check each service + try { checks.database = await services.database.healthCheck(); } catch { checks.database = false; } + try { checks.storage = await services.storage.healthCheck(); } catch { checks.storage = false; } + try { checks.cache = await services.cache.healthCheck(); } catch { checks.cache = false; } + + const allHealthy = Object.values(checks).every(v => v); + const anyHealthy = Object.values(checks).some(v => v); + + const status = allHealthy ? 'healthy' : anyHealthy ? 'degraded' : 'unhealthy'; + + return { + status: allHealthy ? 200 : 503, + jsonBody: { status, services: checks }, + }; + }, +}); +``` + +--- + +## Test Runner Configurations + +### vitest + +> ⚠️ **MANDATORY for projects with heavy SDK imports** (pg, @azure/storage-blob, etc.): +> - `fileParallelism: false` β€” Prevents memory exhaustion from multiple workers loading heavy SDKs. +> - `teardownTimeout: 3000` β€” Kills lingering connections (e.g., `pg.Pool`) keeping process alive after tests. +> - `testTimeout: 10000` β€” Prevents false failures on slow CI. +> +> Without these, test suite **hangs indefinitely** with 13+ files importing heavy SDKs. #1 test infrastructure issue. + +```typescript +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], + fileParallelism: false, + teardownTimeout: 3000, + testTimeout: 10000, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/interfaces/**', 'src/functions/*.ts'], + }, + }, +}); +``` + +```typescript +// tests/setup.ts +import { registerServices, clearServices } from '../src/services/registry'; +import { MockDatabaseService } from './mocks/mockDatabase'; +import { MockStorageService } from './mocks/mockStorage'; +import { MockCacheService } from './mocks/mockCache'; +import itemFixtures from './fixtures/items.json'; + +beforeEach(() => { + registerServices({ + database: new MockDatabaseService({ items: itemFixtures.validItems }), + storage: new MockStorageService(), + cache: new MockCacheService(), + }); +}); + +afterEach(() => { + clearServices(); +}); +``` + +### jest + +```typescript +// jest.config.ts +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + setupFilesAfterSetup: ['/tests/setup.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/interfaces/**', + ], +}; +``` + +### mocha + chai + sinon + +```yaml +# .mocharc.yml +require: + - tsx + - tests/setup.ts +spec: 'tests/**/*.test.ts' +recursive: true +timeout: 10000 +``` + +```typescript +// tests/setup.ts (mocha version) +import { registerServices, clearServices } from '../src/services/registry'; +import { MockDatabaseService } from './mocks/mockDatabase'; +import { MockStorageService } from './mocks/mockStorage'; +import { MockCacheService } from './mocks/mockCache'; +import itemFixtures from './fixtures/items.json'; + +beforeEach(() => { + registerServices({ + database: new MockDatabaseService({ items: itemFixtures.validItems }), + storage: new MockStorageService(), + cache: new MockCacheService(), + }); +}); + +afterEach(() => { + clearServices(); +}); +``` + +--- + +## Validation β€” Zod + +### Schema Definition + +```typescript +// shared/schemas/validation.ts +import { z } from 'zod'; + +export const createItemSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + description: z.string().optional().default(''), + price: z.number().positive('Price must be positive'), + category: z.string().min(1, 'Category is required').max(100), +}); + +export const updateItemSchema = createItemSchema.partial(); + +export const paginationSchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type CreateItemRequest = z.infer; +export type UpdateItemRequest = z.infer; +``` + +--- + +## Structured Logging β€” pino + +### Logger Setup + +```typescript +// src/logger.ts +import pino from 'pino'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: process.env.NODE_ENV === 'development' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +export function getLogger(name?: string) { + return name ? logger.child({ module: name }) : logger; +} +``` + +### Request Logging Middleware + +```typescript +// middleware/requestLogger.ts +import { HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getLogger } from '../logger'; + +const logger = getLogger('http'); + +export function logRequest( + request: HttpRequest, + response: HttpResponseInit, + context: InvocationContext, + durationMs: number +): void { + logger.info({ + method: request.method, + path: request.url, + status: response.status || 200, + durationMs, + functionName: context.functionName, + }, `${request.method} ${request.url} ${response.status || 200} ${durationMs}ms`); +} +``` + +--- + +## Shared Types + +```typescript +// shared/types/entities.ts +export interface Item { + id: string; + name: string; + description: string; + price: number; + category: string; + createdAt: string; + updatedAt: string; +} +``` + +```typescript +// shared/types/api.ts +import { Item } from './entities'; + +// Response contracts +export interface ListItemsResponse { + items: Item[]; + total: number; +} + +export interface SingleItemResponse { + item: Item; +} + +export interface ErrorResponse { + error: { + code: string; + message: string; + details: unknown | null; + }; +} + +export interface HealthResponse { + status: 'healthy' | 'degraded' | 'unhealthy'; + services: Record; +} +``` + +--- + +## ESLint Configuration + +```json +// .eslintrc.json +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "env": { + "node": true, + "es2022": true + } +} +``` + +--- + +## Dependencies Quick Reference + +### Core Dependencies + +| Package | Purpose | +|---------|---------| +| `@azure/functions` | Azure Functions v4 runtime | +| `zod` | Input validation | +| `pino` | Structured logging | +| `uuid` | ID generation | + +### Per Service + +| Service | Package | +|---------|---------| +| Blob Storage | `@azure/storage-blob` | +| PostgreSQL | `pg`, `@types/pg` | +| CosmosDB | `@azure/cosmos` | +| Redis | `ioredis` | +| Migrations | `knex` | + +### Dev Dependencies + +| Package | Purpose | +|---------|---------| +| `typescript` | TypeScript compiler | +| `vitest` / `jest` / `mocha` | Test runner (user's choice) | +| `eslint` + `@typescript-eslint/*` | Linting | +| `prettier` | Formatting | +| `tsx` | TypeScript execution (for scripts) | +| `pino-pretty` | Dev log formatting | diff --git a/resources/agents/shared-references/seed-data.md b/resources/agents/shared-references/seed-data.md new file mode 100644 index 00000000..602d4025 --- /dev/null +++ b/resources/agents/shared-references/seed-data.md @@ -0,0 +1,297 @@ +# Seed Data & Migrations + +> Database schema management and realistic test data seeding patterns. + +--- + +## Core Principle + +**Repeatable, idempotent schema and data management.** Migrations run forward and backward cleanly. Seed data is realistic, usable in both dev and tests. Running seed twice does not duplicate data. + +--- + +## When This Applies + +This reference is used **only when project includes a database service** (PostgreSQL, CosmosDB, Azure SQL). If project only uses Blob Storage, Queue Storage, or Redis, skip this reference. + +> β›› **CRITICAL**: Migration files and seed data scripts MUST contain actual code β€” not empty files or empty directories. Empty `seeds/migrations/` directory is #1 cause of runtime failures that unit tests cannot catch (mocks use in-memory Maps, not SQL tables). Every table in plan's Database Constraints section MUST have corresponding migration file with complete `CREATE TABLE` statement. Every migration file MUST have both `up()` and `down()` functions. `seeds/fixtures/seed-data.json` MUST contain realistic sample data. + +--- + +## Migration Patterns + +### TypeScript β€” Knex Migrations + +#### Setup + +```bash +npm install knex pg +npm install -D @types/pg +``` + +```typescript +// knexfile.ts +import { loadConfig } from './src/services/config'; + +const config = loadConfig(); + +export default { + client: 'pg', + connection: config.database.url, + migrations: { + directory: './seeds/migrations', + extension: 'ts', + }, + seeds: { + directory: './seeds/data', + extension: 'ts', + }, +}; +``` + +#### Migration File + +```typescript +// seeds/migrations/20260101000000_create_items.ts +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('items', (table) => { + table.uuid('id').primary().defaultTo(knex.fn.uuid()); + table.string('name').notNullable(); + table.text('description'); + table.decimal('price', 10, 2).notNullable(); + table.string('category').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // Add indexes + await knex.schema.alterTable('items', (table) => { + table.index('category'); + table.index('created_at'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('items'); +} +``` + +#### Seed Script + +```typescript +// seeds/seed.ts +import knex from 'knex'; +import knexConfig from '../knexfile'; +import seedData from './fixtures/seed-data.json'; + +async function seed() { + const db = knex(knexConfig); + + try { + // Run migrations first + await db.migrate.latest(); + console.log('Migrations applied.'); + + // Seed data β€” idempotent (upsert) + for (const item of seedData.items) { + await db('items') + .insert(item) + .onConflict('id') + .merge(); + } + console.log(`Seeded ${seedData.items.length} items.`); + } finally { + await db.destroy(); + } +} + +seed().catch(console.error); +``` + +#### Seed Data Fixture + +```json +// seeds/fixtures/seed-data.json +{ + "items": [ + { + "id": "seed-001", + "name": "Sample Widget", + "description": "A sample widget for development", + "price": 29.99, + "category": "widgets", + "created_at": "2026-01-15T10:00:00.000Z", + "updated_at": "2026-01-15T10:00:00.000Z" + }, + { + "id": "seed-002", + "name": "Demo Gadget", + "description": "A demo gadget for development", + "price": 49.99, + "category": "gadgets", + "created_at": "2026-02-01T14:00:00.000Z", + "updated_at": "2026-02-01T14:00:00.000Z" + }, + { + "id": "seed-003", + "name": "Test Doohickey", + "description": "A test doohickey for development", + "price": 9.99, + "category": "doohickeys", + "created_at": "2026-03-01T08:00:00.000Z", + "updated_at": "2026-03-01T08:00:00.000Z" + } + ] +} +``` + +#### Package.json Scripts + +```json +{ + "scripts": { + "db:migrate": "knex migrate:latest", + "db:migrate:rollback": "knex migrate:rollback", + "db:seed": "tsx seeds/seed.ts", + "db:reset": "knex migrate:rollback --all && knex migrate:latest && npm run db:seed" + } +} +``` + +--- + +### Python and C# Migrations + +For Python migration patterns (Alembic + SQLAlchemy), see [runtimes/python.md](runtimes/python.md). For C# migration patterns (Entity Framework Core), see [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Testing Migrations & Seed Data + +### TypeScript Tests + +```typescript +// tests/seeds/migration.test.ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import knex, { Knex } from 'knex'; +import knexConfig from '../../knexfile'; + +// These tests require a running database (integration tests) +// Skip if DATABASE_URL is not set +const shouldRun = !!process.env.DATABASE_URL; + +describe.skipIf(!shouldRun)('Database Migrations', () => { + let db: Knex; + + beforeAll(() => { + db = knex(knexConfig); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it('should run migrations forward', async () => { + await db.migrate.latest(); + const tables = await db.raw( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" + ); + const tableNames = tables.rows.map((r: any) => r.table_name); + expect(tableNames).toContain('items'); + }); + + it('should run migrations backward', async () => { + await db.migrate.rollback(undefined, true); + const tables = await db.raw( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" + ); + const tableNames = tables.rows.map((r: any) => r.table_name); + expect(tableNames).not.toContain('items'); + }); + + it('should be idempotent (run forward twice without error)', async () => { + await db.migrate.latest(); + await db.migrate.latest(); // Should not throw + }); +}); +``` + +```typescript +// tests/seeds/seedData.test.ts +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import knex, { Knex } from 'knex'; +import knexConfig from '../../knexfile'; +import seedData from '../../seeds/fixtures/seed-data.json'; + +const shouldRun = !!process.env.DATABASE_URL; + +describe.skipIf(!shouldRun)('Seed Data', () => { + let db: Knex; + + beforeAll(async () => { + db = knex(knexConfig); + await db.migrate.latest(); + }); + + beforeEach(async () => { + await db('items').del(); // Clean slate + }); + + afterAll(async () => { + await db.migrate.rollback(undefined, true); + await db.destroy(); + }); + + it('should seed correct number of rows', async () => { + for (const item of seedData.items) { + await db('items').insert(item).onConflict('id').merge(); + } + const count = await db('items').count('* as total').first(); + expect(Number(count?.total)).toBe(seedData.items.length); + }); + + it('should be idempotent (seeding twice produces same row count)', async () => { + // Seed once + for (const item of seedData.items) { + await db('items').insert(item).onConflict('id').merge(); + } + // Seed again + for (const item of seedData.items) { + await db('items').insert(item).onConflict('id').merge(); + } + const count = await db('items').count('* as total').first(); + expect(Number(count?.total)).toBe(seedData.items.length); + }); +}); +``` + +--- + +## Fixture Data Guidelines + +1. **Use realistic data** β€” Names, descriptions, values should look like real data, not "test1", "test2" +2. **Include edge cases** β€” Empty strings (where valid), long strings, special characters, Unicode, boundary numbers (0, negative, max) +3. **Use stable IDs** β€” Seed data should have predictable IDs (e.g., `seed-001`) so tests can reference specific records +4. **Keep fixtures in JSON** β€” Shared between seed scripts and test fixtures. Easy to read and modify. +5. **Separate seed data from test fixtures** β€” Seed data populates dev database. Test fixtures drive unit test assertions. May overlap but serve different purposes. +6. **Document fixture schema** β€” Add comment block or README explaining what each fixture covers + +### Example Fixture Structure + +``` +seeds/ +β”œβ”€β”€ seed.ts ← Seed script (runs against real DB) +β”œβ”€β”€ migrations/ +β”‚ └── 20260101_create_items.ts +└── fixtures/ + └── seed-data.json ← Seed data (realistic dev data) + +tests/ +β”œβ”€β”€ fixtures/ +β”‚ β”œβ”€β”€ items.json ← Test data (valid + invalid variations) +β”‚ └── users.json ← Test data for another entity +└── mocks/ + └── mockDatabase.ts ← Mock service pre-loaded with fixture data +``` diff --git a/resources/agents/shared-references/service-abstraction.md b/resources/agents/shared-references/service-abstraction.md new file mode 100644 index 00000000..ec230b9d --- /dev/null +++ b/resources/agents/shared-references/service-abstraction.md @@ -0,0 +1,295 @@ +# Service Abstraction Layer + +> Patterns for testable code that works against local mocks and live Azure services with zero code changes. + +--- + +## Core Principle + +**Same application code runs against mocks (tests), local emulators (dev), and Azure services (production).** Only difference is which implementation is injected: + +- **Tests**: In-memory mock (pre-registered via `setup.ts` / `conftest.py`) +- **Local dev**: Real SDK pointing to emulator (via local-dev skill's docker-compose) +- **Azure**: Real SDK pointing to Azure services (via managed identity) + +Function handlers NEVER import Azure SDKs directly. They receive services via dependency injection. + +> ⚠️ **Auto-initialization requirement**: Service registry's `getServices()` MUST auto-initialize with concrete implementations at runtime. User runs `func start` after `npm run build` β€” no manual `registerServices()` call, no startup script. Tests override via `registerServices()` with mocks before each test. + +> ⚠️ **camelCase↔snake_case conversion requirement**: TypeScript entities use camelCase (`displayName`, `coupleId`) but PostgreSQL columns use snake_case (`display_name`, `couple_id`). Concrete database service MUST handle conversion automatically β€” snake_case for outbound SQL, camelCase for inbound results. **Mock database does NOT enforce this** (uses plain Maps), so mismatch only surfaces at runtime against real database. Conversion must be built into concrete implementation. + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Function Handler β”‚ +β”‚ (receives services β€” no SDK imports) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Service Interface β”‚ +β”‚ IStorageService β”‚ IDatabaseService β”‚ ICacheService +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Real Impl β”‚ Mock Impl β”‚ +β”‚ (Azure SDK) β”‚ (in-memory Map/Dict/List) β”‚ +β”‚ ↓ β”‚ ↓ β”‚ +β”‚ Azurite/Azure β”‚ No external deps β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## TypeScript Patterns + +### Service Interface + +```typescript +// services/interfaces/IDatabaseService.ts +export interface IDatabaseService { + findAll(collection: string, options?: QueryOptions): Promise; + findById(collection: string, id: string): Promise; + findOne(collection: string, filter: Record): Promise; + create(collection: string, data: T): Promise; + update(collection: string, id: string, data: Partial): Promise; + delete(collection: string, id: string): Promise; + count(collection: string, filter?: Record): Promise; + healthCheck(): Promise; + + // Execute multiple operations atomically β€” all succeed or all rollback. + // The callback receives a transactional IDatabaseService scoped to the transaction. + transaction(fn: (trx: IDatabaseService) => Promise): Promise; +} + +export interface QueryOptions { + limit?: number; + offset?: number; + orderBy?: string; + orderDirection?: 'asc' | 'desc'; + filter?: Record; +} +``` + +```typescript +// services/interfaces/IStorageService.ts +export interface IStorageService { + upload(container: string, name: string, data: Buffer, contentType?: string): Promise; + download(container: string, name: string): Promise; + list(container: string): Promise; + delete(container: string, name: string): Promise; + healthCheck(): Promise; +} +``` + +```typescript +// services/interfaces/ICacheService.ts +export interface ICacheService { + get(key: string): Promise; + set(key: string, value: T, ttlSeconds?: number): Promise; + delete(key: string): Promise; + clear(pattern: string): Promise; + healthCheck(): Promise; +} +``` + +### Config Module with Environment Validation + +> ⚠️ **Use flat config structure** (not nested objects). Canonical shape β€” tests and implementation must agree. Flat fields are simpler (`config.databaseUrl` not `config.database.url`), avoid ambiguity when multiple agents scaffold independently. +> +> Only list env vars project uses. `REQUIRED_VARS` array drives validation and documentation. **Enhancement service vars** (e.g., `AZURE_OPENAI_ENDPOINT`) are NOT required β€” accessed via `process.env` directly, may be `undefined`. + +```typescript +// services/config.ts +export interface AppConfig { + databaseUrl: string; + storageConnectionString: string; + jwtSecret: string; + azureOpenAiEndpoint: string | undefined; // Optional β€” Enhancement service + azureOpenAiApiKey: string | undefined; // Optional β€” Enhancement service + nodeEnv: string; +} + +const REQUIRED_VARS = ['DATABASE_URL', 'STORAGE_CONNECTION_STRING', 'JWT_SECRET'] as const; + +export function validateEnvironment(): string[] { + const missing: string[] = []; + for (const varName of REQUIRED_VARS) { + if (!process.env[varName]) { + missing.push(varName); + } + } + return missing; +} + +export function loadConfig(): AppConfig { + const missing = validateEnvironment(); + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(', ')}\n\nCopy .env.example to .env and fill in the values.` + ); + } + + return { + databaseUrl: process.env.DATABASE_URL!, + storageConnectionString: process.env.STORAGE_CONNECTION_STRING!, + jwtSecret: process.env.JWT_SECRET!, + azureOpenAiEndpoint: process.env.AZURE_OPENAI_ENDPOINT, + azureOpenAiApiKey: process.env.AZURE_OPENAI_API_KEY, + nodeEnv: process.env.NODE_ENV ?? 'development', + }; +} +``` + +### Concrete Implementation (PostgreSQL Example) + +> **Important**: Includes camelCase↔snake_case key conversion and `collectionToTable()` mapping. See [examples/service-abstraction-examples.md](examples/service-abstraction-examples.md) for complete implementation. + +**Key requirements for concrete implementation**: +- `toSnake()`/`toCamel()`/`keysToSnake()`/`keysToCamel()` conversion utilities +- `collectionToTable()` mapping singular collection names to plural SQL table names (e.g., `user` β†’ `users`) +- `create()` and `update()` strip auto-managed fields (`createdAt`, `updatedAt`, `id`) before building SQL +- `transaction()` uses `BEGIN`/`COMMIT`/`ROLLBACK` with pooled client +- `healthCheck()` executes `SELECT 1` wrapped in try/catch + +### Mock Implementation (For Tests) + +> See [examples/service-abstraction-examples.md](examples/service-abstraction-examples.md) for complete `MockDatabaseService`. + +**Key requirements for mock**: +- In-memory `Map>` storage (collection β†’ id β†’ item) +- Constructor accepts optional `Record` for initial test data +- `findOne()` iterates store values and matches all filter key-value pairs +- `update()` auto-sets `updatedAt` timestamp +- `transaction()` executes callback directly (no real transaction for unit tests) +- Must replicate same implicit behaviors as concrete (field stripping, timestamp handling) + +### Service Registry (DI) + +> **Critical**: Registry MUST auto-initialize with concrete implementations at runtime. `func start` must work without manual `registerServices()` call. Tests pre-register mocks via `setup.ts`, overriding auto-initialization. +> +> ⚠️ **`getServices()` MUST lazily call `initializeServices()` when `services === null`.** Registry that throws "Services not initialized" when none pre-registered is BROKEN β€” `func start` will crash on every request. Correct behavior: if `services` is null, construct concrete implementations from config and cache them. +> +> ⚠️ **Enhancement service safety**: Enhancement services MUST be wrapped in try/catch during construction. If constructor throws, registry must substitute no-op fallback β€” NOT crash all handlers. +> +> See [examples/service-abstraction-examples.md](examples/service-abstraction-examples.md) for complete registry pattern. + +**Key requirements for the registry**: +- `registerServices(registry)` β€” stores provided services (used by tests) +- `getServices()` β€” returns services; auto-initializes if none registered +- `clearServices()` β€” resets to null (used in test teardown) +- `initializeServices()` β€” creates concrete instances; Essential services can throw, Enhancement services wrapped in try/catch with no-op fallback + +### Usage in Function Handlers + +```typescript +// functions/getItems.ts +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { getServices } from '../services/registry'; +import { handleError } from '../errors/errorHandler'; +import { Item } from '../../shared/types/entities'; + +app.http('getItems', { + methods: ['GET'], + authLevel: 'anonymous', + route: 'items', + handler: async (request: HttpRequest, context: InvocationContext): Promise => { + try { + const { database } = getServices(); + const limit = Number(request.query.get('limit')) || 20; + const offset = Number(request.query.get('offset')) || 0; + + const items = await database.findAll('items', { limit, offset }); + return { jsonBody: { items, total: items.length } }; + } catch (error) { + return handleError(error, context); + } + } +}); +``` + +--- + +## Python and C# Patterns + +For Python service abstraction patterns (Protocol interfaces, config, mock implementations, registry), see [runtimes/python.md](runtimes/python.md). For C# (.NET) patterns (interfaces, DI registration, mock implementations), see [runtimes/dotnet.md](runtimes/dotnet.md). + +--- + +## Testing Service Abstractions + +Every service implementation (real and mock) should be tested: + +```typescript +// tests/services/registry.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { registerServices, getServices, clearServices } from '../../src/services/registry'; +import { MockDatabaseService } from '../mocks/mockDatabase'; +import { MockStorageService } from '../mocks/mockStorage'; +import { MockCacheService } from '../mocks/mockCache'; + +describe('ServiceRegistry', () => { + beforeEach(() => { + clearServices(); + }); + + afterEach(() => { + clearServices(); + }); + + it('should return registered mock services (not auto-initialized ones)', () => { + const registry = { + database: new MockDatabaseService(), + storage: new MockStorageService(), + cache: new MockCacheService(), + }; + registerServices(registry); + + const services = getServices(); + expect(services.database).toBe(registry.database); + expect(services.storage).toBe(registry.storage); + expect(services.cache).toBe(registry.cache); + }); + + it('should allow re-registration after clearServices', () => { + const first = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() }; + const second = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() }; + + registerServices(first); + clearServices(); + registerServices(second); + + expect(getServices().database).toBe(second.database); + }); + + it('pre-registered mocks take priority over auto-initialization', () => { + const mock = new MockDatabaseService(); + registerServices({ + database: mock, + storage: new MockStorageService(), + cache: new MockCacheService(), + }); + expect(getServices().database).toBe(mock); + }); + + // ⚠️ MANDATORY β€” Rule 13 auto-initialization test + // This MUST call getServices() after clearServices() β€” a test that only + // calls clearServices() without getServices() is a NO-OP and does not + // satisfy the auto-initialization requirement. + it('should auto-initialize without throwing when Enhancement config is missing (Rule 13)', () => { + clearServices(); + // Set only Essential service env vars + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/testdb'; + process.env.STORAGE_CONNECTION_STRING = 'UseDevelopmentStorage=true'; + // Enhancement env vars intentionally NOT set + delete process.env.AZURE_OPENAI_ENDPOINT; + delete process.env.AZURE_OPENAI_API_KEY; + + // getServices() MUST auto-initialize without throwing + expect(() => getServices()).not.toThrow(); + + const services = getServices(); + expect(services.database).toBeDefined(); + expect(services.storage).toBeDefined(); + expect(services.cache).toBeDefined(); + }); +}); +``` diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index ba5c3a3a..1ff26867 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -19,6 +19,7 @@ import { GroupingItem } from '../tree/azure/grouping/GroupingItem'; import { TenantTreeItem } from '../tree/tenants/TenantTreeItem'; import { createProjectWithCopilot } from '../webviews/copilotOnRails/extension/createProjectWithCopilot'; import { openDeploymentPlanViewFromWorkspace } from '../webviews/copilotOnRails/extension/openDeploymentPlanView'; +import { openDesignPreviewFromCommand } from '../webviews/copilotOnRails/extension/openDesignPreviewCommand'; import { openLocalPlanViewFromWorkspace } from '../webviews/copilotOnRails/extension/openLocalPlanView'; import { openPlanViewFromWorkspace } from '../webviews/copilotOnRails/extension/openScaffoldPlanView'; import { logIn } from './accounts/logIn'; @@ -168,10 +169,13 @@ export function registerCommands(): void { registerCommand('azureResourceGroups.openPlanView', openPlanViewFromWorkspace); registerCommand('azureResourceGroups.openLocalPlanView', openLocalPlanViewFromWorkspace); registerCommand('azureResourceGroups.openDeployPlanView', openDeploymentPlanViewFromWorkspace); + registerCommand('azureResourceGroups.openDesignPreview', openDesignPreviewFromCommand); // Hand-off commands + registerCommand('azureResourceGroups.startProjectPlan', (_context: IActionContext, prompt?: string) => + openChatWithAgent('azure-project-plan', prompt ?? 'Plan a new Azure project: gather requirements, produce `.azure/project-plan.md`, then wait for explicit user approval before handing off to the scaffold agent.')); registerCommand('azureResourceGroups.startProjectScaffold', (_context: IActionContext, prompt?: string) => - openChatWithAgent('azure-project-scaffold', prompt ?? 'Plan and scaffold a new Azure project: gather requirements, produce `.azure/project-plan.md`, require explicit user approval, then scaffold the frontend preview, backend services, database, and API routes.')); + openChatWithAgent('azure-project-scaffold', prompt ?? 'Execute the approved `.azure/project-plan.md` β€” scaffold the frontend preview, backend services, database, and API routes.')); registerCommand('azureResourceGroups.startLocalDevelopment', (_context: IActionContext, prompt?: string) => openChatWithAgent('azure-local-debug', prompt ?? 'The project has been scaffolded. Now set up the local development environment so the user can start building and testing.')); registerCommand('azureResourceGroups.startDeployment', (_context: IActionContext, prompt?: string) => diff --git a/src/webviews/copilotOnRails/extension/controllers/CreateProjectViewController.ts b/src/webviews/copilotOnRails/extension/controllers/CreateProjectViewController.ts index f84e7e2e..d3fb5b03 100644 --- a/src/webviews/copilotOnRails/extension/controllers/CreateProjectViewController.ts +++ b/src/webviews/copilotOnRails/extension/controllers/CreateProjectViewController.ts @@ -39,7 +39,7 @@ export class CreateProjectViewController extends WebviewController> { private sourceFileUri: vscode.Uri | undefined; + private planData: PlanData; constructor(planData: PlanData, sourceFileUri?: vscode.Uri) { super(ext.context, 'Project Plan', 'scaffoldPlanView', {}, ViewColumn.Active, undefined, getCopilotOnRailsBundleLocation()); this.sourceFileUri = sourceFileUri; + this.planData = planData; - this.panel.webview.onDidReceiveMessage((message: { command: string; data?: PlanData; prompt?: string }) => { + this.panel.webview.onDidReceiveMessage((message: { command: string; data?: PlanData; prompt?: string; palette?: PaletteOverride[] }) => { switch (message.command) { case 'ready': - void this.panel.webview.postMessage({ command: 'setPlanData', data: planData }); + void this.panel.webview.postMessage({ command: 'setPlanData', data: this.planData }); break; case 'approvePlan': void vscode.commands.executeCommand('azureProjectCreation.completeStep', 'projectCreation/plan/definePlan'); @@ -43,6 +52,11 @@ export class ScaffoldPlanViewController extends WebviewController/preview/index.html`, but only if it already exists β€” + * we don't want to silently create files in the workspace just + * because someone scrubbed a swatch. + */ + private async persistPaletteOverrides(palette: PaletteOverride[]): Promise { + if (!this.sourceFileUri || palette.length === 0) { + return; + } + const overrideBySlug = new Map(palette.map(p => [p.slug, p.hex.toUpperCase()])); + + // 1. Plan markdown. + try { + const original = Buffer.from(await vscode.workspace.fs.readFile(this.sourceFileUri)).toString('utf-8'); + const rewritten = rewritePaletteInPlanMarkdown(original, overrideBySlug); + if (rewritten !== original) { + await vscode.workspace.fs.writeFile(this.sourceFileUri, Buffer.from(rewritten, 'utf-8')); + } + } catch (err) { + ext.outputChannel.appendLine( + `Failed to update plan file ${this.sourceFileUri.fsPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // 2. Standalone preview HTML (mirror only if the file already exists). + const previewFile = vscode.Uri.file( + path.join(path.dirname(this.sourceFileUri.fsPath), 'preview', 'index.html'), + ); + try { + await vscode.workspace.fs.stat(previewFile); + } catch { + return; + } + + const clonedPlan: PlanData = structuredClone(this.planData); + const designSection = clonedPlan.sections.find(s => s.title.toLowerCase().includes('design system')); + const paletteTable = designSection?.content.find( + (c): c is Extract => + c.type === 'table' + && c.headers.length >= 2 + && c.rows.some(r => /^#[0-9A-Fa-f]{3,8}$/.test((r[1] ?? '').trim())), + ); + if (!paletteTable) { + return; + } + for (const row of paletteTable.rows) { + const override = overrideBySlug.get(paletteSlug((row[0] ?? '').trim())); + if (override) { + row[1] = override; + } + } + + const html = generateDesignPreviewHtml(clonedPlan); + if (!html) { + return; + } + try { + await vscode.workspace.fs.writeFile(previewFile, Buffer.from(html, 'utf-8')); + } catch (err) { + ext.outputChannel.appendLine( + `Failed to update design preview file ${previewFile.fsPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } } diff --git a/src/webviews/copilotOnRails/extension/generateDesignPreviewHtml.ts b/src/webviews/copilotOnRails/extension/generateDesignPreviewHtml.ts new file mode 100644 index 00000000..1e5b3a9e --- /dev/null +++ b/src/webviews/copilotOnRails/extension/generateDesignPreviewHtml.ts @@ -0,0 +1,691 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type PlanContent, type PlanData, type PlanSection } from "../views/utils/parseScaffoldPlanMarkdown"; + +const HEX_COLOR_RE = /^#[0-9A-Fa-f]{3,8}$/; + +interface DesignSpec { + componentLibrary: string; + typography?: string; + styleDirection?: string; + palette: PaletteEntry[]; + pages: PageSpec[]; +} + +interface PaletteEntry { + token: string; + hex: string; +} + +interface PageSpec { + name: string; + route?: string; + regions: ParsedRegion[]; +} + +interface ParsedRegion { + token: string; + label: string; + children?: ParsedRegion[]; + orientation?: 'vertical' | 'horizontal'; +} + +/** + * Build a self-contained HTML preview from the parsed plan's Design System + * section. Returns undefined if the plan has no usable design content (no + * component library + no pages) β€” caller should skip preview generation. + */ +export function generateDesignPreviewHtml(plan: PlanData): string | undefined { + const spec = extractDesignSpec(plan); + if (!spec) { + return undefined; + } + return renderHtml(spec); +} + +/** + * Build a scoped HTML fragment (style + markup + script) for embedding the + * preview inside the plan webview. All CSS selectors are scoped under + * `.dpEmbed` so they don't leak into the host webview, and the script's + * queries / CSS-var writes target the wrapper element only. + */ +export function generateEmbeddedDesignPreviewHtml(plan: PlanData): string | undefined { + const spec = extractDesignSpec(plan); + if (!spec) { + return undefined; + } + return renderEmbeddedFragment(spec); +} + +function extractDesignSpec(plan: PlanData): DesignSpec | undefined { + const section = plan.sections.find(s => s.title.toLowerCase().includes('design system')); + if (!section) { + return undefined; + } + + const kvFor = (key: string): string | undefined => { + const found = section.content.find(c => c.type === 'keyValue' && c.key.toLowerCase() === key.toLowerCase()); + return found?.type === 'keyValue' ? found.value : undefined; + }; + + const componentLibrary = (kvFor('Component Library') ?? '').trim(); + const typography = kvFor('Typography')?.trim(); + const styleDirection = kvFor('Style Direction')?.trim(); + + const palette = extractPalette(section); + const pages = extractPages(section); + + if ((!componentLibrary || componentLibrary === 'β€”') && pages.length === 0) { + return undefined; + } + + return { + componentLibrary: componentLibrary || 'Custom CSS', + typography, + styleDirection, + palette, + pages, + }; +} + +function extractPalette(section: PlanSection): PaletteEntry[] { + const tables = section.content.filter((c): c is Extract => c.type === 'table'); + const swatchTable = tables.find(t => t.headers.length >= 2 && t.rows.some(r => HEX_COLOR_RE.test((r[1] ?? '').trim()))); + if (!swatchTable) { + return []; + } + return swatchTable.rows + .map(r => ({ token: (r[0] ?? '').trim(), hex: (r[1] ?? '').trim() })) + .filter(e => HEX_COLOR_RE.test(e.hex)); +} + +function extractPages(section: PlanSection): PageSpec[] { + const tables = section.content.filter((c): c is Extract => c.type === 'table'); + const pagesTable = tables.find(t => t.headers.some(h => h.toLowerCase() === 'layout')); + if (!pagesTable) { + return []; + } + const layoutIdx = pagesTable.headers.findIndex(h => h.toLowerCase() === 'layout'); + const routeIdx = pagesTable.headers.findIndex(h => h.toLowerCase() === 'route'); + return pagesTable.rows.map((row, i) => ({ + name: (row[0] ?? `Page ${i + 1}`).trim(), + route: routeIdx >= 0 ? stripBackticks((row[routeIdx] ?? '').trim()) : undefined, + regions: parseLayoutCell(row[layoutIdx] ?? ''), + })); +} + +function parseLayoutCell(layout: string): ParsedRegion[] { + if (!layout || layout.trim().length === 0) { + return [{ token: 'placeholder', label: 'layout TBD' }]; + } + const tokens: string[] = []; + let depth = 0; + let buf = ''; + for (const ch of layout) { + if (ch === '(') { depth++; buf += ch; continue; } + if (ch === ')') { depth--; buf += ch; continue; } + if (ch === ',' && depth === 0) { + if (buf.trim()) { tokens.push(buf.trim()); } + buf = ''; + continue; + } + buf += ch; + } + if (buf.trim()) { tokens.push(buf.trim()); } + return tokens.map(parseRegionToken).filter((r): r is ParsedRegion => r !== null); +} + +function parseRegionToken(raw: string): ParsedRegion | null { + const trimmed = raw.trim(); + if (!trimmed) { return null; } + const composite = trimmed.match(/^(two-column|split)\s*\(\s*(.+?)\s*\)$/i); + if (composite) { + const isSplit = composite[1].toLowerCase() === 'split'; + const parts = composite[2].split(isSplit ? '|' : '+').map(p => p.trim()).filter(Boolean); + return { + token: composite[1].toLowerCase(), + label: composite[0], + orientation: 'horizontal', + children: parts.map(p => ({ token: 'pane', label: p })), + }; + } + return { token: trimmed.toLowerCase(), label: trimmed }; +} + +function stripBackticks(s: string): string { + return s.replace(/^`+|`+$/g, ''); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function slugify(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'page'; +} + +function paletteVar(palette: PaletteEntry[], token: string, fallback: string): string { + const entry = palette.find(e => e.token.toLowerCase() === token.toLowerCase()); + return entry?.hex ?? fallback; +} + +function paletteSlug(token: string): string { + return token.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'token'; +} + +// Re-exported so the controller can map slugs (sent by the webview color +// picker) back to plan-palette row tokens when rewriting preview/index.html. +export { paletteSlug }; + +/** + * Rewrite the hex column of the Color Palette markdown table in-place so + * picker edits survive the next plan regeneration. We only touch rows that + * already have a bare hex in column 2 β€” header and separator lines won't + * match, and template placeholder rows like `| Primary | {#0078D4} |` are + * left alone so we never accidentally fill in a draft plan. + */ +export function rewritePaletteInPlanMarkdown( + markdown: string, + overrideBySlug: Map, +): string { + if (overrideBySlug.size === 0) { + return markdown; + } + const lines = markdown.split('\n'); + let inColorPalette = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^###\s+Color\s+Palette\b/i.test(line)) { + inColorPalette = true; + continue; + } + if (inColorPalette && /^(?:##\s|###\s|----*\s*$)/.test(line)) { + inColorPalette = false; + // Fall through so the new section header is also evaluated below. + } + if (!inColorPalette) { + continue; + } + const m = line.match(/^(\|\s*)([^|]+?)(\s*\|\s*)(#[0-9A-Fa-f]{3,8})(\s*\|.*)$/); + if (!m) { + continue; + } + const override = overrideBySlug.get(paletteSlug(m[2].trim())); + if (!override) { + continue; + } + lines[i] = `${m[1]}${m[2]}${m[3]}${override.toUpperCase()}${m[5]}`; + } + return lines.join('\n'); +} + +// `` only accepts #rrggbb. Expand #rgb shorthand, drop +// alpha from #rrggbbaa, and reject anything that doesn't look like a hex color. +function normalizeHex(hex: string): string { + const m = hex.match(/^#([0-9a-f]{3,8})$/i); + if (!m) { return '#000000'; } + const h = m[1].toLowerCase(); + if (h.length === 3) { + return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`; + } + if (h.length === 6) { + return `#${h}`; + } + if (h.length === 8) { + return `#${h.substring(0, 6)}`; + } + return '#000000'; +} + +function renderHtml(spec: DesignSpec): string { + const styles = buildPreviewCss(spec); + const body = buildPreviewBody(spec); + const script = buildPreviewScript(':root'); + + return ` + + + +Design Preview + + + +${body} +${script} + + +`; +} + +function renderEmbeddedFragment(spec: DesignSpec): string { + const scope = '.dpEmbed'; + const styles = scopeCss(buildPreviewCss(spec), scope); + const body = buildPreviewBody(spec); + + // Note: NO + `; +} + +function cssFontFamily(typography: string): string { + // Typography KV is free text. Best-effort: pull the first font name and + // append a sensible fallback stack. Anything quoted stays as-is. + const first = typography.split(',')[0].trim(); + if (!first) { + return "system-ui, -apple-system, sans-serif"; + } + const quoted = /\s/.test(first) ? `'${first.replace(/'/g, '')}'` : first; + return `${quoted}, system-ui, -apple-system, sans-serif`; +} + +function renderRegion(region: ParsedRegion, pageName: string): string { + const cls = `region region-${region.token}`; + switch (region.token) { + case 'header': + return `

${escapeHtml(pageName)}

`; + case 'nav': + return ``; + case 'sidebar': + return ``; + case 'hero': + return `

${escapeHtml(pageName)}

${escapeHtml(region.label === 'hero' ? 'A compelling one-liner about this page goes here.' : region.label)}

`; + case 'main': + return `

Main content for ${escapeHtml(pageName)}. This is placeholder body copy generated from the plan; replace with real content when scaffolding builds the page.

Use the palette and typography defined in Section 5 of the plan.

`; + case 'list': + return `
  • Item one
  • Item two
  • Item three
  • Item four
`; + case 'card-list': + return `
${[1, 2, 3, 4].map(n => `

Card ${n}

Short description of card ${n}.

`).join('')}
`; + case 'grid': + return `
${[1, 2, 3, 4, 5, 6].map(n => `
Tile ${n}
`).join('')}
`; + case 'form': + return `
`; + case 'table': + return `
${[1, 2, 3, 4].map(n => ``).join('')}
NameStatusUpdated
Row ${n}Active2 hours ago
`; + case 'actions': + case 'action-bar': + return `
`; + case 'tabs': + return `
Overview
Details
Activity
`; + case 'modal': + return `

Confirm action

This dialog is part of the planned UX for ${escapeHtml(pageName)}.

`; + case 'footer': + return `
Β© ${new Date().getFullYear()} β€” preview generated from project plan
`; + case 'two-column': + case 'split': { + const panes = (region.children ?? []).map(c => `
${escapeHtml(c.label)}

Pane content placeholder.

`).join(''); + return `
${panes}
`; + } + case 'placeholder': + return `
Layout TBD
`; + default: + return `
${escapeHtml(region.label)}
`; + } +} diff --git a/src/webviews/copilotOnRails/extension/openDesignPreview.ts b/src/webviews/copilotOnRails/extension/openDesignPreview.ts new file mode 100644 index 00000000..9d9ff30c --- /dev/null +++ b/src/webviews/copilotOnRails/extension/openDesignPreview.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from "path"; +import * as vscode from "vscode"; +import { parseScaffoldPlanMarkdown } from "../views/utils/parseScaffoldPlanMarkdown"; +import { generateDesignPreviewHtml } from "./generateDesignPreviewHtml"; + +let currentPreviewPanel: vscode.WebviewPanel | undefined; + +export async function openDesignPreviewForPlan(planUri: vscode.Uri): Promise { + let content: string; + try { + content = Buffer.from(await vscode.workspace.fs.readFile(planUri)).toString('utf-8'); + } catch (err) { + void vscode.window.showErrorMessage( + vscode.l10n.t("Couldn't read the plan file at {0}: {1}", vscode.workspace.asRelativePath(planUri), err instanceof Error ? err.message : String(err)), + ); + return; + } + + const plan = parseScaffoldPlanMarkdown(content); + const html = generateDesignPreviewHtml(plan); + if (!html) { + void vscode.window.showInformationMessage( + vscode.l10n.t("This plan has no Design System section to preview. Add Section 5 (Design System & UI) with a component library and pages, then try again."), + ); + return; + } + + const previewDir = vscode.Uri.file(path.join(path.dirname(planUri.fsPath), 'preview')); + const previewFile = vscode.Uri.joinPath(previewDir, 'index.html'); + try { + await vscode.workspace.fs.createDirectory(previewDir); + await vscode.workspace.fs.writeFile(previewFile, Buffer.from(html, 'utf-8')); + } catch (err) { + // Non-fatal β€” the webview can still render even if the on-disk file + // can't be written (e.g. read-only workspace). + void vscode.window.showWarningMessage( + vscode.l10n.t("Couldn't write preview to disk ({0}); showing in-memory preview only.", err instanceof Error ? err.message : String(err)), + ); + } + + if (currentPreviewPanel) { + currentPreviewPanel.webview.html = html; + currentPreviewPanel.reveal(vscode.ViewColumn.Beside, true); + return; + } + + currentPreviewPanel = vscode.window.createWebviewPanel( + 'azureResourceGroups.designPreview', + vscode.l10n.t('Design Preview'), + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { enableScripts: true, retainContextWhenHidden: true }, + ); + currentPreviewPanel.webview.html = html; + currentPreviewPanel.onDidDispose(() => { + currentPreviewPanel = undefined; + }); +} diff --git a/src/webviews/copilotOnRails/extension/openDesignPreviewCommand.ts b/src/webviews/copilotOnRails/extension/openDesignPreviewCommand.ts new file mode 100644 index 00000000..d28f3796 --- /dev/null +++ b/src/webviews/copilotOnRails/extension/openDesignPreviewCommand.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IActionContext } from "@microsoft/vscode-azext-utils"; +import * as vscode from "vscode"; +import { openDesignPreviewForPlan } from "./openDesignPreview"; + +export async function openDesignPreviewFromCommand(_context: IActionContext, planUri?: vscode.Uri): Promise { + if (planUri) { + await openDesignPreviewForPlan(planUri); + return; + } + + const files = await vscode.workspace.findFiles('**/project-plan.md', '**/node_modules/**', 10); + if (files.length === 0) { + void vscode.window.showInformationMessage( + vscode.l10n.t('No project-plan.md found in the workspace.'), + ); + return; + } + + let selected: vscode.Uri; + if (files.length === 1) { + selected = files[0]; + } else { + const picked = await vscode.window.showQuickPick( + files.map((f) => ({ label: vscode.workspace.asRelativePath(f), uri: f })), + { placeHolder: vscode.l10n.t('Select a plan to preview') }, + ); + if (!picked) { + return; + } + selected = picked.uri; + } + + await openDesignPreviewForPlan(selected); +} diff --git a/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx b/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx index 017c3899..fe98ba5a 100644 --- a/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx +++ b/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Spinner, Textarea } from '@fluentui/react-components'; -import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; +import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, EyeRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { generateEmbeddedDesignPreviewHtml } from '../extension/generateDesignPreviewHtml'; import './styles/scaffoldPlanView.scss'; import { type PlanContent, type PlanData, type PlanSection, type TreeNode } from './utils/parseScaffoldPlanMarkdown'; @@ -81,6 +82,7 @@ export const ScaffoldPlanView = (): JSX.Element => { } return set; }, [feedbackItems]); + useEffect(() => { const handler = (event: MessageEvent) => { const message = event.data; @@ -261,100 +263,102 @@ export const ScaffoldPlanView = (): JSX.Element => { const sections = plan.sections ?? []; const overviewSection = sections.find(s => s.number === 1); const detailSections = sections.filter(s => s.number === 2 || s.number === 3); + const designSection = sections.find(s => s.title.toLowerCase().includes('design system')); const structureSection = sections.find(s => s.title.toLowerCase().includes('project structure')); - return ( -
-
-
-
-
-

Project Plan

-
- {plan.status && plan.status !== 'Unknown' && {plan.status}} - {plan.mode && plan.mode !== 'Unknown' && {plan.mode}} -
-
-
- + return (
+
+
+
+
+

Project Plan

+
+ {plan.status && plan.status !== 'Unknown' && {plan.status}} + {plan.mode && plan.mode !== 'Unknown' && {plan.mode}}
-
- - {isAwaitingRevision && ( -
- - Copilot is revising the plan… +
+
- )} +
+
- {overviewSection && } - -
- {detailSections.map((section) => { - const sectionIdx = sections.indexOf(section); - return ( - - ); - })} + {isAwaitingRevision && ( +
+ + Copilot is revising the plan…
+ )} - {structureSection && } + {overviewSection && } + +
+ {detailSections.map((section) => { + const sectionIdx = sections.indexOf(section); + return ( + + ); + })}
- {drawerOpen && !isAwaitingRevision && ( - setDrawerOpen(false)} - /> - )} + {designSection && } - setConfirmSubmitOpen(false)} + {structureSection && } +
+ + {drawerOpen && !isAwaitingRevision && ( + setDrawerOpen(false)} /> -
+ )} + + setConfirmSubmitOpen(false)} + onSubmit={handleSubmitFeedback} + /> +
); }; @@ -668,3 +672,181 @@ const ContentBlock = ({ item, sectionIdx, contentIdx, disabled, editedCells, onT return
; } }; + +const DesignSystemCard = ({ section, plan }: { section: PlanSection; plan: PlanData }): JSX.Element | null => { + const [previewOpen, setPreviewOpen] = useState(false); + const previewRef = useRef(null); + const { vscodeApi } = useContext(WebviewContext); + + const kvFor = (key: string): string | undefined => { + const found = section.content?.find(c => c.type === 'keyValue' && c.key.toLowerCase() === key.toLowerCase()); + return found?.type === 'keyValue' ? found.value : undefined; + }; + + const componentLibrary = kvFor('Component Library'); + const typography = kvFor('Typography'); + const styleDirection = kvFor('Style Direction'); + + // Regenerate when the plan reference changes (local edits via + // structuredClone, file watcher reloads, Copilot revisions). + const previewHtml = useMemo(() => generateEmbeddedDesignPreviewHtml(plan), [plan]); + + // Inject the preview HTML and wire up its interactivity. The host webview's + // CSP (`script-src cspSource 'nonce-${nonce}'`) blocks inline