@girardmedia/bootspring 1.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/LICENSE +21 -0
- package/README.md +255 -0
- package/agents/README.md +93 -0
- package/agents/api-expert/context.md +416 -0
- package/agents/architecture-expert/context.md +454 -0
- package/agents/backend-expert/context.md +483 -0
- package/agents/code-review-expert/context.md +365 -0
- package/agents/database-expert/context.md +250 -0
- package/agents/devops-expert/context.md +446 -0
- package/agents/frontend-expert/context.md +364 -0
- package/agents/index.js +140 -0
- package/agents/performance-expert/context.md +377 -0
- package/agents/security-expert/context.md +343 -0
- package/agents/testing-expert/context.md +414 -0
- package/agents/ui-ux-expert/context.md +448 -0
- package/agents/vercel-expert/context.md +426 -0
- package/bin/bootspring.js +310 -0
- package/cli/agent.js +337 -0
- package/cli/context.js +194 -0
- package/cli/dashboard.js +150 -0
- package/cli/generate.js +294 -0
- package/cli/init.js +410 -0
- package/cli/loop.js +421 -0
- package/cli/mcp.js +241 -0
- package/cli/memory.js +303 -0
- package/cli/orchestrator.js +400 -0
- package/cli/plugin.js +451 -0
- package/cli/quality.js +332 -0
- package/cli/skill.js +369 -0
- package/cli/task.js +628 -0
- package/cli/telemetry.js +114 -0
- package/cli/todo.js +614 -0
- package/cli/update.js +312 -0
- package/core/config.js +245 -0
- package/core/context.js +329 -0
- package/core/entitlements.js +209 -0
- package/core/index.js +43 -0
- package/core/policies.js +68 -0
- package/core/telemetry.js +247 -0
- package/core/utils.js +380 -0
- package/dashboard/server.js +818 -0
- package/docs/integrations/claude-code.md +42 -0
- package/docs/integrations/codex.md +42 -0
- package/docs/mcp-api-platform.md +102 -0
- package/generators/generate.js +598 -0
- package/generators/index.js +18 -0
- package/hooks/context-detector.js +177 -0
- package/hooks/index.js +35 -0
- package/hooks/prompt-enhancer.js +289 -0
- package/intelligence/git-memory.js +551 -0
- package/intelligence/index.js +59 -0
- package/intelligence/orchestrator.js +964 -0
- package/intelligence/prd.js +447 -0
- package/intelligence/recommendation-weights.json +18 -0
- package/intelligence/recommendations.js +234 -0
- package/mcp/capabilities.js +71 -0
- package/mcp/contracts/mcp-contract.v1.json +497 -0
- package/mcp/registry.js +213 -0
- package/mcp/response-formatter.js +462 -0
- package/mcp/server.js +99 -0
- package/mcp/tools/agent-tool.js +137 -0
- package/mcp/tools/capabilities-tool.js +54 -0
- package/mcp/tools/context-tool.js +49 -0
- package/mcp/tools/dashboard-tool.js +58 -0
- package/mcp/tools/generate-tool.js +46 -0
- package/mcp/tools/loop-tool.js +134 -0
- package/mcp/tools/memory-tool.js +180 -0
- package/mcp/tools/orchestrator-tool.js +232 -0
- package/mcp/tools/plugin-tool.js +76 -0
- package/mcp/tools/quality-tool.js +47 -0
- package/mcp/tools/skill-tool.js +233 -0
- package/mcp/tools/telemetry-tool.js +95 -0
- package/mcp/tools/todo-tool.js +133 -0
- package/package.json +98 -0
- package/plugins/index.js +141 -0
- package/quality/index.js +380 -0
- package/quality/lint-budgets.json +19 -0
- package/skills/index.js +787 -0
- package/skills/patterns/README.md +163 -0
- package/skills/patterns/api/route-handler.md +217 -0
- package/skills/patterns/api/server-action.md +249 -0
- package/skills/patterns/auth/clerk.md +132 -0
- package/skills/patterns/database/prisma.md +180 -0
- package/skills/patterns/payments/stripe.md +272 -0
- package/skills/patterns/security/validation.md +268 -0
- package/skills/patterns/testing/vitest.md +307 -0
- package/templates/bootspring.config.js +83 -0
- package/templates/mcp.json +9 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Input Validation & Security Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for secure input handling.
|
|
4
|
+
|
|
5
|
+
## Zod Schema Validation
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// lib/validations/user.ts
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
|
|
11
|
+
export const CreateUserSchema = z.object({
|
|
12
|
+
email: z.string().email('Invalid email address'),
|
|
13
|
+
name: z.string()
|
|
14
|
+
.min(1, 'Name is required')
|
|
15
|
+
.max(100, 'Name too long')
|
|
16
|
+
.regex(/^[a-zA-Z\s'-]+$/, 'Invalid characters in name'),
|
|
17
|
+
password: z.string()
|
|
18
|
+
.min(8, 'Password must be at least 8 characters')
|
|
19
|
+
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
|
20
|
+
.regex(/[a-z]/, 'Password must contain lowercase letter')
|
|
21
|
+
.regex(/[0-9]/, 'Password must contain number')
|
|
22
|
+
.regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
|
|
23
|
+
age: z.number()
|
|
24
|
+
.int('Age must be a whole number')
|
|
25
|
+
.min(13, 'Must be at least 13 years old')
|
|
26
|
+
.max(120, 'Invalid age')
|
|
27
|
+
.optional()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const UpdateProfileSchema = z.object({
|
|
31
|
+
name: z.string().min(1).max(100).optional(),
|
|
32
|
+
bio: z.string().max(500).optional(),
|
|
33
|
+
website: z.string().url().optional().or(z.literal(''))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type CreateUserInput = z.infer<typeof CreateUserSchema>
|
|
37
|
+
export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Server Action with Validation
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// actions/user.ts
|
|
44
|
+
'use server'
|
|
45
|
+
|
|
46
|
+
import { CreateUserSchema } from '@/lib/validations/user'
|
|
47
|
+
import { prisma } from '@/lib/db'
|
|
48
|
+
|
|
49
|
+
type ActionResult =
|
|
50
|
+
| { success: true; data: { id: string } }
|
|
51
|
+
| { success: false; error: string; fieldErrors?: Record<string, string[]> }
|
|
52
|
+
|
|
53
|
+
export async function createUser(formData: FormData): Promise<ActionResult> {
|
|
54
|
+
// Parse and validate
|
|
55
|
+
const raw = {
|
|
56
|
+
email: formData.get('email'),
|
|
57
|
+
name: formData.get('name'),
|
|
58
|
+
password: formData.get('password'),
|
|
59
|
+
age: formData.get('age') ? Number(formData.get('age')) : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = CreateUserSchema.safeParse(raw)
|
|
63
|
+
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
return {
|
|
66
|
+
success: false,
|
|
67
|
+
error: 'Validation failed',
|
|
68
|
+
fieldErrors: result.error.flatten().fieldErrors
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Hash password before storing
|
|
73
|
+
const hashedPassword = await hashPassword(result.data.password)
|
|
74
|
+
|
|
75
|
+
const user = await prisma.user.create({
|
|
76
|
+
data: {
|
|
77
|
+
...result.data,
|
|
78
|
+
password: hashedPassword
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return { success: true, data: { id: user.id } }
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Sanitizing User Input
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// lib/sanitize.ts
|
|
90
|
+
import DOMPurify from 'isomorphic-dompurify'
|
|
91
|
+
|
|
92
|
+
// Sanitize HTML content (for rich text)
|
|
93
|
+
export function sanitizeHtml(dirty: string): string {
|
|
94
|
+
return DOMPurify.sanitize(dirty, {
|
|
95
|
+
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
|
|
96
|
+
ALLOWED_ATTR: ['href', 'target', 'rel']
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Strip all HTML (for plain text fields)
|
|
101
|
+
export function stripHtml(input: string): string {
|
|
102
|
+
return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sanitize for SQL-safe strings (use with parameterized queries)
|
|
106
|
+
export function escapeString(input: string): string {
|
|
107
|
+
return input
|
|
108
|
+
.replace(/\\/g, '\\\\')
|
|
109
|
+
.replace(/'/g, "\\'")
|
|
110
|
+
.replace(/"/g, '\\"')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// URL validation and sanitization
|
|
114
|
+
export function sanitizeUrl(url: string): string | null {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = new URL(url)
|
|
117
|
+
// Only allow http/https
|
|
118
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
return parsed.toString()
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Rate Limiting
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// lib/rate-limit.ts
|
|
132
|
+
import { Ratelimit } from '@upstash/ratelimit'
|
|
133
|
+
import { Redis } from '@upstash/redis'
|
|
134
|
+
|
|
135
|
+
// Using Upstash Redis for serverless
|
|
136
|
+
const redis = new Redis({
|
|
137
|
+
url: process.env.UPSTASH_REDIS_REST_URL!,
|
|
138
|
+
token: process.env.UPSTASH_REDIS_REST_TOKEN!
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Different rate limiters for different purposes
|
|
142
|
+
export const authLimiter = new Ratelimit({
|
|
143
|
+
redis,
|
|
144
|
+
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per minute
|
|
145
|
+
prefix: 'ratelimit:auth'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
export const apiLimiter = new Ratelimit({
|
|
149
|
+
redis,
|
|
150
|
+
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
|
|
151
|
+
prefix: 'ratelimit:api'
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Usage in API route
|
|
155
|
+
export async function POST(request: Request) {
|
|
156
|
+
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
|
|
157
|
+
|
|
158
|
+
const { success, limit, remaining, reset } = await authLimiter.limit(ip)
|
|
159
|
+
|
|
160
|
+
if (!success) {
|
|
161
|
+
return new Response('Too many requests', {
|
|
162
|
+
status: 429,
|
|
163
|
+
headers: {
|
|
164
|
+
'X-RateLimit-Limit': limit.toString(),
|
|
165
|
+
'X-RateLimit-Remaining': remaining.toString(),
|
|
166
|
+
'X-RateLimit-Reset': reset.toString()
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Continue with handler...
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## CSRF Protection
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// lib/csrf.ts
|
|
179
|
+
import { cookies } from 'next/headers'
|
|
180
|
+
import { randomBytes, createHmac } from 'crypto'
|
|
181
|
+
|
|
182
|
+
const SECRET = process.env.CSRF_SECRET!
|
|
183
|
+
|
|
184
|
+
export async function generateCsrfToken(): Promise<string> {
|
|
185
|
+
const token = randomBytes(32).toString('hex')
|
|
186
|
+
const signature = createHmac('sha256', SECRET).update(token).digest('hex')
|
|
187
|
+
const fullToken = `${token}.${signature}`
|
|
188
|
+
|
|
189
|
+
const cookieStore = await cookies()
|
|
190
|
+
cookieStore.set('csrf-token', fullToken, {
|
|
191
|
+
httpOnly: true,
|
|
192
|
+
secure: process.env.NODE_ENV === 'production',
|
|
193
|
+
sameSite: 'strict',
|
|
194
|
+
maxAge: 60 * 60 // 1 hour
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
return token // Only return the token part to the client
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function validateCsrfToken(clientToken: string): Promise<boolean> {
|
|
201
|
+
const cookieStore = await cookies()
|
|
202
|
+
const cookieToken = cookieStore.get('csrf-token')?.value
|
|
203
|
+
|
|
204
|
+
if (!cookieToken) return false
|
|
205
|
+
|
|
206
|
+
const [storedToken, storedSignature] = cookieToken.split('.')
|
|
207
|
+
const expectedSignature = createHmac('sha256', SECRET)
|
|
208
|
+
.update(storedToken)
|
|
209
|
+
.digest('hex')
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
storedToken === clientToken &&
|
|
213
|
+
storedSignature === expectedSignature
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Security Headers Middleware
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// middleware.ts
|
|
222
|
+
import { NextResponse } from 'next/server'
|
|
223
|
+
import type { NextRequest } from 'next/server'
|
|
224
|
+
|
|
225
|
+
export function middleware(request: NextRequest) {
|
|
226
|
+
const response = NextResponse.next()
|
|
227
|
+
|
|
228
|
+
// Security headers
|
|
229
|
+
response.headers.set('X-Frame-Options', 'DENY')
|
|
230
|
+
response.headers.set('X-Content-Type-Options', 'nosniff')
|
|
231
|
+
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
232
|
+
response.headers.set('X-XSS-Protection', '1; mode=block')
|
|
233
|
+
|
|
234
|
+
// Content Security Policy
|
|
235
|
+
response.headers.set(
|
|
236
|
+
'Content-Security-Policy',
|
|
237
|
+
[
|
|
238
|
+
"default-src 'self'",
|
|
239
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
240
|
+
"style-src 'self' 'unsafe-inline'",
|
|
241
|
+
"img-src 'self' data: https:",
|
|
242
|
+
"font-src 'self'",
|
|
243
|
+
"connect-src 'self' https://api.stripe.com",
|
|
244
|
+
"frame-ancestors 'none'"
|
|
245
|
+
].join('; ')
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return response
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Environment Variable Validation
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// lib/env.ts
|
|
256
|
+
import { z } from 'zod'
|
|
257
|
+
|
|
258
|
+
const envSchema = z.object({
|
|
259
|
+
DATABASE_URL: z.string().url(),
|
|
260
|
+
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
|
261
|
+
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
|
|
262
|
+
NEXT_PUBLIC_URL: z.string().url(),
|
|
263
|
+
NODE_ENV: z.enum(['development', 'production', 'test'])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Validate at build/startup time
|
|
267
|
+
export const env = envSchema.parse(process.env)
|
|
268
|
+
```
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Vitest Testing Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for testing with Vitest in Next.js.
|
|
4
|
+
|
|
5
|
+
## Setup Configuration
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// vitest.config.ts
|
|
9
|
+
import { defineConfig } from 'vitest/config'
|
|
10
|
+
import react from '@vitejs/plugin-react'
|
|
11
|
+
import path from 'path'
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
plugins: [react()],
|
|
15
|
+
test: {
|
|
16
|
+
environment: 'jsdom',
|
|
17
|
+
globals: true,
|
|
18
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
19
|
+
include: ['**/*.test.{ts,tsx}'],
|
|
20
|
+
coverage: {
|
|
21
|
+
provider: 'v8',
|
|
22
|
+
reporter: ['text', 'json', 'html'],
|
|
23
|
+
exclude: ['node_modules/', '*.config.*']
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
resolve: {
|
|
27
|
+
alias: {
|
|
28
|
+
'@': path.resolve(__dirname, './src')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// vitest.setup.ts
|
|
36
|
+
import '@testing-library/jest-dom/vitest'
|
|
37
|
+
import { cleanup } from '@testing-library/react'
|
|
38
|
+
import { afterEach, vi } from 'vitest'
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cleanup()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Mock next/navigation
|
|
45
|
+
vi.mock('next/navigation', () => ({
|
|
46
|
+
useRouter: () => ({
|
|
47
|
+
push: vi.fn(),
|
|
48
|
+
replace: vi.fn(),
|
|
49
|
+
back: vi.fn()
|
|
50
|
+
}),
|
|
51
|
+
usePathname: () => '/',
|
|
52
|
+
useSearchParams: () => new URLSearchParams()
|
|
53
|
+
}))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Unit Testing Functions
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// lib/utils.test.ts
|
|
60
|
+
import { describe, it, expect } from 'vitest'
|
|
61
|
+
import { formatPrice, calculateDiscount, validateEmail } from './utils'
|
|
62
|
+
|
|
63
|
+
describe('formatPrice', () => {
|
|
64
|
+
it('formats cents to dollars', () => {
|
|
65
|
+
expect(formatPrice(1000)).toBe('$10.00')
|
|
66
|
+
expect(formatPrice(1599)).toBe('$15.99')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('handles zero', () => {
|
|
70
|
+
expect(formatPrice(0)).toBe('$0.00')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('calculateDiscount', () => {
|
|
75
|
+
it('applies percentage discount', () => {
|
|
76
|
+
expect(calculateDiscount(100, 10)).toBe(90)
|
|
77
|
+
expect(calculateDiscount(50, 50)).toBe(25)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns original price for zero discount', () => {
|
|
81
|
+
expect(calculateDiscount(100, 0)).toBe(100)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('validateEmail', () => {
|
|
86
|
+
it('accepts valid emails', () => {
|
|
87
|
+
expect(validateEmail('user@example.com')).toBe(true)
|
|
88
|
+
expect(validateEmail('user+tag@domain.co.uk')).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects invalid emails', () => {
|
|
92
|
+
expect(validateEmail('invalid')).toBe(false)
|
|
93
|
+
expect(validateEmail('no@domain')).toBe(false)
|
|
94
|
+
expect(validateEmail('')).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Testing React Components
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// components/button.test.tsx
|
|
103
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
104
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
105
|
+
import { Button } from './button'
|
|
106
|
+
|
|
107
|
+
describe('Button', () => {
|
|
108
|
+
it('renders with children', () => {
|
|
109
|
+
render(<Button>Click me</Button>)
|
|
110
|
+
expect(screen.getByText('Click me')).toBeInTheDocument()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('calls onClick when clicked', () => {
|
|
114
|
+
const handleClick = vi.fn()
|
|
115
|
+
render(<Button onClick={handleClick}>Click</Button>)
|
|
116
|
+
|
|
117
|
+
fireEvent.click(screen.getByText('Click'))
|
|
118
|
+
expect(handleClick).toHaveBeenCalledTimes(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('is disabled when loading', () => {
|
|
122
|
+
render(<Button loading>Submit</Button>)
|
|
123
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('applies variant classes', () => {
|
|
127
|
+
render(<Button variant="destructive">Delete</Button>)
|
|
128
|
+
expect(screen.getByRole('button')).toHaveClass('bg-red-500')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Testing Async Components
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// components/user-profile.test.tsx
|
|
137
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
138
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
139
|
+
import { UserProfile } from './user-profile'
|
|
140
|
+
|
|
141
|
+
// Mock the fetch function
|
|
142
|
+
vi.mock('@/lib/api', () => ({
|
|
143
|
+
fetchUser: vi.fn()
|
|
144
|
+
}))
|
|
145
|
+
|
|
146
|
+
import { fetchUser } from '@/lib/api'
|
|
147
|
+
|
|
148
|
+
describe('UserProfile', () => {
|
|
149
|
+
it('shows loading state initially', () => {
|
|
150
|
+
vi.mocked(fetchUser).mockImplementation(() => new Promise(() => {}))
|
|
151
|
+
|
|
152
|
+
render(<UserProfile userId="123" />)
|
|
153
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('displays user data after loading', async () => {
|
|
157
|
+
vi.mocked(fetchUser).mockResolvedValue({
|
|
158
|
+
name: 'John Doe',
|
|
159
|
+
email: 'john@example.com'
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
render(<UserProfile userId="123" />)
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
|
166
|
+
expect(screen.getByText('john@example.com')).toBeInTheDocument()
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('shows error state on failure', async () => {
|
|
171
|
+
vi.mocked(fetchUser).mockRejectedValue(new Error('Failed'))
|
|
172
|
+
|
|
173
|
+
render(<UserProfile userId="123" />)
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(screen.getByText('Error loading profile')).toBeInTheDocument()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Testing Server Actions
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// actions/posts.test.ts
|
|
186
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
187
|
+
import { createPost, deletePost } from './posts'
|
|
188
|
+
import { prisma } from '@/lib/db'
|
|
189
|
+
import { auth } from '@/lib/auth'
|
|
190
|
+
|
|
191
|
+
vi.mock('@/lib/db', () => ({
|
|
192
|
+
prisma: {
|
|
193
|
+
post: {
|
|
194
|
+
create: vi.fn(),
|
|
195
|
+
delete: vi.fn(),
|
|
196
|
+
findUnique: vi.fn()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
vi.mock('@/lib/auth', () => ({
|
|
202
|
+
auth: vi.fn()
|
|
203
|
+
}))
|
|
204
|
+
|
|
205
|
+
vi.mock('next/cache', () => ({
|
|
206
|
+
revalidatePath: vi.fn()
|
|
207
|
+
}))
|
|
208
|
+
|
|
209
|
+
describe('createPost', () => {
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
vi.clearAllMocks()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('creates post for authenticated user', async () => {
|
|
215
|
+
vi.mocked(auth).mockResolvedValue({ userId: 'user-123' })
|
|
216
|
+
vi.mocked(prisma.post.create).mockResolvedValue({
|
|
217
|
+
id: 'post-1',
|
|
218
|
+
title: 'Test Post',
|
|
219
|
+
authorId: 'user-123'
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const formData = new FormData()
|
|
223
|
+
formData.set('title', 'Test Post')
|
|
224
|
+
|
|
225
|
+
const result = await createPost(formData)
|
|
226
|
+
|
|
227
|
+
expect(result.success).toBe(true)
|
|
228
|
+
expect(prisma.post.create).toHaveBeenCalledWith({
|
|
229
|
+
data: expect.objectContaining({
|
|
230
|
+
title: 'Test Post',
|
|
231
|
+
authorId: 'user-123'
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('returns error for unauthenticated user', async () => {
|
|
237
|
+
vi.mocked(auth).mockResolvedValue({ userId: null })
|
|
238
|
+
|
|
239
|
+
const formData = new FormData()
|
|
240
|
+
formData.set('title', 'Test')
|
|
241
|
+
|
|
242
|
+
const result = await createPost(formData)
|
|
243
|
+
|
|
244
|
+
expect(result.success).toBe(false)
|
|
245
|
+
expect(result.error).toBe('Not authenticated')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Testing Hooks
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
// hooks/use-debounce.test.ts
|
|
254
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
255
|
+
import { renderHook, act } from '@testing-library/react'
|
|
256
|
+
import { useDebounce } from './use-debounce'
|
|
257
|
+
|
|
258
|
+
describe('useDebounce', () => {
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
vi.useFakeTimers()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
afterEach(() => {
|
|
264
|
+
vi.useRealTimers()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('returns initial value immediately', () => {
|
|
268
|
+
const { result } = renderHook(() => useDebounce('hello', 500))
|
|
269
|
+
expect(result.current).toBe('hello')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('debounces value changes', () => {
|
|
273
|
+
const { result, rerender } = renderHook(
|
|
274
|
+
({ value }) => useDebounce(value, 500),
|
|
275
|
+
{ initialProps: { value: 'hello' } }
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
rerender({ value: 'world' })
|
|
279
|
+
expect(result.current).toBe('hello') // Still old value
|
|
280
|
+
|
|
281
|
+
act(() => {
|
|
282
|
+
vi.advanceTimersByTime(500)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
expect(result.current).toBe('world') // Now updated
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Running Tests
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
# Run all tests
|
|
294
|
+
npm test
|
|
295
|
+
|
|
296
|
+
# Watch mode
|
|
297
|
+
npm test -- --watch
|
|
298
|
+
|
|
299
|
+
# Coverage report
|
|
300
|
+
npm test -- --coverage
|
|
301
|
+
|
|
302
|
+
# Run specific file
|
|
303
|
+
npm test -- button.test.tsx
|
|
304
|
+
|
|
305
|
+
# Run tests matching pattern
|
|
306
|
+
npm test -- --grep "createPost"
|
|
307
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootspring Configuration
|
|
3
|
+
* Copy this file to your project root and customize
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
// Project information
|
|
7
|
+
project: {
|
|
8
|
+
name: 'My Project',
|
|
9
|
+
description: 'A modern web application',
|
|
10
|
+
version: '0.1.0'
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
// Technology stack
|
|
14
|
+
stack: {
|
|
15
|
+
framework: 'nextjs', // nextjs, remix, astro
|
|
16
|
+
language: 'typescript', // typescript, javascript
|
|
17
|
+
database: 'postgresql', // postgresql, mysql, mongodb
|
|
18
|
+
hosting: 'vercel' // vercel, aws, railway
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// Enabled plugins
|
|
22
|
+
plugins: {
|
|
23
|
+
auth: {
|
|
24
|
+
enabled: true,
|
|
25
|
+
provider: 'clerk', // clerk, nextauth, auth0, supabase
|
|
26
|
+
features: ['social_login', 'email_password']
|
|
27
|
+
},
|
|
28
|
+
payments: {
|
|
29
|
+
enabled: false,
|
|
30
|
+
provider: 'stripe', // stripe, paddle, lemonsqueezy
|
|
31
|
+
features: ['checkout', 'subscriptions']
|
|
32
|
+
},
|
|
33
|
+
database: {
|
|
34
|
+
enabled: true,
|
|
35
|
+
provider: 'prisma', // prisma, drizzle, typeorm
|
|
36
|
+
features: ['migrations', 'transactions']
|
|
37
|
+
},
|
|
38
|
+
testing: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
provider: 'vitest', // vitest, jest, playwright
|
|
41
|
+
features: ['unit', 'integration']
|
|
42
|
+
},
|
|
43
|
+
security: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
features: ['input_validation', 'rate_limiting']
|
|
46
|
+
},
|
|
47
|
+
ai: {
|
|
48
|
+
enabled: false,
|
|
49
|
+
provider: 'anthropic', // anthropic, openai, google
|
|
50
|
+
features: ['streaming', 'tool_use']
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Dashboard settings
|
|
55
|
+
dashboard: {
|
|
56
|
+
port: 3456,
|
|
57
|
+
autoOpen: false
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// Context generation options
|
|
61
|
+
context: {
|
|
62
|
+
includeEnvVars: true,
|
|
63
|
+
includeTechStack: true,
|
|
64
|
+
includePlugins: true,
|
|
65
|
+
customSections: []
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// Quality gate settings
|
|
69
|
+
quality: {
|
|
70
|
+
preCommit: {
|
|
71
|
+
enabled: true,
|
|
72
|
+
checks: ['lint', 'typecheck']
|
|
73
|
+
},
|
|
74
|
+
prePush: {
|
|
75
|
+
enabled: true,
|
|
76
|
+
checks: ['test', 'build']
|
|
77
|
+
},
|
|
78
|
+
preDeploy: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
checks: ['security', 'coverage']
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|