@brunosps00/dev-workflow 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/lib/constants.js +8 -0
- package/lib/install-deps.js +13 -0
- package/package.json +1 -1
- package/scaffold/en/commands/dw-deps-audit.md +326 -0
- package/scaffold/en/commands/dw-dockerize.md +321 -0
- package/scaffold/en/commands/dw-find-skills.md +158 -0
- package/scaffold/en/commands/dw-fix-qa.md +34 -13
- package/scaffold/en/commands/dw-help.md +4 -0
- package/scaffold/en/commands/dw-new-project.md +350 -0
- package/scaffold/en/commands/dw-run-qa.md +124 -23
- package/scaffold/en/templates/project-onepager.md +129 -0
- package/scaffold/pt-br/commands/dw-deps-audit.md +326 -0
- package/scaffold/pt-br/commands/dw-dockerize.md +321 -0
- package/scaffold/pt-br/commands/dw-find-skills.md +158 -0
- package/scaffold/pt-br/commands/dw-fix-qa.md +34 -13
- package/scaffold/pt-br/commands/dw-help.md +4 -0
- package/scaffold/pt-br/commands/dw-new-project.md +350 -0
- package/scaffold/pt-br/commands/dw-run-qa.md +124 -23
- package/scaffold/pt-br/templates/project-onepager.md +129 -0
- package/scaffold/skills/api-testing-recipes/SKILL.md +104 -0
- package/scaffold/skills/api-testing-recipes/recipes/dotnet-webapp-factory.md +168 -0
- package/scaffold/skills/api-testing-recipes/recipes/http-rest-client.md +130 -0
- package/scaffold/skills/api-testing-recipes/recipes/pytest-httpx.md +157 -0
- package/scaffold/skills/api-testing-recipes/recipes/rust-reqwest.md +173 -0
- package/scaffold/skills/api-testing-recipes/recipes/supertest-node.md +153 -0
- package/scaffold/skills/api-testing-recipes/references/auth-patterns.md +138 -0
- package/scaffold/skills/api-testing-recipes/references/log-conventions.md +117 -0
- package/scaffold/skills/api-testing-recipes/references/matrix-conventions.md +68 -0
- package/scaffold/skills/api-testing-recipes/references/openapi-driven.md +97 -0
- package/scaffold/skills/docker-compose-recipes/SKILL.md +84 -0
- package/scaffold/skills/docker-compose-recipes/references/compose-composition.md +91 -0
- package/scaffold/skills/docker-compose-recipes/references/env-conventions.md +51 -0
- package/scaffold/skills/docker-compose-recipes/references/healthcheck-patterns.md +54 -0
- package/scaffold/skills/docker-compose-recipes/references/prod-vs-dev.md +85 -0
- package/scaffold/skills/docker-compose-recipes/services/elasticsearch.yml +34 -0
- package/scaffold/skills/docker-compose-recipes/services/jaeger.yml +24 -0
- package/scaffold/skills/docker-compose-recipes/services/localstack.yml +30 -0
- package/scaffold/skills/docker-compose-recipes/services/mailhog.yml +23 -0
- package/scaffold/skills/docker-compose-recipes/services/mailpit.yml +27 -0
- package/scaffold/skills/docker-compose-recipes/services/meilisearch.yml +28 -0
- package/scaffold/skills/docker-compose-recipes/services/memcached.yml +19 -0
- package/scaffold/skills/docker-compose-recipes/services/minio.yml +30 -0
- package/scaffold/skills/docker-compose-recipes/services/mysql.yml +30 -0
- package/scaffold/skills/docker-compose-recipes/services/postgres.yml +30 -0
- package/scaffold/skills/docker-compose-recipes/services/rabbitmq.yml +29 -0
- package/scaffold/skills/docker-compose-recipes/services/redis.yml +25 -0
- package/scaffold/skills/docker-compose-recipes/services/smtp4dev.yml +27 -0
- package/scaffold/skills/docker-compose-recipes/services/traefik.yml +42 -0
- package/scaffold/skills/docker-compose-recipes/services/typesense.yml +25 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: project-onepager
|
|
3
|
+
schema_version: "1.0"
|
|
4
|
+
status: draft
|
|
5
|
+
date: YYYY-MM-DD
|
|
6
|
+
shape: frontend | backend | fullstack
|
|
7
|
+
languages: []
|
|
8
|
+
frameworks: { web: '', api: '' }
|
|
9
|
+
package_manager: ''
|
|
10
|
+
monorepo: ''
|
|
11
|
+
services: []
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Projeto: [Nome do projeto]
|
|
15
|
+
|
|
16
|
+
## Proposito
|
|
17
|
+
|
|
18
|
+
[Um paragrafo em linguagem de produto. Quem vai usar, qual problema resolve, como o sucesso se parece em 6-12 semanas. Evite linguagem de implementacao.]
|
|
19
|
+
|
|
20
|
+
## Stack Selecionado
|
|
21
|
+
|
|
22
|
+
| Camada | Escolha | Justificativa |
|
|
23
|
+
|--------|---------|---------------|
|
|
24
|
+
| Forma | frontend / backend / fullstack | [por que essa forma — superficie unica, API para parceiros, etc.] |
|
|
25
|
+
| Frontend | Next.js / Vite+React / n/a | [por que esse framework — SSR, simplicidade SPA, etc.] |
|
|
26
|
+
| Backend | FastAPI / ASP.NET Core minimal / Axum / Fastify / n/a | [por que — expertise do time, ecosystem, alvo de latencia] |
|
|
27
|
+
| Database | Postgres / MySQL / SQLite / MongoDB / nenhum | [por que esse DB — relacional, JSON-heavy, transacional, etc.] |
|
|
28
|
+
| Cache | Redis / Memcached / nenhum | [por que ou por que nao] |
|
|
29
|
+
| Fila | BullMQ / Celery / RabbitMQ / LocalStack SQS / nenhum | [por que ou nao + sync vs async workers] |
|
|
30
|
+
| Email — dev | MailHog (default) / Mailpit / smtp4dev / nenhum | [normalmente MailHog — captura only, nunca envia real] |
|
|
31
|
+
| Email — prod | SMTP / SendGrid / Resend / Postmark / SES / nenhum | [por que esse provider — volume, deliverability, custo] |
|
|
32
|
+
| Object storage | S3 / MinIO (dev) / GCS / nenhum | [por que ou nao] |
|
|
33
|
+
| Search | Meilisearch / Typesense / Elasticsearch / nenhum | [por que esse engine — features, escala, simplicidade] |
|
|
34
|
+
| Observability | Sentry / OTel + Jaeger / nenhum | [por que essa abordagem — so error tracking, tracing completo, etc.] |
|
|
35
|
+
| Reverse proxy | Traefik / Caddy / nenhum | [normalmente so multi-host dev ou prod] |
|
|
36
|
+
| Auth | NextAuth / Lucia / Clerk / fastapi-users / dotnet Identity / JWT custom / nenhum | [por que — social login, B2B, etc.] |
|
|
37
|
+
| Linter / formatter | Biome / ESLint+Prettier / Ruff+Black / dotnet format / cargo fmt+clippy | [preferencia do time] |
|
|
38
|
+
| Package manager | pnpm / npm / yarn / poetry / uv / cargo / dotnet | [preferencia do time] |
|
|
39
|
+
| Monorepo orchestrator | pnpm workspaces / npm workspaces / Turborepo / Nx / n/a | [so para fullstack — caching/build] |
|
|
40
|
+
| CI | GitHub Actions / nenhum | [normalmente GitHub Actions; pular so para repos nao-publicos] |
|
|
41
|
+
|
|
42
|
+
## Servicos & Infra
|
|
43
|
+
|
|
44
|
+
[Servicos gerados a partir da skill docker-compose-recipes. Preenchido pelo /dw-new-project.]
|
|
45
|
+
|
|
46
|
+
| Servico | Porta (host) | UI | Credenciais default |
|
|
47
|
+
|---------|--------------|----|--------------------|
|
|
48
|
+
| postgres | 5432 | — | POSTGRES_USER=app, POSTGRES_PASSWORD=app, POSTGRES_DB=app |
|
|
49
|
+
| redis | 6379 | — | (sem auth em dev) |
|
|
50
|
+
| mailhog | 1025 (smtp), 8025 (UI) | http://localhost:8025 | (sem auth) |
|
|
51
|
+
| ... | ... | ... | ... |
|
|
52
|
+
|
|
53
|
+
## Diagrama da Arquitetura
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
[Diagrama ASCII da forma escolhida. Exemplos:]
|
|
57
|
+
|
|
58
|
+
# So frontend
|
|
59
|
+
[ Browser ] -> [ Next.js (apps/web) ]
|
|
60
|
+
|
|
61
|
+
# Fullstack
|
|
62
|
+
[ Browser ] -> [ Next.js (apps/web) ] -> [ FastAPI (apps/api) ] -> [ Postgres ]
|
|
63
|
+
|-> [ Redis ]
|
|
64
|
+
|-> [ MailHog ]
|
|
65
|
+
|
|
66
|
+
# Com observability
|
|
67
|
+
... -> [ FastAPI ] -> { OTLP } -> [ Jaeger ]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Arquivos Gerados
|
|
71
|
+
|
|
72
|
+
[Preenchido pelo /dw-new-project apos Fase 3 — lista de arquivos criados com origem.]
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
{{TARGET_DIR}}/
|
|
76
|
+
├── apps/
|
|
77
|
+
│ ├── web/ (criado por `pnpm create next-app`)
|
|
78
|
+
│ └── api/ (scaffold inline — FastAPI)
|
|
79
|
+
├── packages/
|
|
80
|
+
│ └── shared/ (criado pelo /dw-new-project)
|
|
81
|
+
├── docker-compose.dev.yml (composto a partir de .agents/skills/docker-compose-recipes/)
|
|
82
|
+
├── .env.example (consolidado dos servicos selecionados)
|
|
83
|
+
├── .gitignore (por stack)
|
|
84
|
+
├── .dockerignore (por stack)
|
|
85
|
+
├── .github/workflows/ci.yml (CI com matrix por app)
|
|
86
|
+
├── package.json (scripts raiz: dev:up/down/logs/reset)
|
|
87
|
+
├── pnpm-workspace.yaml (se pnpm workspaces)
|
|
88
|
+
├── turbo.json (se Turborepo)
|
|
89
|
+
├── README.md (Quick Start + tabela de portas Local Dev)
|
|
90
|
+
└── .dw/
|
|
91
|
+
├── rules/index.md (seed — enriquecer depois via /dw-analyze-project)
|
|
92
|
+
└── spec/projects/<nome>.md (este arquivo)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Escopo MVP
|
|
96
|
+
|
|
97
|
+
[A primeira feature menor que voce vai entregar. Pensada como user stories — vai dirigir a primeira rodada de /dw-create-prd.]
|
|
98
|
+
|
|
99
|
+
- Como [persona], eu posso [acao] para que [beneficio]
|
|
100
|
+
- Como [persona], eu posso [acao] para que [beneficio]
|
|
101
|
+
|
|
102
|
+
Se voce ainda nao tem a primeira feature em mente, tudo bem — deixa placeholder e roda o /dw-create-prd quando tiver.
|
|
103
|
+
|
|
104
|
+
## Nao Estou Fazendo (explicito)
|
|
105
|
+
|
|
106
|
+
[Itens tentadores adiados. Forca disciplina de escopo.]
|
|
107
|
+
|
|
108
|
+
- **[item 1]** — motivo: [fora do v1 porque...]
|
|
109
|
+
- **[item 2]** — motivo: [pode virar v2 se a hipotese X validar]
|
|
110
|
+
|
|
111
|
+
## Premissas-Chave
|
|
112
|
+
|
|
113
|
+
- **[premissa sobre usuarios / mercado / escala]** — teste: [como validar]
|
|
114
|
+
- **[premissa sobre latencia / volume / SLAs]** — teste: [load profile, metrica alvo]
|
|
115
|
+
|
|
116
|
+
## Perguntas em Aberto
|
|
117
|
+
|
|
118
|
+
[O que este one-pager nao consegue responder sozinho. Resolva antes do /dw-create-prd ou escale para um stakeholder.]
|
|
119
|
+
|
|
120
|
+
- [pergunta 1]
|
|
121
|
+
- [pergunta 2]
|
|
122
|
+
|
|
123
|
+
## Proximo Passo
|
|
124
|
+
|
|
125
|
+
Escolha UM:
|
|
126
|
+
|
|
127
|
+
- **`/dw-create-prd`** — quando voce tem a primeira feature em mente e quer rascunhar o PRD em cima deste stack
|
|
128
|
+
- **`/dw-analyze-project`** — apos primeiro commit substancial, para enriquecer `.dw/rules/` com convencoes por modulo
|
|
129
|
+
- **`/dw-deps-audit --scan-only`** — para confirmar que nenhuma dep vulneravel veio dos templates `create-*`
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: api-testing-recipes
|
|
3
|
+
description: Validated API-testing snippets (.http, pytest+httpx, supertest, WebApplicationFactory, reqwest) used by /dw-run-qa and /dw-fix-qa when the project has no UI. Default format is .http (REST Client) for IDE portability.
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Write
|
|
7
|
+
- Grep
|
|
8
|
+
- Glob
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# api-testing-recipes
|
|
12
|
+
|
|
13
|
+
Curated library of **API-testing snippets** that `/dw-run-qa` and `/dw-fix-qa` use when a project is API-only (no Playwright). Each recipe is a ready-to-customize block per stack; the default is `.http` (REST Client) for maximum portability across IDEs.
|
|
14
|
+
|
|
15
|
+
## Why a skill (not inline)
|
|
16
|
+
|
|
17
|
+
- Each recipe is independently maintainable. Bumping `pytest` or `supertest` patterns is a one-file change.
|
|
18
|
+
- Discoverable by AI agents in any project the user installs dev-workflow into.
|
|
19
|
+
- Reusable by future commands (e.g., `dw-bench-api`, `dw-contract-test`) without duplication.
|
|
20
|
+
|
|
21
|
+
## When to Use
|
|
22
|
+
|
|
23
|
+
Read this skill when:
|
|
24
|
+
|
|
25
|
+
- `/dw-run-qa` detected API mode (no UI deps in the manifest) or was invoked with `--api`.
|
|
26
|
+
- `/dw-fix-qa` is retesting a bug whose `evidence_type` is `api-log`.
|
|
27
|
+
- Generating a baseline test suite from an OpenAPI spec.
|
|
28
|
+
- Authoring contract checks against a backend.
|
|
29
|
+
|
|
30
|
+
Do NOT use when:
|
|
31
|
+
|
|
32
|
+
- The project has a UI and `/dw-run-qa` is in UI mode → use Playwright MCP instead.
|
|
33
|
+
- The user wants browser-level acceptance (forms, navigation, accessibility) — that's Playwright territory.
|
|
34
|
+
|
|
35
|
+
## Available Recipes
|
|
36
|
+
|
|
37
|
+
| Format | When to use | Recipe path |
|
|
38
|
+
|--------|-------------|-------------|
|
|
39
|
+
| `.http` (REST Client) — DEFAULT | Universal. Reads in VSCode (REST Client), JetBrains (HTTP Client), Neovim (rest.nvim, kulala), Zed. Stack-agnostic. Best for projects without an existing test runner, or when devs read tests in their IDE. | `recipes/http-rest-client.md` |
|
|
40
|
+
| `pytest + httpx` | Python project (FastAPI, Starlette, Flask). Already runs `pytest` in CI. Async client matches FastAPI's async-first design. | `recipes/pytest-httpx.md` |
|
|
41
|
+
| `supertest` (Node/TS) | Node/TS project (Fastify, Express, NestJS). Already runs `vitest`/`jest`. Integrates with the app's test setup. | `recipes/supertest-node.md` |
|
|
42
|
+
| `WebApplicationFactory<T>` (.NET) | C# project (ASP.NET Core minimal API or MVC). Built-in support for in-process testing without HTTP overhead. | `recipes/dotnet-webapp-factory.md` |
|
|
43
|
+
| `reqwest + tokio::test` (Rust) | Rust project (Axum, Actix-web, Rocket). Async client matches Axum's tower-based design. | `recipes/rust-reqwest.md` |
|
|
44
|
+
|
|
45
|
+
Picking order:
|
|
46
|
+
1. Default to `.http` unless the project already has an established test runner.
|
|
47
|
+
2. If the project has a test runner (`pytest`, `vitest`, `dotnet test`, `cargo test`), prefer the stack-specific recipe so QA tests live alongside unit tests.
|
|
48
|
+
3. The user can override during the interview/run with `--format=http|pytest|supertest|dotnet|rust`.
|
|
49
|
+
|
|
50
|
+
## How to Compose
|
|
51
|
+
|
|
52
|
+
The composing command (`/dw-run-qa` API mode) follows this loop:
|
|
53
|
+
|
|
54
|
+
1. **Pick the recipe** based on the rules above.
|
|
55
|
+
2. **Read the recipe file** (`recipes/<name>.md`) for the variable conventions, test-matrix shape, and an example block.
|
|
56
|
+
3. **For each requirement (RF-XX) in the PRD**, derive a test matrix per `references/matrix-conventions.md`:
|
|
57
|
+
- 200 happy path
|
|
58
|
+
- 4xx — validation, auth, not found, conflict
|
|
59
|
+
- 5xx — server error (synthetic)
|
|
60
|
+
- Contract drift — response shape vs OpenAPI / TS types
|
|
61
|
+
- Authorization cross-tenant
|
|
62
|
+
4. **Generate** one file per RF in `{{PRD_PATH}}/QA/scripts/api/RF-XX-[slug].<ext>` using the recipe's structure. Wire credentials via the patterns in `references/auth-patterns.md` (NEVER hardcode tokens).
|
|
63
|
+
5. **Execute** each request:
|
|
64
|
+
- `.http` → `curl` (Bash) or the in-IDE runner during interactive review.
|
|
65
|
+
- Stack-specific → the project's test runner (`pytest <files>`, `vitest run <files>`, `dotnet test --filter`, `cargo test`).
|
|
66
|
+
6. **Log** every request/response per `references/log-conventions.md` to `{{PRD_PATH}}/QA/logs/api/RF-XX-[slug].log` (one JSONL line per request).
|
|
67
|
+
7. **Assert** per matrix expectation: status code, response shape (use `jq` for `.http`, framework matchers per stack), headers.
|
|
68
|
+
8. **Mark** PASS/FAIL per RF, citing the log path as evidence.
|
|
69
|
+
|
|
70
|
+
## OpenAPI-Driven Mode
|
|
71
|
+
|
|
72
|
+
If the project exposes OpenAPI (`openapi.yaml`/`openapi.json` static, or `/openapi.json` in runtime for FastAPI), follow `references/openapi-driven.md` to:
|
|
73
|
+
- Generate a baseline of 200/4xx tests per endpoint automatically.
|
|
74
|
+
- Detect contract drift by diffing live responses against the spec.
|
|
75
|
+
- Skip endpoints marked `x-internal: true` or those without examples.
|
|
76
|
+
|
|
77
|
+
## Variable Conventions
|
|
78
|
+
|
|
79
|
+
Every recipe uses three variable layers:
|
|
80
|
+
|
|
81
|
+
- **`@base`** — base URL (`http://localhost:3000` in dev). Set once per file.
|
|
82
|
+
- **`@token_admin` / `@token_user` / `@token_guest`** — credential tokens, captured from a login response or read from `.env` / `QA/test-credentials.md`.
|
|
83
|
+
- **`@<resource>_id`** — IDs created during a multi-step flow (e.g., create → fetch → update → delete on the same RF).
|
|
84
|
+
|
|
85
|
+
Per-recipe details in `references/auth-patterns.md`.
|
|
86
|
+
|
|
87
|
+
## References
|
|
88
|
+
|
|
89
|
+
- `references/matrix-conventions.md` — how to derive the {200, 4xx, 5xx, contract drift, authz cross-tenant} matrix from a PRD requirement.
|
|
90
|
+
- `references/auth-patterns.md` — how to capture and reuse JWT / cookie / API-key credentials in scripts; refresh-token patterns; scoped credentials per role.
|
|
91
|
+
- `references/openapi-driven.md` — generating a baseline test suite from an OpenAPI spec; detecting contract drift.
|
|
92
|
+
- `references/log-conventions.md` — JSONL log shape (one line per request: timestamp, method, url, status, request_headers, request_body, response_headers, response_body, ms).
|
|
93
|
+
|
|
94
|
+
## Rules
|
|
95
|
+
|
|
96
|
+
- **Default to `.http`** unless the project already has a test runner.
|
|
97
|
+
- **Never hardcode credentials**. Always use `@variable` references that resolve to env vars or files outside git.
|
|
98
|
+
- **Always log request + response** so the bug evidence is reproducible without re-running.
|
|
99
|
+
- **One file per RF**. Don't pile every requirement into one giant test file.
|
|
100
|
+
- **PASS/FAIL per RF, never per request**. A request that returns 401 when the matrix says it should is a PASS for that case.
|
|
101
|
+
|
|
102
|
+
## Inspired by
|
|
103
|
+
|
|
104
|
+
Hand-curated by dev-workflow. `.http` syntax follows the JetBrains HTTP Client / VSCode REST Client conventions. Per-stack recipes adapt patterns from each ecosystem's official testing docs (FastAPI testing tutorial, NestJS testing recipes, Microsoft.AspNetCore.Mvc.Testing docs, Axum testing examples).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Recipe: `WebApplicationFactory<T>` + xUnit (.NET)
|
|
2
|
+
|
|
3
|
+
Use for ASP.NET Core minimal API or MVC. Microsoft's official integration-testing pattern. Runs the full pipeline (DI, middleware, filters) in-process — no Kestrel port, no flake.
|
|
4
|
+
|
|
5
|
+
## File shape
|
|
6
|
+
|
|
7
|
+
`{{PRD_PATH}}/QA/scripts/api/RF_XX_[Slug]Tests.cs`
|
|
8
|
+
|
|
9
|
+
```csharp
|
|
10
|
+
using System.Net;
|
|
11
|
+
using System.Net.Http.Headers;
|
|
12
|
+
using System.Net.Http.Json;
|
|
13
|
+
using Microsoft.AspNetCore.Mvc.Testing;
|
|
14
|
+
using Xunit;
|
|
15
|
+
|
|
16
|
+
namespace YourProject.QA.Api;
|
|
17
|
+
|
|
18
|
+
public class RF_XX_CreateUserTests : IClassFixture<WebApplicationFactory<Program>>
|
|
19
|
+
{
|
|
20
|
+
private readonly WebApplicationFactory<Program> _factory;
|
|
21
|
+
private readonly string _tokenAdmin = Environment.GetEnvironmentVariable("QA_TOKEN_ADMIN") ?? "";
|
|
22
|
+
private readonly string _tokenOtherOrg = Environment.GetEnvironmentVariable("QA_TOKEN_OTHER_ORG") ?? "";
|
|
23
|
+
|
|
24
|
+
public RF_XX_CreateUserTests(WebApplicationFactory<Program> factory) => _factory = factory;
|
|
25
|
+
|
|
26
|
+
private HttpClient Client(string? token = null)
|
|
27
|
+
{
|
|
28
|
+
var c = _factory.CreateClient();
|
|
29
|
+
if (!string.IsNullOrEmpty(token))
|
|
30
|
+
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
31
|
+
return c;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private record CreateUserDto(string Email, string Name);
|
|
35
|
+
private record UserResponse(string Id, string Email, string Name, DateTime CreatedAt);
|
|
36
|
+
|
|
37
|
+
[Fact]
|
|
38
|
+
public async Task HappyPath_Returns201()
|
|
39
|
+
{
|
|
40
|
+
var r = await Client(_tokenAdmin).PostAsJsonAsync("/users",
|
|
41
|
+
new CreateUserDto($"qa-{Guid.NewGuid():N}@example.com", "QA"));
|
|
42
|
+
Assert.Equal(HttpStatusCode.Created, r.StatusCode);
|
|
43
|
+
var body = await r.Content.ReadFromJsonAsync<UserResponse>();
|
|
44
|
+
Assert.NotNull(body);
|
|
45
|
+
Assert.NotNull(body!.Id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
[Theory]
|
|
49
|
+
[InlineData("{\"name\":\"No email\"}", "email")]
|
|
50
|
+
[InlineData("{\"email\":\"no-name@x.com\"}", "name")]
|
|
51
|
+
[InlineData("{\"email\":\"not-an-email\",\"name\":\"X\"}", "email")]
|
|
52
|
+
public async Task Validation_Returns422_AndMentionsField(string payload, string field)
|
|
53
|
+
{
|
|
54
|
+
var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
|
55
|
+
var r = await Client(_tokenAdmin).PostAsync("/users", content);
|
|
56
|
+
Assert.Equal(HttpStatusCode.UnprocessableEntity, r.StatusCode);
|
|
57
|
+
var msg = await r.Content.ReadAsStringAsync();
|
|
58
|
+
Assert.Contains(field, msg.ToLower());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
[Fact]
|
|
62
|
+
public async Task NoToken_Returns401()
|
|
63
|
+
{
|
|
64
|
+
var r = await Client().PostAsJsonAsync("/users", new CreateUserDto("x@y.com", "x"));
|
|
65
|
+
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
[Fact]
|
|
69
|
+
public async Task CrossTenant_Returns403Or404()
|
|
70
|
+
{
|
|
71
|
+
if (string.IsNullOrEmpty(_tokenOtherOrg)) return;
|
|
72
|
+
// assume a known id from another tenant; in a real suite, create one in setup
|
|
73
|
+
var r = await Client(_tokenOtherOrg).GetAsync("/users/00000000-0000-0000-0000-000000000001");
|
|
74
|
+
Assert.True(r.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.NotFound);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
[Fact]
|
|
78
|
+
public async Task Contract_HasRequiredFields_NoLeaks()
|
|
79
|
+
{
|
|
80
|
+
var create = await Client(_tokenAdmin).PostAsJsonAsync("/users",
|
|
81
|
+
new CreateUserDto($"contract-{Guid.NewGuid():N}@example.com", "Contract"));
|
|
82
|
+
var created = await create.Content.ReadFromJsonAsync<UserResponse>();
|
|
83
|
+
var get = await Client(_tokenAdmin).GetAsync($"/users/{created!.Id}");
|
|
84
|
+
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
|
85
|
+
|
|
86
|
+
var raw = await get.Content.ReadAsStringAsync();
|
|
87
|
+
foreach (var field in new[] { "id", "email", "name", "created_at" })
|
|
88
|
+
Assert.Contains(field, raw, StringComparison.OrdinalIgnoreCase);
|
|
89
|
+
foreach (var leak in new[] { "password_hash", "internal_id", "_raw" })
|
|
90
|
+
Assert.DoesNotContain(leak, raw, StringComparison.OrdinalIgnoreCase);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
Project file (`*.QA.csproj` or extend the existing test project):
|
|
98
|
+
|
|
99
|
+
```xml
|
|
100
|
+
<ItemGroup>
|
|
101
|
+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.*" />
|
|
102
|
+
<PackageReference Include="xunit" Version="2.9.*" />
|
|
103
|
+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.*" />
|
|
104
|
+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.*" />
|
|
105
|
+
</ItemGroup>
|
|
106
|
+
<ItemGroup>
|
|
107
|
+
<InternalsVisibleTo Include="$(AssemblyName)" />
|
|
108
|
+
</ItemGroup>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The `Program` class must be public (for `WebApplicationFactory<Program>`). For minimal APIs, add at the bottom of `Program.cs`:
|
|
112
|
+
|
|
113
|
+
```csharp
|
|
114
|
+
public partial class Program { }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Running
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# all RF tests
|
|
121
|
+
dotnet test --filter FullyQualifiedName~YourProject.QA.Api
|
|
122
|
+
|
|
123
|
+
# one RF
|
|
124
|
+
dotnet test --filter FullyQualifiedName~RF_XX_CreateUserTests
|
|
125
|
+
|
|
126
|
+
# log to QA/logs/api/
|
|
127
|
+
dotnet test --filter FullyQualifiedName~YourProject.QA.Api \
|
|
128
|
+
--logger "console;verbosity=detailed" 2>&1 \
|
|
129
|
+
| tee "QA/logs/api/run-$(date +%F).log"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Logging request/response
|
|
133
|
+
|
|
134
|
+
Use a custom `DelegatingHandler` registered on the factory's client:
|
|
135
|
+
|
|
136
|
+
```csharp
|
|
137
|
+
public class LoggingHandler : DelegatingHandler
|
|
138
|
+
{
|
|
139
|
+
private static readonly string LogPath = "QA/logs/api/RF-XX-create-user.log";
|
|
140
|
+
|
|
141
|
+
protected override async Task<HttpResponseMessage> SendAsync(
|
|
142
|
+
HttpRequestMessage req, CancellationToken ct)
|
|
143
|
+
{
|
|
144
|
+
var sw = Stopwatch.StartNew();
|
|
145
|
+
var res = await base.SendAsync(req, ct);
|
|
146
|
+
sw.Stop();
|
|
147
|
+
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
|
148
|
+
var entry = new {
|
|
149
|
+
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
150
|
+
method = req.Method.Method,
|
|
151
|
+
url = req.RequestUri?.ToString(),
|
|
152
|
+
status = (int)res.StatusCode,
|
|
153
|
+
ms = sw.ElapsedMilliseconds,
|
|
154
|
+
};
|
|
155
|
+
await File.AppendAllTextAsync(LogPath,
|
|
156
|
+
System.Text.Json.JsonSerializer.Serialize(entry) + "\n", ct);
|
|
157
|
+
return res;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Pros / cons
|
|
163
|
+
|
|
164
|
+
- **Pro**: in-process — full DI graph, no port, deterministic.
|
|
165
|
+
- **Pro**: `[Theory]` + `[InlineData]` covers the 4xx matrix.
|
|
166
|
+
- **Pro**: same project as unit tests; `dotnet test` runs both.
|
|
167
|
+
- **Con**: requires `Program` to be partial and public.
|
|
168
|
+
- **Con**: tied to `Microsoft.AspNetCore.Mvc.Testing` package versions.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Recipe: `.http` (REST Client) — DEFAULT
|
|
2
|
+
|
|
3
|
+
Universal API-testing format. One file per RF. Read by VSCode REST Client, JetBrains HTTP Client, Neovim rest.nvim/kulala, Zed Assistant. No test runner needed.
|
|
4
|
+
|
|
5
|
+
## File shape
|
|
6
|
+
|
|
7
|
+
`{{PRD_PATH}}/QA/scripts/api/RF-XX-[slug].http`
|
|
8
|
+
|
|
9
|
+
```http
|
|
10
|
+
### RF-XX [slug] — happy path
|
|
11
|
+
# @name create_user
|
|
12
|
+
POST {{base}}/users
|
|
13
|
+
Authorization: Bearer {{token_admin}}
|
|
14
|
+
Content-Type: application/json
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"email": "qa-{{$randomInt 1 999999}}@example.com",
|
|
18
|
+
"name": "QA User"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
> {%
|
|
22
|
+
client.test("status is 201", () => client.assert(response.status === 201));
|
|
23
|
+
client.test("response has id", () => client.assert(response.body.id != null));
|
|
24
|
+
client.global.set("created_user_id", response.body.id);
|
|
25
|
+
%}
|
|
26
|
+
|
|
27
|
+
### RF-XX — 4xx validation: missing email
|
|
28
|
+
POST {{base}}/users
|
|
29
|
+
Authorization: Bearer {{token_admin}}
|
|
30
|
+
Content-Type: application/json
|
|
31
|
+
|
|
32
|
+
{ "name": "No email" }
|
|
33
|
+
|
|
34
|
+
> {%
|
|
35
|
+
client.test("status is 422", () => client.assert(response.status === 422));
|
|
36
|
+
client.test("error mentions email", () => client.assert(response.body.error.message.toLowerCase().includes("email")));
|
|
37
|
+
%}
|
|
38
|
+
|
|
39
|
+
### RF-XX — 4xx auth: missing token
|
|
40
|
+
POST {{base}}/users
|
|
41
|
+
Content-Type: application/json
|
|
42
|
+
|
|
43
|
+
{ "email": "x@y.com", "name": "x" }
|
|
44
|
+
|
|
45
|
+
> {%
|
|
46
|
+
client.test("status is 401", () => client.assert(response.status === 401));
|
|
47
|
+
%}
|
|
48
|
+
|
|
49
|
+
### RF-XX — 4xx authz: cross-tenant access
|
|
50
|
+
GET {{base}}/users/{{created_user_id}}
|
|
51
|
+
Authorization: Bearer {{token_other_org_admin}}
|
|
52
|
+
|
|
53
|
+
> {%
|
|
54
|
+
client.test("status is 403 or 404", () =>
|
|
55
|
+
client.assert(response.status === 403 || response.status === 404));
|
|
56
|
+
%}
|
|
57
|
+
|
|
58
|
+
### RF-XX — contract drift: response shape vs OpenAPI
|
|
59
|
+
GET {{base}}/users/{{created_user_id}}
|
|
60
|
+
Authorization: Bearer {{token_admin}}
|
|
61
|
+
|
|
62
|
+
> {%
|
|
63
|
+
client.test("has required fields", () => {
|
|
64
|
+
["id", "email", "name", "created_at"].forEach(f =>
|
|
65
|
+
client.assert(response.body[f] != null, `missing ${f}`));
|
|
66
|
+
});
|
|
67
|
+
client.test("no leaked internal fields", () => {
|
|
68
|
+
["password_hash", "internal_id", "_raw"].forEach(f =>
|
|
69
|
+
client.assert(response.body[f] === undefined, `leaked ${f}`));
|
|
70
|
+
});
|
|
71
|
+
%}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Variables
|
|
75
|
+
|
|
76
|
+
Set once at the top of the file (or in a `http-client.env.json` next to it):
|
|
77
|
+
|
|
78
|
+
```http
|
|
79
|
+
@base = {{$dotenv API_BASE_URL}}
|
|
80
|
+
@token_admin = {{$dotenv QA_TOKEN_ADMIN}}
|
|
81
|
+
@token_user = {{$dotenv QA_TOKEN_USER}}
|
|
82
|
+
@token_other_org_admin = {{$dotenv QA_TOKEN_OTHER_ORG}}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or, if the project uses login-based auth, capture the token in a setup request and reference it in subsequent requests:
|
|
86
|
+
|
|
87
|
+
```http
|
|
88
|
+
### Setup — login as admin
|
|
89
|
+
# @name login_admin
|
|
90
|
+
POST {{base}}/auth/login
|
|
91
|
+
Content-Type: application/json
|
|
92
|
+
|
|
93
|
+
{ "email": "{{$dotenv QA_ADMIN_EMAIL}}", "password": "{{$dotenv QA_ADMIN_PASSWORD}}" }
|
|
94
|
+
|
|
95
|
+
> {% client.global.set("token_admin", response.body.access_token); %}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Execution from `dw-run-qa` (CLI fallback)
|
|
99
|
+
|
|
100
|
+
When running outside an IDE (e.g., from the agent in headless mode), parse and execute via `curl`:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# For each ### block, extract method/url/headers/body and execute:
|
|
104
|
+
curl -sS -X POST "$BASE/users" \
|
|
105
|
+
-H "Authorization: Bearer $TOKEN_ADMIN" \
|
|
106
|
+
-H "Content-Type: application/json" \
|
|
107
|
+
-d '{"email":"qa-1@example.com","name":"QA"}' \
|
|
108
|
+
-w '\n%{http_code} %{time_total}s\n' \
|
|
109
|
+
| tee -a "QA/logs/api/RF-XX-create-user.log"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The `dw-run-qa` agent does this loop automatically and writes to the JSONL log per `references/log-conventions.md`.
|
|
113
|
+
|
|
114
|
+
## Assertions
|
|
115
|
+
|
|
116
|
+
Use the inline `> {% ... %}` post-response handler when running in an IDE. For headless `curl` execution, use `jq`:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
RESP=$(curl -sS ...)
|
|
120
|
+
STATUS=$(echo "$RESP" | head -1 | awk '{print $2}')
|
|
121
|
+
[ "$STATUS" = "201" ] || { echo "FAIL: expected 201, got $STATUS"; exit 1; }
|
|
122
|
+
echo "$RESP" | jq -e '.id != null' >/dev/null || { echo "FAIL: missing id"; exit 1; }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Pros / cons
|
|
126
|
+
|
|
127
|
+
- **Pro**: zero install, opens in any IDE, devs read it without running a test runner.
|
|
128
|
+
- **Pro**: each request is a single block, easy to copy-paste into incident tickets.
|
|
129
|
+
- **Con**: no native fixture/teardown — multi-request flows rely on `client.global.set` for state.
|
|
130
|
+
- **Con**: parallel execution requires per-block uniqueness in resource names (use `{{$randomInt}}` or `{{$timestamp}}`).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Recipe: `pytest + httpx` (Python)
|
|
2
|
+
|
|
3
|
+
Use when the project already runs `pytest` (FastAPI, Starlette, Flask + pytest-flask). The async client matches FastAPI's design and gives you fixtures + parametrize for free.
|
|
4
|
+
|
|
5
|
+
## File shape
|
|
6
|
+
|
|
7
|
+
`{{PRD_PATH}}/QA/scripts/api/test_RF_XX_[slug].py`
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
"""RF-XX [slug] — API QA suite."""
|
|
11
|
+
import os
|
|
12
|
+
import pytest
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
BASE = os.environ["API_BASE_URL"]
|
|
16
|
+
TOKEN_ADMIN = os.environ["QA_TOKEN_ADMIN"]
|
|
17
|
+
TOKEN_USER = os.environ["QA_TOKEN_USER"]
|
|
18
|
+
TOKEN_OTHER_ORG = os.environ.get("QA_TOKEN_OTHER_ORG", "")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
async def client():
|
|
23
|
+
async with httpx.AsyncClient(base_url=BASE, timeout=10.0) as c:
|
|
24
|
+
yield c
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auth(token: str) -> dict:
|
|
28
|
+
return {"Authorization": f"Bearer {token}"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------- happy path ----------
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_create_user_happy_path(client):
|
|
35
|
+
r = await client.post("/users", headers=auth(TOKEN_ADMIN),
|
|
36
|
+
json={"email": "qa@example.com", "name": "QA"})
|
|
37
|
+
assert r.status_code == 201, r.text
|
|
38
|
+
body = r.json()
|
|
39
|
+
assert body["id"]
|
|
40
|
+
assert body["email"] == "qa@example.com"
|
|
41
|
+
pytest.created_user_id = body["id"] # share via module attr or use a fixture
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------- 4xx validation ----------
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
@pytest.mark.parametrize("payload, missing_field", [
|
|
48
|
+
({"name": "No email"}, "email"),
|
|
49
|
+
({"email": "no-name@x.com"}, "name"),
|
|
50
|
+
({"email": "not-an-email", "name": "X"}, "email"),
|
|
51
|
+
])
|
|
52
|
+
async def test_create_user_validation(client, payload, missing_field):
|
|
53
|
+
r = await client.post("/users", headers=auth(TOKEN_ADMIN), json=payload)
|
|
54
|
+
assert r.status_code == 422, r.text
|
|
55
|
+
assert missing_field in r.json()["error"]["message"].lower()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------- 4xx auth ----------
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_create_user_no_token(client):
|
|
62
|
+
r = await client.post("/users", json={"email": "x@y.com", "name": "x"})
|
|
63
|
+
assert r.status_code == 401
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_create_user_expired_token(client):
|
|
68
|
+
r = await client.post("/users",
|
|
69
|
+
headers={"Authorization": "Bearer expired.token.here"},
|
|
70
|
+
json={"email": "x@y.com", "name": "x"})
|
|
71
|
+
assert r.status_code == 401
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------- 4xx authz cross-tenant ----------
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_get_user_other_org_denied(client):
|
|
78
|
+
if not TOKEN_OTHER_ORG:
|
|
79
|
+
pytest.skip("QA_TOKEN_OTHER_ORG not set")
|
|
80
|
+
r = await client.get(f"/users/{pytest.created_user_id}",
|
|
81
|
+
headers=auth(TOKEN_OTHER_ORG))
|
|
82
|
+
assert r.status_code in (403, 404)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------- contract drift ----------
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_get_user_contract(client):
|
|
89
|
+
r = await client.get(f"/users/{pytest.created_user_id}",
|
|
90
|
+
headers=auth(TOKEN_ADMIN))
|
|
91
|
+
assert r.status_code == 200
|
|
92
|
+
body = r.json()
|
|
93
|
+
for field in ("id", "email", "name", "created_at"):
|
|
94
|
+
assert field in body, f"missing {field}"
|
|
95
|
+
for leaked in ("password_hash", "internal_id", "_raw"):
|
|
96
|
+
assert leaked not in body, f"leaked {leaked}"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
`pyproject.toml` (or `pytest.ini`):
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
[tool.pytest.ini_options]
|
|
105
|
+
asyncio_mode = "auto"
|
|
106
|
+
testpaths = ["QA/scripts/api"]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Running
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# all RF tests
|
|
113
|
+
pytest QA/scripts/api/
|
|
114
|
+
|
|
115
|
+
# one RF
|
|
116
|
+
pytest QA/scripts/api/test_RF_01_create_user.py -v
|
|
117
|
+
|
|
118
|
+
# capture log to QA/logs/api/
|
|
119
|
+
pytest QA/scripts/api/ -v --tb=short 2>&1 | tee QA/logs/api/run-$(date +%F).log
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Logging request/response (for QA evidence)
|
|
123
|
+
|
|
124
|
+
Wrap `client` to log every call:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
import json, time
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
|
|
130
|
+
LOG = Path("QA/logs/api/RF-XX-create-user.log")
|
|
131
|
+
|
|
132
|
+
class LoggingClient(httpx.AsyncClient):
|
|
133
|
+
async def request(self, method, url, **kw):
|
|
134
|
+
start = time.time()
|
|
135
|
+
r = await super().request(method, url, **kw)
|
|
136
|
+
ms = int((time.time() - start) * 1000)
|
|
137
|
+
LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
with LOG.open("a") as f:
|
|
139
|
+
f.write(json.dumps({
|
|
140
|
+
"ts": time.time(),
|
|
141
|
+
"method": method,
|
|
142
|
+
"url": str(r.request.url),
|
|
143
|
+
"status": r.status_code,
|
|
144
|
+
"ms": ms,
|
|
145
|
+
"request_body": kw.get("json"),
|
|
146
|
+
"response_body": r.json() if r.headers.get("content-type", "").startswith("application/json") else None,
|
|
147
|
+
}) + "\n")
|
|
148
|
+
return r
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Pros / cons
|
|
152
|
+
|
|
153
|
+
- **Pro**: parametrize covers the 4xx matrix in one block.
|
|
154
|
+
- **Pro**: fixtures handle auth + setup/teardown cleanly.
|
|
155
|
+
- **Pro**: integrates with existing `pytest` config + CI.
|
|
156
|
+
- **Con**: not portable to non-Python projects.
|
|
157
|
+
- **Con**: requires `httpx` and `pytest-asyncio` in dev deps.
|