From a9ddffa97c96884d49ad0786d6247ad17a715c1c Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 18 Jan 2026 06:32:19 +0000 Subject: [PATCH] feat: add integration tests for initialization flow - Implemented end-to-end integration tests covering database reset, bootstrap admin creation, demo user provisioning, scenario installation, and general creation. - Added support for deterministic seeding and auto admin general creation. - Created a new package for integration tests with necessary configurations and scripts. - Updated various modules to support new features, including admin user handling and scenario seeding. - Enhanced error handling and validation in the join and orchestrator modules. --- .env.ci.example | 7 +- app/game-api/src/router/join/index.ts | 48 +- .../src/scenario/scenarioSeeder.ts | 22 +- app/gateway-api/src/adminRouter.ts | 85 ++++ .../src/orchestrator/gatewayOrchestrator.ts | 32 +- .../src/orchestrator/seedProfileDatabase.ts | 163 +++++++ app/gateway-api/src/router.ts | 46 ++ docs/integration-tests.md | 72 +++ package.json | 1 + pnpm-lock.yaml | 31 ++ tools/integration-tests/package.json | 22 + .../test/initialization.test.ts | 448 ++++++++++++++++++ tools/integration-tests/tsconfig.json | 8 + tools/integration-tests/vitest.config.ts | 18 + tsconfig.paths.json | 2 + 15 files changed, 997 insertions(+), 8 deletions(-) create mode 100644 app/gateway-api/src/orchestrator/seedProfileDatabase.ts create mode 100644 docs/integration-tests.md create mode 100644 tools/integration-tests/package.json create mode 100644 tools/integration-tests/test/initialization.test.ts create mode 100644 tools/integration-tests/tsconfig.json create mode 100644 tools/integration-tests/vitest.config.ts diff --git a/.env.ci.example b/.env.ci.example index b5cd784..77d814a 100644 --- a/.env.ci.example +++ b/.env.ci.example @@ -2,13 +2,16 @@ POSTGRES_DB=sammo_test POSTGRES_USER=sammo POSTGRES_PASSWORD=ci-postgres POSTGRES_SCHEMA=public +GAME_TOKEN_SECRET=ci-secret REDIS_PASSWORD=ci-redis REDIS_HOST=127.0.0.1 REDIS_PORT=16379 REDIS_DB=0 -GAME_TOKEN_SECRET=ci-secret +REDIS_URL=redis://:ci-redis@127.0.0.1:16379/0 GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_DB_SCHEMA=public +GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true +GATEWAY_BOOTSTRAP_TOKEN=ci-bootstrap GATEWAY_API_HOST=127.0.0.1 GATEWAY_API_PORT=13000 GATEWAY_PUBLIC_URL=http://localhost:13000 @@ -16,5 +19,7 @@ GAME_API_HOST=127.0.0.1 GAME_API_PORT=14000 PROFILE=hwe SCENARIO=default +INTEGRATION_WORLD_SEED=integration-seed +INTEGRATION_JOIN_ALLOW_CITY=true KAKAO_REST_KEY=ci-kakao-rest-key KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 10dbe6a..3123f2f 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto'; import type { WorldStateRow } from '../../context.js'; import { authedProcedure, router } from '../../trpc.js'; -import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; +import { asNumber, asRecord, asStringArray, parseBooleanWithFallback } from '@sammo-ts/common'; import { isPersonalityTraitKey, isWarTraitKey, @@ -250,13 +250,57 @@ export const joinRouter = router({ const maxId = await db.general.aggregate({ _max: { id: true } }); const nextId = (maxId._max.id ?? 0) + 1; - const cityList = await db.city.findMany({ select: { id: true }, orderBy: { id: 'asc' } }); + const cityList = await db.city.findMany({ + select: { id: true, level: true }, + orderBy: { id: 'asc' }, + }); if (!cityList.length) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '도시 정보를 찾을 수 없습니다.', }); } + // 통합 테스트 전용: ENV로 지정한 경우에만 도시 지정 허용. + const allowCityOverride = parseBooleanWithFallback(process.env.INTEGRATION_JOIN_ALLOW_CITY, false); + if (allowCityOverride && typeof input.inheritCity === 'number') { + const override = cityList.find((city) => city.id === input.inheritCity); + if (!override) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '지정한 도시를 찾을 수 없습니다.', + }); + } + if (override.level !== 5 && override.level !== 6) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '통합 테스트에서는 소성/중성 도시만 지정할 수 있습니다.', + }); + } + const general = await db.general.create({ + data: { + id: nextId, + userId, + name: generalName, + nationId: 0, + cityId: override.id, + troopId: 0, + npcState: 0, + leadership: input.leadership, + strength: input.strength, + intel: input.intel, + personalCode: chosenPersonality ?? 'None', + specialCode: 'None', + special2Code: 'None', + turnTime: new Date(), + meta: { + createdBy: 'join', + }, + }, + }); + + return { ok: true, generalId: general.id }; + } + const cityIndex = hashString(userId) % cityList.length; const cityId = cityList[cityIndex]?.id ?? cityList[0].id; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index cb50f5b..84ee4be 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,4 +1,5 @@ import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra'; +import { asRecord } from '@sammo-ts/common'; import { buildScenarioBootstrap, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic'; import type { MapLoaderOptions } from './mapLoader.js'; @@ -11,6 +12,8 @@ import { loadUnitSetDefinitionByName } from './unitSetLoader.js'; const DEFAULT_TICK_SECONDS = 120 * 60; const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_RICE = 1000; +const DEFAULT_OPENING_PART_YEAR = 3; +const INTEGRATION_WORLD_SEED_ENV = 'INTEGRATION_WORLD_SEED'; const MINUTES_TO_MS = 60_000; @@ -180,6 +183,18 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD; const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE; + const scenarioConst = asRecord(seed.scenarioConfig.const); + if ( + typeof scenarioConst.openingPartYear !== 'number' || + Number.isNaN(scenarioConst.openingPartYear) + ) { + scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR; + } + const scenarioConfig = { + ...seed.scenarioConfig, + const: scenarioConst, + }; + const worldConfig: Record = { fiction: install?.fiction, fictionMode: install?.fiction === 0 ? '연의' : install?.fiction === 1 ? '가상' : undefined, @@ -202,6 +217,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom lastTurnTime: formatDateTime(now), }; + const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]; + if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) { + worldMeta.hiddenSeed = integrationSeed.trim(); + } + if (install?.preopenAt) { worldMeta.preopenAt = formatDateTime(install.preopenAt); } @@ -233,7 +253,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom currentYear: startState.currentYear, currentMonth: startState.currentMonth, tickSeconds, - config: asJson({ ...seed.scenarioConfig, ...worldConfig }), + config: asJson({ ...scenarioConfig, ...worldConfig }), meta: asJson(worldMeta), }, }); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 1d68a65..c0d40d2 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1,8 +1,11 @@ import { randomBytes } from 'node:crypto'; +import path from 'node:path'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; + import { procedure, router } from './trpc.js'; import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; @@ -10,6 +13,7 @@ import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; import type { GatewayApiContext } from './context.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; +import { seedProfileDatabase } from './orchestrator/seedProfileDatabase.js'; const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES); @@ -670,6 +674,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ @@ -779,6 +784,11 @@ export const adminRouter = router({ options: autorunUser.options, } : null, + adminUser: { + id: adminAuth.user.id, + username: adminAuth.user.username, + displayName: adminAuth.user.displayName, + }, }, }; @@ -807,6 +817,81 @@ export const adminRouter = router({ return { ok: true, action: actionRecord }; }), + installNow: profileAdminProcedure + .input( + z.object({ + profileName: z.string().min(1), + install: zInstallOptions, + reason: z.string().max(200).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const profile = await ctx.profiles.getProfile(input.profileName); + if (!profile) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Profile not found.', + }); + } + + const scenarioValue = String(input.install.scenarioId); + let updatedProfile = profile; + if (profile.scenario !== scenarioValue) { + const updated = await ctx.profiles.updateScenario(profile.profileName, scenarioValue); + if (updated) { + updatedProfile = updated; + } + } + + const databaseUrl = resolvePostgresConfigFromEnv({ + env: process.env, + schema: updatedProfile.profile, + }).url; + const resourcesRoot = path.resolve(process.cwd(), 'resources'); + + await seedProfileDatabase({ + databaseUrl, + scenarioId: input.install.scenarioId, + tickSeconds: input.install.turnTermMinutes * 60, + now: new Date(), + installOptions: { + turnTermMinutes: input.install.turnTermMinutes, + sync: input.install.sync, + fiction: input.install.fiction, + extend: input.install.extend, + blockGeneralCreate: input.install.blockGeneralCreate, + npcMode: input.install.npcMode, + showImgLevel: input.install.showImgLevel, + tournamentTrig: input.install.tournamentTrig, + joinMode: input.install.joinMode, + autorunUser: input.install.autorunUser + ? { + limitMinutes: input.install.autorunUser.limitMinutes, + options: Object.fromEntries( + input.install.autorunUser.options.map((option) => [option, true]) + ), + } + : null, + }, + scenarioOptions: { scenarioRoot: path.join(resourcesRoot, 'scenario') }, + mapOptions: { mapRoot: path.join(resourcesRoot, 'map') }, + unitSetOptions: { unitSetRoot: path.join(resourcesRoot, 'unitset') }, + adminUser: { + id: adminAuth.user.id, + username: adminAuth.user.username, + displayName: adminAuth.user.displayName, + }, + }); + + await ctx.profiles.updateStatus(updatedProfile.profileName, 'RUNNING', { + preopenAt: null, + openAt: null, + scheduledStartAt: null, + }); + + return { ok: true }; + }), requestAction: adminProcedure .input( z.object({ diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 341fd3e..1e763ad 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine'; +import { type ScenarioInstallOptions } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; @@ -8,6 +8,7 @@ import type { BuildRunner } from './buildRunner.js'; import type { ProcessManager } from './processManager.js'; import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js'; import type { GitWorkspaceManager } from './workspaceManager.js'; +import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js'; export interface GatewayProcessConfig { workspaceRoot: string; @@ -94,6 +95,11 @@ interface GatewayAdminActionRecord { limitMinutes?: number; options?: string[]; } | null; + adminUser?: { + id?: string; + username?: string; + displayName?: string | null; + }; openAt?: string | null; preopenAt?: string | null; gitRef?: string | null; @@ -146,11 +152,12 @@ const parseInstallOptions = ( ): { installOptions: ScenarioInstallOptions | null; scenarioId: number | null; + adminUser: AdminSeedUser | null; openAt: Date | null; preopenAt: Date | null; } => { if (!isRecord(action.install)) { - return { installOptions: null, scenarioId: null, openAt: null, preopenAt: null }; + return { installOptions: null, scenarioId: null, adminUser: null, openAt: null, preopenAt: null }; } const install = action.install; @@ -196,6 +203,20 @@ const parseInstallOptions = ( const openAt = parseDateTime(install.openAt ?? null); const preopenAt = parseDateTime(install.preopenAt ?? null); + const adminUser = + isRecord(install.adminUser) && typeof install.adminUser.id === 'string' + ? { + id: install.adminUser.id, + username: + typeof install.adminUser.username === 'string' + ? install.adminUser.username + : install.adminUser.id, + displayName: + typeof install.adminUser.displayName === 'string' + ? install.adminUser.displayName + : undefined, + } + : null; const installOptions: ScenarioInstallOptions = { turnTermMinutes, @@ -214,6 +235,7 @@ const parseInstallOptions = ( return { installOptions, scenarioId, + adminUser, openAt, preopenAt, }; @@ -582,7 +604,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.buildInFlight = true; this.resetInFlight.add(profile.profileName); try { - const { installOptions, scenarioId: installScenarioId, openAt, preopenAt } = parseInstallOptions(action); + const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } = + parseInstallOptions(action); const tickOverride = installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined; const seedInfo = await this.resolveResetSeedInfo(profile, { @@ -622,7 +645,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } const workspace = await this.workspaceManager.prepare(commitSha); const resourceRoot = path.join(workspace.root, 'resources'); - await seedScenarioToDatabase({ + await seedProfileDatabase({ databaseUrl: seedInfo.databaseUrl, scenarioId: seedInfo.scenarioId, tickSeconds: seedInfo.tickSeconds, @@ -631,6 +654,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') }, mapOptions: { mapRoot: path.join(resourceRoot, 'map') }, unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') }, + adminUser, }); await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', { completedAt, diff --git a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts new file mode 100644 index 0000000..d361afb --- /dev/null +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -0,0 +1,163 @@ +import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import { asRecord } from '@sammo-ts/common'; + +export interface AdminSeedUser { + id: string; + username: string; + displayName?: string | null; +} + +export interface SeedProfileDatabaseOptions { + databaseUrl: string; + scenarioId: number; + tickSeconds?: number; + now?: Date; + installOptions?: ScenarioInstallOptions; + scenarioOptions?: Parameters[0]['scenarioOptions']; + mapOptions?: Parameters[0]['mapOptions']; + unitSetOptions?: Parameters[0]['unitSetOptions']; + adminUser?: AdminSeedUser | null; +} + +const DEFAULT_STAT_TOTAL = 165; +const DEFAULT_STAT_MIN = 15; +const DEFAULT_STAT_MAX = 80; + +const hashString = (value: string): number => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash; +}; + +const resolveAdminStats = (config: Record) => { + const stat = asRecord(config.stat); + const total = typeof stat.total === 'number' && Number.isFinite(stat.total) ? stat.total : DEFAULT_STAT_TOTAL; + const min = typeof stat.min === 'number' && Number.isFinite(stat.min) ? stat.min : DEFAULT_STAT_MIN; + const max = typeof stat.max === 'number' && Number.isFinite(stat.max) ? stat.max : DEFAULT_STAT_MAX; + + const stats = [min, min, min]; + let remaining = Math.max(0, total - min * 3); + let guard = 0; + while (remaining > 0 && guard < 1000) { + let progressed = false; + for (let i = 0; i < stats.length && remaining > 0; i += 1) { + if (stats[i] >= max) { + continue; + } + stats[i] += 1; + remaining -= 1; + progressed = true; + } + if (!progressed) { + break; + } + guard += 1; + } + + return { + leadership: stats[0], + strength: stats[1], + intelligence: stats[2], + }; +}; + +const resolveAdminName = async ( + prisma: Awaited>['prisma'], + adminUser: AdminSeedUser +): Promise => { + const base = (adminUser.displayName ?? adminUser.username ?? '').trim() || '관리자'; + const existing = await prisma.general.findFirst({ where: { name: base } }); + if (!existing) { + return base; + } + const suffix = adminUser.id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 4) || 'admin'; + for (let i = 1; i <= 5; i += 1) { + const candidate = `${base}_${suffix}${i}`; + const found = await prisma.general.findFirst({ where: { name: candidate } }); + if (!found) { + return candidate; + } + } + return `${base}_${suffix}${Date.now().toString(36)}`; +}; + +// 초기 설치/통합 테스트에서 관리자 장수를 자동 생성한다. +const ensureAdminGeneral = async (databaseUrl: string, adminUser: AdminSeedUser): Promise => { + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const prisma = connector.prisma; + const existing = await prisma.general.findFirst({ where: { userId: adminUser.id } }); + if (existing) { + return; + } + + const worldState = await prisma.worldState.findFirst(); + if (!worldState) { + return; + } + + const cityRows = await prisma.city.findMany({ + select: { id: true }, + orderBy: { id: 'asc' }, + }); + const cityId = + cityRows.length > 0 ? cityRows[hashString(adminUser.id) % cityRows.length]!.id : 0; + + const maxId = await prisma.general.aggregate({ _max: { id: true } }); + const nextId = (maxId._max.id ?? 0) + 1; + const stats = resolveAdminStats(asRecord(worldState.config)); + const name = await resolveAdminName(prisma, adminUser); + const meta = asRecord(worldState.meta); + const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null; + const turnTime = + rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); + + await prisma.general.create({ + data: { + id: nextId, + userId: adminUser.id, + name, + nationId: 0, + cityId, + troopId: 0, + npcState: 0, + leadership: stats.leadership, + strength: stats.strength, + intel: stats.intelligence, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + turnTime, + meta: { + createdBy: 'admin-seed', + }, + }, + }); + } finally { + await connector.disconnect(); + } +}; + +// 시나리오 시드 후 관리자 장수를 포함한 초기 데이터를 준비한다. +export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) => { + const result = await seedScenarioToDatabase({ + scenarioId: options.scenarioId, + databaseUrl: options.databaseUrl, + tickSeconds: options.tickSeconds, + now: options.now, + installOptions: options.installOptions, + scenarioOptions: options.scenarioOptions, + mapOptions: options.mapOptions, + unitSetOptions: options.unitSetOptions, + }); + + if (options.adminUser) { + await ensureAdminGeneral(options.databaseUrl, options.adminUser); + } + + return result; +}; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 1bca421..61a3acc 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -15,6 +15,7 @@ const zUsername = z.string().min(2).max(32); const zPassword = z.string().min(6).max(128); const zProfile = z.string().min(1).max(64); const zOAuthMode = z.enum(['login', 'change_pw']); +const zBootstrapToken = z.string().min(1); const parseDate = (value: string): Date | null => { const parsed = parseISO(value); @@ -61,6 +62,51 @@ export const appRouter = router({ }), admin: adminRouter, auth: router({ + bootstrapLocal: procedure + .input( + z.object({ + token: zBootstrapToken, + username: zUsername, + password: zPassword, + displayName: z.string().min(2).max(40).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const expected = process.env.GATEWAY_BOOTSTRAP_TOKEN ?? ''; + if (!expected) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Bootstrap is disabled.', + }); + } + if (input.token !== expected) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid bootstrap token.', + }); + } + const existing = await ctx.prisma.appUser.findFirst({ + select: { id: true }, + }); + if (existing) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Bootstrap is already completed.', + }); + } + const created = await ctx.users.createUser({ + username: input.username, + password: input.password, + displayName: input.displayName, + }); + await ctx.users.updateRoles(created.id, ['superuser']); + const session = await ctx.sessions.createSession(created); + return { + user: toPublicUser(created), + sessionToken: session.sessionToken, + issuedAt: session.issuedAt, + }; + }), kakaoStart: procedure .input( z diff --git a/docs/integration-tests.md b/docs/integration-tests.md new file mode 100644 index 0000000..0269bfa --- /dev/null +++ b/docs/integration-tests.md @@ -0,0 +1,72 @@ +# Integration Tests (Initialization Flow) + +This document describes the end-to-end integration test that exercises the +gateway, game-api, and game-engine initialization flow. + +## Scope + +The initialization integration test validates: + +- database reset and schema readiness +- bootstrap admin creation +- demo user provisioning (10 accounts) +- scenario install for `che` with `scenario_2` and 1-minute ticks +- deterministic seeding (via env) +- auto admin general creation +- general creation for demo users with constrained city selection +- reserved turn submission (uprising, founding, appointment) +- running three turns and validating founding outcomes + +The test runs real Postgres and Redis, and talks to the API servers via tRPC. + +## Files + +- `tools/integration-tests/test/initialization.test.ts` +- `tools/integration-tests/vitest.config.ts` + +## Prerequisites + +- Postgres and Redis are running and reachable. +- `.env.ci` is present at the repo root and contains: + - Postgres and Redis connection settings. + - `GAME_TOKEN_SECRET`, `KAKAO_REST_KEY`, `KAKAO_REDIRECT_URI` + - `GATEWAY_BOOTSTRAP_TOKEN` + - `GATEWAY_API_HOST`, `GATEWAY_API_PORT`, `GAME_API_HOST`, `GAME_API_PORT` + - `PROFILE=che`, `SCENARIO=2` +- `pnpm install` and `pnpm build` have already completed. + +## Safety + +The test truncates the `public` and `che` schemas and flushes Redis. Use a +dedicated CI/local database and Redis instance. + +## Running + +Use the root helper script: + +```sh +pnpm test:integration +``` + +Or run the package script directly: + +```sh +pnpm --filter @sammo-ts/integration-tests test:integration +``` + +## Environment Flags + +- `INTEGRATION_WORLD_SEED`: injected into world meta as `hiddenSeed` for + deterministic RNG. +- `INTEGRATION_JOIN_ALLOW_CITY=true`: allows test-only city selection when + creating generals (still restricted to level 5/6 cities). + +These are loaded from `.env.ci` and can be overridden per run. + +## Notes + +- `auth.bootstrapLocal` only works when no users exist; the test resets the DB + to satisfy this precondition. +- `profiles.installNow` seeds the scenario and auto-creates the admin general. +- The test runs turn processing via `turnDaemon.run` and validates founding + rules at the third turn. diff --git a/package.json b/package.json index 0b1cd1a..d6b0410 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint:fix": "turbo lint:fix", "format": "prettier --write .", "test": "turbo test", + "test:integration": "pnpm --filter @sammo-ts/integration-tests test:integration", "build": "turbo build", "typecheck": "turbo typecheck", "dev": "turbo dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3611b07..e022219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: '@sammo-ts/infra': specifier: workspace:* version: link:../../packages/infra + '@sammo-ts/logic': + specifier: workspace:* + version: link:../../packages/logic '@trpc/server': specifier: ^11.8.1 version: 11.8.1(typescript@5.9.3) @@ -394,6 +397,34 @@ importers: tools/build-scripts: {} + tools/integration-tests: + dependencies: + '@sammo-ts/common': + specifier: workspace:* + version: link:../../packages/common + '@sammo-ts/game-api': + specifier: workspace:* + version: link:../../app/game-api + '@sammo-ts/game-engine': + specifier: workspace:* + version: link:../../app/game-engine + '@sammo-ts/gateway-api': + specifier: workspace:* + version: link:../../app/gateway-api + '@sammo-ts/infra': + specifier: workspace:* + version: link:../../packages/infra + '@trpc/client': + specifier: ^11.8.1 + version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3) + devDependencies: + vite-tsconfig-paths: + specifier: ^6.0.3 + version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) + packages: '@babel/generator@7.28.5': diff --git a/tools/integration-tests/package.json b/tools/integration-tests/package.json new file mode 100644 index 0000000..0687d6c --- /dev/null +++ b/tools/integration-tests/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sammo-ts/integration-tests", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test:integration": "vitest run --config vitest.config.ts", + "typecheck": "tsc -b" + }, + "dependencies": { + "@sammo-ts/common": "workspace:*", + "@sammo-ts/game-api": "workspace:*", + "@sammo-ts/game-engine": "workspace:*", + "@sammo-ts/gateway-api": "workspace:*", + "@sammo-ts/infra": "workspace:*", + "@trpc/client": "^11.8.1" + }, + "devDependencies": { + "vite-tsconfig-paths": "^6.0.3", + "vitest": "^4.0.16" + } +} diff --git a/tools/integration-tests/test/initialization.test.ts b/tools/integration-tests/test/initialization.test.ts new file mode 100644 index 0000000..f247645 --- /dev/null +++ b/tools/integration-tests/test/initialization.test.ts @@ -0,0 +1,448 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; + +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; + +import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; +import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; +import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import { createGameApiServer } from '@sammo-ts/game-api'; +import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { + createGatewayPostgresConnector, + createGamePostgresConnector, + resolvePostgresConfigFromEnv, + createRedisConnector, + resolveRedisConfigFromEnv, +} from '@sammo-ts/infra'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +const parseEnvFile = (rawText: string): Record => { + const env: Record = {}; + const lines = rawText.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const index = trimmed.indexOf('='); + if (index < 0) { + continue; + } + const key = trimmed.slice(0, index).trim(); + let value = trimmed.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +}; + +const loadEnv = async () => { + const envPath = path.join(workspaceRoot, '.env.ci'); + const raw = await fs.readFile(envPath, 'utf8'); + const values = parseEnvFile(raw); + for (const [key, value] of Object.entries(values)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true'; + process.env.INTEGRATION_WORLD_SEED ??= 'integration-seed'; +}; + +const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv) => + new Promise((resolve, reject) => { + execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`)); + return; + } + resolve(); + }); + }); + +const ensureSchema = async (schema: string) => { + const adminUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + const connector = createGatewayPostgresConnector({ url: adminUrl }); + await connector.connect(); + try { + await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + } finally { + await connector.disconnect(); + } +}; + +const truncateSchema = async (schema: string) => { + const adminUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + const connector = createGatewayPostgresConnector({ url: adminUrl }); + await connector.connect(); + try { + const rows = (await connector.prisma.$queryRawUnsafe( + `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + )) as Array<{ tablename: string }>; + if (rows.length === 0) { + return; + } + const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', '); + await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`); + } finally { + await connector.disconnect(); + } +}; + +const resetDatabase = async () => { + await ensureSchema('public'); + await ensureSchema('che'); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway'], { + ...process.env, + POSTGRES_SCHEMA: 'public', + }); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game'], { + ...process.env, + POSTGRES_SCHEMA: 'che', + }); + await truncateSchema('public'); + await truncateSchema('che'); +}; + +const resetRedis = async () => { + const redis = createRedisConnector(resolveRedisConfigFromEnv()); + await redis.connect(); + try { + await redis.client.flushDb(); + } finally { + await redis.disconnect(); + } +}; + +const createGatewayClient = (baseUrl: string, trpcPath: string, sessionTokenRef: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${trpcPath}`, + headers: () => + sessionTokenRef.value + ? { + 'x-session-token': sessionTokenRef.value, + } + : {}, + }), + ], + }); + +const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${trpcPath}`, + headers: () => + accessTokenRef.value + ? { + authorization: `Bearer ${accessTokenRef.value}`, + } + : {}, + }), + ], + }); + +describe('integration initialization flow', () => { + let gatewayServer: Awaited> | null = null; + let gameServer: Awaited> | null = null; + let turnDaemon: Awaited> | null = null; + let turnDaemonLoop: Promise | null = null; + + beforeAll(async () => { + await loadEnv(); + await resetDatabase(); + await resetRedis(); + + gatewayServer = await createGatewayApiServer(); + await gatewayServer.app.listen({ + host: gatewayServer.config.host, + port: gatewayServer.config.port, + }); + + gameServer = await createGameApiServer(); + await gameServer.app.listen({ + host: gameServer.config.host, + port: gameServer.config.port, + }); + }); + + afterAll(async () => { + if (turnDaemon) { + await turnDaemon.lifecycle.stop('integration-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + if (gameServer) { + await gameServer.app.close(); + } + if (gatewayServer) { + await gatewayServer.app.close(); + } + }); + + it('seeds scenario, creates users, and validates founding flow', async () => { + if (!gatewayServer || !gameServer) { + throw new Error('servers not initialized'); + } + + const gatewayUrl = `http://localhost:${gatewayServer.config.port}`; + const gameUrl = `http://localhost:${gameServer.config.port}`; + const adminSessionRef: { value?: string } = {}; + const gameAccessRef: { value?: string } = {}; + const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef); + const gameClient = createGameClient(gameUrl, gameServer.config.trpcPath, gameAccessRef); + + const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({ + token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '', + username: 'admin', + password: 'admin-pass-123', + displayName: '관리자', + }); + adminSessionRef.value = bootstrap.sessionToken; + expect(bootstrap.user.username).toBe('admin'); + + const demoUsers = Array.from({ length: 10 }, (_, idx) => ({ + username: `demo${idx + 1}`, + password: `demo-pass-${idx + 1}`, + displayName: `데모${idx + 1}`, + })); + + for (const user of demoUsers) { + await gatewayClient.admin.users.createLocal.mutate({ + username: user.username, + password: user.password, + displayName: user.displayName, + }); + const lookup = await gatewayClient.admin.users.lookup.query({ username: user.username }); + expect(lookup?.username).toBe(user.username); + const login = await gatewayClient.auth.login.mutate({ + username: user.username, + password: user.password, + }); + expect(login.sessionToken).toBeTruthy(); + } + + await gatewayClient.admin.profiles.upsert.mutate({ + profile: 'che', + scenario: '2', + apiPort: Number(process.env.GAME_API_PORT ?? 14000), + status: 'RUNNING', + }); + + await gatewayClient.admin.profiles.installNow.mutate({ + profileName: 'che:2', + install: { + scenarioId: 2, + turnTermMinutes: 1, + sync: false, + fiction: 0, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 0, + tournamentTrig: false, + joinMode: 'full', + autorunUser: null, + }, + }); + + const publicMap = await gameClient.public.getCachedMap.query(); + expect(publicMap.result).toBe(true); + const nations = await gameClient.public.getNationList.query(); + expect(Array.isArray(nations)).toBe(true); + + const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: adminSessionRef.value ?? '', + profile: 'che:2', + }); + const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({ + gatewayToken: adminGatewayToken.gameToken, + }); + gameAccessRef.value = adminAccess.accessToken; + const adminGeneral = await gameClient.general.me.query(); + expect(adminGeneral?.general).toBeTruthy(); + + const cityCandidates = publicMap.cityList + .map((row: number[]) => ({ + id: row[0], + level: row[1], + nationId: row[3], + })) + .filter( + (city: { id: number; level: number; nationId: number }) => + (city.level === 5 || city.level === 6) && city.nationId === 0 + ) + .map((city: { id: number }) => city.id); + + expect(cityCandidates.length).toBeGreaterThanOrEqual(10); + + const userSessions: Array<{ + username: string; + sessionToken: string; + accessToken: string; + generalId: number; + cityId: number; + }> = []; + + for (const [idx, user] of demoUsers.entries()) { + const login = await gatewayClient.auth.login.mutate({ + username: user.username, + password: user.password, + }); + const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: login.sessionToken, + profile: 'che:2', + }); + const access = await gameClient.auth.exchangeGatewayToken.mutate({ + gatewayToken: gatewayToken.gameToken, + }); + const accessRef = { value: access.accessToken }; + const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); + const cityId = cityCandidates[idx] ?? cityCandidates[0]!; + const created = await userGameClient.join.createGeneral.mutate({ + name: `${user.displayName}`, + leadership: 55, + strength: 55, + intel: 55, + character: 'Random', + inheritCity: cityId, + }); + userSessions.push({ + username: user.username, + sessionToken: login.sessionToken, + accessToken: access.accessToken, + generalId: created.generalId, + cityId, + }); + } + + const lords = userSessions.slice(0, 3).sort((a, b) => a.generalId - b.generalId); + const nationIds = lords.map((_, idx) => idx + 1); + const nationByGeneralId = new Map(lords.map((lord, idx) => [lord.generalId, nationIds[idx]])); + const joinNationIds = [nationIds[0]!, nationIds[1]!]; + + for (const [idx, user] of userSessions.entries()) { + const accessRef = { value: user.accessToken }; + const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); + if (idx < 3) { + await userGameClient.turns.reserved.setGeneral.mutate({ + generalId: user.generalId, + turnIndex: 0, + action: 'che_거병', + }); + await userGameClient.turns.reserved.setGeneral.mutate({ + generalId: user.generalId, + turnIndex: 1, + action: 'che_건국', + args: { + nationName: `TestNation${idx + 1}`, + nationType: 'che_def', + colorType: idx, + }, + }); + } else { + const destNationId = joinNationIds[(idx - 3) % joinNationIds.length]!; + await userGameClient.turns.reserved.setGeneral.mutate({ + generalId: user.generalId, + turnIndex: 0, + action: 'che_임관', + args: { destNationId }, + }); + await userGameClient.turns.reserved.setGeneral.mutate({ + generalId: user.generalId, + turnIndex: 1, + action: 'che_임관', + args: { destNationId }, + }); + } + } + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:2', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + + const waitForStatus = async (timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = await gameClient.turnDaemon.status.query({ timeoutMs: 3000 }); + if (status) { + return status; + } + await sleep(200); + } + throw new Error('turn daemon status timeout'); + }; + + const runTurn = async () => { + const status = await waitForStatus(); + const prevRunAt = status.lastRunAt; + await gameClient.turnDaemon.run.mutate({ + reason: 'manual', + targetTime: status.nextTurnTime ?? undefined, + }); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const nextStatus = await waitForStatus(); + if (!nextStatus.running && nextStatus.lastRunAt && nextStatus.lastRunAt !== prevRunAt) { + return nextStatus; + } + await sleep(200); + } + throw new Error('turn run timeout'); + }; + + await runTurn(); + await runTurn(); + await runTurn(); + + const connector = createGamePostgresConnector({ url: gameDatabaseUrl }); + await connector.connect(); + try { + const nationRows = (await connector.prisma.nation.findMany({ + where: { id: { gt: 0 } }, + select: { id: true, level: true, capitalCityId: true }, + })) as Array<{ id: number; level: number; capitalCityId: number | null }>; + expect(nationRows).toHaveLength(3); + + const generalRows = (await connector.prisma.general.findMany({ + where: { nationId: { gt: 0 } }, + select: { id: true, nationId: true }, + })) as Array<{ id: number; nationId: number }>; + const generalCountMap = new Map(); + for (const row of generalRows) { + generalCountMap.set(row.nationId, (generalCountMap.get(row.nationId) ?? 0) + 1); + } + + for (const lord of lords) { + const nationId = nationByGeneralId.get(lord.generalId)!; + const nation = nationRows.find((row) => row.id === nationId); + expect(nation).toBeTruthy(); + const count = generalCountMap.get(nationId) ?? 0; + if (count >= 2) { + expect(nation!.level).toBeGreaterThan(0); + expect(nation!.capitalCityId).toBe(lord.cityId); + } else { + expect(nation!.level).toBe(0); + } + } + } finally { + await connector.disconnect(); + } + }); +}); diff --git a/tools/integration-tests/tsconfig.json b/tools/integration-tests/tsconfig.json new file mode 100644 index 0000000..8d01748 --- /dev/null +++ b/tools/integration-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noEmit": true + }, + "include": ["test/**/*.ts"] +} diff --git a/tools/integration-tests/vitest.config.ts b/tools/integration-tests/vitest.config.ts new file mode 100644 index 0000000..97f9331 --- /dev/null +++ b/tools/integration-tests/vitest.config.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; + +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: [path.resolve(__dirname, '../../tsconfig.paths.json')], + }), + ], + test: { + environment: 'node', + globals: true, + include: ['test/**/*.test.ts'], + testTimeout: 120_000, + }, +}); diff --git a/tsconfig.paths.json b/tsconfig.paths.json index c0315a3..834d1db 100644 --- a/tsconfig.paths.json +++ b/tsconfig.paths.json @@ -9,6 +9,8 @@ "@sammo-ts/infra/*": ["packages/infra/src/*"], "@sammo-ts/logic": ["packages/logic/src/index.ts"], "@sammo-ts/logic/*": ["packages/logic/src/*"], + "@sammo-ts/gateway-api": ["app/gateway-api/src/index.ts"], + "@sammo-ts/gateway-api/*": ["app/gateway-api/src/*"], "@sammo-ts/game-api": ["app/game-api/src/index.ts"], "@sammo-ts/game-api/*": ["app/game-api/src/*"], "@sammo-ts/game-engine": ["app/game-engine/src/index.ts"],