create-tigra 3.0.2 → 3.0.5

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 (38) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/template/client/.env.example +19 -0
  4. package/template/client/Dockerfile +14 -4
  5. package/template/client/next.config.ts +7 -42
  6. package/template/client/package-lock.json +1896 -146
  7. package/template/client/package.json +3 -1
  8. package/template/client/scripts/next-dev.cjs +47 -0
  9. package/template/client/src/app/layout.tsx +10 -3
  10. package/template/client/src/app/providers.tsx +15 -2
  11. package/template/client/src/instrumentation-client.ts +38 -0
  12. package/template/client/src/instrumentation.ts +42 -0
  13. package/template/client/src/lib/env.ts +13 -2
  14. package/template/client/src/middleware.ts +105 -18
  15. package/template/server/.env.example +22 -1
  16. package/template/server/.env.example.production +16 -1
  17. package/template/server/Dockerfile +16 -8
  18. package/template/server/package-lock.json +2136 -86
  19. package/template/server/package.json +6 -0
  20. package/template/server/src/app.ts +30 -11
  21. package/template/server/src/config/env.ts +23 -2
  22. package/template/server/src/config/rate-limit.config.ts +6 -0
  23. package/template/server/src/libs/logger.ts +15 -0
  24. package/template/server/src/libs/observability/sentry.ts +42 -0
  25. package/template/server/src/libs/requestLogger.ts +8 -2
  26. package/template/server/src/libs/storage/file-storage.service.ts +144 -16
  27. package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
  28. package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
  29. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +25 -41
  30. package/template/server/src/modules/auth/auth.repo.ts +0 -12
  31. package/template/server/src/modules/auth/auth.service.ts +29 -57
  32. package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
  33. package/template/server/src/modules/files/files.controller.ts +180 -0
  34. package/template/server/src/modules/files/files.routes.ts +46 -0
  35. package/template/server/src/server.ts +6 -0
  36. package/template/server/src/test/integration.setup.ts +170 -0
  37. package/template/server/vitest.config.ts +10 -1
  38. package/template/server/vitest.integration.config.ts +50 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Files Controller
3
+ *
4
+ * Auth-gated, owner-scoped streaming of PRIVATE-tier files.
5
+ *
6
+ * SECURITY model — three independent guards, any one of which forces a 404
7
+ * (never a 403: we do not leak whether a file exists):
8
+ * 1. Owner-scoped by construction. The on-disk path is built from the
9
+ * AUTHENTICATED user's id (request.user.userId), never a client-supplied
10
+ * owner. A user can therefore only ever read files under their OWN private
11
+ * directory — there is no parameter that selects another user's files.
12
+ * 2. Filename validation. The :filename segment is rejected if it contains a
13
+ * path separator, a `..` traversal, or a null byte.
14
+ * 3. Path containment. After path.resolve, the absolute target must still sit
15
+ * inside the user's resolved private directory — a final belt-and-braces
16
+ * check against any traversal the validation missed.
17
+ *
18
+ * NOTE for apps with a Prisma file/upload model: swap the path-based
19
+ * owner-scoping below for a DB lookup keyed on ownerId — load the row by id,
20
+ * 404 if it doesn't exist OR its ownerId !== request.user.userId, then stream
21
+ * from the stored path. The Range/streaming half of this handler stays the same.
22
+ */
23
+
24
+ import { createReadStream } from 'node:fs';
25
+ import fs from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import type { FastifyReply } from 'fastify';
28
+ import { fileStorageService } from '@libs/storage/file-storage.service.js';
29
+ import { NotFoundError } from '@shared/errors/errors.js';
30
+ import type { AuthenticatedRequest } from '@shared/types/index.js';
31
+ import { logger } from '@libs/logger.js';
32
+
33
+ /** Map a file extension to a Content-Type. Falls back to octet-stream. */
34
+ const CONTENT_TYPES: Record<string, string> = {
35
+ '.webp': 'image/webp',
36
+ '.png': 'image/png',
37
+ '.jpg': 'image/jpeg',
38
+ '.jpeg': 'image/jpeg',
39
+ '.gif': 'image/gif',
40
+ '.svg': 'image/svg+xml',
41
+ '.pdf': 'application/pdf',
42
+ '.txt': 'text/plain; charset=utf-8',
43
+ '.json': 'application/json; charset=utf-8',
44
+ '.csv': 'text/csv; charset=utf-8',
45
+ '.zip': 'application/zip',
46
+ '.mp4': 'video/mp4',
47
+ '.webm': 'video/webm',
48
+ '.mp3': 'audio/mpeg',
49
+ };
50
+
51
+ function contentTypeFor(filename: string): string {
52
+ return CONTENT_TYPES[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
53
+ }
54
+
55
+ /** Reject any filename that is not a single safe path segment. */
56
+ function isUnsafeFilename(filename: string): boolean {
57
+ return (
58
+ !filename ||
59
+ filename.includes('\0') ||
60
+ filename.includes('/') ||
61
+ filename.includes('\\') ||
62
+ filename.includes('..')
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Parse a single `bytes=start-end` Range header against a known total size.
68
+ * Returns null when there is no (usable) range and the full file should be
69
+ * sent, or an `unsatisfiable` marker when the range is out of bounds (→ 416).
70
+ */
71
+ function parseRange(
72
+ rangeHeader: string | undefined,
73
+ size: number,
74
+ ): { start: number; end: number } | null | 'unsatisfiable' {
75
+ if (!rangeHeader) return null;
76
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
77
+ if (!match) return null; // unparseable → ignore, serve full body (RFC 7233)
78
+
79
+ const [, startRaw, endRaw] = match;
80
+ if (startRaw === '' && endRaw === '') return null;
81
+
82
+ let start: number;
83
+ let end: number;
84
+ if (startRaw === '') {
85
+ // Suffix range: last N bytes.
86
+ const suffix = Number(endRaw);
87
+ if (suffix <= 0) return 'unsatisfiable';
88
+ start = Math.max(size - suffix, 0);
89
+ end = size - 1;
90
+ } else {
91
+ start = Number(startRaw);
92
+ end = endRaw === '' ? size - 1 : Number(endRaw);
93
+ }
94
+
95
+ if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) {
96
+ return 'unsatisfiable';
97
+ }
98
+ if (end >= size) end = size - 1;
99
+ return { start, end };
100
+ }
101
+
102
+ class FilesController {
103
+ /**
104
+ * GET /api/v1/files/:filename
105
+ *
106
+ * Streams a PRIVATE file owned by the authenticated user. Honors a Range
107
+ * header (206 partial content); otherwise sends the full file (200).
108
+ */
109
+ async getPrivateFile(
110
+ request: AuthenticatedRequest,
111
+ reply: FastifyReply,
112
+ ): Promise<void> {
113
+ const userId = request.user.userId;
114
+ const { filename } = request.params as { filename: string };
115
+
116
+ // Guard 2: filename validation. Any violation → 404 (no existence leak).
117
+ if (isUnsafeFilename(filename)) {
118
+ logger.warn({ msg: 'Rejected unsafe private file name', userId, filename });
119
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
120
+ }
121
+
122
+ // Guard 1: owner-scoped path, derived from the authenticated user id only.
123
+ const filePath = fileStorageService.getPrivateFilePath(userId, filename);
124
+
125
+ // Guard 3: path containment. The resolved absolute path must remain inside
126
+ // the user's resolved private directory.
127
+ const userPrivateRoot = path.resolve(
128
+ fileStorageService.getPrivateDir(),
129
+ 'users',
130
+ userId,
131
+ );
132
+ const resolved = path.resolve(filePath);
133
+ if (resolved !== userPrivateRoot && !resolved.startsWith(userPrivateRoot + path.sep)) {
134
+ logger.warn({ msg: 'Private file path escaped owner root', userId, filename });
135
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
136
+ }
137
+
138
+ // Stat the file — missing/inaccessible/dir → 404 (no existence leak).
139
+ let stat;
140
+ try {
141
+ stat = await fs.stat(resolved);
142
+ } catch {
143
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
144
+ }
145
+ if (!stat.isFile()) {
146
+ throw new NotFoundError('File not found', 'FILE_NOT_FOUND');
147
+ }
148
+
149
+ const size = stat.size;
150
+ const contentType = contentTypeFor(filename);
151
+
152
+ reply.header('Accept-Ranges', 'bytes');
153
+ reply.header('Content-Type', contentType);
154
+ // Private files must never be cached by shared/proxy caches.
155
+ reply.header('Cache-Control', 'private');
156
+
157
+ const range = parseRange(request.headers.range, size);
158
+
159
+ if (range === 'unsatisfiable') {
160
+ reply.header('Content-Range', `bytes */${size}`);
161
+ reply.code(416);
162
+ return reply.send('Range Not Satisfiable');
163
+ }
164
+
165
+ if (range) {
166
+ const { start, end } = range;
167
+ reply.code(206);
168
+ reply.header('Content-Range', `bytes ${start}-${end}/${size}`);
169
+ reply.header('Content-Length', String(end - start + 1));
170
+ return reply.send(createReadStream(resolved, { start, end }));
171
+ }
172
+
173
+ // No range → full file.
174
+ reply.code(200);
175
+ reply.header('Content-Length', String(size));
176
+ return reply.send(createReadStream(resolved));
177
+ }
178
+ }
179
+
180
+ export const filesController = new FilesController();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Files Routes
3
+ *
4
+ * Auth-gated streaming of PRIVATE-tier files. This is the ONLY way to read a
5
+ * file stored under UPLOAD_PRIVATE_DIR — that directory is deliberately NOT
6
+ * served by @fastify/static (see app.ts), so a private file can never leak
7
+ * through /uploads/*.
8
+ */
9
+
10
+ import type { FastifyInstance } from 'fastify';
11
+ import { z } from 'zod';
12
+ import { filesController } from './files.controller.js';
13
+ import { authenticate } from '@libs/auth.js';
14
+ import { RATE_LIMITS } from '@config/rate-limit.config.js';
15
+
16
+ // Params are intentionally validated as a plain string here — the security
17
+ // checks (separator / `..` / null-byte rejection + path containment) live in
18
+ // the controller so a violation maps to 404 (no existence leak) rather than the
19
+ // 400 a Zod-level regex rejection would produce.
20
+ const GetPrivateFileParamsSchema = z.object({
21
+ filename: z.string().min(1),
22
+ });
23
+
24
+ export async function filesRoutes(fastify: FastifyInstance): Promise<void> {
25
+ /**
26
+ * Get a private file (owner-scoped, auth required)
27
+ *
28
+ * GET /api/v1/files/:filename
29
+ * Auth: Required (JWT) — 401 if unauthenticated.
30
+ * Serves UPLOAD_PRIVATE_DIR/users/{authenticatedUserId}/:filename.
31
+ * Honors a Range header (206 partial content); 404 on any miss/violation.
32
+ */
33
+ fastify.get<{ Params: { filename: string } }>(
34
+ '/files/:filename',
35
+ {
36
+ preValidation: [authenticate],
37
+ schema: {
38
+ params: GetPrivateFileParamsSchema,
39
+ },
40
+ config: {
41
+ rateLimit: RATE_LIMITS.FILES_GET,
42
+ },
43
+ },
44
+ filesController.getPrivateFile.bind(filesController),
45
+ );
46
+ }
@@ -1,3 +1,9 @@
1
+ // Initialize Sentry as early as possible — before the app (and its routes) are
2
+ // imported/built — so error capture is active before any request is handled.
3
+ // Inert no-op when SENTRY_DSN is unset. See src/libs/observability/sentry.ts.
4
+ import { initSentry } from '@libs/observability/sentry.js';
5
+ initSentry();
6
+
1
7
  import { env } from '@config/env.js';
2
8
  import { logger } from '@libs/logger.js';
3
9
  import { testDatabaseConnection, disconnectPrisma } from '@libs/prisma.js';
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Integration test setup — REAL Testcontainers, NOT mocks.
3
+ *
4
+ * This file is the load-bearing piece of the money-path integration harness.
5
+ * It is wired via `vitest.integration.config.ts` → `setupFiles`, and is SEPARATE
6
+ * from the Docker-free unit `src/test/setup.ts` (which mocks env/DB/Redis).
7
+ *
8
+ * ── The central gotcha: env-before-import ──────────────────────────────────
9
+ * `src/config/env.ts` validates `process.env` at IMPORT time and calls
10
+ * `process.exit(1)` if DATABASE_URL / JWT_SECRET / … are missing. So the
11
+ * container connection strings MUST be in `process.env` BEFORE anything that
12
+ * transitively imports `env.ts` (which is `@/app`, `@/libs/prisma`, etc.).
13
+ *
14
+ * How we guarantee that ordering:
15
+ * 1. Vitest evaluates `setupFiles` — INCLUDING their top-level `await` — to
16
+ * completion BEFORE it evaluates any test module. We start the containers
17
+ * and run the schema sync here with TOP-LEVEL await, then populate
18
+ * process.env.
19
+ * 2. The test files themselves import `@/app` / `@/libs/prisma` LAZILY
20
+ * (`await import(...)` inside `beforeAll`), so a test module's own static
21
+ * import graph never evaluates `env.ts` before this setup has finished.
22
+ *
23
+ * ── Why a process-global singleton ─────────────────────────────────────────
24
+ * Vitest runs `setupFiles` ONCE PER TEST FILE, not once for the whole run. With
25
+ * `singleFork: true` (vitest.integration.config.ts) every test file shares ONE
26
+ * worker process, so we boot the MySQL + Redis containers exactly once and cache
27
+ * them on `globalThis`. The second (and later) test files reuse the running
28
+ * containers instead of each spinning up their own pair and racing two
29
+ * concurrent `prisma db push` invocations against a saturated Docker daemon
30
+ * (which produced intermittent `P1001: can't reach database server`).
31
+ *
32
+ * ── Teardown ───────────────────────────────────────────────────────────────
33
+ * Because the containers are shared across files, we CANNOT tear them down from
34
+ * a per-file `afterAll` (the first file to finish would stop the DB out from
35
+ * under the others). Instead we stop them once on the worker process's
36
+ * `beforeExit`, and rely on Testcontainers' Ryuk reaper as the backstop — it
37
+ * removes any container we started the moment this process dies, even on crash.
38
+ *
39
+ * The committed template ships NO `prisma/migrations/` directory (apps run
40
+ * `prisma migrate dev` locally to author their first migration). `prisma migrate
41
+ * deploy` therefore has nothing to apply against a fresh container. We use
42
+ * `prisma db push` instead — it syncs the Prisma schema straight to the empty
43
+ * container database, which is exactly what an ephemeral test DB wants.
44
+ */
45
+ import { execSync } from 'node:child_process';
46
+ import { fileURLToPath } from 'node:url';
47
+ import path from 'node:path';
48
+ import os from 'node:os';
49
+ import { MySqlContainer, type StartedMySqlContainer } from '@testcontainers/mysql';
50
+ import { RedisContainer, type StartedRedisContainer } from '@testcontainers/redis';
51
+
52
+ const __filename = fileURLToPath(import.meta.url);
53
+ const __dirname = path.dirname(__filename);
54
+ const serverRoot = path.resolve(__dirname, '..', '..');
55
+
56
+ interface IntegrationContainers {
57
+ mysql: StartedMySqlContainer;
58
+ redis: StartedRedisContainer;
59
+ databaseUrl: string;
60
+ redisUrl: string;
61
+ uploadPublicDir: string;
62
+ uploadPrivateDir: string;
63
+ }
64
+
65
+ // Singleton cache on globalThis — survives across the per-file setup invocations
66
+ // that share one worker process (singleFork). Keyed under a unique symbol so it
67
+ // can't collide with anything else on the global object.
68
+ const CONTAINERS_KEY = Symbol.for('create-tigra.integration.containers');
69
+ const TEARDOWN_KEY = Symbol.for('create-tigra.integration.teardownRegistered');
70
+
71
+ type GlobalWithContainers = typeof globalThis & {
72
+ [CONTAINERS_KEY]?: Promise<IntegrationContainers>;
73
+ [TEARDOWN_KEY]?: boolean;
74
+ };
75
+
76
+ const g = globalThis as GlobalWithContainers;
77
+
78
+ async function bootContainers(): Promise<IntegrationContainers> {
79
+ const mysql = await new MySqlContainer('mysql:8.0')
80
+ .withDatabase('test_db')
81
+ .withUsername('test')
82
+ .withUserPassword('test')
83
+ .withRootPassword('rootpass')
84
+ .start();
85
+
86
+ const redis = await new RedisContainer('redis:7-alpine').start();
87
+
88
+ // MySqlContainer.getConnectionUri() returns `mysql://user:pass@host:port/db`
89
+ // — exactly Prisma's DATABASE_URL shape.
90
+ const databaseUrl = mysql.getConnectionUri();
91
+ const redisUrl = redis.getConnectionUrl();
92
+
93
+ // Populate env BEFORE any consumer of env.ts is imported.
94
+ process.env.NODE_ENV = 'test';
95
+ process.env.DATABASE_URL = databaseUrl;
96
+ process.env.REDIS_URL = redisUrl;
97
+ process.env.JWT_SECRET = 'integration-test-jwt-secret-at-least-32-chars-long';
98
+ process.env.JWT_ACCESS_EXPIRY = '15m';
99
+ process.env.JWT_REFRESH_EXPIRY = '7d';
100
+ // Tests don't send email and don't activate via the verify flow — register
101
+ // must mint tokens and login must work immediately, so disable verification.
102
+ process.env.REQUIRE_USER_VERIFICATION = 'false';
103
+ // This is a money-PATH harness, not a rate-limit test. Disabling the limiter
104
+ // keeps the suite deterministic (no per-IP counters, no auto-block self-ban
105
+ // across repeated register/login calls).
106
+ process.env.RATE_LIMIT_ENABLED = 'false';
107
+ // In NODE_ENV=test the app's CORS config (see app.ts) is NOT the dev "allow
108
+ // all" branch — it reads CORS_ORIGIN, and an undefined value makes
109
+ // @fastify/cors throw "Invalid CORS origin option" → 500 on EVERY request.
110
+ // Pin a concrete origin so the real app boots a valid CORS policy.
111
+ process.env.CORS_ORIGIN = 'http://localhost:3000';
112
+
113
+ // Two-tier upload dirs under an isolated temp root, set BEFORE env.ts is ever
114
+ // evaluated (the storage-service singleton reads env.UPLOAD_*_DIR at
115
+ // construction, which happens when a test lazily imports @/app). The
116
+ // files-route integration test writes fixtures directly into these dirs.
117
+ const uploadsRoot = path.join(os.tmpdir(), `create-tigra-uploads-${process.pid}`);
118
+ const uploadPublicDir = path.join(uploadsRoot, 'public');
119
+ const uploadPrivateDir = path.join(uploadsRoot, 'private');
120
+ process.env.UPLOAD_PUBLIC_DIR = uploadPublicDir;
121
+ process.env.UPLOAD_PRIVATE_DIR = uploadPrivateDir;
122
+
123
+ // Sync the Prisma schema into the fresh container DB. `db push` (not
124
+ // `migrate deploy`) because the template ships no migrations. The container's
125
+ // wait strategy already guarantees MySQL is accepting connections by here.
126
+ execSync('npx prisma db push --skip-generate --accept-data-loss', {
127
+ cwd: serverRoot,
128
+ env: process.env,
129
+ stdio: 'inherit',
130
+ });
131
+
132
+ return { mysql, redis, databaseUrl, redisUrl, uploadPublicDir, uploadPrivateDir };
133
+ }
134
+
135
+ // Boot once (or await the in-flight boot) — TOP-LEVEL await, completes before
136
+ // any test module is evaluated.
137
+ if (!g[CONTAINERS_KEY]) {
138
+ g[CONTAINERS_KEY] = bootContainers();
139
+ }
140
+ const containers = await g[CONTAINERS_KEY];
141
+
142
+ // Subsequent per-file setup runs need env populated too (the boot only set it on
143
+ // the first run). Re-assert from the cached values — cheap and idempotent.
144
+ process.env.DATABASE_URL = containers.databaseUrl;
145
+ process.env.REDIS_URL = containers.redisUrl;
146
+ process.env.NODE_ENV = 'test';
147
+ process.env.JWT_SECRET ??= 'integration-test-jwt-secret-at-least-32-chars-long';
148
+ process.env.REQUIRE_USER_VERIFICATION = 'false';
149
+ process.env.RATE_LIMIT_ENABLED = 'false';
150
+ process.env.CORS_ORIGIN = 'http://localhost:3000';
151
+ process.env.UPLOAD_PUBLIC_DIR = containers.uploadPublicDir;
152
+ process.env.UPLOAD_PRIVATE_DIR = containers.uploadPrivateDir;
153
+
154
+ // Register the teardown exactly once for the whole run. Tied to the worker
155
+ // process lifecycle (NOT a per-file afterAll, which would stop the shared
156
+ // containers while later files still need them). Ryuk is the crash-safe backstop.
157
+ if (!g[TEARDOWN_KEY]) {
158
+ g[TEARDOWN_KEY] = true;
159
+ process.once('beforeExit', () => {
160
+ void containers.redis.stop();
161
+ void containers.mysql.stop();
162
+ });
163
+ }
164
+
165
+ export const containerInfo = {
166
+ databaseUrl: containers.databaseUrl,
167
+ redisUrl: containers.redisUrl,
168
+ uploadPublicDir: containers.uploadPublicDir,
169
+ uploadPrivateDir: containers.uploadPrivateDir,
170
+ };
@@ -23,11 +23,20 @@ export default defineConfig({
23
23
  functions: 80,
24
24
  branches: 80,
25
25
  statements: 80,
26
+ // Money-path forward-contract: any future payments module is held to a
27
+ // stricter bar than the 80% global gate. The scaffold ships no payments
28
+ // module, so this glob matches nothing today and is vacuously satisfied;
29
+ // the moment an app adds `src/modules/payments/**`, these thresholds
30
+ // start enforcing. Verified to not red an empty match on vitest v4.
31
+ 'src/modules/payments/**': { lines: 95, branches: 90 },
26
32
  },
27
33
  },
28
34
  setupFiles: ['./src/test/setup.ts'],
29
35
  include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
30
- exclude: ['node_modules', 'dist'],
36
+ // Keep the unit gate Docker-free: integration tests boot Testcontainers and
37
+ // live behind `npm run test:integration` (vitest.integration.config.ts).
38
+ // The `**/*.test.ts` include would otherwise sweep in `*.integration.test.ts`.
39
+ exclude: ['node_modules', 'dist', '**/*.integration.test.ts'],
31
40
  testTimeout: 10000,
32
41
  hookTimeout: 10000,
33
42
  },
@@ -0,0 +1,50 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'path';
3
+
4
+ /**
5
+ * Integration test config — REAL Testcontainers (MySQL 8 + Redis), app.inject().
6
+ *
7
+ * Deliberately SEPARATE from the Docker-free unit `vitest.config.ts`:
8
+ * - includes ONLY `*.integration.test.ts`
9
+ * - uses `src/test/integration.setup.ts` (boots containers) — NOT the mocking
10
+ * unit setup `src/test/setup.ts`
11
+ * - runs serially in a single fork: the containers are shared process-wide and
12
+ * started once via the setup file's top-level await, so parallel workers
13
+ * must not fight over them or each re-boot Docker.
14
+ * - generous timeouts: container boot + `prisma db push` is slow on a cold
15
+ * Docker daemon / first image pull.
16
+ *
17
+ * Run with: npm run test:integration
18
+ */
19
+ export default defineConfig({
20
+ test: {
21
+ globals: true,
22
+ environment: 'node',
23
+ setupFiles: ['./src/test/integration.setup.ts'],
24
+ include: ['**/*.integration.test.ts'],
25
+ exclude: ['node_modules', 'dist'],
26
+ // Containers are shared — never parallelize across files or forks.
27
+ // Vitest 4 flattened `poolOptions.forks.singleFork` to the top-level
28
+ // `singleFork` option (the nested form is deprecated/removed).
29
+ pool: 'forks',
30
+ singleFork: true,
31
+ fileParallelism: false,
32
+ // Container boot + image pull + schema push can take well over a minute on
33
+ // a cold daemon; the setup file does all of that under hookTimeout.
34
+ testTimeout: 120_000,
35
+ hookTimeout: 120_000,
36
+ teardownTimeout: 60_000,
37
+ // No coverage gate on the integration suite — coverage thresholds live in
38
+ // the unit config. This suite is about real end-to-end behavior.
39
+ },
40
+ resolve: {
41
+ alias: {
42
+ '@': resolve(__dirname, './src'),
43
+ '@modules': resolve(__dirname, './src/modules'),
44
+ '@libs': resolve(__dirname, './src/libs'),
45
+ '@config': resolve(__dirname, './src/config'),
46
+ '@shared': resolve(__dirname, './src/shared'),
47
+ '@jobs': resolve(__dirname, './src/jobs'),
48
+ },
49
+ },
50
+ });