A full PLAN → APPLY → UNIFY cycle for building a POST /api/todos endpoint with input validation.
Add a POST endpoint to an Express todo API:
- Valid body
{ "text": "Buy milk" }→ 201 + created todo - Empty text → 400 error
- Missing text field → 400 error
Objective: Implement POST /api/todos with validation.
ACs:
- AC-1:
{ "text": "Buy milk" }→ 201 +{ id, text, done: false } - AC-2:
{ "text": "" }→ 400 +{ error: "text is required" } - AC-3:
{ }→ 400 +{ error: "text is required" }
Gate check — all 3 ACs pass the 5-point checklist:
| AC | Specific Input | Observable Action | Verifiable Outcome | Independent | No Leakage |
|---|---|---|---|---|---|
| AC-1 | { text: "Buy milk" } |
POST /api/todos | 201 + body | ✅ | ✅ |
| AC-2 | { text: "" } |
POST /api/todos | 400 + error | ✅ | ✅ |
| AC-3 | { } |
POST /api/todos | 400 + error | ✅ | ✅ |
All PASS → Status: APPROVED
→ See full: .system/PLAN.md
RED:
// todos.test.js
const request = require('supertest');
const app = require('./app');
test('POST /api/todos creates a todo', async () => {
const res = await request(app)
.post('/api/todos')
.send({ text: 'Buy milk' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ text: 'Buy milk', done: false });
expect(res.body.id).toBeDefined();
});$ npx jest todos.test.js
FAIL
Expected: 201
Received: 404
RED confirmed ✅
GREEN:
// In app.js — add route
let todos = [];
let nextId = 1;
app.post('/api/todos', (req, res) => {
const todo = { id: String(nextId++), text: req.body.text, done: false };
todos.push(todo);
res.status(201).json(todo);
});PASS ✓ POST /api/todos creates a todo (15 ms)
RED:
test('POST /api/todos rejects empty text', async () => {
const res = await request(app)
.post('/api/todos')
.send({ text: '' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'text is required' });
});FAIL
Expected: 400
Received: 201
GREEN:
app.post('/api/todos', (req, res) => {
if (!req.body.text || !req.body.text.trim()) {
return res.status(400).json({ error: 'text is required' });
}
// ... existing creation logic
});PASS ✓ POST /api/todos creates a todo
✓ POST /api/todos rejects empty text
RED:
test('POST /api/todos rejects missing text', async () => {
const res = await request(app)
.post('/api/todos')
.send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'text is required' });
});FAIL
Expected: 400
Received: 201
GREEN:
Already works — !req.body.text catches undefined. No code change needed.
PASS ✓ POST /api/todos creates a todo
✓ POST /api/todos rejects empty text
✓ POST /api/todos rejects missing text
Extract validation to middleware:
// src/middleware/validate.js
function validateTodoBody(req, res, next) {
if (!req.body.text || !req.body.text.trim()) {
return res.status(400).json({ error: 'text is required' });
}
next();
}Tests still pass. Clean.
=== UNIFY ===
1. TEST SUITE:
PASS ./todos.test.js
✓ POST /api/todos creates a todo (15 ms)
✓ POST /api/todos rejects empty text (8 ms)
✓ POST /api/todos rejects missing text (5 ms)
Tests: 3 passed, 3 total
2. AC VERIFICATION:
AC-1: Given { text: "Buy milk" }, When POST, Then 201 + { id, text, done } → ✅
AC-2: Given { text: "" }, When POST, Then 400 + { error } → ✅
AC-3: Given { }, When POST, Then 400 + { error } → ✅
3. PLAN vs ACTUAL:
Planned: POST endpoint with validation
Actually done: POST + validation middleware extracted for reuse
Files changed: app.js, src/middleware/validate.js, todos.test.js
4. DEBT / DEFERRED:
- No persistence (in-memory only)
- No input sanitization (XSS risk)
- No rate limiting
5. NEXT ACTION:
Implement GET /api/todos — list all todos
Status: PASS → proceed to next PLAN
=== END UNIFY ===
→ See full: .system/STATE.md | .system/LOG.md
examples/rest-api/
├── .system/
│ ├── PLAN.md # Plan with 3 ACs + gate check (APPROVED)
│ ├── STATE.md # After UNIFY: next action = GET /api/todos
│ ├── LOG.md # 2 decisions, 3 debt items, 2 failure logs
│ └── TDD_RULES.md # Execution rules
└── README.md # This walkthrough