create-tigra 3.0.0 → 3.0.3
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/package.json +1 -1
- package/template/client/.env.example +19 -0
- package/template/client/Dockerfile +3 -3
- package/template/client/next.config.ts +7 -42
- package/template/client/package-lock.json +1896 -146
- package/template/client/package.json +2 -0
- package/template/client/src/app/layout.tsx +10 -3
- package/template/client/src/app/providers.tsx +15 -2
- package/template/client/src/instrumentation-client.ts +30 -0
- package/template/client/src/instrumentation.ts +38 -0
- package/template/client/src/lib/env.ts +13 -2
- package/template/client/src/middleware.ts +105 -18
- package/template/server/.env.example +269 -236
- package/template/server/.env.example.production +236 -208
- package/template/server/Dockerfile +7 -5
- package/template/server/docker-compose.yml +17 -0
- package/template/server/package-lock.json +2136 -86
- package/template/server/package.json +6 -0
- package/template/server/src/app.ts +335 -303
- package/template/server/src/config/env.ts +171 -143
- package/template/server/src/config/rate-limit.config.ts +6 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/ip-block.ts +220 -212
- package/template/server/src/libs/logger.ts +15 -0
- package/template/server/src/libs/observability/sentry.ts +42 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/requestLogger.ts +8 -2
- package/template/server/src/libs/storage/file-storage.service.ts +144 -16
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/admin/__tests__/admin.integration.test.ts +128 -0
- package/template/server/src/modules/auth/__tests__/auth.integration.test.ts +138 -0
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- package/template/server/src/modules/files/__tests__/files.integration.test.ts +157 -0
- package/template/server/src/modules/files/files.controller.ts +180 -0
- package/template/server/src/modules/files/files.routes.ts +46 -0
- package/template/server/src/server.ts +6 -0
- package/template/server/src/test/integration.setup.ts +170 -0
- package/template/server/vitest.config.ts +10 -1
- package/template/server/vitest.integration.config.ts +50 -0
|
@@ -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
|
-
|
|
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
|
+
});
|