create-simone-app 0.1.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 +70 -0
- package/bin/create-simone-app.ts +6 -0
- package/package.json +61 -0
- package/src/cli.ts +97 -0
- package/src/matrix.ts +39 -0
- package/src/options.ts +137 -0
- package/src/prompts.ts +108 -0
- package/src/scaffold.ts +304 -0
- package/src/templates.ts +77 -0
- package/templates/api/base/.agents/skills/ai-rules/SKILL.md +12 -0
- package/templates/api/base/.agents/skills/plan-feature/SKILL.md +13 -0
- package/templates/api/base/.agents/skills/review-diff/SKILL.md +12 -0
- package/templates/api/base/.agents/skills/tdd-step/SKILL.md +14 -0
- package/templates/api/base/.claude/agents/reviewer.md +20 -0
- package/templates/api/base/.claude/hooks/format.sh +22 -0
- package/templates/api/base/.claude/settings.json +38 -0
- package/templates/api/base/.codex/agents/reviewer.toml +7 -0
- package/templates/api/base/.codex/config.toml +10 -0
- package/templates/api/base/.codex/hooks.json +12 -0
- package/templates/api/base/.env.example +2 -0
- package/templates/api/base/.github/workflows/ci.yml +18 -0
- package/templates/api/base/.prettierignore +10 -0
- package/templates/api/base/.prettierrc +20 -0
- package/templates/api/base/CLAUDE.md +1 -0
- package/templates/api/base/README.md +43 -0
- package/templates/api/base/TASK.md.template +30 -0
- package/templates/api/base/_gitignore +8 -0
- package/templates/api/base/docs/agents/base.md +54 -0
- package/templates/api/base/eslint.config.js +21 -0
- package/templates/api/base/openapi.yaml +233 -0
- package/templates/api/base/package.json +41 -0
- package/templates/api/base/scripts/agents-build.ts +26 -0
- package/templates/api/base/scripts/rules-check.ts +48 -0
- package/templates/api/base/scripts/rules-sync.ts +35 -0
- package/templates/api/base/scripts/rules.config.ts +28 -0
- package/templates/api/base/scripts/seed-regen.ts +64 -0
- package/templates/api/base/src/data/orders.json +187 -0
- package/templates/api/base/src/data/products.json +142 -0
- package/templates/api/base/src/data/repository.ts +55 -0
- package/templates/api/base/src/data/schemas.ts +44 -0
- package/templates/api/base/src/data/users.json +352 -0
- package/templates/api/base/src/lib/appName.ts +2 -0
- package/templates/api/base/src/lib/http-error.ts +14 -0
- package/templates/api/base/src/lib/pagination.ts +55 -0
- package/templates/api/base/src/modules/orders/orders.repository.ts +26 -0
- package/templates/api/base/src/modules/orders/orders.schema.ts +11 -0
- package/templates/api/base/src/modules/products/products.repository.ts +17 -0
- package/templates/api/base/src/modules/products/products.schema.ts +21 -0
- package/templates/api/base/src/modules/stats/stats.repository.ts +37 -0
- package/templates/api/base/src/modules/users/users.repository.ts +26 -0
- package/templates/api/base/src/modules/users/users.schema.ts +15 -0
- package/templates/api/base/test/docs.test.ts +23 -0
- package/templates/api/base/test/health.test.ts +19 -0
- package/templates/api/base/test/orders.test.ts +82 -0
- package/templates/api/base/test/products.test.ts +54 -0
- package/templates/api/base/test/repository.test.ts +45 -0
- package/templates/api/base/test/smoke.test.ts +7 -0
- package/templates/api/base/test/stats.test.ts +40 -0
- package/templates/api/base/test/users.test.ts +84 -0
- package/templates/api/base/tsconfig.json +22 -0
- package/templates/api/base/vitest.config.ts +16 -0
- package/templates/api/variants/features/no-github-ci/variant.json +1 -0
- package/templates/api/variants/framework/express/docs/agents/framework-express.md +9 -0
- package/templates/api/variants/framework/express/src/app.ts +37 -0
- package/templates/api/variants/framework/express/src/middleware/errorHandler.ts +13 -0
- package/templates/api/variants/framework/express/src/middleware/validate.ts +59 -0
- package/templates/api/variants/framework/express/src/modules/docs/docs.router.ts +15 -0
- package/templates/api/variants/framework/express/src/modules/health/health.router.ts +7 -0
- package/templates/api/variants/framework/express/src/modules/orders/orders.router.ts +14 -0
- package/templates/api/variants/framework/express/src/modules/products/products.router.ts +24 -0
- package/templates/api/variants/framework/express/src/modules/stats/stats.router.ts +9 -0
- package/templates/api/variants/framework/express/src/modules/users/users.router.ts +24 -0
- package/templates/api/variants/framework/express/src/server.ts +8 -0
- package/templates/api/variants/framework/express/test/errorHandler.test.ts +89 -0
- package/templates/api/variants/framework/express/variant.json +15 -0
- package/templates/fe/base/.agents/skills/ai-rules/SKILL.md +12 -0
- package/templates/fe/base/.agents/skills/plan-feature/SKILL.md +13 -0
- package/templates/fe/base/.agents/skills/review-diff/SKILL.md +12 -0
- package/templates/fe/base/.agents/skills/tdd-step/SKILL.md +14 -0
- package/templates/fe/base/.claude/agents/reviewer.md +21 -0
- package/templates/fe/base/.claude/hooks/format.sh +22 -0
- package/templates/fe/base/.claude/settings.json +41 -0
- package/templates/fe/base/.codex/agents/reviewer.toml +7 -0
- package/templates/fe/base/.codex/config.toml +10 -0
- package/templates/fe/base/.codex/hooks.json +12 -0
- package/templates/fe/base/.env.example +2 -0
- package/templates/fe/base/.github/workflows/ci.yml +37 -0
- package/templates/fe/base/.prettierignore +14 -0
- package/templates/fe/base/.prettierrc +20 -0
- package/templates/fe/base/CLAUDE.md +1 -0
- package/templates/fe/base/README.md +37 -0
- package/templates/fe/base/TASK.md.template +31 -0
- package/templates/fe/base/_gitignore +11 -0
- package/templates/fe/base/docs/agents/base.md +69 -0
- package/templates/fe/base/e2e/users.spec.ts +82 -0
- package/templates/fe/base/eslint.config.js +169 -0
- package/templates/fe/base/index.html +13 -0
- package/templates/fe/base/package.json +63 -0
- package/templates/fe/base/playwright.config.ts +17 -0
- package/templates/fe/base/public/favicon.svg +1 -0
- package/templates/fe/base/public/icons.svg +24 -0
- package/templates/fe/base/scripts/agents-build.ts +26 -0
- package/templates/fe/base/scripts/rules-check.ts +48 -0
- package/templates/fe/base/scripts/rules-sync.ts +35 -0
- package/templates/fe/base/scripts/rules.config.ts +35 -0
- package/templates/fe/base/src/components/shared/EmptyState.tsx +23 -0
- package/templates/fe/base/src/components/shared/ErrorState.tsx +37 -0
- package/templates/fe/base/src/components/shared/PageHeader.tsx +21 -0
- package/templates/fe/base/src/features/users/UserSearch.tsx +48 -0
- package/templates/fe/base/src/features/users/UsersPage.tsx +58 -0
- package/templates/fe/base/src/features/users/UsersTable.tsx +245 -0
- package/templates/fe/base/src/features/users/UsersTableSkeleton.tsx +16 -0
- package/templates/fe/base/src/features/users/__tests__/UserSearch.test.tsx +30 -0
- package/templates/fe/base/src/features/users/__tests__/UsersPage.test.tsx +69 -0
- package/templates/fe/base/src/features/users/__tests__/UsersTable.test.tsx +120 -0
- package/templates/fe/base/src/features/users/__tests__/useUsers.test.tsx +62 -0
- package/templates/fe/base/src/features/users/useUsers.ts +22 -0
- package/templates/fe/base/src/features/users/users.api.ts +22 -0
- package/templates/fe/base/src/features/users/users.schema.ts +43 -0
- package/templates/fe/base/src/lib/apiClient.test.ts +56 -0
- package/templates/fe/base/src/lib/apiClient.ts +42 -0
- package/templates/fe/base/src/lib/appName.ts +2 -0
- package/templates/fe/base/src/lib/env.test.ts +21 -0
- package/templates/fe/base/src/lib/env.ts +26 -0
- package/templates/fe/base/src/lib/queryClient.ts +15 -0
- package/templates/fe/base/src/lib/router.ts +11 -0
- package/templates/fe/base/src/main.tsx +21 -0
- package/templates/fe/base/src/routeTree.gen.ts +77 -0
- package/templates/fe/base/src/routes/__root.tsx +83 -0
- package/templates/fe/base/src/routes/index.tsx +26 -0
- package/templates/fe/base/src/routes/users/index.tsx +24 -0
- package/templates/fe/base/src/stores/seam.test.ts +21 -0
- package/templates/fe/base/src/test/api-mock.ts +22 -0
- package/templates/fe/base/src/test/fixtures/users.ts +39 -0
- package/templates/fe/base/src/test/msw/handlers.ts +9 -0
- package/templates/fe/base/src/test/msw/server.ts +5 -0
- package/templates/fe/base/src/test/render.tsx +45 -0
- package/templates/fe/base/src/test/setup.ts +15 -0
- package/templates/fe/base/tsconfig.json +25 -0
- package/templates/fe/base/vite.config.ts +22 -0
- package/templates/fe/base/vitest.config.ts +21 -0
- package/templates/fe/variants/features/no-github-ci/variant.json +1 -0
- package/templates/fe/variants/features/no-msw/src/test/api-mock.ts +52 -0
- package/templates/fe/variants/features/no-msw/src/test/setup.ts +13 -0
- package/templates/fe/variants/features/no-msw/variant.json +4 -0
- package/templates/fe/variants/features/no-playwright/.github/workflows/ci.yml +19 -0
- package/templates/fe/variants/features/no-playwright/variant.json +5 -0
- package/templates/fe/variants/state/jotai/docs/agents/state-jotai.md +5 -0
- package/templates/fe/variants/state/jotai/src/stores/index.ts +15 -0
- package/templates/fe/variants/state/jotai/src/stores/ui.atoms.test.tsx +29 -0
- package/templates/fe/variants/state/jotai/src/stores/ui.atoms.ts +39 -0
- package/templates/fe/variants/state/jotai/variant.json +12 -0
- package/templates/fe/variants/state/zustand/docs/agents/state-zustand.md +5 -0
- package/templates/fe/variants/state/zustand/src/stores/index.ts +12 -0
- package/templates/fe/variants/state/zustand/src/stores/ui.store.test.ts +34 -0
- package/templates/fe/variants/state/zustand/src/stores/ui.store.ts +62 -0
- package/templates/fe/variants/state/zustand/variant.json +5 -0
- package/templates/fe/variants/ui/base-ui/components.json +25 -0
- package/templates/fe/variants/ui/base-ui/docs/agents/ui-base-ui.md +7 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/alert.tsx +75 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/button.tsx +57 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/input.tsx +19 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/skeleton.tsx +13 -0
- package/templates/fe/variants/ui/base-ui/src/components/ui/table.tsx +113 -0
- package/templates/fe/variants/ui/base-ui/src/index.css +130 -0
- package/templates/fe/variants/ui/base-ui/src/lib/utils.test.ts +10 -0
- package/templates/fe/variants/ui/base-ui/src/lib/utils.ts +1 -0
- package/templates/fe/variants/ui/base-ui/variant.json +23 -0
- package/templates/fe/variants/ui/radix/components.json +25 -0
- package/templates/fe/variants/ui/radix/docs/agents/ui-radix.md +7 -0
- package/templates/fe/variants/ui/radix/src/components/ui/alert.tsx +75 -0
- package/templates/fe/variants/ui/radix/src/components/ui/button.tsx +66 -0
- package/templates/fe/variants/ui/radix/src/components/ui/input.tsx +18 -0
- package/templates/fe/variants/ui/radix/src/components/ui/skeleton.tsx +13 -0
- package/templates/fe/variants/ui/radix/src/components/ui/table.tsx +113 -0
- package/templates/fe/variants/ui/radix/src/index.css +130 -0
- package/templates/fe/variants/ui/radix/src/lib/utils.test.ts +10 -0
- package/templates/fe/variants/ui/radix/src/lib/utils.ts +1 -0
- package/templates/fe/variants/ui/radix/variant.json +23 -0
- package/templates/fullstack/.github/workflows/ci.yml +18 -0
- package/templates/fullstack/AGENTS.md +7 -0
- package/templates/fullstack/CLAUDE.md +1 -0
- package/templates/fullstack/README.md +16 -0
- package/templates/fullstack/_gitignore +11 -0
- package/templates/fullstack/package.json +16 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import request from 'supertest'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { createApp } from '../src/app'
|
|
5
|
+
import {
|
|
6
|
+
ordersRepository,
|
|
7
|
+
productsRepository,
|
|
8
|
+
usersRepository,
|
|
9
|
+
} from '../src/data/repository'
|
|
10
|
+
|
|
11
|
+
const app = createApp()
|
|
12
|
+
const pick = <T>(rows: readonly T[], i: number): T => {
|
|
13
|
+
const row = rows[i]
|
|
14
|
+
if (!row) throw new Error('empty fixture')
|
|
15
|
+
return row
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('POST /api/orders', () => {
|
|
19
|
+
beforeEach(() => ordersRepository.reset())
|
|
20
|
+
|
|
21
|
+
it('creates an order with server-side prices and total → 201', async () => {
|
|
22
|
+
const user = pick(usersRepository.all(), 0)
|
|
23
|
+
const p1 = pick(productsRepository.all(), 0)
|
|
24
|
+
const p2 = pick(productsRepository.all(), 1)
|
|
25
|
+
const res = await request(app)
|
|
26
|
+
.post('/api/orders')
|
|
27
|
+
.send({
|
|
28
|
+
userId: user.id,
|
|
29
|
+
items: [
|
|
30
|
+
{ productId: p1.id, qty: 2 },
|
|
31
|
+
{ productId: p2.id, qty: 1 },
|
|
32
|
+
],
|
|
33
|
+
})
|
|
34
|
+
expect(res.status).toBe(201)
|
|
35
|
+
expect(res.body).toMatchObject({
|
|
36
|
+
userId: user.id,
|
|
37
|
+
items: [
|
|
38
|
+
{ productId: p1.id, qty: 2, unitPrice: p1.price },
|
|
39
|
+
{ productId: p2.id, qty: 1, unitPrice: p2.price },
|
|
40
|
+
],
|
|
41
|
+
total: Math.round((p1.price * 2 + p2.price) * 100) / 100,
|
|
42
|
+
})
|
|
43
|
+
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/)
|
|
44
|
+
expect(ordersRepository.all()).toHaveLength(11)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('qty < 1 → 422', async () => {
|
|
48
|
+
const user = pick(usersRepository.all(), 0)
|
|
49
|
+
const p1 = pick(productsRepository.all(), 0)
|
|
50
|
+
const res = await request(app)
|
|
51
|
+
.post('/api/orders')
|
|
52
|
+
.send({ userId: user.id, items: [{ productId: p1.id, qty: 0 }] })
|
|
53
|
+
expect(res.status).toBe(422)
|
|
54
|
+
expect(res.body.details[0].path).toBe('items.0.qty')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('empty items → 422', async () => {
|
|
58
|
+
const res = await request(app)
|
|
59
|
+
.post('/api/orders')
|
|
60
|
+
.send({ userId: 'x', items: [] })
|
|
61
|
+
expect(res.status).toBe(422)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('unknown user → 404', async () => {
|
|
65
|
+
const p1 = pick(productsRepository.all(), 0)
|
|
66
|
+
const res = await request(app)
|
|
67
|
+
.post('/api/orders')
|
|
68
|
+
.send({ userId: 'ghost', items: [{ productId: p1.id, qty: 1 }] })
|
|
69
|
+
expect(res.status).toBe(404)
|
|
70
|
+
expect(res.body).toEqual({ error: 'User ghost not found' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('unknown product → 404, nothing persisted', async () => {
|
|
74
|
+
const user = pick(usersRepository.all(), 0)
|
|
75
|
+
const res = await request(app)
|
|
76
|
+
.post('/api/orders')
|
|
77
|
+
.send({ userId: user.id, items: [{ productId: 'ghost', qty: 1 }] })
|
|
78
|
+
expect(res.status).toBe(404)
|
|
79
|
+
expect(res.body).toEqual({ error: 'Product ghost not found' })
|
|
80
|
+
expect(ordersRepository.all()).toHaveLength(10)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import request from 'supertest'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { createApp } from '../src/app'
|
|
5
|
+
import { productsRepository } from '../src/data/repository'
|
|
6
|
+
|
|
7
|
+
const app = createApp()
|
|
8
|
+
|
|
9
|
+
describe('GET /api/products', () => {
|
|
10
|
+
it('returns the list contract with all 20 products', async () => {
|
|
11
|
+
const res = await request(app).get('/api/products')
|
|
12
|
+
expect(res.status).toBe(200)
|
|
13
|
+
expect(res.body).toMatchObject({ page: 1, limit: 20, total: 20 })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('category filters', async () => {
|
|
17
|
+
const category = productsRepository.all()[0]?.category
|
|
18
|
+
if (!category) throw new Error('empty fixture')
|
|
19
|
+
const res = await request(app).get(`/api/products?category=${category}`)
|
|
20
|
+
expect(res.body.total).toBe(
|
|
21
|
+
productsRepository.all().filter((p) => p.category === category).length,
|
|
22
|
+
)
|
|
23
|
+
for (const p of res.body.data) expect(p.category).toBe(category)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('unknown category → 422', async () => {
|
|
27
|
+
const res = await request(app).get('/api/products?category=weapons')
|
|
28
|
+
expect(res.status).toBe(422)
|
|
29
|
+
expect(res.body.details[0].path).toBe('category')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('sort=-price sorts numerically descending', async () => {
|
|
33
|
+
const res = await request(app).get('/api/products?sort=-price')
|
|
34
|
+
const prices: number[] = res.body.data.map(
|
|
35
|
+
(p: { price: number }) => p.price,
|
|
36
|
+
)
|
|
37
|
+
expect(prices).toEqual([...prices].sort((a, b) => b - a))
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('GET /api/products/:id', () => {
|
|
42
|
+
it('returns the product', async () => {
|
|
43
|
+
const target = productsRepository.all()[3]
|
|
44
|
+
if (!target) throw new Error('empty fixture')
|
|
45
|
+
const res = await request(app).get(`/api/products/${target.id}`)
|
|
46
|
+
expect(res.body).toEqual(target)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('unknown id → 404', async () => {
|
|
50
|
+
const res = await request(app).get('/api/products/nope')
|
|
51
|
+
expect(res.status).toBe(404)
|
|
52
|
+
expect(res.body).toEqual({ error: 'Product nope not found' })
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ordersRepository,
|
|
5
|
+
productsRepository,
|
|
6
|
+
usersRepository,
|
|
7
|
+
} from '../src/data/repository'
|
|
8
|
+
|
|
9
|
+
describe('repository', () => {
|
|
10
|
+
beforeEach(() => ordersRepository.reset())
|
|
11
|
+
|
|
12
|
+
it('loads the committed fixtures', () => {
|
|
13
|
+
expect(usersRepository.all()).toHaveLength(50)
|
|
14
|
+
expect(productsRepository.all()).toHaveLength(20)
|
|
15
|
+
expect(ordersRepository.all()).toHaveLength(10)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('findById returns the row or undefined', () => {
|
|
19
|
+
const first = usersRepository.all()[0]
|
|
20
|
+
if (!first) throw new Error('empty fixture')
|
|
21
|
+
expect(usersRepository.findById(first.id)).toEqual(first)
|
|
22
|
+
expect(usersRepository.findById('missing')).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('insert validates and reset reloads the fixture', () => {
|
|
26
|
+
const order = ordersRepository.all()[0]
|
|
27
|
+
if (!order) throw new Error('empty fixture')
|
|
28
|
+
ordersRepository.insert({
|
|
29
|
+
...order,
|
|
30
|
+
id: 'b2c3d4e5-0000-4000-8000-000000000000',
|
|
31
|
+
})
|
|
32
|
+
expect(ordersRepository.all()).toHaveLength(11)
|
|
33
|
+
expect(() => ordersRepository.insert({ ...order, total: -1 })).toThrow()
|
|
34
|
+
ordersRepository.reset()
|
|
35
|
+
expect(ordersRepository.all()).toHaveLength(10)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('orders reference existing users and products', () => {
|
|
39
|
+
for (const order of ordersRepository.all()) {
|
|
40
|
+
expect(usersRepository.findById(order.userId)).toBeDefined()
|
|
41
|
+
for (const item of order.items)
|
|
42
|
+
expect(productsRepository.findById(item.productId)).toBeDefined()
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import request from 'supertest'
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { createApp } from '../src/app'
|
|
5
|
+
import {
|
|
6
|
+
ordersRepository,
|
|
7
|
+
productsRepository,
|
|
8
|
+
usersRepository,
|
|
9
|
+
} from '../src/data/repository'
|
|
10
|
+
|
|
11
|
+
const app = createApp()
|
|
12
|
+
|
|
13
|
+
describe('GET /api/stats', () => {
|
|
14
|
+
beforeEach(() => ordersRepository.reset())
|
|
15
|
+
|
|
16
|
+
it('counts, revenue and top category from the fixtures', async () => {
|
|
17
|
+
const res = await request(app).get('/api/stats')
|
|
18
|
+
expect(res.status).toBe(200)
|
|
19
|
+
expect(res.body).toMatchObject({ users: 50, products: 20, orders: 10 })
|
|
20
|
+
const revenue =
|
|
21
|
+
Math.round(
|
|
22
|
+
ordersRepository.all().reduce((s, o) => s + o.total, 0) * 100,
|
|
23
|
+
) / 100
|
|
24
|
+
expect(res.body.revenue).toBe(revenue)
|
|
25
|
+
expect(productsRepository.all().map((p) => p.category)).toContain(
|
|
26
|
+
res.body.topCategory,
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('reflects new orders', async () => {
|
|
31
|
+
const user = usersRepository.all()[0]
|
|
32
|
+
const product = productsRepository.all()[0]
|
|
33
|
+
if (!user || !product) throw new Error('empty fixture')
|
|
34
|
+
await request(app)
|
|
35
|
+
.post('/api/orders')
|
|
36
|
+
.send({ userId: user.id, items: [{ productId: product.id, qty: 1 }] })
|
|
37
|
+
const res = await request(app).get('/api/stats')
|
|
38
|
+
expect(res.body.orders).toBe(11)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import request from 'supertest'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { createApp } from '../src/app'
|
|
5
|
+
import { usersRepository } from '../src/data/repository'
|
|
6
|
+
|
|
7
|
+
const app = createApp()
|
|
8
|
+
|
|
9
|
+
describe('GET /api/users', () => {
|
|
10
|
+
it('defaults to page 1, limit 20 and returns the list contract', async () => {
|
|
11
|
+
const res = await request(app).get('/api/users')
|
|
12
|
+
expect(res.status).toBe(200)
|
|
13
|
+
expect(res.body).toMatchObject({ page: 1, limit: 20, total: 50 })
|
|
14
|
+
expect(res.body.data).toHaveLength(20)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('paginates', async () => {
|
|
18
|
+
const res = await request(app).get('/api/users?page=3&limit=20')
|
|
19
|
+
expect(res.body.data).toHaveLength(10)
|
|
20
|
+
expect(res.body.page).toBe(3)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('q filters by name or email, case-insensitive', async () => {
|
|
24
|
+
const target = usersRepository.all()[0]
|
|
25
|
+
if (!target) throw new Error('empty fixture')
|
|
26
|
+
const res = await request(app).get(
|
|
27
|
+
`/api/users?q=${encodeURIComponent(target.email.toUpperCase())}`,
|
|
28
|
+
)
|
|
29
|
+
expect(res.status).toBe(200)
|
|
30
|
+
expect(res.body.total).toBeGreaterThanOrEqual(1)
|
|
31
|
+
expect(res.body.data.map((u: { id: string }) => u.id)).toContain(target.id)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('q with no match → empty data, total 0', async () => {
|
|
35
|
+
const res = await request(app).get('/api/users?q=zzzzzzzzzz')
|
|
36
|
+
expect(res.body).toMatchObject({ data: [], total: 0 })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('sort=-name sorts descending', async () => {
|
|
40
|
+
const res = await request(app).get('/api/users?sort=-name&limit=100')
|
|
41
|
+
const names: string[] = res.body.data.map((u: { name: string }) => u.name)
|
|
42
|
+
expect(names).toEqual([...names].sort((a, b) => b.localeCompare(a)))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('sort=createdAt sorts ascending', async () => {
|
|
46
|
+
const res = await request(app).get('/api/users?sort=createdAt&limit=100')
|
|
47
|
+
const dates: string[] = res.body.data.map(
|
|
48
|
+
(u: { createdAt: string }) => u.createdAt,
|
|
49
|
+
)
|
|
50
|
+
expect(dates).toEqual([...dates].sort())
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('limit > 100 → 422', async () => {
|
|
54
|
+
const res = await request(app).get('/api/users?limit=101')
|
|
55
|
+
expect(res.status).toBe(422)
|
|
56
|
+
expect(res.body.error).toBe('Invalid query')
|
|
57
|
+
expect(res.body.details[0].path).toBe('limit')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('unknown sort field → 422', async () => {
|
|
61
|
+
const res = await request(app).get('/api/users?sort=password')
|
|
62
|
+
expect(res.status).toBe(422)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('GET /api/users/:id', () => {
|
|
67
|
+
it('returns the user', async () => {
|
|
68
|
+
const target = usersRepository.all()[5]
|
|
69
|
+
if (!target) throw new Error('empty fixture')
|
|
70
|
+
const res = await request(app).get(`/api/users/${target.id}`)
|
|
71
|
+
expect(res.status).toBe(200)
|
|
72
|
+
expect(res.body).toEqual(target)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('unknown id → 404 typed', async () => {
|
|
76
|
+
const res = await request(app).get(
|
|
77
|
+
'/api/users/00000000-0000-4000-8000-000000000000',
|
|
78
|
+
)
|
|
79
|
+
expect(res.status).toBe(404)
|
|
80
|
+
expect(res.body).toEqual({
|
|
81
|
+
error: 'User 00000000-0000-4000-8000-000000000000 not found',
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"lib": ["ESNext"],
|
|
8
|
+
"types": ["bun"],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"noUnusedLocals": true,
|
|
12
|
+
"noUnusedParameters": true,
|
|
13
|
+
"noImplicitOverride": true,
|
|
14
|
+
"noFallthroughCasesInSwitch": true,
|
|
15
|
+
"verbatimModuleSyntax": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"allowImportingTsExtensions": true,
|
|
18
|
+
"noEmit": true,
|
|
19
|
+
"skipLibCheck": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["src", "test", "scripts", "*.ts", "eslint.config.js"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: ['test/**/*.test.ts'],
|
|
6
|
+
env: { LOG_LEVEL: 'silent' },
|
|
7
|
+
// CI runners can stall the first supertest request; 5s is too tight for a cold process.
|
|
8
|
+
testTimeout: 15_000,
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: 'v8',
|
|
11
|
+
include: ['src/**/*.ts'],
|
|
12
|
+
exclude: ['src/server.ts'],
|
|
13
|
+
thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "remove": [".github"] }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Backend conventions (Express 5)
|
|
2
|
+
|
|
3
|
+
- `src/app.ts` exports `createApp()` (no listen); `src/server.ts` listens on `PORT` (default 4000). Tests mount `createApp()` on supertest.
|
|
4
|
+
- One folder per module in `src/modules/<name>/` with `<name>.schema.ts` (zod), `<name>.repository.ts`, `<name>.router.ts`.
|
|
5
|
+
- Errors: throw `HttpError(status, message, details?)` from `src/lib/http-error.ts`; `errorHandler` renders `{ error, details? }`. Unknown errors → 500 without leaking the message.
|
|
6
|
+
- Input: wrap handlers in `validate({ query, body, params }, handler)` from `src/middleware/validate.ts`; invalid input → 422 `{ error: 'Invalid query', details: [{ path, message }] }`.
|
|
7
|
+
- Status codes: 200 read, 201 create, 404 missing resource, 422 validation, 500 unexpected. Nothing else without a reason in the commit body.
|
|
8
|
+
- `src/` uses only `node:*` APIs (Vitest runs on Node); `Bun.*` is allowed in `scripts/` only.
|
|
9
|
+
- `openapi.yaml` is hand-written and served parsed at `GET /docs.json`; update it in the same commit as the route.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import cors from 'cors'
|
|
2
|
+
import express, { type Express } from 'express'
|
|
3
|
+
import helmet from 'helmet'
|
|
4
|
+
import { pinoHttp } from 'pino-http'
|
|
5
|
+
|
|
6
|
+
import { HttpError } from './lib/http-error'
|
|
7
|
+
import { errorHandler } from './middleware/errorHandler'
|
|
8
|
+
import { docsRouter } from './modules/docs/docs.router'
|
|
9
|
+
import { healthRouter } from './modules/health/health.router'
|
|
10
|
+
import { ordersRouter } from './modules/orders/orders.router'
|
|
11
|
+
import { productsRouter } from './modules/products/products.router'
|
|
12
|
+
import { statsRouter } from './modules/stats/stats.router'
|
|
13
|
+
import { usersRouter } from './modules/users/users.router'
|
|
14
|
+
|
|
15
|
+
/** Builds the app without listening: tests mount it on supertest, server.ts on a port. */
|
|
16
|
+
export const createApp = (): Express => {
|
|
17
|
+
const app = express()
|
|
18
|
+
|
|
19
|
+
app.use(helmet())
|
|
20
|
+
app.use(cors())
|
|
21
|
+
app.use(express.json())
|
|
22
|
+
app.use(pinoHttp({ level: process.env.LOG_LEVEL ?? 'info' }))
|
|
23
|
+
|
|
24
|
+
app.use('/api/health', healthRouter)
|
|
25
|
+
app.use('/api/users', usersRouter)
|
|
26
|
+
app.use('/api/products', productsRouter)
|
|
27
|
+
app.use('/api/orders', ordersRouter)
|
|
28
|
+
app.use('/api/stats', statsRouter)
|
|
29
|
+
app.use('/docs.json', docsRouter)
|
|
30
|
+
|
|
31
|
+
app.use((req, _res, next) => {
|
|
32
|
+
next(new HttpError(404, `Route ${req.method} ${req.path} not found`))
|
|
33
|
+
})
|
|
34
|
+
app.use(errorHandler)
|
|
35
|
+
|
|
36
|
+
return app
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ErrorRequestHandler } from 'express'
|
|
2
|
+
|
|
3
|
+
import { HttpError } from '../lib/http-error'
|
|
4
|
+
|
|
5
|
+
/** Last middleware: stable error shape `{ error, details? }`. Unknown errors → 500. */
|
|
6
|
+
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
|
7
|
+
if (err instanceof HttpError) {
|
|
8
|
+
res.status(err.status).json({ error: err.message, details: err.details })
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
req.log?.error(err)
|
|
12
|
+
res.status(500).json({ error: 'Internal Server Error' })
|
|
13
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Request, RequestHandler, Response } from 'express'
|
|
2
|
+
import type { ZodType } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { HttpError } from '../lib/http-error'
|
|
5
|
+
|
|
6
|
+
interface Schemas<Q, B, P> {
|
|
7
|
+
query?: ZodType<Q>
|
|
8
|
+
body?: ZodType<B>
|
|
9
|
+
params?: ZodType<P>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Validated<Q, B, P> {
|
|
13
|
+
query: Q
|
|
14
|
+
body: B
|
|
15
|
+
params: P
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Handler<Q, B, P> = (
|
|
19
|
+
input: Validated<Q, B, P>,
|
|
20
|
+
req: Request,
|
|
21
|
+
res: Response,
|
|
22
|
+
) => Promise<void> | void
|
|
23
|
+
|
|
24
|
+
const parse = <T>(
|
|
25
|
+
schema: ZodType<T> | undefined,
|
|
26
|
+
raw: unknown,
|
|
27
|
+
where: string,
|
|
28
|
+
): T => {
|
|
29
|
+
if (!schema) return raw as T
|
|
30
|
+
const result = schema.safeParse(raw)
|
|
31
|
+
if (result.success) return result.data
|
|
32
|
+
throw new HttpError(
|
|
33
|
+
422,
|
|
34
|
+
`Invalid ${where}`,
|
|
35
|
+
result.error.issues.map((i) => ({
|
|
36
|
+
path: i.path.join('.'),
|
|
37
|
+
message: i.message,
|
|
38
|
+
})),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates query/body/params with zod and hands the typed result to the handler.
|
|
44
|
+
* Invalid input → 422 `{ error: 'Invalid query', details: [{ path, message }] }`.
|
|
45
|
+
* Express 5 makes `req.query` read-only, so the parsed values travel via `input`.
|
|
46
|
+
*/
|
|
47
|
+
export const validate =
|
|
48
|
+
<Q = unknown, B = unknown, P = unknown>(
|
|
49
|
+
schemas: Schemas<Q, B, P>,
|
|
50
|
+
handler: Handler<Q, B, P>,
|
|
51
|
+
): RequestHandler =>
|
|
52
|
+
async (req, res) => {
|
|
53
|
+
const input: Validated<Q, B, P> = {
|
|
54
|
+
query: parse(schemas.query, req.query, 'query'),
|
|
55
|
+
body: parse(schemas.body, req.body, 'body'),
|
|
56
|
+
params: parse(schemas.params, req.params, 'params'),
|
|
57
|
+
}
|
|
58
|
+
await handler(input, req, res)
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { parse } from 'yaml'
|
|
4
|
+
|
|
5
|
+
// openapi.yaml is hand-written; parsed once at startup with `yaml` (no native YAML import).
|
|
6
|
+
// node:fs, not Bun.file: Vitest runs src/ on Node, Bun APIs stay in scripts/.
|
|
7
|
+
const spec: unknown = parse(
|
|
8
|
+
readFileSync(new URL('../../../openapi.yaml', import.meta.url), 'utf8'),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export const docsRouter = Router()
|
|
12
|
+
|
|
13
|
+
docsRouter.get('/', (_req, res) => {
|
|
14
|
+
res.json(spec)
|
|
15
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
|
|
3
|
+
import { validate } from '../../middleware/validate'
|
|
4
|
+
import { createOrder } from './orders.repository'
|
|
5
|
+
import { CreateOrderBodySchema } from './orders.schema'
|
|
6
|
+
|
|
7
|
+
export const ordersRouter = Router()
|
|
8
|
+
|
|
9
|
+
ordersRouter.post(
|
|
10
|
+
'/',
|
|
11
|
+
validate({ body: CreateOrderBodySchema }, ({ body }, _req, res) => {
|
|
12
|
+
res.status(201).json(createOrder(body))
|
|
13
|
+
}),
|
|
14
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
|
|
3
|
+
import { notFound } from '../../lib/http-error'
|
|
4
|
+
import { validate } from '../../middleware/validate'
|
|
5
|
+
import { findProduct, searchProducts } from './products.repository'
|
|
6
|
+
import { ProductIdParamsSchema, ProductsQuerySchema } from './products.schema'
|
|
7
|
+
|
|
8
|
+
export const productsRouter = Router()
|
|
9
|
+
|
|
10
|
+
productsRouter.get(
|
|
11
|
+
'/',
|
|
12
|
+
validate({ query: ProductsQuerySchema }, ({ query }, _req, res) => {
|
|
13
|
+
res.json(searchProducts(query))
|
|
14
|
+
}),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
productsRouter.get(
|
|
18
|
+
'/:id',
|
|
19
|
+
validate({ params: ProductIdParamsSchema }, ({ params }, _req, res) => {
|
|
20
|
+
const product = findProduct(params.id)
|
|
21
|
+
if (!product) throw notFound('Product', params.id)
|
|
22
|
+
res.json(product)
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
|
|
3
|
+
import { notFound } from '../../lib/http-error'
|
|
4
|
+
import { validate } from '../../middleware/validate'
|
|
5
|
+
import { findUser, searchUsers } from './users.repository'
|
|
6
|
+
import { UserIdParamsSchema, UsersQuerySchema } from './users.schema'
|
|
7
|
+
|
|
8
|
+
export const usersRouter = Router()
|
|
9
|
+
|
|
10
|
+
usersRouter.get(
|
|
11
|
+
'/',
|
|
12
|
+
validate({ query: UsersQuerySchema }, ({ query }, _req, res) => {
|
|
13
|
+
res.json(searchUsers(query))
|
|
14
|
+
}),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
usersRouter.get(
|
|
18
|
+
'/:id',
|
|
19
|
+
validate({ params: UserIdParamsSchema }, ({ params }, _req, res) => {
|
|
20
|
+
const user = findUser(params.id)
|
|
21
|
+
if (!user) throw notFound('User', params.id)
|
|
22
|
+
res.json(user)
|
|
23
|
+
}),
|
|
24
|
+
)
|