fix: 기본 등용과 대상 임관 명령을 복구
Ref 기본 일반장 명령 46개 exact-set 검사를 추가하고 등용 및 장수대상임관을 사용자 명령표에 복원한다. 실제 DB와 API, 데몬, Chromium을 연결해 등용 서신 수신과 수락 임관까지 검증하며 내부 등용수락 명령의 profile 로딩도 바로잡는다.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15154);
|
||||
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15155);
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'default_recruit_commands_live_integration';
|
||||
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
|
||||
const expectedGameProfile = `${profileId}:${scenario}`;
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile;
|
||||
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
|
||||
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
|
||||
const databaseUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_DATABASE_URL ?? '';
|
||||
const redisUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_REDIS_URL ?? '';
|
||||
const gameSecret = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_GAME_SECRET ?? '';
|
||||
const imageUploadSecretFile = process.env.GAME_IMAGE_UPLOAD_SECRET_FILE ?? '';
|
||||
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
|
||||
if (databaseUrl && databaseSchema !== profileId) {
|
||||
throw new Error(
|
||||
`Default recruit commands live schema must match its profile ID: ${databaseSchema ?? '(missing)'} != ${profileId}`
|
||||
);
|
||||
}
|
||||
if (gameProfile !== expectedGameProfile) {
|
||||
throw new Error(
|
||||
`PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}`
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['defaultRecruitCommandsLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 90_000,
|
||||
expect: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/default-recruit-commands-live'),
|
||||
use: {
|
||||
baseURL,
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'node app/game-api/dist/index.js',
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${apiPort}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
REDIS_URL: redisUrl,
|
||||
GAME_TOKEN_SECRET: gameSecret,
|
||||
GAME_IMAGE_UPLOAD_SECRET_FILE: imageUploadSecretFile,
|
||||
GAME_API_ROLE: 'server',
|
||||
GAME_API_HOST: '127.0.0.1',
|
||||
GAME_API_PORT: String(apiPort),
|
||||
PROFILE: profileId,
|
||||
SCENARIO: scenario,
|
||||
GAME_PROFILE_NAME: gameProfile,
|
||||
DAEMON_REQUEST_TIMEOUT_MS: '15000',
|
||||
},
|
||||
},
|
||||
{
|
||||
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
|
||||
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||
|
||||
const databaseUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_DATABASE_URL;
|
||||
const redisUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_REDIS_URL;
|
||||
const gameTokenSecret = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'default_recruit_commands_live_integration:2';
|
||||
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
|
||||
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
|
||||
const recruiterId = 7_751;
|
||||
const recipientId = 7_752;
|
||||
const followerId = 7_753;
|
||||
const fixtureNationId = 9_917;
|
||||
const recruiterUserId = 'default-recruit-live-recruiter';
|
||||
const recipientUserId = 'default-recruit-live-recipient';
|
||||
const followerUserId = 'default-recruit-live-follower';
|
||||
const fixtureNow = new Date('2099-08-01T00:00:00.000Z');
|
||||
const dueAt = new Date('2099-08-01T00:01:00.000Z');
|
||||
const runThrough = new Date('2099-08-01T00:02:00.000Z');
|
||||
|
||||
const installSession = async (page: Page, userId: string, displayName: string): Promise<void> => {
|
||||
const issuedAt = new Date();
|
||||
const token = encryptGameSessionToken(
|
||||
{
|
||||
version: 1,
|
||||
profile,
|
||||
issuedAt: issuedAt.toISOString(),
|
||||
expiresAt: new Date(issuedAt.getTime() + 3_600_000).toISOString(),
|
||||
sessionId: `default-recruit-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName,
|
||||
roles: ['user'],
|
||||
canUseGeneralPicture: false,
|
||||
},
|
||||
sanctions: {},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
gameTokenSecret!
|
||||
);
|
||||
await page.addInitScript(
|
||||
({ gameToken, gameProfile }) => {
|
||||
localStorage.setItem('sammo-game-token', gameToken);
|
||||
localStorage.setItem('sammo-game-profile', gameProfile);
|
||||
},
|
||||
{ gameToken: token, gameProfile: profile }
|
||||
);
|
||||
};
|
||||
|
||||
const newPage = async (browser: Browser, userId: string, displayName: string): Promise<Page> => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1365, height: 1000 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installSession(page, userId, displayName);
|
||||
await page.route('**/image/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
})
|
||||
);
|
||||
return page;
|
||||
};
|
||||
|
||||
const reserveFirstTurn = async (page: Page, commandName: string, targetName: string): Promise<void> => {
|
||||
await page.goto('.');
|
||||
const commandPanel = page.locator('[data-main-target="commands"]');
|
||||
await expect(commandPanel).toBeVisible();
|
||||
await commandPanel.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
||||
await picker.getByRole('button', { name: commandName, exact: true }).click();
|
||||
const targetOption = picker.locator('select option').filter({ hasText: targetName });
|
||||
await expect(targetOption).toHaveCount(1);
|
||||
const targetValue = await targetOption.getAttribute('value');
|
||||
if (!targetValue) throw new Error(`Missing target value for ${targetName}.`);
|
||||
await picker.locator('select').selectOption(targetValue);
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(picker).toBeHidden();
|
||||
};
|
||||
|
||||
test.describe('default recruitment commands through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
|
||||
test.skip(!hasLiveFixture, 'dedicated PostgreSQL, Redis, and a game token secret are required');
|
||||
|
||||
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
|
||||
let daemonLoop: Promise<void> | undefined;
|
||||
let nationId = 0;
|
||||
let nationName = '';
|
||||
let capitalCityId = 0;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
const profileId = profile.split(':', 1)[0] ?? '';
|
||||
if (schema !== profileId || !schema.endsWith('default_recruit_commands_live_integration')) {
|
||||
throw new Error(`Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId}`);
|
||||
}
|
||||
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||
process.env.INTEGRATION_WORLD_SEED = 'default-recruit-commands-live-seed';
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: fixtureNow,
|
||||
gameClockMode: 'manual',
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
joinMode: 'full',
|
||||
npcMode: 0,
|
||||
showImgLevel: 3,
|
||||
serverId: profile,
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousSeed === undefined) delete process.env.INTEGRATION_WORLD_SEED;
|
||||
else process.env.INTEGRATION_WORLD_SEED = previousSeed;
|
||||
}
|
||||
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.message.deleteMany();
|
||||
await db.generalTurn.deleteMany({ where: { generalId: { in: [recruiterId, recipientId, followerId] } } });
|
||||
await db.generalTurnRevision.deleteMany({
|
||||
where: { generalId: { in: [recruiterId, recipientId, followerId] } },
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: { in: [recruiterId, recipientId, followerId] } } });
|
||||
await db.general.updateMany({
|
||||
data: { turnTime: new Date('2199-01-01T00:00:00.000Z'), turnTick: null },
|
||||
});
|
||||
|
||||
const capitalCity = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
nationId = fixtureNationId;
|
||||
nationName = '실등용국';
|
||||
capitalCityId = capitalCity.id;
|
||||
await db.nation.upsert({
|
||||
where: { id: fixtureNationId },
|
||||
create: {
|
||||
id: fixtureNationId,
|
||||
name: nationName,
|
||||
color: '#225500',
|
||||
capitalCityId,
|
||||
level: 1,
|
||||
meta: { gennum: 0, scout: 0 },
|
||||
},
|
||||
update: { name: nationName, capitalCityId, level: 1, meta: { gennum: 0, scout: 0 } },
|
||||
});
|
||||
await db.city.update({
|
||||
where: { id: capitalCityId },
|
||||
data: { nationId, supplyState: 1 },
|
||||
});
|
||||
|
||||
const baseGeneral = {
|
||||
cityId: capitalCityId,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
officerLevel: 0,
|
||||
experience: 100,
|
||||
dedication: 100,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
turnTime: dueAt,
|
||||
turnTick: null,
|
||||
meta: { killturn: 960, belong: 1 },
|
||||
penalty: {},
|
||||
} as const;
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
...baseGeneral,
|
||||
id: recruiterId,
|
||||
userId: recruiterUserId,
|
||||
name: '실등용제안자',
|
||||
nationId,
|
||||
officerLevel: 1,
|
||||
},
|
||||
{
|
||||
...baseGeneral,
|
||||
id: recipientId,
|
||||
userId: recipientUserId,
|
||||
name: '실등용수신자',
|
||||
nationId: 0,
|
||||
},
|
||||
{
|
||||
...baseGeneral,
|
||||
id: followerId,
|
||||
userId: followerUserId,
|
||||
name: '실대상임관자',
|
||||
nationId: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.generalTurn.createMany({
|
||||
data: [recruiterId, recipientId, followerId].map((generalId) => ({
|
||||
generalId,
|
||||
turnIdx: 0,
|
||||
actionCode: '휴식',
|
||||
arg: {},
|
||||
})),
|
||||
});
|
||||
await db.generalTurnRevision.createMany({
|
||||
data: [recruiterId, recipientId, followerId].map((generalId) => ({ generalId, revision: 1 })),
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (runtime) {
|
||||
await runtime.lifecycle.stop('default recruit commands live complete');
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
test('shows both commands, delivers the recruitment letter, and appoints on acceptance', async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
const recruiterPage = await newPage(browser, recruiterUserId, '실등용제안자');
|
||||
await reserveFirstTurn(recruiterPage, '등용', '실등용수신자');
|
||||
await expect(
|
||||
db.generalTurn.findUniqueOrThrow({
|
||||
where: { generalId_turnIdx: { generalId: recruiterId, turnIdx: 0 } },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
actionCode: 'che_등용',
|
||||
arg: { destGeneralId: recipientId },
|
||||
});
|
||||
|
||||
const followerPage = await newPage(browser, followerUserId, '실대상임관자');
|
||||
await reserveFirstTurn(followerPage, '장수를 따라 임관', '실등용제안자');
|
||||
await expect(
|
||||
db.generalTurn.findUniqueOrThrow({
|
||||
where: { generalId_turnIdx: { generalId: followerId, turnIdx: 0 } },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
actionCode: 'che_장수대상임관',
|
||||
arg: { destGeneralID: recruiterId },
|
||||
});
|
||||
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
redisUrl: redisUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
exclusiveFastForward: true,
|
||||
leaseOwnerId: 'default-recruit-commands-live-daemon',
|
||||
});
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
await expect.poll(() => runtime?.lifecycle.getStatus().lastTurnTime).toBeTruthy();
|
||||
runtime.lifecycle.requestRun('manual', runThrough);
|
||||
await expect
|
||||
.poll(() => {
|
||||
const status = runtime?.lifecycle.getStatus();
|
||||
return status?.lastError ?? status?.lastRunAt ?? null;
|
||||
})
|
||||
.not.toBeNull();
|
||||
expect(runtime.lifecycle.getStatus().lastError).toBeUndefined();
|
||||
|
||||
await expect
|
||||
.poll(() => db.general.findUniqueOrThrow({ where: { id: followerId } }))
|
||||
.toMatchObject({
|
||||
nationId,
|
||||
cityId: capitalCityId,
|
||||
officerLevel: 1,
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
db.message.findFirst({
|
||||
where: { mailbox: recipientId, type: 'private' },
|
||||
orderBy: { id: 'desc' },
|
||||
})
|
||||
)
|
||||
.not.toBeNull();
|
||||
const storedMessage = await db.message.findFirstOrThrow({
|
||||
where: { mailbox: recipientId, type: 'private' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
expect(storedMessage.message).toMatchObject({
|
||||
src: { generalId: recruiterId, nationId },
|
||||
dest: { generalId: recipientId, nationId: 0 },
|
||||
option: { action: 'scout' },
|
||||
});
|
||||
const recipientPage = await newPage(browser, recipientUserId, '실등용수신자');
|
||||
const dialogs: string[] = [];
|
||||
recipientPage.on('dialog', async (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
await recipientPage.goto('.');
|
||||
await expect(recipientPage.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
const letter = recipientPage.locator('.PrivateTalk .msg-plate').filter({ hasText: '망명 권유 서신' });
|
||||
await expect(letter).toHaveCount(1);
|
||||
await expect(letter).toHaveAttribute('data-id', String(storedMessage.id));
|
||||
const accept = letter.getByRole('button', { name: '수락', exact: true });
|
||||
await expect(accept).toBeEnabled();
|
||||
await accept.click();
|
||||
|
||||
await expect
|
||||
.poll(() => db.general.findUniqueOrThrow({ where: { id: recipientId } }))
|
||||
.toMatchObject({
|
||||
nationId,
|
||||
cityId: capitalCityId,
|
||||
officerLevel: 1,
|
||||
});
|
||||
expect(dialogs).toEqual(['수락하시겠습니까?']);
|
||||
const invalidated = await db.message.findUniqueOrThrow({ where: { id: storedMessage.id } });
|
||||
expect(invalidated.validUntil.getTime()).toBeLessThan(storedMessage.validUntil.getTime());
|
||||
await expect(
|
||||
db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: recipientUserId, eventType: 'messageRespond' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 1, target: 'ENGINE' });
|
||||
await expect(
|
||||
db.message.findFirst({
|
||||
where: {
|
||||
mailbox: recipientId,
|
||||
id: { not: storedMessage.id },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
message: expect.objectContaining({ text: `${nationName}으로 등용 제의 수락` }),
|
||||
});
|
||||
|
||||
await recruiterPage.screenshot({ path: testInfo.outputPath('recruit-command-reserved.png'), fullPage: true });
|
||||
await followerPage.screenshot({ path: testInfo.outputPath('follow-appointment-complete.png'), fullPage: true });
|
||||
await recipientPage.screenshot({ path: testInfo.outputPath('recruitment-accepted.png'), fullPage: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user