@brunosps00/dev-workflow 0.8.0 → 0.9.0
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 +18 -14
- package/bin/dev-workflow.js +1 -1
- package/lib/constants.js +8 -2
- package/lib/init.js +6 -0
- package/lib/install-deps.js +0 -5
- package/lib/migrate-gsd.js +164 -0
- package/lib/uninstall.js +2 -2
- package/package.json +1 -1
- package/scaffold/en/commands/dw-analyze-project.md +6 -11
- package/scaffold/en/commands/dw-autopilot.md +6 -13
- package/scaffold/en/commands/dw-brainstorm.md +1 -1
- package/scaffold/en/commands/dw-code-review.md +6 -5
- package/scaffold/en/commands/dw-create-prd.md +5 -4
- package/scaffold/en/commands/dw-create-techspec.md +5 -4
- package/scaffold/en/commands/dw-execute-phase.md +149 -0
- package/scaffold/en/commands/dw-fix-qa.md +34 -13
- package/scaffold/en/commands/dw-help.md +5 -2
- package/scaffold/en/commands/dw-intel.md +98 -29
- package/scaffold/en/commands/dw-map-codebase.md +125 -0
- package/scaffold/en/commands/dw-new-project.md +1 -1
- package/scaffold/en/commands/dw-plan-checker.md +144 -0
- package/scaffold/en/commands/dw-quick.md +30 -12
- package/scaffold/en/commands/dw-redesign-ui.md +5 -9
- package/scaffold/en/commands/dw-refactoring-analysis.md +6 -5
- package/scaffold/en/commands/dw-resume.md +10 -8
- package/scaffold/en/commands/dw-run-plan.md +14 -20
- package/scaffold/en/commands/dw-run-qa.md +124 -23
- package/scaffold/en/commands/dw-run-task.md +5 -4
- package/scaffold/en/commands/dw-update.md +3 -1
- package/scaffold/en/templates/idea-onepager.md +1 -1
- package/scaffold/pt-br/commands/dw-analyze-project.md +6 -11
- package/scaffold/pt-br/commands/dw-autopilot.md +6 -13
- package/scaffold/pt-br/commands/dw-brainstorm.md +1 -1
- package/scaffold/pt-br/commands/dw-code-review.md +6 -5
- package/scaffold/pt-br/commands/dw-create-prd.md +5 -4
- package/scaffold/pt-br/commands/dw-create-techspec.md +5 -4
- package/scaffold/pt-br/commands/dw-execute-phase.md +149 -0
- package/scaffold/pt-br/commands/dw-fix-qa.md +34 -13
- package/scaffold/pt-br/commands/dw-help.md +5 -2
- package/scaffold/pt-br/commands/dw-intel.md +98 -29
- package/scaffold/pt-br/commands/dw-map-codebase.md +125 -0
- package/scaffold/pt-br/commands/dw-new-project.md +1 -1
- package/scaffold/pt-br/commands/dw-plan-checker.md +144 -0
- package/scaffold/pt-br/commands/dw-quick.md +30 -12
- package/scaffold/pt-br/commands/dw-redesign-ui.md +5 -9
- package/scaffold/pt-br/commands/dw-refactoring-analysis.md +6 -5
- package/scaffold/pt-br/commands/dw-resume.md +10 -8
- package/scaffold/pt-br/commands/dw-run-plan.md +16 -22
- package/scaffold/pt-br/commands/dw-run-qa.md +124 -23
- package/scaffold/pt-br/commands/dw-run-task.md +5 -4
- package/scaffold/pt-br/commands/dw-update.md +3 -1
- package/scaffold/pt-br/templates/idea-onepager.md +1 -1
- 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/dw-codebase-intel/SKILL.md +101 -0
- package/scaffold/skills/dw-codebase-intel/agents/intel-updater.md +318 -0
- package/scaffold/skills/dw-codebase-intel/references/incremental-update.md +79 -0
- package/scaffold/skills/dw-codebase-intel/references/intel-format.md +208 -0
- package/scaffold/skills/dw-codebase-intel/references/query-patterns.md +148 -0
- package/scaffold/skills/dw-execute-phase/SKILL.md +133 -0
- package/scaffold/skills/dw-execute-phase/agents/executor.md +264 -0
- package/scaffold/skills/dw-execute-phase/agents/plan-checker.md +215 -0
- package/scaffold/skills/dw-execute-phase/references/atomic-commits.md +143 -0
- package/scaffold/skills/dw-execute-phase/references/plan-verification.md +156 -0
- package/scaffold/skills/dw-execute-phase/references/wave-coordination.md +102 -0
|
@@ -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.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Recipe: `reqwest + tokio::test` (Rust)
|
|
2
|
+
|
|
3
|
+
Use for Axum, Actix-web, Rocket. Async HTTP client that pairs naturally with each framework's tower / actix-rt runtime.
|
|
4
|
+
|
|
5
|
+
Two execution modes:
|
|
6
|
+
|
|
7
|
+
- **A: against a running server** — best when the project already exposes the API in dev.
|
|
8
|
+
- **B: in-process via `axum::Router::oneshot`** — fastest, no port, no flake.
|
|
9
|
+
|
|
10
|
+
## File shape (mode A — running server, framework-agnostic)
|
|
11
|
+
|
|
12
|
+
`{{PRD_PATH}}/QA/scripts/api/rf_xx_[slug].rs` (typically a `tests/` integration target):
|
|
13
|
+
|
|
14
|
+
```rust
|
|
15
|
+
// RF-XX [slug] — API QA suite
|
|
16
|
+
use reqwest::{Client, StatusCode};
|
|
17
|
+
use serde_json::{json, Value};
|
|
18
|
+
|
|
19
|
+
fn base() -> String { std::env::var("API_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()) }
|
|
20
|
+
fn token_admin() -> String { std::env::var("QA_TOKEN_ADMIN").unwrap_or_default() }
|
|
21
|
+
fn token_other() -> String { std::env::var("QA_TOKEN_OTHER_ORG").unwrap_or_default() }
|
|
22
|
+
|
|
23
|
+
fn client() -> Client {
|
|
24
|
+
Client::builder().timeout(std::time::Duration::from_secs(10)).build().unwrap()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[tokio::test]
|
|
28
|
+
async fn happy_path_returns_201() {
|
|
29
|
+
let r = client().post(format!("{}/users", base()))
|
|
30
|
+
.bearer_auth(token_admin())
|
|
31
|
+
.json(&json!({ "email": format!("qa-{}@example.com", uuid::Uuid::new_v4()), "name": "QA" }))
|
|
32
|
+
.send().await.unwrap();
|
|
33
|
+
assert_eq!(r.status(), StatusCode::CREATED);
|
|
34
|
+
let body: Value = r.json().await.unwrap();
|
|
35
|
+
assert!(body.get("id").is_some());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[tokio::test]
|
|
39
|
+
async fn validation_missing_email_returns_422() {
|
|
40
|
+
let r = client().post(format!("{}/users", base()))
|
|
41
|
+
.bearer_auth(token_admin())
|
|
42
|
+
.json(&json!({ "name": "No email" }))
|
|
43
|
+
.send().await.unwrap();
|
|
44
|
+
assert_eq!(r.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
45
|
+
let body: Value = r.json().await.unwrap();
|
|
46
|
+
let msg = body["error"]["message"].as_str().unwrap_or_default().to_lowercase();
|
|
47
|
+
assert!(msg.contains("email"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[tokio::test]
|
|
51
|
+
async fn no_token_returns_401() {
|
|
52
|
+
let r = client().post(format!("{}/users", base()))
|
|
53
|
+
.json(&json!({ "email": "x@y.com", "name": "x" }))
|
|
54
|
+
.send().await.unwrap();
|
|
55
|
+
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[tokio::test]
|
|
59
|
+
async fn cross_tenant_denied() {
|
|
60
|
+
if token_other().is_empty() { return; }
|
|
61
|
+
let r = client().get(format!("{}/users/{}", base(), "00000000-0000-0000-0000-000000000001"))
|
|
62
|
+
.bearer_auth(token_other())
|
|
63
|
+
.send().await.unwrap();
|
|
64
|
+
assert!(matches!(r.status(), StatusCode::FORBIDDEN | StatusCode::NOT_FOUND));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[tokio::test]
|
|
68
|
+
async fn contract_required_fields_present_no_leaks() {
|
|
69
|
+
let create = client().post(format!("{}/users", base()))
|
|
70
|
+
.bearer_auth(token_admin())
|
|
71
|
+
.json(&json!({ "email": format!("c-{}@example.com", uuid::Uuid::new_v4()), "name": "C" }))
|
|
72
|
+
.send().await.unwrap();
|
|
73
|
+
let created: Value = create.json().await.unwrap();
|
|
74
|
+
let id = created["id"].as_str().unwrap();
|
|
75
|
+
|
|
76
|
+
let get = client().get(format!("{}/users/{}", base(), id))
|
|
77
|
+
.bearer_auth(token_admin())
|
|
78
|
+
.send().await.unwrap();
|
|
79
|
+
assert_eq!(get.status(), StatusCode::OK);
|
|
80
|
+
let body: Value = get.json().await.unwrap();
|
|
81
|
+
|
|
82
|
+
for f in ["id", "email", "name", "created_at"] { assert!(body.get(f).is_some(), "missing {f}"); }
|
|
83
|
+
for leak in ["password_hash", "internal_id", "_raw"] {
|
|
84
|
+
assert!(body.get(leak).is_none(), "leaked {leak}");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## File shape (mode B — Axum in-process via `Router::oneshot`)
|
|
90
|
+
|
|
91
|
+
```rust
|
|
92
|
+
use axum::body::Body;
|
|
93
|
+
use axum::http::{Request, StatusCode};
|
|
94
|
+
use http_body_util::BodyExt;
|
|
95
|
+
use my_app::build_router;
|
|
96
|
+
use serde_json::{json, Value};
|
|
97
|
+
use tower::util::ServiceExt;
|
|
98
|
+
|
|
99
|
+
#[tokio::test]
|
|
100
|
+
async fn happy_path_oneshot() {
|
|
101
|
+
let app = build_router().await;
|
|
102
|
+
let req = Request::post("/users")
|
|
103
|
+
.header("authorization", "Bearer test-admin")
|
|
104
|
+
.header("content-type", "application/json")
|
|
105
|
+
.body(Body::from(json!({"email":"qa@example.com","name":"QA"}).to_string()))
|
|
106
|
+
.unwrap();
|
|
107
|
+
let res = app.oneshot(req).await.unwrap();
|
|
108
|
+
assert_eq!(res.status(), StatusCode::CREATED);
|
|
109
|
+
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
|
110
|
+
let body: Value = serde_json::from_slice(&bytes).unwrap();
|
|
111
|
+
assert!(body.get("id").is_some());
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`build_router()` is the project's exported async function that returns the `axum::Router` — same one used in `main.rs`.
|
|
116
|
+
|
|
117
|
+
## Configuration
|
|
118
|
+
|
|
119
|
+
`Cargo.toml`:
|
|
120
|
+
|
|
121
|
+
```toml
|
|
122
|
+
[dev-dependencies]
|
|
123
|
+
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
|
124
|
+
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
|
125
|
+
serde_json = "1"
|
|
126
|
+
uuid = { version = "1", features = ["v4"] }
|
|
127
|
+
# mode B (Axum):
|
|
128
|
+
http-body-util = "0.1"
|
|
129
|
+
tower = { version = "0.5", features = ["util"] }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Running
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# all
|
|
136
|
+
cargo test --test rf_xx_create_user -- --nocapture
|
|
137
|
+
|
|
138
|
+
# log to QA/logs/api/
|
|
139
|
+
cargo test --test rf_xx_create_user -- --nocapture 2>&1 \
|
|
140
|
+
| tee "QA/logs/api/run-$(date +%F).log"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Logging request/response
|
|
144
|
+
|
|
145
|
+
`reqwest` doesn't ship a logging middleware out of the box. Two options:
|
|
146
|
+
|
|
147
|
+
- **Wrap at the test layer**: small helper that calls `client.execute(req)` and writes JSONL.
|
|
148
|
+
- **Tower middleware in mode B**: insert a `tower::Layer` that logs request/response. Reuse the project's tracing/logging layer if it has one.
|
|
149
|
+
|
|
150
|
+
```rust
|
|
151
|
+
use std::fs::OpenOptions;
|
|
152
|
+
use std::io::Write;
|
|
153
|
+
|
|
154
|
+
async fn log_request(method: &str, url: &str, status: u16, ms: u128, body: &str) {
|
|
155
|
+
std::fs::create_dir_all("QA/logs/api").ok();
|
|
156
|
+
let mut f = OpenOptions::new().create(true).append(true)
|
|
157
|
+
.open("QA/logs/api/RF-XX-create-user.log").unwrap();
|
|
158
|
+
let entry = serde_json::json!({
|
|
159
|
+
"ts": chrono::Utc::now().timestamp_millis(),
|
|
160
|
+
"method": method, "url": url, "status": status, "ms": ms,
|
|
161
|
+
"response_body": body,
|
|
162
|
+
});
|
|
163
|
+
writeln!(f, "{entry}").ok();
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Pros / cons
|
|
168
|
+
|
|
169
|
+
- **Pro (mode B)**: in-process, no port, fastest, deterministic.
|
|
170
|
+
- **Pro**: `tokio::test` integrates with the project's existing `cargo test` flow.
|
|
171
|
+
- **Pro**: type-safe assertions on response bodies.
|
|
172
|
+
- **Con (mode A)**: depends on the server being up before `cargo test`.
|
|
173
|
+
- **Con (mode B)**: requires the project to expose a `build_router()` factory.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Recipe: `supertest` (Node.js / TypeScript)
|
|
2
|
+
|
|
3
|
+
Use for Fastify, Express, NestJS projects that already run `vitest` or `jest`. supertest binds directly to the app instance and runs in-process — no port allocation, no flake.
|
|
4
|
+
|
|
5
|
+
## File shape
|
|
6
|
+
|
|
7
|
+
`{{PRD_PATH}}/QA/scripts/api/RF-XX-[slug].test.ts`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// RF-XX [slug] — API QA suite
|
|
11
|
+
import request from 'supertest';
|
|
12
|
+
import { describe, expect, it, beforeAll } from 'vitest';
|
|
13
|
+
import { buildApp } from '../../../src/app'; // or: import app from '../../../src/server';
|
|
14
|
+
|
|
15
|
+
const BASE = process.env.API_BASE_URL ?? 'http://localhost:3000';
|
|
16
|
+
const TOKEN_ADMIN = process.env.QA_TOKEN_ADMIN ?? '';
|
|
17
|
+
const TOKEN_USER = process.env.QA_TOKEN_USER ?? '';
|
|
18
|
+
const TOKEN_OTHER_ORG = process.env.QA_TOKEN_OTHER_ORG ?? '';
|
|
19
|
+
|
|
20
|
+
let app: Awaited<ReturnType<typeof buildApp>>;
|
|
21
|
+
let createdUserId: string;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => { app = await buildApp(); });
|
|
24
|
+
|
|
25
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
26
|
+
|
|
27
|
+
describe('RF-XX create user', () => {
|
|
28
|
+
|
|
29
|
+
it('happy path returns 201 and id', async () => {
|
|
30
|
+
const r = await request(app.server).post('/users')
|
|
31
|
+
.set(auth(TOKEN_ADMIN))
|
|
32
|
+
.send({ email: `qa-${Date.now()}@example.com`, name: 'QA' });
|
|
33
|
+
expect(r.status).toBe(201);
|
|
34
|
+
expect(r.body.id).toBeDefined();
|
|
35
|
+
createdUserId = r.body.id;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it.each([
|
|
39
|
+
[{ name: 'No email' }, 'email'],
|
|
40
|
+
[{ email: 'no-name@x.com' }, 'name'],
|
|
41
|
+
[{ email: 'not-an-email', name: 'X' }, 'email'],
|
|
42
|
+
])('validation: %j → mentions %s', async (payload, missing) => {
|
|
43
|
+
const r = await request(app.server).post('/users')
|
|
44
|
+
.set(auth(TOKEN_ADMIN))
|
|
45
|
+
.send(payload);
|
|
46
|
+
expect(r.status).toBe(422);
|
|
47
|
+
expect(r.body.error.message.toLowerCase()).toContain(missing);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('no token returns 401', async () => {
|
|
51
|
+
const r = await request(app.server).post('/users')
|
|
52
|
+
.send({ email: 'x@y.com', name: 'x' });
|
|
53
|
+
expect(r.status).toBe(401);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('expired token returns 401', async () => {
|
|
57
|
+
const r = await request(app.server).post('/users')
|
|
58
|
+
.set({ Authorization: 'Bearer expired.token.here' })
|
|
59
|
+
.send({ email: 'x@y.com', name: 'x' });
|
|
60
|
+
expect(r.status).toBe(401);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('cross-tenant access denied', async () => {
|
|
64
|
+
if (!TOKEN_OTHER_ORG) return;
|
|
65
|
+
const r = await request(app.server).get(`/users/${createdUserId}`)
|
|
66
|
+
.set(auth(TOKEN_OTHER_ORG));
|
|
67
|
+
expect([403, 404]).toContain(r.status);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('contract: required fields present, leaked fields absent', async () => {
|
|
71
|
+
const r = await request(app.server).get(`/users/${createdUserId}`)
|
|
72
|
+
.set(auth(TOKEN_ADMIN));
|
|
73
|
+
expect(r.status).toBe(200);
|
|
74
|
+
for (const f of ['id', 'email', 'name', 'created_at']) {
|
|
75
|
+
expect(r.body[f]).toBeDefined();
|
|
76
|
+
}
|
|
77
|
+
for (const f of ['password_hash', 'internal_id', '_raw']) {
|
|
78
|
+
expect(r.body[f]).toBeUndefined();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
`vitest.config.ts`:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { defineConfig } from 'vitest/config';
|
|
90
|
+
export default defineConfig({
|
|
91
|
+
test: {
|
|
92
|
+
include: ['QA/scripts/api/**/*.test.ts'],
|
|
93
|
+
testTimeout: 10_000,
|
|
94
|
+
hookTimeout: 30_000,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Running
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# all
|
|
103
|
+
pnpm vitest run QA/scripts/api
|
|
104
|
+
|
|
105
|
+
# one RF
|
|
106
|
+
pnpm vitest run QA/scripts/api/RF-01-create-user.test.ts
|
|
107
|
+
|
|
108
|
+
# log to QA/logs/api/
|
|
109
|
+
pnpm vitest run QA/scripts/api 2>&1 | tee "QA/logs/api/run-$(date +%F).log"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Logging request/response
|
|
113
|
+
|
|
114
|
+
Wrap the supertest agent in a small helper that emits to JSONL:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import fs from 'node:fs';
|
|
118
|
+
|
|
119
|
+
const LOG = 'QA/logs/api/RF-XX-create-user.log';
|
|
120
|
+
fs.mkdirSync('QA/logs/api', { recursive: true });
|
|
121
|
+
|
|
122
|
+
function logRequest(method: string, url: string, status: number, ms: number, reqBody: unknown, resBody: unknown) {
|
|
123
|
+
fs.appendFileSync(LOG, JSON.stringify({
|
|
124
|
+
ts: Date.now(), method, url, status, ms, request_body: reqBody, response_body: resBody,
|
|
125
|
+
}) + '\n');
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## NestJS variant
|
|
130
|
+
|
|
131
|
+
Use `@nestjs/testing`'s `Test.createTestingModule(...)` + `app.getHttpServer()` instead of `buildApp`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { Test } from '@nestjs/testing';
|
|
135
|
+
import { AppModule } from '../../../src/app.module';
|
|
136
|
+
|
|
137
|
+
let app: INestApplication;
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
140
|
+
app = moduleRef.createNestApplication();
|
|
141
|
+
await app.init();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// then: request(app.getHttpServer()) ...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Pros / cons
|
|
148
|
+
|
|
149
|
+
- **Pro**: in-process, no port allocation, fastest possible.
|
|
150
|
+
- **Pro**: integrates with `vitest`/`jest` watch + coverage.
|
|
151
|
+
- **Pro**: `it.each` covers the 4xx matrix in one block.
|
|
152
|
+
- **Con**: only works against a `supertest`-compatible HTTP framework.
|
|
153
|
+
- **Con**: requires the `buildApp` factory pattern; one-off scripts/handlers may need refactor.
|