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.
This commit is contained in:
2026-01-18 06:32:19 +00:00
parent 1b7423e1f6
commit a9ddffa97c
15 changed files with 997 additions and 8 deletions
@@ -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,
@@ -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<typeof seedScenarioToDatabase>[0]['scenarioOptions'];
mapOptions?: Parameters<typeof seedScenarioToDatabase>[0]['mapOptions'];
unitSetOptions?: Parameters<typeof seedScenarioToDatabase>[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<string, unknown>) => {
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<ReturnType<typeof createGamePostgresConnector>>['prisma'],
adminUser: AdminSeedUser
): Promise<string> => {
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<void> => {
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;
};