@basicbenframework/create 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/index.js +205 -0
- package/package.json +30 -0
- package/template/.env.example +24 -0
- package/template/README.md +59 -0
- package/template/basicben.config.js +33 -0
- package/template/index.html +54 -0
- package/template/migrations/001_create_users.js +15 -0
- package/template/migrations/002_create_posts.js +18 -0
- package/template/public/.gitkeep +0 -0
- package/template/seeds/01_users.js +29 -0
- package/template/seeds/02_posts.js +43 -0
- package/template/src/client/components/Alert.jsx +11 -0
- package/template/src/client/components/Avatar.jsx +11 -0
- package/template/src/client/components/BackLink.jsx +10 -0
- package/template/src/client/components/Button.jsx +19 -0
- package/template/src/client/components/Card.jsx +10 -0
- package/template/src/client/components/Empty.jsx +6 -0
- package/template/src/client/components/Input.jsx +12 -0
- package/template/src/client/components/Loading.jsx +6 -0
- package/template/src/client/components/Logo.jsx +40 -0
- package/template/src/client/components/Nav/DarkModeToggle.jsx +23 -0
- package/template/src/client/components/Nav/DesktopNav.jsx +32 -0
- package/template/src/client/components/Nav/MobileNav.jsx +107 -0
- package/template/src/client/components/NavLink.jsx +10 -0
- package/template/src/client/components/PageHeader.jsx +8 -0
- package/template/src/client/components/PostCard.jsx +19 -0
- package/template/src/client/components/Textarea.jsx +12 -0
- package/template/src/client/components/ThemeContext.jsx +5 -0
- package/template/src/client/contexts/ToastContext.jsx +94 -0
- package/template/src/client/layouts/AppLayout.jsx +60 -0
- package/template/src/client/layouts/AuthLayout.jsx +33 -0
- package/template/src/client/layouts/DocsLayout.jsx +60 -0
- package/template/src/client/layouts/RootLayout.jsx +25 -0
- package/template/src/client/pages/Auth.jsx +55 -0
- package/template/src/client/pages/Authentication.jsx +236 -0
- package/template/src/client/pages/Database.jsx +426 -0
- package/template/src/client/pages/Feed.jsx +34 -0
- package/template/src/client/pages/FeedPost.jsx +37 -0
- package/template/src/client/pages/GettingStarted.jsx +136 -0
- package/template/src/client/pages/Home.jsx +206 -0
- package/template/src/client/pages/PostForm.jsx +69 -0
- package/template/src/client/pages/Posts.jsx +59 -0
- package/template/src/client/pages/Profile.jsx +68 -0
- package/template/src/client/pages/Routing.jsx +207 -0
- package/template/src/client/pages/Testing.jsx +251 -0
- package/template/src/client/pages/Validation.jsx +210 -0
- package/template/src/controllers/AuthController.js +81 -0
- package/template/src/controllers/HomeController.js +17 -0
- package/template/src/controllers/PostController.js +86 -0
- package/template/src/controllers/ProfileController.js +66 -0
- package/template/src/helpers/api.js +24 -0
- package/template/src/main.jsx +9 -0
- package/template/src/middleware/auth.js +16 -0
- package/template/src/models/Post.js +63 -0
- package/template/src/models/User.js +42 -0
- package/template/src/routes/App.jsx +38 -0
- package/template/src/routes/api/auth.js +7 -0
- package/template/src/routes/api/posts.js +15 -0
- package/template/src/routes/api/profile.js +8 -0
- package/template/src/server/index.js +16 -0
- package/template/vite.config.js +18 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useTheme } from '../components/ThemeContext'
|
|
2
|
+
import { Card } from '../components/Card'
|
|
3
|
+
import { PageHeader } from '../components/PageHeader'
|
|
4
|
+
import { AppLayout } from '../layouts/AppLayout'
|
|
5
|
+
import { DocsLayout } from '../layouts/DocsLayout'
|
|
6
|
+
|
|
7
|
+
export function Routing() {
|
|
8
|
+
const { t } = useTheme()
|
|
9
|
+
|
|
10
|
+
const CodeBlock = ({ children, title }) => (
|
|
11
|
+
<div className="mt-4">
|
|
12
|
+
{title && <div className={`text-xs font-medium mb-2 ${t.muted}`}>{title}</div>}
|
|
13
|
+
<div className={`rounded-lg p-4 font-mono text-sm ${t.card} border ${t.border} overflow-x-auto`}>
|
|
14
|
+
<pre className={t.text}>{children}</pre>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<PageHeader
|
|
22
|
+
title="Routing"
|
|
23
|
+
subtitle="API routes, controllers, and middleware"
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
<div className="space-y-6">
|
|
27
|
+
<Card>
|
|
28
|
+
<h2 className="text-lg font-semibold mb-2">Route Files</h2>
|
|
29
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
30
|
+
Routes are defined in the <code>src/routes/</code> directory. Each file exports route definitions that map HTTP methods to handlers.
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<CodeBlock title="src/routes/posts.js">
|
|
34
|
+
{`import { PostController } from '../controllers/PostController.js'
|
|
35
|
+
import { auth } from '../middleware/auth.js'
|
|
36
|
+
|
|
37
|
+
export default [
|
|
38
|
+
{ method: 'GET', path: '/api/posts', handler: PostController.index, middleware: [auth] },
|
|
39
|
+
{ method: 'GET', path: '/api/posts/:id', handler: PostController.show },
|
|
40
|
+
{ method: 'POST', path: '/api/posts', handler: PostController.store, middleware: [auth] },
|
|
41
|
+
{ method: 'PUT', path: '/api/posts/:id', handler: PostController.update, middleware: [auth] },
|
|
42
|
+
{ method: 'DELETE', path: '/api/posts/:id', handler: PostController.destroy, middleware: [auth] },
|
|
43
|
+
]`}
|
|
44
|
+
</CodeBlock>
|
|
45
|
+
|
|
46
|
+
<CodeBlock title="Generate a route file">
|
|
47
|
+
{`npx basicben make:route posts`}
|
|
48
|
+
</CodeBlock>
|
|
49
|
+
</Card>
|
|
50
|
+
|
|
51
|
+
<Card>
|
|
52
|
+
<h2 className="text-lg font-semibold mb-2">Controllers</h2>
|
|
53
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
54
|
+
Controllers handle the business logic for your routes. They receive the request and return a response.
|
|
55
|
+
</p>
|
|
56
|
+
|
|
57
|
+
<CodeBlock title="src/controllers/PostController.js">
|
|
58
|
+
{`import { db } from 'basicben'
|
|
59
|
+
|
|
60
|
+
export const PostController = {
|
|
61
|
+
// GET /api/posts
|
|
62
|
+
index: async (req, res) => {
|
|
63
|
+
const posts = await (await db.table('posts'))
|
|
64
|
+
.where('user_id', req.user.id)
|
|
65
|
+
.orderBy('created_at', 'DESC')
|
|
66
|
+
.get()
|
|
67
|
+
|
|
68
|
+
return res.json({ posts })
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// GET /api/posts/:id
|
|
72
|
+
show: async (req, res) => {
|
|
73
|
+
const post = await (await db.table('posts')).find(req.params.id)
|
|
74
|
+
|
|
75
|
+
if (!post) {
|
|
76
|
+
return res.status(404).json({ error: 'Post not found' })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return res.json({ post })
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// POST /api/posts
|
|
83
|
+
store: async (req, res) => {
|
|
84
|
+
const { title, content } = req.body
|
|
85
|
+
|
|
86
|
+
const result = await (await db.table('posts')).insert({
|
|
87
|
+
title,
|
|
88
|
+
content,
|
|
89
|
+
user_id: req.user.id
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return res.status(201).json({
|
|
93
|
+
id: result.lastInsertRowid
|
|
94
|
+
})
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// PUT /api/posts/:id
|
|
98
|
+
update: async (req, res) => {
|
|
99
|
+
const { title, content } = req.body
|
|
100
|
+
|
|
101
|
+
await (await db.table('posts'))
|
|
102
|
+
.where('id', req.params.id)
|
|
103
|
+
.where('user_id', req.user.id)
|
|
104
|
+
.update({ title, content })
|
|
105
|
+
|
|
106
|
+
return res.json({ success: true })
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// DELETE /api/posts/:id
|
|
110
|
+
destroy: async (req, res) => {
|
|
111
|
+
await (await db.table('posts'))
|
|
112
|
+
.where('id', req.params.id)
|
|
113
|
+
.where('user_id', req.user.id)
|
|
114
|
+
.delete()
|
|
115
|
+
|
|
116
|
+
return res.json({ success: true })
|
|
117
|
+
}
|
|
118
|
+
}`}
|
|
119
|
+
</CodeBlock>
|
|
120
|
+
|
|
121
|
+
<CodeBlock title="Generate a controller">
|
|
122
|
+
{`npx basicben make:controller Post`}
|
|
123
|
+
</CodeBlock>
|
|
124
|
+
</Card>
|
|
125
|
+
|
|
126
|
+
<Card>
|
|
127
|
+
<h2 className="text-lg font-semibold mb-2">Middleware</h2>
|
|
128
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
129
|
+
Middleware runs before your route handler. Use it for authentication, logging, validation, etc.
|
|
130
|
+
</p>
|
|
131
|
+
|
|
132
|
+
<CodeBlock title="src/middleware/auth.js">
|
|
133
|
+
{`import { verifyToken } from 'basicben/auth'
|
|
134
|
+
import { db } from 'basicben'
|
|
135
|
+
|
|
136
|
+
export const auth = async (req, res, next) => {
|
|
137
|
+
const header = req.headers.authorization
|
|
138
|
+
|
|
139
|
+
if (!header?.startsWith('Bearer ')) {
|
|
140
|
+
return res.status(401).json({ error: 'Unauthorized' })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const token = header.slice(7)
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const payload = await verifyToken(token)
|
|
147
|
+
const user = await (await db.table('users')).find(payload.userId)
|
|
148
|
+
|
|
149
|
+
if (!user) {
|
|
150
|
+
return res.status(401).json({ error: 'Unauthorized' })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
req.user = user
|
|
154
|
+
next()
|
|
155
|
+
} catch {
|
|
156
|
+
return res.status(401).json({ error: 'Invalid token' })
|
|
157
|
+
}
|
|
158
|
+
}`}
|
|
159
|
+
</CodeBlock>
|
|
160
|
+
|
|
161
|
+
<CodeBlock title="Generate middleware">
|
|
162
|
+
{`npx basicben make:middleware auth`}
|
|
163
|
+
</CodeBlock>
|
|
164
|
+
</Card>
|
|
165
|
+
|
|
166
|
+
<Card>
|
|
167
|
+
<h2 className="text-lg font-semibold mb-2">Route Parameters</h2>
|
|
168
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
169
|
+
Access URL parameters via <code>req.params</code> and query strings via <code>req.query</code>.
|
|
170
|
+
</p>
|
|
171
|
+
|
|
172
|
+
<CodeBlock title="Parameters and query strings">
|
|
173
|
+
{`// Route: /api/posts/:id
|
|
174
|
+
// URL: /api/posts/123?include=author
|
|
175
|
+
|
|
176
|
+
export const show = async (req, res) => {
|
|
177
|
+
const { id } = req.params // { id: '123' }
|
|
178
|
+
const { include } = req.query // { include: 'author' }
|
|
179
|
+
|
|
180
|
+
// ...
|
|
181
|
+
}`}
|
|
182
|
+
</CodeBlock>
|
|
183
|
+
</Card>
|
|
184
|
+
|
|
185
|
+
<Card>
|
|
186
|
+
<h2 className="text-lg font-semibold mb-2">Request Body</h2>
|
|
187
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
188
|
+
JSON request bodies are automatically parsed and available on <code>req.body</code>.
|
|
189
|
+
</p>
|
|
190
|
+
|
|
191
|
+
<CodeBlock title="Accessing request body">
|
|
192
|
+
{`export const store = async (req, res) => {
|
|
193
|
+
const { title, content, published } = req.body
|
|
194
|
+
|
|
195
|
+
// Validate and use the data
|
|
196
|
+
if (!title) {
|
|
197
|
+
return res.status(400).json({ error: 'Title is required' })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ...
|
|
201
|
+
}`}
|
|
202
|
+
</CodeBlock>
|
|
203
|
+
</Card>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useTheme } from '../components/ThemeContext'
|
|
2
|
+
import { Card } from '../components/Card'
|
|
3
|
+
import { PageHeader } from '../components/PageHeader'
|
|
4
|
+
import { AppLayout } from '../layouts/AppLayout'
|
|
5
|
+
import { DocsLayout } from '../layouts/DocsLayout'
|
|
6
|
+
|
|
7
|
+
export function Testing() {
|
|
8
|
+
const { t } = useTheme()
|
|
9
|
+
|
|
10
|
+
const CodeBlock = ({ children, title }) => (
|
|
11
|
+
<div className="mt-4">
|
|
12
|
+
{title && <div className={`text-xs font-medium mb-2 ${t.muted}`}>{title}</div>}
|
|
13
|
+
<div className={`rounded-lg p-4 font-mono text-sm ${t.card} border ${t.border} overflow-x-auto`}>
|
|
14
|
+
<pre className={t.text}>{children}</pre>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<PageHeader
|
|
22
|
+
title="Testing"
|
|
23
|
+
subtitle="Write and run tests with Vitest"
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
<div className="space-y-6">
|
|
27
|
+
<Card>
|
|
28
|
+
<h2 className="text-lg font-semibold mb-2">Overview</h2>
|
|
29
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
30
|
+
BasicBen uses Vitest for fast, modern testing. Tests are automatically discovered in files ending with <code>.test.js</code> or <code>.spec.js</code>.
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
34
|
+
<div className={`rounded-lg p-3 ${t.card} border ${t.border}`}>
|
|
35
|
+
<code className="text-sm font-semibold">npm run test</code>
|
|
36
|
+
<p className={`text-xs mt-1 ${t.muted}`}>Run tests once</p>
|
|
37
|
+
</div>
|
|
38
|
+
<div className={`rounded-lg p-3 ${t.card} border ${t.border}`}>
|
|
39
|
+
<code className="text-sm font-semibold">npm run test:watch</code>
|
|
40
|
+
<p className={`text-xs mt-1 ${t.muted}`}>Run tests in watch mode</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</Card>
|
|
44
|
+
|
|
45
|
+
<Card>
|
|
46
|
+
<h2 className="text-lg font-semibold mb-2">Writing Tests</h2>
|
|
47
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
48
|
+
Create test files alongside your code or in a <code>tests/</code> directory.
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<CodeBlock title="src/utils/helpers.test.js">
|
|
52
|
+
{`import { describe, it, expect } from 'vitest'
|
|
53
|
+
import { formatDate, slugify } from './helpers'
|
|
54
|
+
|
|
55
|
+
describe('formatDate', () => {
|
|
56
|
+
it('formats a date correctly', () => {
|
|
57
|
+
const date = new Date('2024-01-15')
|
|
58
|
+
expect(formatDate(date)).toBe('January 15, 2024')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('handles invalid dates', () => {
|
|
62
|
+
expect(formatDate(null)).toBe('')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('slugify', () => {
|
|
67
|
+
it('converts text to a slug', () => {
|
|
68
|
+
expect(slugify('Hello World')).toBe('hello-world')
|
|
69
|
+
expect(slugify('This is a TEST')).toBe('this-is-a-test')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('removes special characters', () => {
|
|
73
|
+
expect(slugify('Hello! World?')).toBe('hello-world')
|
|
74
|
+
})
|
|
75
|
+
})`}
|
|
76
|
+
</CodeBlock>
|
|
77
|
+
</Card>
|
|
78
|
+
|
|
79
|
+
<Card>
|
|
80
|
+
<h2 className="text-lg font-semibold mb-2">Testing API Routes</h2>
|
|
81
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
82
|
+
Test your API endpoints using the built-in test client.
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
<CodeBlock title="tests/api/posts.test.js">
|
|
86
|
+
{`import { describe, it, expect, beforeEach } from 'vitest'
|
|
87
|
+
import { testClient, resetDatabase } from 'basicben/testing'
|
|
88
|
+
|
|
89
|
+
describe('POST /api/posts', () => {
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
await resetDatabase()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('creates a post when authenticated', async () => {
|
|
95
|
+
const { body, status } = await testClient
|
|
96
|
+
.post('/api/posts')
|
|
97
|
+
.auth(testUser)
|
|
98
|
+
.send({
|
|
99
|
+
title: 'Test Post',
|
|
100
|
+
content: 'This is a test'
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(status).toBe(201)
|
|
104
|
+
expect(body.id).toBeDefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns 401 when not authenticated', async () => {
|
|
108
|
+
const { status } = await testClient
|
|
109
|
+
.post('/api/posts')
|
|
110
|
+
.send({ title: 'Test' })
|
|
111
|
+
|
|
112
|
+
expect(status).toBe(401)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('validates required fields', async () => {
|
|
116
|
+
const { body, status } = await testClient
|
|
117
|
+
.post('/api/posts')
|
|
118
|
+
.auth(testUser)
|
|
119
|
+
.send({})
|
|
120
|
+
|
|
121
|
+
expect(status).toBe(400)
|
|
122
|
+
expect(body.errors.title).toBeDefined()
|
|
123
|
+
})
|
|
124
|
+
})`}
|
|
125
|
+
</CodeBlock>
|
|
126
|
+
</Card>
|
|
127
|
+
|
|
128
|
+
<Card>
|
|
129
|
+
<h2 className="text-lg font-semibold mb-2">Test Database</h2>
|
|
130
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
131
|
+
Tests run against an isolated test database that resets between tests.
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
<CodeBlock title="Database helpers">
|
|
135
|
+
{`import { resetDatabase, seedDatabase, factory } from 'basicben/testing'
|
|
136
|
+
|
|
137
|
+
describe('User tests', () => {
|
|
138
|
+
beforeEach(async () => {
|
|
139
|
+
// Reset database to clean state
|
|
140
|
+
await resetDatabase()
|
|
141
|
+
|
|
142
|
+
// Optionally seed with test data
|
|
143
|
+
await seedDatabase()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('can create users with factory', async () => {
|
|
147
|
+
// Create a user using the factory
|
|
148
|
+
const user = await factory.user.create({
|
|
149
|
+
name: 'Test User',
|
|
150
|
+
email: 'test@example.com'
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(user.id).toBeDefined()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('can create multiple records', async () => {
|
|
157
|
+
// Create 5 posts for a user
|
|
158
|
+
const posts = await factory.post.createMany(5, {
|
|
159
|
+
user_id: testUser.id
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
expect(posts).toHaveLength(5)
|
|
163
|
+
})
|
|
164
|
+
})`}
|
|
165
|
+
</CodeBlock>
|
|
166
|
+
</Card>
|
|
167
|
+
|
|
168
|
+
<Card>
|
|
169
|
+
<h2 className="text-lg font-semibold mb-2">Mocking</h2>
|
|
170
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
171
|
+
Use Vitest's built-in mocking for external dependencies.
|
|
172
|
+
</p>
|
|
173
|
+
|
|
174
|
+
<CodeBlock title="Mocking modules">
|
|
175
|
+
{`import { describe, it, expect, vi } from 'vitest'
|
|
176
|
+
import { sendEmail } from './email'
|
|
177
|
+
|
|
178
|
+
// Mock the email module
|
|
179
|
+
vi.mock('./email', () => ({
|
|
180
|
+
sendEmail: vi.fn()
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
describe('Registration', () => {
|
|
184
|
+
it('sends welcome email after registration', async () => {
|
|
185
|
+
await testClient
|
|
186
|
+
.post('/api/auth/register')
|
|
187
|
+
.send({
|
|
188
|
+
name: 'New User',
|
|
189
|
+
email: 'new@example.com',
|
|
190
|
+
password: 'password123'
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
expect(sendEmail).toHaveBeenCalledWith(
|
|
194
|
+
'new@example.com',
|
|
195
|
+
'Welcome!',
|
|
196
|
+
expect.any(String)
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
})`}
|
|
200
|
+
</CodeBlock>
|
|
201
|
+
</Card>
|
|
202
|
+
|
|
203
|
+
<Card>
|
|
204
|
+
<h2 className="text-lg font-semibold mb-2">Code Coverage</h2>
|
|
205
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
206
|
+
Generate code coverage reports to see what's tested.
|
|
207
|
+
</p>
|
|
208
|
+
|
|
209
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
210
|
+
<div className={`rounded-lg p-3 ${t.card} border ${t.border}`}>
|
|
211
|
+
<code className="text-sm font-semibold">npm run test:coverage</code>
|
|
212
|
+
<p className={`text-xs mt-1 ${t.muted}`}>Run tests with coverage</p>
|
|
213
|
+
</div>
|
|
214
|
+
<div className={`rounded-lg p-3 ${t.card} border ${t.border}`}>
|
|
215
|
+
<code className="text-sm font-semibold">npm run test:ui</code>
|
|
216
|
+
<p className={`text-xs mt-1 ${t.muted}`}>Open Vitest UI</p>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<CodeBlock title="Coverage output">
|
|
221
|
+
{`----------|---------|----------|---------|---------|
|
|
222
|
+
File | % Stmts | % Branch | % Funcs | % Lines |
|
|
223
|
+
----------|---------|----------|---------|---------|
|
|
224
|
+
All files | 85.71 | 78.26 | 90.00 | 85.71 |
|
|
225
|
+
auth.js | 100.00 | 100.00 | 100.00 | 100.00 |
|
|
226
|
+
posts.js | 75.00 | 66.67 | 80.00 | 75.00 |
|
|
227
|
+
----------|---------|----------|---------|---------|`}
|
|
228
|
+
</CodeBlock>
|
|
229
|
+
</Card>
|
|
230
|
+
|
|
231
|
+
<Card>
|
|
232
|
+
<h2 className="text-lg font-semibold mb-2">Testing Best Practices</h2>
|
|
233
|
+
<div className="space-y-3 mt-4">
|
|
234
|
+
{[
|
|
235
|
+
{ title: 'Isolate tests', desc: 'Each test should be independent and not rely on other tests' },
|
|
236
|
+
{ title: 'Reset state', desc: 'Use beforeEach to reset the database and any mocks' },
|
|
237
|
+
{ title: 'Test behavior', desc: 'Test what the code does, not how it does it' },
|
|
238
|
+
{ title: 'Use descriptive names', desc: 'Test names should describe the expected behavior' },
|
|
239
|
+
{ title: 'Keep tests fast', desc: 'Mock external services and use test databases' },
|
|
240
|
+
].map(({ title, desc }) => (
|
|
241
|
+
<div key={title} className={`rounded-lg p-3 ${t.card} border ${t.border}`}>
|
|
242
|
+
<div className="font-semibold text-sm">{title}</div>
|
|
243
|
+
<p className={`text-xs mt-1 ${t.muted}`}>{desc}</p>
|
|
244
|
+
</div>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</Card>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useTheme } from '../components/ThemeContext'
|
|
2
|
+
import { Card } from '../components/Card'
|
|
3
|
+
import { PageHeader } from '../components/PageHeader'
|
|
4
|
+
import { AppLayout } from '../layouts/AppLayout'
|
|
5
|
+
import { DocsLayout } from '../layouts/DocsLayout'
|
|
6
|
+
|
|
7
|
+
export function Validation() {
|
|
8
|
+
const { t } = useTheme()
|
|
9
|
+
|
|
10
|
+
const CodeBlock = ({ children, title }) => (
|
|
11
|
+
<div className="mt-4">
|
|
12
|
+
{title && <div className={`text-xs font-medium mb-2 ${t.muted}`}>{title}</div>}
|
|
13
|
+
<div className={`rounded-lg p-4 font-mono text-sm ${t.card} border ${t.border} overflow-x-auto`}>
|
|
14
|
+
<pre className={t.text}>{children}</pre>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<PageHeader
|
|
22
|
+
title="Validation"
|
|
23
|
+
subtitle="Request validation with built-in rules"
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
<div className="space-y-6">
|
|
27
|
+
<Card>
|
|
28
|
+
<h2 className="text-lg font-semibold mb-2">Basic Usage</h2>
|
|
29
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
30
|
+
Validate request data using the <code>validate</code> function and built-in rules.
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<CodeBlock title="Validating request body">
|
|
34
|
+
{`import { validate, rules } from 'basicben/validation'
|
|
35
|
+
|
|
36
|
+
export const store = async (req, res) => {
|
|
37
|
+
const result = await validate(req.body, {
|
|
38
|
+
title: [rules.required, rules.minLength(3), rules.maxLength(100)],
|
|
39
|
+
email: [rules.required, rules.email],
|
|
40
|
+
age: [rules.required, rules.number, rules.min(18)]
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (!result.valid) {
|
|
44
|
+
return res.status(400).json({ errors: result.errors })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Data is valid, continue...
|
|
48
|
+
}`}
|
|
49
|
+
</CodeBlock>
|
|
50
|
+
|
|
51
|
+
<CodeBlock title="Error response format">
|
|
52
|
+
{`{
|
|
53
|
+
"errors": {
|
|
54
|
+
"title": ["Title is required"],
|
|
55
|
+
"email": ["Invalid email format"],
|
|
56
|
+
"age": ["Must be at least 18"]
|
|
57
|
+
}
|
|
58
|
+
}`}
|
|
59
|
+
</CodeBlock>
|
|
60
|
+
</Card>
|
|
61
|
+
|
|
62
|
+
<Card>
|
|
63
|
+
<h2 className="text-lg font-semibold mb-2">Available Rules</h2>
|
|
64
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
65
|
+
BasicBen includes commonly used validation rules out of the box.
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
69
|
+
{[
|
|
70
|
+
{ rule: 'required', desc: 'Field must be present and not empty' },
|
|
71
|
+
{ rule: 'email', desc: 'Must be a valid email address' },
|
|
72
|
+
{ rule: 'number', desc: 'Must be a number' },
|
|
73
|
+
{ rule: 'string', desc: 'Must be a string' },
|
|
74
|
+
{ rule: 'boolean', desc: 'Must be true or false' },
|
|
75
|
+
{ rule: 'array', desc: 'Must be an array' },
|
|
76
|
+
{ rule: 'minLength(n)', desc: 'String must be at least n characters' },
|
|
77
|
+
{ rule: 'maxLength(n)', desc: 'String must be at most n characters' },
|
|
78
|
+
{ rule: 'min(n)', desc: 'Number must be at least n' },
|
|
79
|
+
{ rule: 'max(n)', desc: 'Number must be at most n' },
|
|
80
|
+
{ rule: 'regex(pattern)', desc: 'Must match the regex pattern' },
|
|
81
|
+
{ rule: 'in(values)', desc: 'Must be one of the given values' },
|
|
82
|
+
].map(({ rule, desc }) => (
|
|
83
|
+
<div key={rule} className={`rounded-lg p-3 ${t.card} border ${t.border}`}>
|
|
84
|
+
<code className="text-sm font-semibold">{rule}</code>
|
|
85
|
+
<p className={`text-xs mt-1 ${t.muted}`}>{desc}</p>
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
</Card>
|
|
90
|
+
|
|
91
|
+
<Card>
|
|
92
|
+
<h2 className="text-lg font-semibold mb-2">Database Rules</h2>
|
|
93
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
94
|
+
Validate against your database with <code>unique</code> and <code>exists</code> rules.
|
|
95
|
+
</p>
|
|
96
|
+
|
|
97
|
+
<CodeBlock title="Unique validation">
|
|
98
|
+
{`// Check if email is unique in users table
|
|
99
|
+
email: [rules.required, rules.email, rules.unique('users')]
|
|
100
|
+
|
|
101
|
+
// With custom column
|
|
102
|
+
slug: [rules.unique('categories', 'slug')]
|
|
103
|
+
|
|
104
|
+
// Exclude current record (for updates)
|
|
105
|
+
email: [rules.unique('users', 'email', currentUserId)]`}
|
|
106
|
+
</CodeBlock>
|
|
107
|
+
|
|
108
|
+
<CodeBlock title="Exists validation">
|
|
109
|
+
{`// Check if user_id exists in users table
|
|
110
|
+
user_id: [rules.required, rules.exists('users')]
|
|
111
|
+
|
|
112
|
+
// With custom column
|
|
113
|
+
category: [rules.exists('categories', 'slug')]`}
|
|
114
|
+
</CodeBlock>
|
|
115
|
+
</Card>
|
|
116
|
+
|
|
117
|
+
<Card>
|
|
118
|
+
<h2 className="text-lg font-semibold mb-2">Custom Rules</h2>
|
|
119
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
120
|
+
Create custom validation rules for specific requirements.
|
|
121
|
+
</p>
|
|
122
|
+
|
|
123
|
+
<CodeBlock title="Creating a custom rule">
|
|
124
|
+
{`// Custom rule as a function
|
|
125
|
+
const isSlug = (value, field) => {
|
|
126
|
+
if (!/^[a-z0-9-]+$/.test(value)) {
|
|
127
|
+
return \`\${field} must only contain lowercase letters, numbers, and hyphens\`
|
|
128
|
+
}
|
|
129
|
+
return null // Return null if valid
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Use it in validation
|
|
133
|
+
const result = await validate(req.body, {
|
|
134
|
+
slug: [rules.required, isSlug]
|
|
135
|
+
})`}
|
|
136
|
+
</CodeBlock>
|
|
137
|
+
|
|
138
|
+
<CodeBlock title="Async custom rule">
|
|
139
|
+
{`// Async rule for complex validation
|
|
140
|
+
const isAvailableUsername = async (value, field) => {
|
|
141
|
+
const existing = await (await db.table('users'))
|
|
142
|
+
.where('username', value)
|
|
143
|
+
.first()
|
|
144
|
+
|
|
145
|
+
if (existing) {
|
|
146
|
+
return 'Username is already taken'
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = await validate(req.body, {
|
|
152
|
+
username: [rules.required, isAvailableUsername]
|
|
153
|
+
})`}
|
|
154
|
+
</CodeBlock>
|
|
155
|
+
</Card>
|
|
156
|
+
|
|
157
|
+
<Card>
|
|
158
|
+
<h2 className="text-lg font-semibold mb-2">Optional Fields</h2>
|
|
159
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
160
|
+
Fields without the <code>required</code> rule are optional. Other rules only run if the field has a value.
|
|
161
|
+
</p>
|
|
162
|
+
|
|
163
|
+
<CodeBlock title="Optional field validation">
|
|
164
|
+
{`const result = await validate(req.body, {
|
|
165
|
+
name: [rules.required], // Required
|
|
166
|
+
bio: [rules.maxLength(500)], // Optional, but if provided must be <= 500 chars
|
|
167
|
+
website: [rules.url] // Optional, but if provided must be a valid URL
|
|
168
|
+
})`}
|
|
169
|
+
</CodeBlock>
|
|
170
|
+
</Card>
|
|
171
|
+
|
|
172
|
+
<Card>
|
|
173
|
+
<h2 className="text-lg font-semibold mb-2">Nested Objects</h2>
|
|
174
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
175
|
+
Validate nested objects using dot notation.
|
|
176
|
+
</p>
|
|
177
|
+
|
|
178
|
+
<CodeBlock title="Nested validation">
|
|
179
|
+
{`// Request body: { user: { name: 'John', email: 'john@example.com' } }
|
|
180
|
+
|
|
181
|
+
const result = await validate(req.body, {
|
|
182
|
+
'user.name': [rules.required, rules.minLength(2)],
|
|
183
|
+
'user.email': [rules.required, rules.email]
|
|
184
|
+
})`}
|
|
185
|
+
</CodeBlock>
|
|
186
|
+
</Card>
|
|
187
|
+
|
|
188
|
+
<Card>
|
|
189
|
+
<h2 className="text-lg font-semibold mb-2">Custom Error Messages</h2>
|
|
190
|
+
<p className={`text-sm ${t.muted} mb-4`}>
|
|
191
|
+
Override default error messages with custom ones.
|
|
192
|
+
</p>
|
|
193
|
+
|
|
194
|
+
<CodeBlock title="Custom messages">
|
|
195
|
+
{`const result = await validate(req.body, {
|
|
196
|
+
email: [
|
|
197
|
+
{ rule: rules.required, message: 'Please enter your email' },
|
|
198
|
+
{ rule: rules.email, message: 'Please enter a valid email address' }
|
|
199
|
+
],
|
|
200
|
+
password: [
|
|
201
|
+
{ rule: rules.required, message: 'Password is required' },
|
|
202
|
+
{ rule: rules.minLength(8), message: 'Password must be at least 8 characters' }
|
|
203
|
+
]
|
|
204
|
+
})`}
|
|
205
|
+
</CodeBlock>
|
|
206
|
+
</Card>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|