A REST API for recipes, users, ingredients, and tags. Built test-first — 82 e2e tests are RED by design. The intern's job: make them green.
- Runtime: Node.js 20+, TypeScript (strict)
- Web: Express 5
- DB: PostgreSQL +
pg(raw queries, no ORM) - Auth: JWT (
jsonwebtoken) + bcrypt - Test: Vitest + supertest
- Migrations: golang-migrate (CLI)
- Format: Biome
- Node.js 20+
- PostgreSQL (any recent version) running locally
- Two databases:
app_devandapp_test golang-migrateCLI
createdb app_dev
createdb app_testnpm install
cp .env.example .env
cp .env.example .env.test
# Edit both files: set DATABASE_URL to point to the right DB
# .env → app_dev
# .env.test → app_test| Command | What it does |
|---|---|
npm test |
Apply migrations to test DB, then run all tests |
npm run test:watch |
Vitest watch mode |
npm run build |
tsc to dist/ |
npm run migrate:up |
Apply pending migrations |
npm run migrate:down:1 |
Roll back one migration |
npm run migrate:status |
Show current migration version |
npm run migrate:create <name> |
Create new migration pair (up + down) |
npm run typecheck |
tsc --noEmit |
npm run format |
Biome format (write) |
npm run format:check |
Biome format (check only) |
npx vitest run tests/e2e/auth.test.ts
npx vitest run tests/e2e/recipes.test.tsRun a single test by name (regex match on it() title):
npx vitest run -t "returns 201 with user"Watch mode for a single file (faster reloads while implementing one endpoint):
npx vitest watch tests/e2e/auth.test.ts.
├── .env.example
├── .env.test # used by tests via dotenv-cli
├── .gitignore
├── biome.json
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── db/
│ └── migrations/
│ ├── 001_init.up.sql # ← write your schema here
│ └── 001_init.down.sql # ← write the rollback
├── src/
│ └── app.ts # Express factory (currently stub)
└── tests/
└── e2e/
├── helpers.ts # API-based seeders (no direct DB)
├── auth.test.ts # 15 tests
├── users.test.ts # 10 tests
├── recipes.test.ts # 31 tests (CRUD + filters + negative)
├── ingredients.test.ts # 10 tests
└── tags.test.ts # 16 tests
flowchart LR
subgraph Anon[Anonymous]
R1[Register]
L1[Login]
V1[View recipe]
V2[Browse recipes]
V3[Filter by tag or difficulty]
V4[View recipes by tag /tags/:slug/recipes]
P1[View public profile]
end
subgraph Author[Authenticated]
CR[Create recipe]
ER[Edit own recipe]
DR[Delete own recipe]
CI[Add / edit / delete ingredients]
CT[Create tag]
AT[Tag own recipe]
DT[Detach tag from own recipe]
ME[Get current user /auth/me]
UP[Update own profile]
end
V2 --> V3
CR --> AT
CR --> CI
ER --> CI
DR -. cascades .-> AT
DR -. cascades .-> CI
Two actors: Anonymous (can register, log in, browse, view) and Authenticated (CRUD on own recipes, manage ingredients/tags, update own profile).
- Run
npm test— all 82 tests fail (routes don't exist yet). - Pick the simplest test, e.g.
auth.test.ts→ "POST /auth/register returns 201". - Write the schema in
001_init.up.sql(users table first). - Add the matching
001_init.down.sqlrollback. - Run
npm run migrate:upto apply. - Build the route:
- validator (your choice — no Zod required, but allowed in later phases)
- service (calls
pgdirectly, hashes password with bcrypt, signs JWT) - controller (formats JSON response)
- mount in
src/app.ts
- Run
npm test— that test green, others still red. - Repeat.
Implement modules however you like (single file per resource, or split into controller/service/repo). The only requirement is that tests stay API-based — tests/e2e/helpers.ts must not be modified to query the DB directly.
All requests/responses are JSON. Auth uses Authorization: Bearer <token> (JWT).
erDiagram
users ||--o{ recipes : "authors"
recipes ||--o{ ingredients : "has"
recipes ||--o{ recipe_tags : "tagged"
tags ||--o{ recipe_tags : "applied to"
users {
bigserial id PK
text email
text password
text name
timestamptz created_at
}
recipes {
bigserial id PK
text title
text description
int cooking_time
text difficulty
bigint author_id FK
timestamptz created_at
}
ingredients {
bigserial id PK
bigint recipe_id FK
text name
text quantity
}
tags {
bigserial id PK
text name
text slug
}
recipe_tags {
bigint recipe_id FK
bigint tag_id FK
}
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [{ "path": "email", "message": "Invalid email" }] } }| Code | HTTP |
|---|---|
VALIDATION_ERROR |
400 |
UNAUTHORIZED |
401 |
FORBIDDEN |
403 |
NOT_FOUND |
404 |
CONFLICT |
409 |
INTERNAL |
500 |
| Method | Path | Auth | Body | Success |
|---|---|---|---|---|
| POST | /auth/register |
– | { email, password, name } |
201 { user, token } |
| POST | /auth/login |
– | { email, password } |
200 { user, token } |
| GET | /auth/me |
✓ | – | 200 { user } |
| GET | /users/:id |
– | – | 200 { user } |
| PATCH | /users/:id |
✓ self | { name?, email?, password?, currentPassword? } |
200 { user } |
| POST | /recipes |
✓ | { title, description?, cookingTime, difficulty } |
201 { recipe } |
| GET | /recipes |
– | query params (see Filters) | 200 { data, page, limit, total } |
| GET | /recipes/:id |
– | – | 200 { recipe, ingredients, tags } |
| PATCH | /recipes/:id |
✓ author | partial | 200 { recipe } |
| DELETE | /recipes/:id |
✓ author | – | 204 |
| POST | /recipes/:recipeId/ingredients |
✓ author | { name, quantity } |
201 { ingredient } |
| GET | /recipes/:recipeId/ingredients |
– | – | 200 { data } |
| PATCH | /ingredients/:id |
✓ author | partial | 200 { ingredient } |
| DELETE | /ingredients/:id |
✓ author | – | 204 |
| POST | /tags |
✓ | { name, slug } |
201 { tag } |
| GET | /tags |
– | – | 200 { data } |
| POST | /recipes/:recipeId/tags |
✓ author | { tagId } or { slug } |
201 { tag } |
| DELETE | /recipes/:recipeId/tags/:tagId |
✓ author | – | 204 |
| GET | /tags/:slug/recipes |
– | query params | 200 { tag, data, page, limit, total } |
difficulty:easy | medium | hardsort:createdAtorcookingTime, directionasc | desc(e.g.sort=cookingTime:asc)
| Param | Meaning |
|---|---|
difficulty |
exact match |
cookingTimeMax |
upper bound (inclusive) |
authorId |
filter by author |
tag |
repeatable; recipes must have all listed tag slugs (AND) |
search |
case-insensitive substring on title |
sort |
createdAt:desc (default), cookingTime:asc, etc. |
page |
1-based, default 1 |
limit |
default 20, max 100, min 1 |
User: { id, email, name, createdAt }
Recipe: { id, title, description, cookingTime, difficulty, authorId, createdAt }
Ingredient: { id, recipeId, name, quantity }
Tag: { id, name, slug }Passwords are never returned in responses. DB columns are snake_case (cooking_time, author_id, created_at); API responses are camelCase. Map in your repository layer.
email: valid email formatpassword: minimum 8 characterscookingTime: integer ≥ 1slug: lowercase,[a-z0-9-]+difficulty: enum- Password change: must include
currentPassword, which must match the stored hash
- No ORM. Use the
pgdriver directly. Mapsnake_casecolumns tocamelCaseresponses in your repository/service layer. - Passwords: hashed with bcrypt before storage. Never returned in responses.
- Migrations: always use
npm run migrate:create <name>. Edit both the.up.sqland.down.sqlfiles. Never edit applied migrations. - Error handling: throw or return a structured error. The response must follow the error envelope above.
- JWT: sign with the secret from
JWT_SECRET. Payload includessub(user id) andemail.