create-appystack 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 +54 -0
- package/bin/index.js +243 -0
- package/package.json +39 -0
- package/template/.claude/skills/recipe/SKILL.md +71 -0
- package/template/.claude/skills/recipe/domains/care-provider-operations.md +185 -0
- package/template/.claude/skills/recipe/domains/youtube-launch-optimizer.md +154 -0
- package/template/.claude/skills/recipe/references/file-crud.md +295 -0
- package/template/.claude/skills/recipe/references/nav-shell.md +233 -0
- package/template/.dockerignore +39 -0
- package/template/.env.example +13 -0
- package/template/.github/workflows/ci.yml +43 -0
- package/template/.husky/pre-commit +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +8 -0
- package/template/.vscode/launch.json +59 -0
- package/template/CLAUDE.md +114 -0
- package/template/Dockerfile +56 -0
- package/template/README.md +219 -0
- package/template/client/index.html +13 -0
- package/template/client/package.json +43 -0
- package/template/client/src/App.test.tsx +67 -0
- package/template/client/src/App.tsx +11 -0
- package/template/client/src/components/ErrorFallback.test.tsx +64 -0
- package/template/client/src/components/ErrorFallback.tsx +18 -0
- package/template/client/src/config/env.test.ts +64 -0
- package/template/client/src/config/env.ts +34 -0
- package/template/client/src/contexts/AppContext.test.tsx +81 -0
- package/template/client/src/contexts/AppContext.tsx +52 -0
- package/template/client/src/demo/ContactForm.test.tsx +97 -0
- package/template/client/src/demo/ContactForm.tsx +100 -0
- package/template/client/src/demo/DemoPage.tsx +56 -0
- package/template/client/src/demo/SocketDemo.test.tsx +160 -0
- package/template/client/src/demo/SocketDemo.tsx +65 -0
- package/template/client/src/demo/StatusGrid.test.tsx +181 -0
- package/template/client/src/demo/StatusGrid.tsx +77 -0
- package/template/client/src/demo/TechStackDisplay.test.tsx +63 -0
- package/template/client/src/demo/TechStackDisplay.tsx +75 -0
- package/template/client/src/hooks/useServerStatus.test.ts +133 -0
- package/template/client/src/hooks/useServerStatus.ts +67 -0
- package/template/client/src/hooks/useSocket.test.ts +152 -0
- package/template/client/src/hooks/useSocket.ts +43 -0
- package/template/client/src/lib/utils.test.ts +33 -0
- package/template/client/src/lib/utils.ts +14 -0
- package/template/client/src/main.test.tsx +113 -0
- package/template/client/src/main.tsx +14 -0
- package/template/client/src/pages/LandingPage.test.tsx +30 -0
- package/template/client/src/pages/LandingPage.tsx +29 -0
- package/template/client/src/styles/index.css +50 -0
- package/template/client/src/test/msw/browser.ts +4 -0
- package/template/client/src/test/msw/handlers.ts +12 -0
- package/template/client/src/test/msw/msw-example.test.ts +69 -0
- package/template/client/src/test/msw/server.ts +14 -0
- package/template/client/src/test/setup.ts +10 -0
- package/template/client/src/utils/api.test.ts +79 -0
- package/template/client/src/utils/api.ts +42 -0
- package/template/client/src/vite-env.d.ts +13 -0
- package/template/client/tsconfig.json +17 -0
- package/template/client/vite.config.ts +38 -0
- package/template/client/vitest.config.ts +36 -0
- package/template/docker-compose.yml +19 -0
- package/template/e2e/smoke.test.ts +95 -0
- package/template/e2e/socket.test.ts +96 -0
- package/template/eslint.config.js +2 -0
- package/template/package.json +50 -0
- package/template/playwright.config.ts +14 -0
- package/template/scripts/customize.ts +175 -0
- package/template/server/nodemon.json +5 -0
- package/template/server/package.json +45 -0
- package/template/server/src/app.test.ts +103 -0
- package/template/server/src/config/env.test.ts +97 -0
- package/template/server/src/config/env.ts +29 -0
- package/template/server/src/config/logger.test.ts +58 -0
- package/template/server/src/config/logger.ts +17 -0
- package/template/server/src/helpers/response.test.ts +53 -0
- package/template/server/src/helpers/response.ts +17 -0
- package/template/server/src/index.ts +118 -0
- package/template/server/src/middleware/errorHandler.test.ts +84 -0
- package/template/server/src/middleware/errorHandler.ts +27 -0
- package/template/server/src/middleware/rateLimiter.test.ts +68 -0
- package/template/server/src/middleware/rateLimiter.ts +8 -0
- package/template/server/src/middleware/requestLogger.test.ts +111 -0
- package/template/server/src/middleware/requestLogger.ts +17 -0
- package/template/server/src/middleware/validate.test.ts +213 -0
- package/template/server/src/middleware/validate.ts +23 -0
- package/template/server/src/routes/health.test.ts +17 -0
- package/template/server/src/routes/health.ts +12 -0
- package/template/server/src/routes/info.test.ts +20 -0
- package/template/server/src/routes/info.ts +19 -0
- package/template/server/src/shared.test.ts +53 -0
- package/template/server/src/shutdown.test.ts +98 -0
- package/template/server/src/socket.test.ts +185 -0
- package/template/server/src/static.test.ts +166 -0
- package/template/server/tsconfig.json +16 -0
- package/template/server/vitest.config.ts +22 -0
- package/template/shared/package.json +19 -0
- package/template/shared/src/constants.ts +11 -0
- package/template/shared/src/index.ts +8 -0
- package/template/shared/src/types.ts +33 -0
- package/template/shared/tsconfig.json +10 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { join, dirname } from 'node:path'; // used for production static file serving below
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { Server } from 'socket.io';
|
|
6
|
+
import helmet from 'helmet';
|
|
7
|
+
import compression from 'compression';
|
|
8
|
+
import cors from 'cors';
|
|
9
|
+
import { env } from './config/env.js';
|
|
10
|
+
import { logger } from './config/logger.js';
|
|
11
|
+
import { requestLogger } from './middleware/requestLogger.js';
|
|
12
|
+
import { errorHandler } from './middleware/errorHandler.js';
|
|
13
|
+
import { apiLimiter } from './middleware/rateLimiter.js';
|
|
14
|
+
import healthRouter from './routes/health.js';
|
|
15
|
+
import infoRouter from './routes/info.js';
|
|
16
|
+
import type { ServerToClientEvents, ClientToServerEvents } from '@appystack-template/shared';
|
|
17
|
+
import { SOCKET_EVENTS } from '@appystack-template/shared';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = dirname(__filename);
|
|
21
|
+
|
|
22
|
+
const app = express();
|
|
23
|
+
const httpServer = createServer(app);
|
|
24
|
+
|
|
25
|
+
const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer, {
|
|
26
|
+
cors: {
|
|
27
|
+
origin: env.CLIENT_URL,
|
|
28
|
+
methods: ['GET', 'POST'],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Middleware
|
|
33
|
+
app.use(helmet());
|
|
34
|
+
app.use(compression());
|
|
35
|
+
app.use(cors({ origin: env.CLIENT_URL }));
|
|
36
|
+
app.use(express.json());
|
|
37
|
+
app.use(requestLogger);
|
|
38
|
+
|
|
39
|
+
// Rate limiting — apply before all routes
|
|
40
|
+
app.use(apiLimiter);
|
|
41
|
+
|
|
42
|
+
// Routes
|
|
43
|
+
app.use(healthRouter);
|
|
44
|
+
app.use(infoRouter);
|
|
45
|
+
|
|
46
|
+
// Production static file serving — serve the built client app
|
|
47
|
+
if (env.isProduction) {
|
|
48
|
+
const clientDist = join(__dirname, '../../client/dist');
|
|
49
|
+
app.use(express.static(clientDist));
|
|
50
|
+
|
|
51
|
+
// SPA fallback — serve index.html for all non-API routes
|
|
52
|
+
app.get('*splat', (_req, res) => {
|
|
53
|
+
res.sendFile(join(clientDist, 'index.html'));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 404 catch-all — must be after all routes (only reached in non-production for unknown API routes)
|
|
58
|
+
app.use((_req, res) => {
|
|
59
|
+
res.status(404).json({
|
|
60
|
+
status: 'error',
|
|
61
|
+
error: 'Not found',
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Global error handler — must be last middleware (4 params)
|
|
67
|
+
app.use(errorHandler);
|
|
68
|
+
|
|
69
|
+
// Socket.io
|
|
70
|
+
io.on('connection', (socket) => {
|
|
71
|
+
// Auth pattern — uncomment and adapt for your app:
|
|
72
|
+
// const token = socket.handshake.auth.token as string | undefined;
|
|
73
|
+
// if (!token) { socket.disconnect(); return; }
|
|
74
|
+
// try {
|
|
75
|
+
// const payload = verifyToken(token); // replace with your JWT verify function
|
|
76
|
+
// socket.data.userId = payload.sub;
|
|
77
|
+
// } catch {
|
|
78
|
+
// socket.disconnect();
|
|
79
|
+
// return;
|
|
80
|
+
// }
|
|
81
|
+
logger.info({ socketId: socket.id }, 'Client connected');
|
|
82
|
+
|
|
83
|
+
socket.on(SOCKET_EVENTS.CLIENT_PING, () => {
|
|
84
|
+
logger.info({ socketId: socket.id }, 'Received client:ping');
|
|
85
|
+
socket.emit(SOCKET_EVENTS.SERVER_PONG, {
|
|
86
|
+
message: 'pong',
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
socket.on('disconnect', () => {
|
|
92
|
+
logger.info({ socketId: socket.id }, 'Client disconnected');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Start server — skip in test environment to prevent EADDRINUSE when multiple
|
|
97
|
+
// test files import this module. Tests use supertest with app directly.
|
|
98
|
+
if (!env.isTest) {
|
|
99
|
+
httpServer.listen(env.PORT, () => {
|
|
100
|
+
logger.info(`Server running on http://localhost:${env.PORT}`);
|
|
101
|
+
logger.info(`Client URL: ${env.CLIENT_URL}`);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Graceful shutdown
|
|
106
|
+
const shutdown = () => {
|
|
107
|
+
logger.info('Shutting down gracefully...');
|
|
108
|
+
io.close();
|
|
109
|
+
httpServer.close(() => {
|
|
110
|
+
logger.info('Server closed');
|
|
111
|
+
process.exit(0);
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
process.on('SIGTERM', shutdown);
|
|
116
|
+
process.on('SIGINT', shutdown);
|
|
117
|
+
|
|
118
|
+
export { app, httpServer };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express, { type Request, type Response, type NextFunction } from 'express';
|
|
4
|
+
import { AppError, errorHandler } from './errorHandler.js';
|
|
5
|
+
|
|
6
|
+
function buildApp(thrower: (req: Request, res: Response, next: NextFunction) => void) {
|
|
7
|
+
const app = express();
|
|
8
|
+
app.get('/test', thrower);
|
|
9
|
+
app.use(errorHandler);
|
|
10
|
+
return app;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('AppError', () => {
|
|
14
|
+
it('stores statusCode and message', () => {
|
|
15
|
+
const err = new AppError(422, 'Unprocessable');
|
|
16
|
+
expect(err.statusCode).toBe(422);
|
|
17
|
+
expect(err.message).toBe('Unprocessable');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('defaults isOperational to true', () => {
|
|
21
|
+
const err = new AppError(400, 'Bad input');
|
|
22
|
+
expect(err.isOperational).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('allows isOperational to be set false', () => {
|
|
26
|
+
const err = new AppError(500, 'Crash', false);
|
|
27
|
+
expect(err.isOperational).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is an instance of Error', () => {
|
|
31
|
+
const err = new AppError(404, 'Not found');
|
|
32
|
+
expect(err).toBeInstanceOf(Error);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('errorHandler middleware', () => {
|
|
37
|
+
it('returns the AppError statusCode and message when isOperational is true', async () => {
|
|
38
|
+
const app = buildApp((_req, _res, next) => {
|
|
39
|
+
next(new AppError(404, 'Resource not found'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const res = await request(app).get('/test');
|
|
43
|
+
|
|
44
|
+
expect(res.status).toBe(404);
|
|
45
|
+
expect(res.body.status).toBe('error');
|
|
46
|
+
expect(res.body.error).toBe('Resource not found');
|
|
47
|
+
expect(res.body.timestamp).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns 500 and generic message for non-operational AppError', async () => {
|
|
51
|
+
const app = buildApp((_req, _res, next) => {
|
|
52
|
+
next(new AppError(500, 'Internal detail', false));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const res = await request(app).get('/test');
|
|
56
|
+
|
|
57
|
+
expect(res.status).toBe(500);
|
|
58
|
+
expect(res.body.status).toBe('error');
|
|
59
|
+
expect(res.body.error).toBe('Internal server error');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns 500 and generic message for unknown (non-AppError) errors', async () => {
|
|
63
|
+
const app = buildApp((_req, _res, next) => {
|
|
64
|
+
next(new Error('Unexpected crash'));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const res = await request(app).get('/test');
|
|
68
|
+
|
|
69
|
+
expect(res.status).toBe(500);
|
|
70
|
+
expect(res.body.status).toBe('error');
|
|
71
|
+
expect(res.body.error).toBe('Internal server error');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns the correct status for a 403 AppError', async () => {
|
|
75
|
+
const app = buildApp((_req, _res, next) => {
|
|
76
|
+
next(new AppError(403, 'Forbidden'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const res = await request(app).get('/test');
|
|
80
|
+
|
|
81
|
+
expect(res.status).toBe(403);
|
|
82
|
+
expect(res.body.error).toBe('Forbidden');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { logger } from '../config/logger.js';
|
|
3
|
+
|
|
4
|
+
export class AppError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
public statusCode: number,
|
|
7
|
+
message: string,
|
|
8
|
+
public isOperational = true
|
|
9
|
+
) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'AppError';
|
|
12
|
+
Error.captureStackTrace(this, this.constructor);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
|
|
17
|
+
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
|
18
|
+
const isOperational = err instanceof AppError ? err.isOperational : false;
|
|
19
|
+
|
|
20
|
+
logger.error({ err, statusCode, isOperational }, err.message);
|
|
21
|
+
|
|
22
|
+
res.status(statusCode).json({
|
|
23
|
+
status: 'error',
|
|
24
|
+
error: isOperational ? err.message : 'Internal server error',
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import rateLimit from 'express-rate-limit';
|
|
5
|
+
|
|
6
|
+
// Build a test-specific limiter with a very low limit so we can trigger 429 without
|
|
7
|
+
// sending 100 real requests.
|
|
8
|
+
function buildApp(limit: number) {
|
|
9
|
+
const testLimiter = rateLimit({
|
|
10
|
+
windowMs: 60 * 1000,
|
|
11
|
+
limit,
|
|
12
|
+
standardHeaders: 'draft-8',
|
|
13
|
+
legacyHeaders: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(testLimiter);
|
|
18
|
+
app.get('/ping', (_req, res) => {
|
|
19
|
+
res.json({ ok: true });
|
|
20
|
+
});
|
|
21
|
+
return app;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('rateLimiter middleware', () => {
|
|
25
|
+
it('allows requests under the limit', async () => {
|
|
26
|
+
const app = buildApp(5);
|
|
27
|
+
|
|
28
|
+
const res = await request(app).get('/ping');
|
|
29
|
+
|
|
30
|
+
expect(res.status).toBe(200);
|
|
31
|
+
expect(res.body.ok).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns 429 once the per-window limit is exceeded', async () => {
|
|
35
|
+
const app = buildApp(3);
|
|
36
|
+
|
|
37
|
+
// Send 3 allowed requests
|
|
38
|
+
for (let i = 0; i < 3; i++) {
|
|
39
|
+
const res = await request(app).get('/ping');
|
|
40
|
+
expect(res.status).toBe(200);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The 4th request should be rate-limited
|
|
44
|
+
const limited = await request(app).get('/ping');
|
|
45
|
+
expect(limited.status).toBe(429);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('includes RateLimit headers on successful responses', async () => {
|
|
49
|
+
const app = buildApp(10);
|
|
50
|
+
|
|
51
|
+
const res = await request(app).get('/ping');
|
|
52
|
+
|
|
53
|
+
// draft-8 headers use RateLimit-* format
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
// At least one rate-limit related header should be present
|
|
56
|
+
const headers = Object.keys(res.headers).join(' ').toLowerCase();
|
|
57
|
+
expect(headers).toMatch(/ratelimit/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not set legacy X-RateLimit headers', async () => {
|
|
61
|
+
const app = buildApp(10);
|
|
62
|
+
|
|
63
|
+
const res = await request(app).get('/ping');
|
|
64
|
+
|
|
65
|
+
expect(res.headers['x-ratelimit-limit']).toBeUndefined();
|
|
66
|
+
expect(res.headers['x-ratelimit-remaining']).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { requestLogger } from './requestLogger.js';
|
|
5
|
+
import { logger } from '../config/logger.js';
|
|
6
|
+
|
|
7
|
+
function buildApp() {
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use(requestLogger);
|
|
10
|
+
app.get('/ok', (_req, res) => {
|
|
11
|
+
res.json({ ok: true });
|
|
12
|
+
});
|
|
13
|
+
app.get('/client-error', (_req, res) => {
|
|
14
|
+
res.status(404).json({ error: 'not found' });
|
|
15
|
+
});
|
|
16
|
+
app.get('/server-error', (_req, res) => {
|
|
17
|
+
res.status(500).json({ error: 'crash' });
|
|
18
|
+
});
|
|
19
|
+
app.get('/redirect', (_req, res) => {
|
|
20
|
+
res.status(301).redirect('/ok');
|
|
21
|
+
});
|
|
22
|
+
return app;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('requestLogger middleware', () => {
|
|
26
|
+
it('allows a successful request to pass through', async () => {
|
|
27
|
+
const res = await request(buildApp()).get('/ok');
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
expect(res.body.ok).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not block 4xx responses', async () => {
|
|
33
|
+
const res = await request(buildApp()).get('/client-error');
|
|
34
|
+
expect(res.status).toBe(404);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not block 5xx responses', async () => {
|
|
38
|
+
const res = await request(buildApp()).get('/server-error');
|
|
39
|
+
expect(res.status).toBe(500);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('assigns a unique request id', async () => {
|
|
43
|
+
const app = buildApp();
|
|
44
|
+
const res1 = await request(app).get('/ok');
|
|
45
|
+
const res2 = await request(app).get('/ok');
|
|
46
|
+
// Both requests succeed; id is internal but pino-http attaches nothing to body
|
|
47
|
+
expect(res1.status).toBe(200);
|
|
48
|
+
expect(res2.status).toBe(200);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('requestLogger customLogLevel', () => {
|
|
53
|
+
// pino-http calls methods on a child logger created via logger.child({req}).
|
|
54
|
+
// We intercept logger.child to return a controlled mock so we can assert
|
|
55
|
+
// which log level method is actually invoked for each HTTP status code.
|
|
56
|
+
|
|
57
|
+
interface ChildMock {
|
|
58
|
+
info: ReturnType<typeof vi.fn>;
|
|
59
|
+
warn: ReturnType<typeof vi.fn>;
|
|
60
|
+
error: ReturnType<typeof vi.fn>;
|
|
61
|
+
child: ReturnType<typeof vi.fn>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let childMock: ChildMock;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
childMock = {
|
|
68
|
+
info: vi.fn(),
|
|
69
|
+
warn: vi.fn(),
|
|
70
|
+
error: vi.fn(),
|
|
71
|
+
child: vi.fn(),
|
|
72
|
+
};
|
|
73
|
+
// child().child() may be called too — wire up nested child to same mock
|
|
74
|
+
childMock.child.mockReturnValue(childMock);
|
|
75
|
+
vi.spyOn(logger, 'child').mockReturnValue(
|
|
76
|
+
childMock as unknown as ReturnType<typeof logger.child>
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
vi.restoreAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('logs at info level for 2xx responses', async () => {
|
|
85
|
+
await request(buildApp()).get('/ok');
|
|
86
|
+
expect(childMock.info).toHaveBeenCalled();
|
|
87
|
+
expect(childMock.warn).not.toHaveBeenCalled();
|
|
88
|
+
expect(childMock.error).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('logs at info level for 3xx responses', async () => {
|
|
92
|
+
await request(buildApp()).get('/redirect').redirects(0);
|
|
93
|
+
expect(childMock.info).toHaveBeenCalled();
|
|
94
|
+
expect(childMock.warn).not.toHaveBeenCalled();
|
|
95
|
+
expect(childMock.error).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('logs at warn level for 4xx responses', async () => {
|
|
99
|
+
await request(buildApp()).get('/client-error');
|
|
100
|
+
expect(childMock.warn).toHaveBeenCalled();
|
|
101
|
+
expect(childMock.info).not.toHaveBeenCalled();
|
|
102
|
+
expect(childMock.error).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('logs at error level for 5xx responses', async () => {
|
|
106
|
+
await request(buildApp()).get('/server-error');
|
|
107
|
+
expect(childMock.error).toHaveBeenCalled();
|
|
108
|
+
expect(childMock.info).not.toHaveBeenCalled();
|
|
109
|
+
expect(childMock.warn).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pinoHttp from 'pino-http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { logger } from '../config/logger.js';
|
|
4
|
+
|
|
5
|
+
export const requestLogger = pinoHttp({
|
|
6
|
+
logger,
|
|
7
|
+
genReqId: () => crypto.randomUUID(),
|
|
8
|
+
customLogLevel: (_req, res) => {
|
|
9
|
+
const status = res.statusCode;
|
|
10
|
+
if (status >= 500) return 'error';
|
|
11
|
+
if (status >= 400) return 'warn';
|
|
12
|
+
return 'info';
|
|
13
|
+
},
|
|
14
|
+
customSuccessMessage: (req, res) => {
|
|
15
|
+
return `${req.method} ${req.url} ${res.statusCode}`;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express, { type Request, type Response, type NextFunction } from 'express';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { validate } from './validate.js';
|
|
6
|
+
|
|
7
|
+
// Attach a catch-all error handler so unhandled errors produce a JSON body for assertions
|
|
8
|
+
function withErrorHandler(app: ReturnType<typeof express>) {
|
|
9
|
+
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
10
|
+
res.status(500).json({ status: 'error', error: err.message });
|
|
11
|
+
});
|
|
12
|
+
return app;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const nameSchema = z.object({ name: z.string().min(1) });
|
|
16
|
+
const paramsSchema = z.object({ id: z.string().uuid() });
|
|
17
|
+
|
|
18
|
+
function buildBodyApp() {
|
|
19
|
+
const app = express();
|
|
20
|
+
app.use(express.json());
|
|
21
|
+
app.post('/items', validate({ body: nameSchema }), (req, res) => {
|
|
22
|
+
res.json({ received: req.body });
|
|
23
|
+
});
|
|
24
|
+
return withErrorHandler(app);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildParamsApp() {
|
|
28
|
+
const app = express();
|
|
29
|
+
app.get('/items/:id', validate({ params: paramsSchema }), (req, res) => {
|
|
30
|
+
res.json({ id: req.params.id });
|
|
31
|
+
});
|
|
32
|
+
return withErrorHandler(app);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Query-only validation: Zod parses req.query without mutating it.
|
|
36
|
+
// We verify invalid inputs are rejected with 400 (Zod error path).
|
|
37
|
+
// We do not test valid-input body passthrough here because req.query
|
|
38
|
+
// is a getter-only property in Express 5 / Node's IncomingMessage;
|
|
39
|
+
// assigning the parsed value would throw TypeError.
|
|
40
|
+
function buildQueryValidationApp() {
|
|
41
|
+
const querySchema = z.object({ page: z.string().regex(/^\d+$/, 'must be digits only') });
|
|
42
|
+
const app = express();
|
|
43
|
+
app.get('/items', validate({ query: querySchema }), (_req, res) => {
|
|
44
|
+
res.json({ ok: true });
|
|
45
|
+
});
|
|
46
|
+
return withErrorHandler(app);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('validate middleware — body', () => {
|
|
50
|
+
const app = buildBodyApp();
|
|
51
|
+
|
|
52
|
+
it('passes valid body to the route handler', async () => {
|
|
53
|
+
const res = await request(app)
|
|
54
|
+
.post('/items')
|
|
55
|
+
.send({ name: 'Widget' })
|
|
56
|
+
.set('Content-Type', 'application/json');
|
|
57
|
+
|
|
58
|
+
expect(res.status).toBe(200);
|
|
59
|
+
expect(res.body.received.name).toBe('Widget');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns 400 with Zod errors when body field fails validation', async () => {
|
|
63
|
+
const res = await request(app)
|
|
64
|
+
.post('/items')
|
|
65
|
+
.send({ name: '' })
|
|
66
|
+
.set('Content-Type', 'application/json');
|
|
67
|
+
|
|
68
|
+
expect(res.status).toBe(400);
|
|
69
|
+
expect(res.body.status).toBe('error');
|
|
70
|
+
expect(typeof res.body.error).toBe('string');
|
|
71
|
+
expect(res.body.timestamp).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns 400 when required body field is missing', async () => {
|
|
75
|
+
const res = await request(app).post('/items').send({}).set('Content-Type', 'application/json');
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(400);
|
|
78
|
+
expect(res.body.status).toBe('error');
|
|
79
|
+
expect(typeof res.body.error).toBe('string');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('validate middleware — query', () => {
|
|
84
|
+
const app = buildQueryValidationApp();
|
|
85
|
+
|
|
86
|
+
it('returns 400 with Zod errors when query parameter fails validation', async () => {
|
|
87
|
+
const res = await request(app).get('/items?page=abc');
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(400);
|
|
90
|
+
expect(res.body.status).toBe('error');
|
|
91
|
+
expect(typeof res.body.error).toBe('string');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns 400 when required query parameter is missing', async () => {
|
|
95
|
+
const res = await request(app).get('/items');
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(400);
|
|
98
|
+
expect(res.body.status).toBe('error');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('validate middleware — params', () => {
|
|
103
|
+
const app = buildParamsApp();
|
|
104
|
+
|
|
105
|
+
it('passes valid UUID param to the route handler', async () => {
|
|
106
|
+
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
107
|
+
const res = await request(app).get(`/items/${uuid}`);
|
|
108
|
+
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(res.body.id).toBe(uuid);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns 400 with Zod errors for invalid UUID param', async () => {
|
|
114
|
+
const res = await request(app).get('/items/not-a-uuid');
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(400);
|
|
117
|
+
expect(res.body.status).toBe('error');
|
|
118
|
+
expect(typeof res.body.error).toBe('string');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Multi-schema validation: body + query validated together in a single middleware call
|
|
123
|
+
const multiBodySchema = z.object({ name: z.string().min(1) });
|
|
124
|
+
const multiQuerySchema = z.object({ page: z.string().regex(/^\d+$/, 'must be digits only') });
|
|
125
|
+
|
|
126
|
+
function buildMultiSchemaApp() {
|
|
127
|
+
const app = express();
|
|
128
|
+
app.use(express.json());
|
|
129
|
+
app.post('/multi', validate({ body: multiBodySchema, query: multiQuerySchema }), (req, res) => {
|
|
130
|
+
res.json({ name: req.body.name, page: req.query['page'] });
|
|
131
|
+
});
|
|
132
|
+
return withErrorHandler(app);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('validate middleware — multi-schema (body + query)', () => {
|
|
136
|
+
const app = buildMultiSchemaApp();
|
|
137
|
+
|
|
138
|
+
it('passes when both body and query satisfy their schemas', async () => {
|
|
139
|
+
const res = await request(app)
|
|
140
|
+
.post('/multi?page=1')
|
|
141
|
+
.send({ name: 'Widget' })
|
|
142
|
+
.set('Content-Type', 'application/json');
|
|
143
|
+
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
expect(res.body.name).toBe('Widget');
|
|
146
|
+
expect(res.body.page).toBe('1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns 400 when body fails validation but query is valid', async () => {
|
|
150
|
+
const res = await request(app)
|
|
151
|
+
.post('/multi?page=1')
|
|
152
|
+
.send({ name: '' })
|
|
153
|
+
.set('Content-Type', 'application/json');
|
|
154
|
+
|
|
155
|
+
expect(res.status).toBe(400);
|
|
156
|
+
expect(res.body.status).toBe('error');
|
|
157
|
+
expect(typeof res.body.error).toBe('string');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns 400 when query fails validation but body is valid', async () => {
|
|
161
|
+
const res = await request(app)
|
|
162
|
+
.post('/multi?page=abc')
|
|
163
|
+
.send({ name: 'Widget' })
|
|
164
|
+
.set('Content-Type', 'application/json');
|
|
165
|
+
|
|
166
|
+
expect(res.status).toBe(400);
|
|
167
|
+
expect(res.body.status).toBe('error');
|
|
168
|
+
expect(typeof res.body.error).toBe('string');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns 400 when both body and query fail validation', async () => {
|
|
172
|
+
const res = await request(app)
|
|
173
|
+
.post('/multi?page=abc')
|
|
174
|
+
.send({ name: '' })
|
|
175
|
+
.set('Content-Type', 'application/json');
|
|
176
|
+
|
|
177
|
+
expect(res.status).toBe(400);
|
|
178
|
+
expect(res.body.status).toBe('error');
|
|
179
|
+
expect(typeof res.body.error).toBe('string');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Non-Zod error propagation: schema throws a plain Error → next(err) rather than 400
|
|
184
|
+
function buildThrowingApp() {
|
|
185
|
+
const throwingSchema = {
|
|
186
|
+
parse: () => {
|
|
187
|
+
throw new Error('unexpected schema failure');
|
|
188
|
+
},
|
|
189
|
+
} as unknown as z.ZodType;
|
|
190
|
+
|
|
191
|
+
const app = express();
|
|
192
|
+
app.use(express.json());
|
|
193
|
+
app.post('/throw', validate({ body: throwingSchema }), (_req, res) => {
|
|
194
|
+
res.json({ ok: true });
|
|
195
|
+
});
|
|
196
|
+
return withErrorHandler(app);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
describe('validate middleware — non-Zod error propagation', () => {
|
|
200
|
+
const app = buildThrowingApp();
|
|
201
|
+
|
|
202
|
+
it('calls next(err) and reaches the error handler when schema throws a plain Error', async () => {
|
|
203
|
+
const res = await request(app)
|
|
204
|
+
.post('/throw')
|
|
205
|
+
.send({ any: 'data' })
|
|
206
|
+
.set('Content-Type', 'application/json');
|
|
207
|
+
|
|
208
|
+
// The catch-all error handler (added by withErrorHandler) returns 500 for non-Zod errors
|
|
209
|
+
expect(res.status).toBe(500);
|
|
210
|
+
expect(res.body.status).toBe('error');
|
|
211
|
+
expect(res.body.error).toBe('unexpected schema failure');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z, ZodError } from 'zod';
|
|
2
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
3
|
+
|
|
4
|
+
export function validate(schema: { body?: z.ZodType; query?: z.ZodType; params?: z.ZodType }) {
|
|
5
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
6
|
+
try {
|
|
7
|
+
if (schema.body) req.body = schema.body.parse(req.body);
|
|
8
|
+
// Express 5: req.query is a getter — use Object.assign to mutate in place
|
|
9
|
+
if (schema.query) Object.assign(req.query, schema.query.parse(req.query));
|
|
10
|
+
if (schema.params) req.params = schema.params.parse(req.params);
|
|
11
|
+
next();
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (err instanceof ZodError) {
|
|
14
|
+
const message = err.errors.map((e) => e.message).join('; ');
|
|
15
|
+
res
|
|
16
|
+
.status(400)
|
|
17
|
+
.json({ status: 'error', error: message, timestamp: new Date().toISOString() });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
next(err);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import healthRouter from './health.js';
|
|
5
|
+
|
|
6
|
+
const app = express();
|
|
7
|
+
app.use(healthRouter);
|
|
8
|
+
|
|
9
|
+
describe('GET /health', () => {
|
|
10
|
+
it('returns ok status', async () => {
|
|
11
|
+
const res = await request(app).get('/health');
|
|
12
|
+
expect(res.status).toBe(200);
|
|
13
|
+
expect(res.body.status).toBe('ok');
|
|
14
|
+
expect(res.body.timestamp).toBeDefined();
|
|
15
|
+
expect(res.body.data.status).toBe('ok');
|
|
16
|
+
});
|
|
17
|
+
});
|