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:
+6
-1
@@ -2,13 +2,16 @@ POSTGRES_DB=sammo_test
|
|||||||
POSTGRES_USER=sammo
|
POSTGRES_USER=sammo
|
||||||
POSTGRES_PASSWORD=ci-postgres
|
POSTGRES_PASSWORD=ci-postgres
|
||||||
POSTGRES_SCHEMA=public
|
POSTGRES_SCHEMA=public
|
||||||
|
GAME_TOKEN_SECRET=ci-secret
|
||||||
REDIS_PASSWORD=ci-redis
|
REDIS_PASSWORD=ci-redis
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PORT=16379
|
REDIS_PORT=16379
|
||||||
REDIS_DB=0
|
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_REDIS_PREFIX=sammo:gateway
|
||||||
GATEWAY_DB_SCHEMA=public
|
GATEWAY_DB_SCHEMA=public
|
||||||
|
GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true
|
||||||
|
GATEWAY_BOOTSTRAP_TOKEN=ci-bootstrap
|
||||||
GATEWAY_API_HOST=127.0.0.1
|
GATEWAY_API_HOST=127.0.0.1
|
||||||
GATEWAY_API_PORT=13000
|
GATEWAY_API_PORT=13000
|
||||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
GATEWAY_PUBLIC_URL=http://localhost:13000
|
||||||
@@ -16,5 +19,7 @@ GAME_API_HOST=127.0.0.1
|
|||||||
GAME_API_PORT=14000
|
GAME_API_PORT=14000
|
||||||
PROFILE=hwe
|
PROFILE=hwe
|
||||||
SCENARIO=default
|
SCENARIO=default
|
||||||
|
INTEGRATION_WORLD_SEED=integration-seed
|
||||||
|
INTEGRATION_JOIN_ALLOW_CITY=true
|
||||||
KAKAO_REST_KEY=ci-kakao-rest-key
|
KAKAO_REST_KEY=ci-kakao-rest-key
|
||||||
KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback
|
KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
|
|||||||
|
|
||||||
import type { WorldStateRow } from '../../context.js';
|
import type { WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, router } from '../../trpc.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 {
|
import {
|
||||||
isPersonalityTraitKey,
|
isPersonalityTraitKey,
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
@@ -250,13 +250,57 @@ export const joinRouter = router({
|
|||||||
|
|
||||||
const maxId = await db.general.aggregate({ _max: { id: true } });
|
const maxId = await db.general.aggregate({ _max: { id: true } });
|
||||||
const nextId = (maxId._max.id ?? 0) + 1;
|
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) {
|
if (!cityList.length) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: '도시 정보를 찾을 수 없습니다.',
|
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 cityIndex = hashString(userId) % cityList.length;
|
||||||
const cityId = cityList[cityIndex]?.id ?? cityList[0].id;
|
const cityId = cityList[cityIndex]?.id ?? cityList[0].id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
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 { buildScenarioBootstrap, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { MapLoaderOptions } from './mapLoader.js';
|
import type { MapLoaderOptions } from './mapLoader.js';
|
||||||
@@ -11,6 +12,8 @@ import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
|
|||||||
const DEFAULT_TICK_SECONDS = 120 * 60;
|
const DEFAULT_TICK_SECONDS = 120 * 60;
|
||||||
const DEFAULT_GENERAL_GOLD = 1000;
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
const DEFAULT_GENERAL_RICE = 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;
|
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 generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
|
||||||
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
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<string, unknown> = {
|
const worldConfig: Record<string, unknown> = {
|
||||||
fiction: install?.fiction,
|
fiction: install?.fiction,
|
||||||
fictionMode: install?.fiction === 0 ? '연의' : install?.fiction === 1 ? '가상' : undefined,
|
fictionMode: install?.fiction === 0 ? '연의' : install?.fiction === 1 ? '가상' : undefined,
|
||||||
@@ -202,6 +217,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
lastTurnTime: formatDateTime(now),
|
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) {
|
if (install?.preopenAt) {
|
||||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||||
}
|
}
|
||||||
@@ -233,7 +253,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
currentYear: startState.currentYear,
|
currentYear: startState.currentYear,
|
||||||
currentMonth: startState.currentMonth,
|
currentMonth: startState.currentMonth,
|
||||||
tickSeconds,
|
tickSeconds,
|
||||||
config: asJson({ ...seed.scenarioConfig, ...worldConfig }),
|
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
||||||
meta: asJson(worldMeta),
|
meta: asJson(worldMeta),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.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 { AdminAuthContext } from './adminAuth.js';
|
||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.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 zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||||
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
||||||
@@ -670,6 +674,7 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -779,6 +784,11 @@ export const adminRouter = router({
|
|||||||
options: autorunUser.options,
|
options: autorunUser.options,
|
||||||
}
|
}
|
||||||
: null,
|
: 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 };
|
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
|
requestAction: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from 'node:path';
|
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 { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||||
import { isRecord } from '@sammo-ts/common';
|
import { isRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import type { BuildRunner } from './buildRunner.js';
|
|||||||
import type { ProcessManager } from './processManager.js';
|
import type { ProcessManager } from './processManager.js';
|
||||||
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
|
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
|
||||||
import type { GitWorkspaceManager } from './workspaceManager.js';
|
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||||
|
import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
|
||||||
|
|
||||||
export interface GatewayProcessConfig {
|
export interface GatewayProcessConfig {
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
@@ -94,6 +95,11 @@ interface GatewayAdminActionRecord {
|
|||||||
limitMinutes?: number;
|
limitMinutes?: number;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
|
adminUser?: {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
};
|
||||||
openAt?: string | null;
|
openAt?: string | null;
|
||||||
preopenAt?: string | null;
|
preopenAt?: string | null;
|
||||||
gitRef?: string | null;
|
gitRef?: string | null;
|
||||||
@@ -146,11 +152,12 @@ const parseInstallOptions = (
|
|||||||
): {
|
): {
|
||||||
installOptions: ScenarioInstallOptions | null;
|
installOptions: ScenarioInstallOptions | null;
|
||||||
scenarioId: number | null;
|
scenarioId: number | null;
|
||||||
|
adminUser: AdminSeedUser | null;
|
||||||
openAt: Date | null;
|
openAt: Date | null;
|
||||||
preopenAt: Date | null;
|
preopenAt: Date | null;
|
||||||
} => {
|
} => {
|
||||||
if (!isRecord(action.install)) {
|
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;
|
const install = action.install;
|
||||||
@@ -196,6 +203,20 @@ const parseInstallOptions = (
|
|||||||
|
|
||||||
const openAt = parseDateTime(install.openAt ?? null);
|
const openAt = parseDateTime(install.openAt ?? null);
|
||||||
const preopenAt = parseDateTime(install.preopenAt ?? 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 = {
|
const installOptions: ScenarioInstallOptions = {
|
||||||
turnTermMinutes,
|
turnTermMinutes,
|
||||||
@@ -214,6 +235,7 @@ const parseInstallOptions = (
|
|||||||
return {
|
return {
|
||||||
installOptions,
|
installOptions,
|
||||||
scenarioId,
|
scenarioId,
|
||||||
|
adminUser,
|
||||||
openAt,
|
openAt,
|
||||||
preopenAt,
|
preopenAt,
|
||||||
};
|
};
|
||||||
@@ -582,7 +604,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.buildInFlight = true;
|
this.buildInFlight = true;
|
||||||
this.resetInFlight.add(profile.profileName);
|
this.resetInFlight.add(profile.profileName);
|
||||||
try {
|
try {
|
||||||
const { installOptions, scenarioId: installScenarioId, openAt, preopenAt } = parseInstallOptions(action);
|
const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } =
|
||||||
|
parseInstallOptions(action);
|
||||||
const tickOverride =
|
const tickOverride =
|
||||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||||
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
||||||
@@ -622,7 +645,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||||
const resourceRoot = path.join(workspace.root, 'resources');
|
const resourceRoot = path.join(workspace.root, 'resources');
|
||||||
await seedScenarioToDatabase({
|
await seedProfileDatabase({
|
||||||
databaseUrl: seedInfo.databaseUrl,
|
databaseUrl: seedInfo.databaseUrl,
|
||||||
scenarioId: seedInfo.scenarioId,
|
scenarioId: seedInfo.scenarioId,
|
||||||
tickSeconds: seedInfo.tickSeconds,
|
tickSeconds: seedInfo.tickSeconds,
|
||||||
@@ -631,6 +654,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
|
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
|
||||||
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
|
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
|
||||||
unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') },
|
unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') },
|
||||||
|
adminUser,
|
||||||
});
|
});
|
||||||
await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', {
|
await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', {
|
||||||
completedAt,
|
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;
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ const zUsername = z.string().min(2).max(32);
|
|||||||
const zPassword = z.string().min(6).max(128);
|
const zPassword = z.string().min(6).max(128);
|
||||||
const zProfile = z.string().min(1).max(64);
|
const zProfile = z.string().min(1).max(64);
|
||||||
const zOAuthMode = z.enum(['login', 'change_pw']);
|
const zOAuthMode = z.enum(['login', 'change_pw']);
|
||||||
|
const zBootstrapToken = z.string().min(1);
|
||||||
|
|
||||||
const parseDate = (value: string): Date | null => {
|
const parseDate = (value: string): Date | null => {
|
||||||
const parsed = parseISO(value);
|
const parsed = parseISO(value);
|
||||||
@@ -61,6 +62,51 @@ export const appRouter = router({
|
|||||||
}),
|
}),
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
auth: router({
|
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
|
kakaoStart: procedure
|
||||||
.input(
|
.input(
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint:fix": "turbo lint:fix",
|
"lint:fix": "turbo lint:fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
|
"test:integration": "pnpm --filter @sammo-ts/integration-tests test:integration",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
|
|||||||
Generated
+31
@@ -215,6 +215,9 @@ importers:
|
|||||||
'@sammo-ts/infra':
|
'@sammo-ts/infra':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/infra
|
version: link:../../packages/infra
|
||||||
|
'@sammo-ts/logic':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/logic
|
||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: ^11.8.1
|
specifier: ^11.8.1
|
||||||
version: 11.8.1(typescript@5.9.3)
|
version: 11.8.1(typescript@5.9.3)
|
||||||
@@ -394,6 +397,34 @@ importers:
|
|||||||
|
|
||||||
tools/build-scripts: {}
|
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:
|
packages:
|
||||||
|
|
||||||
'@babel/generator@7.28.5':
|
'@babel/generator@7.28.5':
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> => {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
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<void>((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<GatewayAppRouter>({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${baseUrl}${trpcPath}`,
|
||||||
|
headers: () =>
|
||||||
|
sessionTokenRef.value
|
||||||
|
? {
|
||||||
|
'x-session-token': sessionTokenRef.value,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { value?: string }) =>
|
||||||
|
createTRPCProxyClient<GameAppRouter>({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${baseUrl}${trpcPath}`,
|
||||||
|
headers: () =>
|
||||||
|
accessTokenRef.value
|
||||||
|
? {
|
||||||
|
authorization: `Bearer ${accessTokenRef.value}`,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('integration initialization flow', () => {
|
||||||
|
let gatewayServer: Awaited<ReturnType<typeof createGatewayApiServer>> | null = null;
|
||||||
|
let gameServer: Awaited<ReturnType<typeof createGameApiServer>> | null = null;
|
||||||
|
let turnDaemon: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | null = null;
|
||||||
|
let turnDaemonLoop: Promise<void> | 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<number, number>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["test/**/*.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"@sammo-ts/infra/*": ["packages/infra/src/*"],
|
"@sammo-ts/infra/*": ["packages/infra/src/*"],
|
||||||
"@sammo-ts/logic": ["packages/logic/src/index.ts"],
|
"@sammo-ts/logic": ["packages/logic/src/index.ts"],
|
||||||
"@sammo-ts/logic/*": ["packages/logic/src/*"],
|
"@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/index.ts"],
|
||||||
"@sammo-ts/game-api/*": ["app/game-api/src/*"],
|
"@sammo-ts/game-api/*": ["app/game-api/src/*"],
|
||||||
"@sammo-ts/game-engine": ["app/game-engine/src/index.ts"],
|
"@sammo-ts/game-engine": ["app/game-engine/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user