원클릭으로
create-e2e-test
// Create E2E test file for a specified module. Use when adding end-to-end tests for controllers or unit tests for services and repositories.
// Create E2E test file for a specified module. Use when adding end-to-end tests for controllers or unit tests for services and repositories.
Use when releasing mx-core server (apps/core), @mx-space/api-client, or @mx-space/cli (mxs) — version bump, changelog, git tag, Docker build, GitHub Release, and Dokploy redeploy. Triggers on "发版", "release a new version", "cut a release", "bump version", "publish api-client", "publish cli", "release mxs".
Use when an agent must inspect, draft, validate, edit, publish, unpublish, delete, or configure mx-space content through packages/cli or the mxs binary.
Author and review Drizzle SQL migrations safely for rolling deploys. Triggers when editing apps/core/src/database/schema/*.ts or apps/core/src/database/migrations/*.sql, when the user runs drizzle-kit generate, when "lint-migrations" reports a violation, or on prompts like "迁移", "改 schema", "alter table", "add a column", "drop column", "migration safety". Enforces the expand-contract pattern because mx-core ships rolling deploys (Dokploy, 2 replicas) where new and old pods coexist for tens of seconds during cutover.
MX Space API design conventions. Apply when writing controllers, API endpoints, or handling HTTP requests.
Create a new NestJS module with repository, service, controller, schema, and Drizzle table definition. Use when adding new feature modules, API endpoints, or business domains.
Review code for MX Space project conventions. Checks NestJS patterns, Drizzle ORM repositories, Zod schemas, API design, etc.
| name | create-e2e-test |
| description | Create E2E test file for a specified module. Use when adding end-to-end tests for controllers or unit tests for services and repositories. |
| argument-hint | <module-name> |
| disable-model-invocation | true |
Create test file for a module. Module name: $ARGUMENTS
apps/core/test/src/modules/<module-name>/<module-name>.controller.spec.tsapps/core/test/src/modules/<module-name>/<module-name>.repository.spec.tsFor testing controllers, services, or business logic without a real database:
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { <Name>Controller } from '~/modules/<name>/<name>.controller'
import { <Name>Service } from '~/modules/<name>/<name>.service'
describe('<Name>Controller', () => {
let controller: <Name>Controller
const mock<Name>Service = {
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
deleteById: vi.fn(),
}
beforeEach(async () => {
vi.clearAllMocks()
const module = await Test.createTestingModule({
controllers: [<Name>Controller],
providers: [
{
provide: <Name>Service,
useValue: mock<Name>Service,
},
],
}).compile()
controller = module.get(<Name>Controller)
})
it('should be defined', () => {
expect(controller).toBeDefined()
})
it('calls service.findById with the correct id', async () => {
const mockRow = { id: '1234567890', name: 'Test', createdAt: new Date() }
mock<Name>Service.findById.mockResolvedValue(mockRow)
const result = await controller.getById({ id: '1234567890' })
expect(mock<Name>Service.findById).toHaveBeenCalledWith('1234567890')
expect(result).toEqual(mockRow)
})
})
createE2EAppFor testing controllers through the full HTTP pipeline (with mocked DB):
import { describe, expect, it } from 'vitest'
import { <Name>Controller } from '~/modules/<name>/<name>.controller'
import { <Name>Service } from '~/modules/<name>/<name>.service'
import { <Name>Repository } from '~/modules/<name>/<name>.repository'
import { createE2EApp } from 'test/helper/create-e2e-app'
describe('<Name>Controller (e2e)', () => {
const proxy = createE2EApp({
controllers: [<Name>Controller],
providers: [<Name>Service, <Name>Repository],
})
it('GET /<name>s returns paginated list', async () => {
const res = await proxy.app.inject({
method: 'GET',
url: '/<name>s',
})
expect(res.statusCode).toBe(200)
const json = res.json()
// ResponseInterceptor wraps arrays as { data: [...] }
// Paginated responses include { data: [...], pagination: {...} }
})
it('GET /<name>s/:id returns single item', async () => {
const res = await proxy.app.inject({
method: 'GET',
url: '/<name>s/invalid-id',
})
// Invalid Snowflake IDs should return 400
expect(res.statusCode).toBe(400)
})
})
createE2EApp BehaviorThe createE2EApp helper (from test/helper/create-e2e-app.ts):
AuthGuard with AuthTestingGuard — pass header 'test-token': 1 for authenticated requestsResponseInterceptor, JSONTransformInterceptor, DbQueryInterceptor, HttpCacheInterceptor){ app } — use proxy.app.inject({ method, url, payload }) for HTTP requestsafterAllAuth in tests: All requests are treated as unauthenticated by default. To authenticate, pass the test header:
const res = await proxy.app.inject({
method: 'POST',
url: '/<name>s',
payload: { name: 'Test' },
headers: { 'test-token': 1 }, // <-- authenticates as mock admin
})
For testing repository logic against a real database using testcontainers:
import path from 'node:path'
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import { beforeAll, beforeEach, describe, expect, it, afterAll } from 'vitest'
import { <name>s } from '~/database/schema'
import { <Name>Repository } from '~/modules/<name>/<name>.repository'
import { SnowflakeService } from '~/shared/id/snowflake.service'
const verifyUrl = process.env.PG_VERIFY_URL
const describeIfPg = verifyUrl ? describe : describe.skip
describeIfPg('<Name>Repository', () => {
let pool: Pool
let db: NodePgDatabase<typeof import('~/database/schema')>
let repository: <Name>Repository
let snowflake: SnowflakeService
beforeAll(async () => {
pool = new Pool({ connectionString: verifyUrl })
db = drizzle(pool, { casing: 'snake_case' })
const migrationsFolder = path.resolve(
__dirname,
'../../../../src/database/migrations',
)
await migrate(db, { migrationsFolder })
snowflake = new SnowflakeService()
repository = new <Name>Repository(db as any, snowflake)
}, 60_000)
beforeEach(async () => {
// Truncate tables between tests (respect FK order)
await pool.query('truncate table <name>s cascade')
})
afterAll(async () => {
if (pool) await pool.end()
})
it('creates a row with a generated Snowflake id', async () => {
const created = await repository.create({
name: 'test',
})
expect(typeof created.id).toBe('string')
expect(created.id).toMatch(/^[1-9]\d+$/) // Snowflake format
expect(created.name).toBe('test')
})
it('findById returns null for missing id', async () => {
const result = await repository.findById('9999999999999999999')
expect(result).toBeNull()
})
it('update mutates only specified fields', async () => {
const created = await repository.create({ name: 'old' })
const updated = await repository.update(created.id, { name: 'new' })
expect(updated?.name).toBe('new')
})
it('deleteById removes the row', async () => {
const created = await repository.create({ name: 'to-delete' })
await repository.deleteById(created.id)
const found = await repository.findById(created.id)
expect(found).toBeNull()
})
})
To run repository tests that need PostgreSQL, start a container first:
# Set PG_VERIFY_URL to a running PostgreSQL instance
export PG_VERIFY_URL=postgres://mx:mx@127.0.0.1:5432/mx_verify
# Or use docker:
docker run -d --name pg-test -e POSTGRES_USER=mx -e POSTGRES_PASSWORD=mx -e POSTGRES_DB=mx_verify -p 5433:5432 postgres:17-alpine
export PG_VERIFY_URL=postgres://mx:mx@127.0.0.1:5433/mx_verify
Then run the test:
# Run a single test file
pnpm test -- test/src/modules/<name>/<name>.repository.spec.ts
# Run all tests
pnpm test
# Watch mode
pnpm -C apps/core run test:watch
// Status codes
expect(res.statusCode).toBe(200)
expect(res.statusCode).toBe(201) // Created
expect(res.statusCode).toBe(204) // No Content (deletes)
expect(res.statusCode).toBe(400) // Validation error
expect(res.statusCode).toBe(404) // Not found
// JSON body (auto snake_case via JSONTransformInterceptor)
const json = res.json()
expect(json).toMatchObject({ name: 'test' })
// Paginated response shape
expect(json).toMatchObject({
data: expect.any(Array),
pagination: expect.objectContaining({
total: expect.any(Number),
current_page: expect.any(Number),
total_page: expect.any(Number),
}),
})
// Valid Snowflake ID format
expect(created.id).toMatch(/^[1-9]\d+$/)
expect(typeof created.id).toBe('string')
// Invalid ID throws
await expect(repository.findById('not-an-id')).rejects.toThrow()
When testing with a real database, use Drizzle directly to set up test data:
await db.insert(<name>s).values([
{ id: snowflake.nextId(), name: 'Item 1' },
{ id: snowflake.nextId(), name: 'Item 2' },
])
test/helper/create-e2e-app.ts — E2E app setup with mock auth and Redistest/helper/setup-e2e.ts — Low-level NestJS testing module setuptest/helper/pg-testcontainer.ts — PostgreSQL 17 testcontainers helpertest/helper/redis-mock.helper.ts — In-memory Redis mocktest/mock/guard/auth.guard.ts — AuthTestingGuard (bypasses auth with test-token header)test/mock/modules/ — Module-level mockscreateE2EApp helper uses mock Redis and bypasses auth. For real DB tests, use the repository test pattern.proxy.app.inject() to send HTTP requests through the full interceptor chain.JSONTransformInterceptor — field names become snake_case.data and pagination fields.process.env.PG_VERIFY_URL — use describeIfPg / describe.skip pattern so they don't fail in CI without a database.