@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.
Files changed (73) hide show
  1. package/README.md +18 -14
  2. package/bin/dev-workflow.js +1 -1
  3. package/lib/constants.js +8 -2
  4. package/lib/init.js +6 -0
  5. package/lib/install-deps.js +0 -5
  6. package/lib/migrate-gsd.js +164 -0
  7. package/lib/uninstall.js +2 -2
  8. package/package.json +1 -1
  9. package/scaffold/en/commands/dw-analyze-project.md +6 -11
  10. package/scaffold/en/commands/dw-autopilot.md +6 -13
  11. package/scaffold/en/commands/dw-brainstorm.md +1 -1
  12. package/scaffold/en/commands/dw-code-review.md +6 -5
  13. package/scaffold/en/commands/dw-create-prd.md +5 -4
  14. package/scaffold/en/commands/dw-create-techspec.md +5 -4
  15. package/scaffold/en/commands/dw-execute-phase.md +149 -0
  16. package/scaffold/en/commands/dw-fix-qa.md +34 -13
  17. package/scaffold/en/commands/dw-help.md +5 -2
  18. package/scaffold/en/commands/dw-intel.md +98 -29
  19. package/scaffold/en/commands/dw-map-codebase.md +125 -0
  20. package/scaffold/en/commands/dw-new-project.md +1 -1
  21. package/scaffold/en/commands/dw-plan-checker.md +144 -0
  22. package/scaffold/en/commands/dw-quick.md +30 -12
  23. package/scaffold/en/commands/dw-redesign-ui.md +5 -9
  24. package/scaffold/en/commands/dw-refactoring-analysis.md +6 -5
  25. package/scaffold/en/commands/dw-resume.md +10 -8
  26. package/scaffold/en/commands/dw-run-plan.md +14 -20
  27. package/scaffold/en/commands/dw-run-qa.md +124 -23
  28. package/scaffold/en/commands/dw-run-task.md +5 -4
  29. package/scaffold/en/commands/dw-update.md +3 -1
  30. package/scaffold/en/templates/idea-onepager.md +1 -1
  31. package/scaffold/pt-br/commands/dw-analyze-project.md +6 -11
  32. package/scaffold/pt-br/commands/dw-autopilot.md +6 -13
  33. package/scaffold/pt-br/commands/dw-brainstorm.md +1 -1
  34. package/scaffold/pt-br/commands/dw-code-review.md +6 -5
  35. package/scaffold/pt-br/commands/dw-create-prd.md +5 -4
  36. package/scaffold/pt-br/commands/dw-create-techspec.md +5 -4
  37. package/scaffold/pt-br/commands/dw-execute-phase.md +149 -0
  38. package/scaffold/pt-br/commands/dw-fix-qa.md +34 -13
  39. package/scaffold/pt-br/commands/dw-help.md +5 -2
  40. package/scaffold/pt-br/commands/dw-intel.md +98 -29
  41. package/scaffold/pt-br/commands/dw-map-codebase.md +125 -0
  42. package/scaffold/pt-br/commands/dw-new-project.md +1 -1
  43. package/scaffold/pt-br/commands/dw-plan-checker.md +144 -0
  44. package/scaffold/pt-br/commands/dw-quick.md +30 -12
  45. package/scaffold/pt-br/commands/dw-redesign-ui.md +5 -9
  46. package/scaffold/pt-br/commands/dw-refactoring-analysis.md +6 -5
  47. package/scaffold/pt-br/commands/dw-resume.md +10 -8
  48. package/scaffold/pt-br/commands/dw-run-plan.md +16 -22
  49. package/scaffold/pt-br/commands/dw-run-qa.md +124 -23
  50. package/scaffold/pt-br/commands/dw-run-task.md +5 -4
  51. package/scaffold/pt-br/commands/dw-update.md +3 -1
  52. package/scaffold/pt-br/templates/idea-onepager.md +1 -1
  53. package/scaffold/skills/api-testing-recipes/SKILL.md +104 -0
  54. package/scaffold/skills/api-testing-recipes/recipes/dotnet-webapp-factory.md +168 -0
  55. package/scaffold/skills/api-testing-recipes/recipes/http-rest-client.md +130 -0
  56. package/scaffold/skills/api-testing-recipes/recipes/pytest-httpx.md +157 -0
  57. package/scaffold/skills/api-testing-recipes/recipes/rust-reqwest.md +173 -0
  58. package/scaffold/skills/api-testing-recipes/recipes/supertest-node.md +153 -0
  59. package/scaffold/skills/api-testing-recipes/references/auth-patterns.md +138 -0
  60. package/scaffold/skills/api-testing-recipes/references/log-conventions.md +117 -0
  61. package/scaffold/skills/api-testing-recipes/references/matrix-conventions.md +68 -0
  62. package/scaffold/skills/api-testing-recipes/references/openapi-driven.md +97 -0
  63. package/scaffold/skills/dw-codebase-intel/SKILL.md +101 -0
  64. package/scaffold/skills/dw-codebase-intel/agents/intel-updater.md +318 -0
  65. package/scaffold/skills/dw-codebase-intel/references/incremental-update.md +79 -0
  66. package/scaffold/skills/dw-codebase-intel/references/intel-format.md +208 -0
  67. package/scaffold/skills/dw-codebase-intel/references/query-patterns.md +148 -0
  68. package/scaffold/skills/dw-execute-phase/SKILL.md +133 -0
  69. package/scaffold/skills/dw-execute-phase/agents/executor.md +264 -0
  70. package/scaffold/skills/dw-execute-phase/agents/plan-checker.md +215 -0
  71. package/scaffold/skills/dw-execute-phase/references/atomic-commits.md +143 -0
  72. package/scaffold/skills/dw-execute-phase/references/plan-verification.md +156 -0
  73. 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.