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.
Files changed (99) hide show
  1. package/README.md +54 -0
  2. package/bin/index.js +243 -0
  3. package/package.json +39 -0
  4. package/template/.claude/skills/recipe/SKILL.md +71 -0
  5. package/template/.claude/skills/recipe/domains/care-provider-operations.md +185 -0
  6. package/template/.claude/skills/recipe/domains/youtube-launch-optimizer.md +154 -0
  7. package/template/.claude/skills/recipe/references/file-crud.md +295 -0
  8. package/template/.claude/skills/recipe/references/nav-shell.md +233 -0
  9. package/template/.dockerignore +39 -0
  10. package/template/.env.example +13 -0
  11. package/template/.github/workflows/ci.yml +43 -0
  12. package/template/.husky/pre-commit +1 -0
  13. package/template/.prettierignore +7 -0
  14. package/template/.prettierrc +8 -0
  15. package/template/.vscode/launch.json +59 -0
  16. package/template/CLAUDE.md +114 -0
  17. package/template/Dockerfile +56 -0
  18. package/template/README.md +219 -0
  19. package/template/client/index.html +13 -0
  20. package/template/client/package.json +43 -0
  21. package/template/client/src/App.test.tsx +67 -0
  22. package/template/client/src/App.tsx +11 -0
  23. package/template/client/src/components/ErrorFallback.test.tsx +64 -0
  24. package/template/client/src/components/ErrorFallback.tsx +18 -0
  25. package/template/client/src/config/env.test.ts +64 -0
  26. package/template/client/src/config/env.ts +34 -0
  27. package/template/client/src/contexts/AppContext.test.tsx +81 -0
  28. package/template/client/src/contexts/AppContext.tsx +52 -0
  29. package/template/client/src/demo/ContactForm.test.tsx +97 -0
  30. package/template/client/src/demo/ContactForm.tsx +100 -0
  31. package/template/client/src/demo/DemoPage.tsx +56 -0
  32. package/template/client/src/demo/SocketDemo.test.tsx +160 -0
  33. package/template/client/src/demo/SocketDemo.tsx +65 -0
  34. package/template/client/src/demo/StatusGrid.test.tsx +181 -0
  35. package/template/client/src/demo/StatusGrid.tsx +77 -0
  36. package/template/client/src/demo/TechStackDisplay.test.tsx +63 -0
  37. package/template/client/src/demo/TechStackDisplay.tsx +75 -0
  38. package/template/client/src/hooks/useServerStatus.test.ts +133 -0
  39. package/template/client/src/hooks/useServerStatus.ts +67 -0
  40. package/template/client/src/hooks/useSocket.test.ts +152 -0
  41. package/template/client/src/hooks/useSocket.ts +43 -0
  42. package/template/client/src/lib/utils.test.ts +33 -0
  43. package/template/client/src/lib/utils.ts +14 -0
  44. package/template/client/src/main.test.tsx +113 -0
  45. package/template/client/src/main.tsx +14 -0
  46. package/template/client/src/pages/LandingPage.test.tsx +30 -0
  47. package/template/client/src/pages/LandingPage.tsx +29 -0
  48. package/template/client/src/styles/index.css +50 -0
  49. package/template/client/src/test/msw/browser.ts +4 -0
  50. package/template/client/src/test/msw/handlers.ts +12 -0
  51. package/template/client/src/test/msw/msw-example.test.ts +69 -0
  52. package/template/client/src/test/msw/server.ts +14 -0
  53. package/template/client/src/test/setup.ts +10 -0
  54. package/template/client/src/utils/api.test.ts +79 -0
  55. package/template/client/src/utils/api.ts +42 -0
  56. package/template/client/src/vite-env.d.ts +13 -0
  57. package/template/client/tsconfig.json +17 -0
  58. package/template/client/vite.config.ts +38 -0
  59. package/template/client/vitest.config.ts +36 -0
  60. package/template/docker-compose.yml +19 -0
  61. package/template/e2e/smoke.test.ts +95 -0
  62. package/template/e2e/socket.test.ts +96 -0
  63. package/template/eslint.config.js +2 -0
  64. package/template/package.json +50 -0
  65. package/template/playwright.config.ts +14 -0
  66. package/template/scripts/customize.ts +175 -0
  67. package/template/server/nodemon.json +5 -0
  68. package/template/server/package.json +45 -0
  69. package/template/server/src/app.test.ts +103 -0
  70. package/template/server/src/config/env.test.ts +97 -0
  71. package/template/server/src/config/env.ts +29 -0
  72. package/template/server/src/config/logger.test.ts +58 -0
  73. package/template/server/src/config/logger.ts +17 -0
  74. package/template/server/src/helpers/response.test.ts +53 -0
  75. package/template/server/src/helpers/response.ts +17 -0
  76. package/template/server/src/index.ts +118 -0
  77. package/template/server/src/middleware/errorHandler.test.ts +84 -0
  78. package/template/server/src/middleware/errorHandler.ts +27 -0
  79. package/template/server/src/middleware/rateLimiter.test.ts +68 -0
  80. package/template/server/src/middleware/rateLimiter.ts +8 -0
  81. package/template/server/src/middleware/requestLogger.test.ts +111 -0
  82. package/template/server/src/middleware/requestLogger.ts +17 -0
  83. package/template/server/src/middleware/validate.test.ts +213 -0
  84. package/template/server/src/middleware/validate.ts +23 -0
  85. package/template/server/src/routes/health.test.ts +17 -0
  86. package/template/server/src/routes/health.ts +12 -0
  87. package/template/server/src/routes/info.test.ts +20 -0
  88. package/template/server/src/routes/info.ts +19 -0
  89. package/template/server/src/shared.test.ts +53 -0
  90. package/template/server/src/shutdown.test.ts +98 -0
  91. package/template/server/src/socket.test.ts +185 -0
  92. package/template/server/src/static.test.ts +166 -0
  93. package/template/server/tsconfig.json +16 -0
  94. package/template/server/vitest.config.ts +22 -0
  95. package/template/shared/package.json +19 -0
  96. package/template/shared/src/constants.ts +11 -0
  97. package/template/shared/src/index.ts +8 -0
  98. package/template/shared/src/types.ts +33 -0
  99. package/template/shared/tsconfig.json +10 -0
@@ -0,0 +1,175 @@
1
+ import { intro, outro, text, cancel, isCancel } from '@clack/prompts';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const ROOT = resolve(__dirname, '..');
8
+
9
+ function readFile(relPath: string): string {
10
+ return readFileSync(resolve(ROOT, relPath), 'utf-8');
11
+ }
12
+
13
+ function writeFile(relPath: string, content: string): void {
14
+ writeFileSync(resolve(ROOT, relPath), content, 'utf-8');
15
+ }
16
+
17
+ function replaceAll(content: string, from: string, to: string): string {
18
+ return content.split(from).join(to);
19
+ }
20
+
21
+ async function main(): Promise<void> {
22
+ intro('AppyStack Template Customization');
23
+
24
+ const projectName = await text({
25
+ message: 'Project name (e.g. my-app)',
26
+ placeholder: 'my-app',
27
+ validate(value) {
28
+ if (!value || value.trim().length === 0) return 'Project name is required';
29
+ if (!/^[a-z0-9-]+$/.test(value.trim()))
30
+ return 'Use lowercase letters, numbers, and hyphens only';
31
+ },
32
+ });
33
+ if (isCancel(projectName)) {
34
+ cancel('Customization cancelled.');
35
+ process.exit(0);
36
+ }
37
+
38
+ const packageScope = await text({
39
+ message: 'Package scope (e.g. @myorg)',
40
+ placeholder: '@myorg',
41
+ validate(value) {
42
+ if (!value || value.trim().length === 0) return 'Package scope is required';
43
+ if (!value.trim().startsWith('@')) return 'Scope must start with @';
44
+ if (!/^@[a-z0-9-]+$/.test(value.trim())) return 'Use @lowercase-letters-numbers-hyphens only';
45
+ },
46
+ });
47
+ if (isCancel(packageScope)) {
48
+ cancel('Customization cancelled.');
49
+ process.exit(0);
50
+ }
51
+
52
+ const serverPortInput = await text({
53
+ message: 'Server port',
54
+ placeholder: '5501',
55
+ initialValue: '5501',
56
+ validate(value) {
57
+ const port = Number(value);
58
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
59
+ return 'Enter a valid port number (1–65535)';
60
+ },
61
+ });
62
+ if (isCancel(serverPortInput)) {
63
+ cancel('Customization cancelled.');
64
+ process.exit(0);
65
+ }
66
+
67
+ const clientPortInput = await text({
68
+ message: 'Client port',
69
+ placeholder: '5500',
70
+ initialValue: '5500',
71
+ validate(value) {
72
+ const port = Number(value);
73
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
74
+ return 'Enter a valid port number (1–65535)';
75
+ },
76
+ });
77
+ if (isCancel(clientPortInput)) {
78
+ cancel('Customization cancelled.');
79
+ process.exit(0);
80
+ }
81
+
82
+ const description = await text({
83
+ message: 'Project description',
84
+ placeholder: 'My awesome app',
85
+ validate(value) {
86
+ if (!value || value.trim().length === 0) return 'Description is required';
87
+ },
88
+ });
89
+ if (isCancel(description)) {
90
+ cancel('Customization cancelled.');
91
+ process.exit(0);
92
+ }
93
+
94
+ const name = (projectName as string).trim();
95
+ const scope = (packageScope as string).trim();
96
+ const serverPort = (serverPortInput as string).trim();
97
+ const clientPort = (clientPortInput as string).trim();
98
+ const desc = (description as string).trim();
99
+
100
+ const oldScope = '@appystack-template';
101
+ const oldRootName = '@appydave/appystack-template';
102
+ const oldServerPort = '5501';
103
+ const oldClientPort = '5500';
104
+ const oldTitle = 'AppyStack Template';
105
+ const oldDescription = 'RVETS stack boilerplate (React, Vite, Express, TypeScript, Socket.io)';
106
+
107
+ // --- Root package.json ---
108
+ let rootPkg = readFile('package.json');
109
+ rootPkg = replaceAll(rootPkg, oldRootName, `${scope}/${name}`);
110
+ rootPkg = replaceAll(rootPkg, oldDescription, desc);
111
+ writeFile('package.json', rootPkg);
112
+
113
+ // --- shared/package.json ---
114
+ let sharedPkg = readFile('shared/package.json');
115
+ sharedPkg = replaceAll(sharedPkg, oldScope, scope);
116
+ writeFile('shared/package.json', sharedPkg);
117
+
118
+ // --- server/package.json ---
119
+ let serverPkg = readFile('server/package.json');
120
+ serverPkg = replaceAll(serverPkg, oldScope, scope);
121
+ writeFile('server/package.json', serverPkg);
122
+
123
+ // --- client/package.json ---
124
+ let clientPkg = readFile('client/package.json');
125
+ clientPkg = replaceAll(clientPkg, oldScope, scope);
126
+ writeFile('client/package.json', clientPkg);
127
+
128
+ // --- .env.example ---
129
+ let envExample = readFile('.env.example');
130
+ envExample = replaceAll(envExample, `PORT=${oldServerPort}`, `PORT=${serverPort}`);
131
+ envExample = replaceAll(
132
+ envExample,
133
+ `CLIENT_URL=http://localhost:${oldClientPort}`,
134
+ `CLIENT_URL=http://localhost:${clientPort}`
135
+ );
136
+ writeFile('.env.example', envExample);
137
+
138
+ // --- server/src/config/env.ts ---
139
+ let envTs = readFile('server/src/config/env.ts');
140
+ envTs = replaceAll(
141
+ envTs,
142
+ `PORT: z.coerce.number().default(${oldServerPort})`,
143
+ `PORT: z.coerce.number().default(${serverPort})`
144
+ );
145
+ envTs = replaceAll(
146
+ envTs,
147
+ `CLIENT_URL: z.string().default('http://localhost:${oldClientPort}')`,
148
+ `CLIENT_URL: z.string().default('http://localhost:${clientPort}')`
149
+ );
150
+ writeFile('server/src/config/env.ts', envTs);
151
+
152
+ // --- client/vite.config.ts ---
153
+ let viteConfig = readFile('client/vite.config.ts');
154
+ viteConfig = replaceAll(viteConfig, `port: ${oldClientPort}`, `port: ${clientPort}`);
155
+ viteConfig = replaceAll(
156
+ viteConfig,
157
+ `target: 'http://localhost:${oldServerPort}'`,
158
+ `target: 'http://localhost:${serverPort}'`
159
+ );
160
+ writeFile('client/vite.config.ts', viteConfig);
161
+
162
+ // --- client/index.html ---
163
+ let indexHtml = readFile('client/index.html');
164
+ indexHtml = replaceAll(indexHtml, `<title>${oldTitle}</title>`, `<title>${name}</title>`);
165
+ writeFile('client/index.html', indexHtml);
166
+
167
+ outro(
168
+ `Done! Project customized as "${scope}/${name}". Run "npm install" to update the workspace symlinks.`
169
+ );
170
+ }
171
+
172
+ main().catch((err: unknown) => {
173
+ console.error(err);
174
+ process.exit(1);
175
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "watch": ["src"],
3
+ "ext": "ts,json",
4
+ "exec": "tsx src/index.ts"
5
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@appystack-template/server",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Express + Socket.io server for AppyStack template",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "scripts": {
9
+ "dev": "nodemon",
10
+ "build": "tsc && tsc-alias",
11
+ "start": "node dist/index.js",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "test:coverage": "vitest run --coverage"
16
+ },
17
+ "dependencies": {
18
+ "@appystack-template/shared": "*",
19
+ "compression": "^1.7.5",
20
+ "cors": "^2.8.5",
21
+ "dotenv": "^16.4.7",
22
+ "express": "^5.0.1",
23
+ "express-rate-limit": "^8.2.1",
24
+ "helmet": "^8.0.0",
25
+ "pino": "^9.6.0",
26
+ "pino-http": "^10.4.0",
27
+ "pino-pretty": "^13.0.0",
28
+ "socket.io": "^4.8.1",
29
+ "zod": "^3.24.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/compression": "^1.7.5",
33
+ "@types/cors": "^2.8.17",
34
+ "@types/express": "^5.0.0",
35
+ "@types/node": "^22.10.5",
36
+ "@types/supertest": "^6.0.2",
37
+ "@vitest/coverage-v8": "^4.0.18",
38
+ "nodemon": "^3.1.9",
39
+ "socket.io-client": "^4.8.1",
40
+ "supertest": "^7.0.0",
41
+ "tsc-alias": "^1.8.16",
42
+ "tsx": "^4.19.2",
43
+ "vitest": "^4.0.18"
44
+ }
45
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import request from 'supertest';
3
+ import type { AddressInfo } from 'node:net';
4
+ import { io as ioc } from 'socket.io-client';
5
+ import { SOCKET_EVENTS } from '@appystack-template/shared';
6
+ import { app, httpServer } from './index.js';
7
+
8
+ describe('Express app (via index.ts export)', () => {
9
+ it('GET /health returns 200', async () => {
10
+ const res = await request(app).get('/health');
11
+ expect(res.status).toBe(200);
12
+ expect(res.body.status).toBe('ok');
13
+ });
14
+
15
+ it('GET /api/info returns 200', async () => {
16
+ const res = await request(app).get('/api/info');
17
+ expect(res.status).toBe(200);
18
+ });
19
+
20
+ it('GET /unknown-route returns 404', async () => {
21
+ const res = await request(app).get('/unknown-route-that-does-not-exist');
22
+ expect(res.status).toBe(404);
23
+ expect(res.body.status).toBe('error');
24
+ expect(res.body.error).toBe('Not found');
25
+ });
26
+ });
27
+
28
+ describe('Socket.io via httpServer export', () => {
29
+ let port: number;
30
+
31
+ beforeAll(async () => {
32
+ // In test mode index.ts skips auto-listen to prevent EADDRINUSE across
33
+ // parallel test files. Start the server on a random port here instead.
34
+ if (!httpServer.listening) {
35
+ await new Promise<void>((resolve) => httpServer.listen(0, resolve));
36
+ }
37
+ port = (httpServer.address() as AddressInfo).port;
38
+ });
39
+
40
+ afterAll(async () => {
41
+ if (httpServer.listening) {
42
+ await new Promise<void>((resolve, reject) =>
43
+ httpServer.close((err) => (err ? reject(err) : resolve()))
44
+ );
45
+ }
46
+ });
47
+
48
+ it('connects and receives server:pong after client:ping', () => {
49
+ return new Promise<void>((resolve, reject) => {
50
+ const url = `http://localhost:${port}`;
51
+
52
+ const client = ioc(url, { forceNew: true, transports: ['websocket'] });
53
+
54
+ client.on('connect_error', (err) => {
55
+ client.disconnect();
56
+ reject(err);
57
+ });
58
+
59
+ client.on('connect', () => {
60
+ client.on(SOCKET_EVENTS.SERVER_PONG, (data) => {
61
+ try {
62
+ expect(data.message).toBe('pong');
63
+ expect(typeof data.timestamp).toBe('string');
64
+ client.disconnect();
65
+ resolve();
66
+ } catch (err) {
67
+ client.disconnect();
68
+ reject(err);
69
+ }
70
+ });
71
+ client.emit(SOCKET_EVENTS.CLIENT_PING);
72
+ });
73
+ });
74
+ });
75
+
76
+ it('can connect and disconnect cleanly', () => {
77
+ return new Promise<void>((resolve, reject) => {
78
+ const url = `http://localhost:${port}`;
79
+
80
+ const client = ioc(url, { forceNew: true, transports: ['websocket'] });
81
+
82
+ client.on('connect_error', (err) => {
83
+ client.disconnect();
84
+ reject(err);
85
+ });
86
+
87
+ client.on('connect', () => {
88
+ expect(client.connected).toBe(true);
89
+ client.disconnect();
90
+ });
91
+
92
+ client.on('disconnect', (reason) => {
93
+ try {
94
+ expect(client.connected).toBe(false);
95
+ expect(reason).toBe('io client disconnect');
96
+ resolve();
97
+ } catch (err) {
98
+ reject(err);
99
+ }
100
+ });
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ describe('env config', () => {
4
+ const originalEnv = process.env;
5
+
6
+ beforeEach(() => {
7
+ process.env = { ...originalEnv };
8
+ // Clear the module cache so env.ts re-executes with new process.env
9
+ // We use dynamic import with cache-busting instead
10
+ });
11
+
12
+ afterEach(() => {
13
+ process.env = originalEnv;
14
+ });
15
+
16
+ it('applies default values when optional vars are absent', async () => {
17
+ process.env.NODE_ENV = 'test';
18
+ delete process.env.PORT;
19
+ delete process.env.CLIENT_URL;
20
+
21
+ const { env } = await import('./env.js?defaults=' + Date.now());
22
+
23
+ expect(env.PORT).toBe(5501);
24
+ expect(env.CLIENT_URL).toBe('http://localhost:5500');
25
+ expect(env.NODE_ENV).toBe('test');
26
+ });
27
+
28
+ it('reads PORT as a number when set', async () => {
29
+ process.env.NODE_ENV = 'test';
30
+ process.env.PORT = '9999';
31
+
32
+ const { env } = await import('./env.js?port=' + Date.now());
33
+
34
+ expect(env.PORT).toBe(9999);
35
+ });
36
+
37
+ it('exposes convenience boolean flags', async () => {
38
+ process.env.NODE_ENV = 'test';
39
+
40
+ const { env } = await import('./env.js?flags=' + Date.now());
41
+
42
+ expect(env.isTest).toBe(true);
43
+ expect(env.isDevelopment).toBe(false);
44
+ expect(env.isProduction).toBe(false);
45
+ });
46
+
47
+ it('sets isDevelopment true when NODE_ENV is development', async () => {
48
+ process.env.NODE_ENV = 'development';
49
+
50
+ const { env } = await import('./env.js?dev=' + Date.now());
51
+
52
+ expect(env.isDevelopment).toBe(true);
53
+ expect(env.isTest).toBe(false);
54
+ expect(env.isProduction).toBe(false);
55
+ });
56
+
57
+ it('sets isProduction true when NODE_ENV is production', async () => {
58
+ process.env.NODE_ENV = 'production';
59
+
60
+ const { env } = await import('./env.js?prod=' + Date.now());
61
+
62
+ expect(env.isProduction).toBe(true);
63
+ expect(env.isDevelopment).toBe(false);
64
+ expect(env.isTest).toBe(false);
65
+ });
66
+
67
+ it('exits with code 1 when NODE_ENV is an invalid value', async () => {
68
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
69
+ process.env.NODE_ENV = 'staging';
70
+ vi.resetModules();
71
+ // env.ts calls process.exit(1) on failure but the mock does not stop execution,
72
+ // so spreading parsed.data (undefined) throws — catch that expected error.
73
+ try {
74
+ await import('./env.js?' + Date.now());
75
+ } catch {
76
+ // expected: module throws after mocked exit because parsed.data is undefined
77
+ }
78
+ expect(exitSpy).toHaveBeenCalledWith(1);
79
+ exitSpy.mockRestore();
80
+ });
81
+
82
+ it('exits with code 1 when PORT is not a number', async () => {
83
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
84
+ process.env.NODE_ENV = 'test';
85
+ process.env.PORT = 'not-a-number';
86
+ vi.resetModules();
87
+ // env.ts calls process.exit(1) on failure but the mock does not stop execution,
88
+ // so spreading parsed.data (undefined) throws — catch that expected error.
89
+ try {
90
+ await import('./env.js?' + Date.now());
91
+ } catch {
92
+ // expected: module throws after mocked exit because parsed.data is undefined
93
+ }
94
+ expect(exitSpy).toHaveBeenCalledWith(1);
95
+ exitSpy.mockRestore();
96
+ });
97
+ });
@@ -0,0 +1,29 @@
1
+ import dotenv from 'dotenv';
2
+ import { z } from 'zod';
3
+
4
+ dotenv.config();
5
+
6
+ const envSchema = z.object({
7
+ // TODO: Update defaults for your project
8
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
9
+ PORT: z.coerce.number().default(5501),
10
+ CLIENT_URL: z.string().default('http://localhost:5500'),
11
+ });
12
+
13
+ const parsed = envSchema.safeParse(process.env);
14
+
15
+ if (!parsed.success) {
16
+ console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
17
+ process.exit(1);
18
+ }
19
+
20
+ /**
21
+ * Validated server environment configuration loaded from .env via Zod.
22
+ * Includes NODE_ENV, PORT, CLIENT_URL, and derived boolean flags (isDevelopment, isProduction, isTest).
23
+ */
24
+ export const env = {
25
+ ...parsed.data,
26
+ isDevelopment: parsed.data.NODE_ENV === 'development',
27
+ isProduction: parsed.data.NODE_ENV === 'production',
28
+ isTest: parsed.data.NODE_ENV === 'test',
29
+ };
@@ -0,0 +1,58 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ const mockPino = vi.fn(() => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn() }));
4
+
5
+ vi.mock('pino', () => ({
6
+ default: mockPino,
7
+ }));
8
+
9
+ describe('logger', () => {
10
+ beforeEach(() => {
11
+ vi.resetModules();
12
+ mockPino.mockClear();
13
+ });
14
+
15
+ it('uses level debug and pino-pretty transport in development', async () => {
16
+ vi.doMock('./env.js', () => ({
17
+ env: { isDevelopment: true },
18
+ }));
19
+
20
+ await import('./logger.js?' + Date.now());
21
+
22
+ expect(mockPino).toHaveBeenCalledOnce();
23
+ const callArg = (
24
+ mockPino.mock.calls[0] as unknown as [{ level: string; transport?: { target: string } }]
25
+ )[0];
26
+ expect(callArg.level).toBe('debug');
27
+ expect(callArg.transport).toBeDefined();
28
+ expect(callArg.transport?.target).toBe('pino-pretty');
29
+ });
30
+
31
+ it('uses level info and no transport in production', async () => {
32
+ vi.doMock('./env.js', () => ({
33
+ env: { isDevelopment: false },
34
+ }));
35
+
36
+ await import('./logger.js?' + Date.now());
37
+
38
+ expect(mockPino).toHaveBeenCalledOnce();
39
+ const callArg = (
40
+ mockPino.mock.calls[0] as unknown as [{ level: string; transport?: unknown }]
41
+ )[0];
42
+ expect(callArg.level).toBe('info');
43
+ expect(callArg.transport).toBeUndefined();
44
+ });
45
+
46
+ it('exports a valid pino logger instance with info, error, and debug methods', async () => {
47
+ vi.doMock('./env.js', () => ({
48
+ env: { isDevelopment: false },
49
+ }));
50
+
51
+ const { logger } = await import('./logger.js?' + Date.now());
52
+
53
+ expect(logger).toBeDefined();
54
+ expect(typeof logger.info).toBe('function');
55
+ expect(typeof logger.error).toBe('function');
56
+ expect(typeof logger.debug).toBe('function');
57
+ });
58
+ });
@@ -0,0 +1,17 @@
1
+ import pino from 'pino';
2
+ import { env } from './env.js';
3
+
4
+ /** Pino logger instance. Uses pino-pretty in development; JSON output in production/test. */
5
+ export const logger = pino({
6
+ level: env.isDevelopment ? 'debug' : 'info',
7
+ ...(env.isDevelopment && {
8
+ transport: {
9
+ target: 'pino-pretty',
10
+ options: {
11
+ colorize: true,
12
+ translateTime: 'HH:MM:ss',
13
+ ignore: 'pid,hostname',
14
+ },
15
+ },
16
+ }),
17
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { apiSuccess, apiFailure } from './response.js';
5
+
6
+ function buildApp() {
7
+ const app = express();
8
+ app.get('/success', (_req, res) => {
9
+ apiSuccess(res, { name: 'test' });
10
+ });
11
+ app.get('/success-201', (_req, res) => {
12
+ apiSuccess(res, { created: true }, 201);
13
+ });
14
+ app.get('/failure', (_req, res) => {
15
+ apiFailure(res, 'Something went wrong');
16
+ });
17
+ app.get('/failure-404', (_req, res) => {
18
+ apiFailure(res, 'Not found', 404);
19
+ });
20
+ return app;
21
+ }
22
+
23
+ describe('apiSuccess', () => {
24
+ it('returns 200 with status ok and data', async () => {
25
+ const res = await request(buildApp()).get('/success');
26
+ expect(res.status).toBe(200);
27
+ expect(res.body.status).toBe('ok');
28
+ expect(res.body.data).toEqual({ name: 'test' });
29
+ expect(res.body.timestamp).toBeDefined();
30
+ });
31
+
32
+ it('returns the provided status code', async () => {
33
+ const res = await request(buildApp()).get('/success-201');
34
+ expect(res.status).toBe(201);
35
+ expect(res.body.data).toEqual({ created: true });
36
+ });
37
+ });
38
+
39
+ describe('apiFailure', () => {
40
+ it('returns 400 with status error and message', async () => {
41
+ const res = await request(buildApp()).get('/failure');
42
+ expect(res.status).toBe(400);
43
+ expect(res.body.status).toBe('error');
44
+ expect(res.body.error).toBe('Something went wrong');
45
+ expect(res.body.timestamp).toBeDefined();
46
+ });
47
+
48
+ it('returns the provided status code', async () => {
49
+ const res = await request(buildApp()).get('/failure-404');
50
+ expect(res.status).toBe(404);
51
+ expect(res.body.error).toBe('Not found');
52
+ });
53
+ });
@@ -0,0 +1,17 @@
1
+ import type { Response } from 'express';
2
+
3
+ export function apiSuccess<T>(res: Response, data: T, statusCode = 200): void {
4
+ res.status(statusCode).json({
5
+ status: 'ok',
6
+ data,
7
+ timestamp: new Date().toISOString(),
8
+ });
9
+ }
10
+
11
+ export function apiFailure(res: Response, error: string, statusCode = 400): void {
12
+ res.status(statusCode).json({
13
+ status: 'error',
14
+ error,
15
+ timestamp: new Date().toISOString(),
16
+ });
17
+ }