fix: serialize profile reset operations

This commit is contained in:
2026-07-31 13:19:27 +00:00
parent d959c9b06d
commit 26fbba39b4
17 changed files with 1715 additions and 378 deletions
+87 -3
View File
@@ -32,6 +32,7 @@ const buildCaller = async (
});
const session = await sessions.createSession({ ...admin, roles: adminRoles });
const createdInputs: GatewayOperationCreateInput[] = [];
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
const createdRuntimeActions: Array<Record<string, unknown>> = [];
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
const profile = {
@@ -59,10 +60,12 @@ const buildCaller = async (
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
getOperation: async () => null,
getOperation: async (id) => operationRecords.get(id) ?? null,
createOperation: async (input) => {
createdInputs.push(input);
return createOperation(input);
const operation = await createOperation(input);
operationRecords.set(operation.id, operation);
return operation;
},
claimNextOperation: async () => null,
completeOperation: async () => {
@@ -103,7 +106,11 @@ const buildCaller = async (
reconcileNow: async () => {},
runScheduleNow: async () => {},
runBuildQueueNow: async () => {},
runOperationsNow: async () => {},
runOperationsNow: async () => {
for (const [id, operation] of operationRecords) {
operationRecords.set(id, { ...operation, status: 'SUCCEEDED' });
}
},
cleanupStaleWorkspaces: async () => ({ removed: [], skipped: [] }),
listRuntimeStates: async () => [],
},
@@ -180,6 +187,83 @@ describe('admin operation API', () => {
});
});
describe('legacy profile install API', () => {
const install = {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1,
extend: false,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 0,
tournamentTrig: false,
joinMode: 'full' as const,
gitRef: 'HEAD',
};
const buildResetOperation = (input: GatewayOperationCreateInput) => ({
id: '22222222-2222-4222-8222-222222222222',
profileName: input.profileName,
type: 'RESET' as const,
status: 'QUEUED' as const,
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
requestedBy: input.requestedBy,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z',
});
it('queues profiles.install instead of seeding the live database directly', async () => {
const harness = await buildCaller(async (input) => buildResetOperation(input));
await expect(
harness.caller.admin.profiles.install({
profileName: 'che:2',
install,
reason: 'durable install',
})
).resolves.toMatchObject({ ok: true, operationId: '22222222-2222-4222-8222-222222222222' });
expect(harness.createdInputs).toHaveLength(1);
expect(harness.createdInputs[0]).toMatchObject({
profileName: 'che:2',
type: 'RESET',
sourceMode: 'COMMIT',
reason: 'durable install',
});
expect(harness.createdInputs[0]?.payload).toMatchObject({
install: {
scenarioId: 1010,
adminUser: { id: harness.admin.id },
},
});
});
it('queues installNow through the same reset operation boundary', async () => {
const harness = await buildCaller(async (input) => buildResetOperation(input));
await expect(
harness.caller.admin.profiles.installNow({
profileName: 'che:2',
install,
reason: 'no direct seed',
})
).resolves.toEqual({ ok: true, operationId: '22222222-2222-4222-8222-222222222222' });
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
sourceMode: 'COMMIT',
reason: 'no direct seed',
payload: {
install: {
scenarioId: 1010,
adminUser: { id: harness.admin.id },
},
},
});
});
});
describe('admin runtime clock action API', () => {
const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => {
throw new Error('not used');
@@ -41,11 +41,14 @@ const createHarness = (
failStart = false,
failStop = false,
processesPresent = true,
missingOnDelete = false
missingOnDelete = false,
workspaceManager?: GitWorkspaceManager,
startGate?: Promise<void>
) => {
let nextOperation: GatewayOperationRecord | null = operation;
const statuses: string[] = [];
const completions: GatewayOperationStatus[] = [];
const completionFields: Array<{ resolvedCommitSha?: string | null; error?: string | null } | undefined> = [];
const started: ProcessDefinition[] = [];
const stopped: string[] = [];
const deleted: string[] = [];
@@ -67,6 +70,7 @@ const createHarness = (
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
listActiveOperationProfileNames: async () => [profile.profileName],
getOperation: async () => operation,
createOperation: async () => operation,
claimNextOperation: async () => {
@@ -74,8 +78,9 @@ const createHarness = (
nextOperation = null;
return result;
},
completeOperation: async (_id, status) => {
completeOperation: async (_id, status, fields) => {
completions.push(status);
completionFields.push(fields);
return { ...operation, status };
},
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
@@ -94,7 +99,8 @@ const createHarness = (
]
: [],
start: async (definition) => {
if (failStart) {
await startGate;
if (failStart && started.length === 2) {
throw new Error('pm2 unavailable');
}
started.push(definition);
@@ -121,10 +127,12 @@ const createHarness = (
buildRunner: {
run: async () => ({ ok: true, exitCode: 0, output: '' }),
},
workspaceManager: new GitWorkspaceManager({
repoRoot: '/tmp/not-used',
worktreeRoot: '/tmp/not-used-worktrees',
}),
workspaceManager:
workspaceManager ??
new GitWorkspaceManager({
repoRoot: '/tmp/not-used',
worktreeRoot: '/tmp/not-used-worktrees',
}),
processConfig: {
workspaceRoot: '/srv/sammo',
redisKeyPrefix: 'sammo:test',
@@ -137,10 +145,20 @@ const createHarness = (
adminActionIntervalMs: 60_000,
});
return { orchestrator, statuses, completions, started, stopped, deleted };
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted };
};
describe('GatewayOrchestrator first-class operations', () => {
it('does not reconcile a profile while a durable operation is active', async () => {
const harness = createHarness(buildOperation('START'));
await harness.orchestrator.reconcileNow();
expect(harness.started).toEqual([]);
expect(harness.stopped).toEqual([]);
expect(harness.deleted).toEqual([]);
});
it('starts every profile process and records success', async () => {
const harness = createHarness(buildOperation('START'));
@@ -212,6 +230,11 @@ describe('GatewayOrchestrator first-class operations', () => {
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.deleted).toEqual([
'sammo:che:2:auction-worker',
'sammo:che:2:turn-daemon',
'sammo:che:2:game-api',
]);
});
it('attempts to stop every role before reporting a partial PM2 failure', async () => {
@@ -235,4 +258,60 @@ describe('GatewayOrchestrator first-class operations', () => {
]);
expect(harness.completions).toEqual(['FAILED']);
});
it('records the resolved commit even when reset workspace preparation fails', async () => {
const resolvedCommitSha = 'abcdef0123456789abcdef0123456789abcdef01';
const resetOperation: GatewayOperationRecord = {
id: '33333333-3333-4333-8333-333333333333',
profileName: profile.profileName,
type: 'RESET',
status: 'RUNNING',
sourceMode: 'COMMIT',
sourceRef: 'requested-ref',
payload: { install: { scenarioId: 1010, turnTermMinutes: 60 } },
requestedBy: 'admin',
createdAt: '2026-07-31T00:00:00.000Z',
startedAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z',
};
const workspaceManager = {
resolveCommit: async () => resolvedCommitSha,
prepare: async () => {
throw new Error('injected workspace preparation failure');
},
remove: async () => {},
} as unknown as GitWorkspaceManager;
const harness = createHarness(resetOperation, false, false, true, false, workspaceManager);
await harness.orchestrator.runOperationsNow();
expect(harness.completions).toEqual(['FAILED']);
expect(harness.completionFields).toEqual([
{
resolvedCommitSha,
error: 'injected workspace preparation failure',
},
]);
});
it('drains an in-flight operation before shutdown completes', async () => {
let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, startGate);
harness.orchestrator.start();
await new Promise<void>((resolve) => setImmediate(resolve));
let stopped = false;
const stopPromise = harness.orchestrator.stop().then(() => {
stopped = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(stopped).toBe(false);
releaseStart?.();
await stopPromise;
expect(harness.completions).toEqual(['SUCCEEDED']);
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import path from 'node:path';
import {
buildProfileMigrationCommand,
buildProcessDefinitions,
buildWorkspaceCommands,
planProfileReconcile,
@@ -161,7 +162,24 @@ describe('buildWorkspaceCommands', () => {
['--filter', '@sammo-ts/logic', 'build'],
['--filter', '@sammo-ts/game-api', 'build'],
['--filter', '@sammo-ts/game-engine', 'build'],
['--filter', '@sammo-ts/gateway-api', 'build'],
]);
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
});
it('deploys the game schema migration after building the selected workspace', () => {
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
const databaseUrl = 'postgresql://integration.invalid/sammo?schema=che';
const command = buildProfileMigrationCommand(workspaceRoot, databaseUrl, { NODE_ENV: 'production' });
expect(command).toEqual({
command: 'pnpm',
args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
cwd: workspaceRoot,
env: {
NODE_ENV: 'production',
DATABASE_URL: databaseUrl,
},
});
});
});
@@ -0,0 +1,221 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
const databaseUrl = process.env.GATEWAY_OPERATION_DATABASE_URL;
const describeDatabase = describe.runIf(Boolean(databaseUrl));
const profileName = 'lease-test:1010';
const secondProfileName = 'lease-test-2:1010';
describeDatabase('gateway operation lease and profile serialization', () => {
const connector = createGatewayPostgresConnector({ url: databaseUrl ?? '' });
const repository = createGatewayProfileRepository(connector.prisma);
beforeAll(async () => {
await connector.connect();
await repository.upsertProfile({
profile: 'lease-test',
scenario: '1010',
apiPort: 15999,
status: 'STOPPED',
});
await repository.upsertProfile({
profile: 'lease-test-2',
scenario: '1010',
apiPort: 15998,
status: 'STOPPED',
});
});
afterEach(async () => {
await connector.prisma.gatewayOperation.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
await connector.prisma.gatewayProfile.updateMany({
where: { profileName: { in: [profileName, secondProfileName] } },
data: { buildStatus: 'IDLE', buildError: null },
});
});
afterAll(async () => {
await connector.prisma.gatewayOperation.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
await connector.prisma.gatewayProfile.deleteMany({
where: { profileName: { in: [profileName, secondProfileName] } },
});
await connector.disconnect();
});
it('allows only one queued or running operation for a profile', async () => {
const results = await Promise.allSettled([
repository.createOperation({
profileName,
type: 'RESET',
sourceMode: 'BRANCH',
sourceRef: 'main',
requestedBy: 'admin-a',
}),
repository.createOperation({
profileName,
type: 'STOP',
requestedBy: 'admin-b',
}),
]);
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1);
});
it('serializes running operations globally across profiles', async () => {
const first = await repository.createOperation({
profileName,
type: 'STOP',
requestedBy: 'admin-a',
});
const second = await repository.createOperation({
profileName: secondProfileName,
type: 'START',
requestedBy: 'admin-b',
});
const now = new Date('2030-01-01T00:00:00.000Z');
await expect(
repository.claimNextOperation(now, { ownerId: 'worker-a', durationMs: 1_000 })
).resolves.toMatchObject({ id: first.id, leaseOwner: 'worker-a' });
await expect(
repository.claimNextOperation(now, { ownerId: 'worker-b', durationMs: 1_000 })
).resolves.toBeNull();
await repository.completeOperation(first.id, 'SUCCEEDED', { error: null }, 'worker-a');
await expect(
repository.claimNextOperation(now, { ownerId: 'worker-b', durationMs: 1_000 })
).resolves.toMatchObject({ id: second.id, leaseOwner: 'worker-b' });
});
it('does not let a future queued operation suppress runtime reconciliation early', async () => {
const now = new Date('2030-01-01T00:00:00.000Z');
await repository.createOperation({
profileName,
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: 'abcdef',
scheduledAt: new Date(now.getTime() + 60_000).toISOString(),
requestedBy: 'admin',
});
await expect(repository.listActiveOperationProfileNames?.(now)).resolves.not.toContain(profileName);
await expect(repository.listActiveOperationProfileNames?.(new Date(now.getTime() + 60_000))).resolves.toContain(
profileName
);
});
it('reclaims the same expired RUNNING operation without creating a second generation', async () => {
const operation = await repository.createOperation({
profileName,
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: 'abcdef',
payload: { install: { scenarioId: 1010 } },
requestedBy: 'admin',
});
const startedAt = new Date('2030-01-01T00:00:00.000Z');
const firstClaim = await repository.claimNextOperation(startedAt, {
ownerId: 'worker-a',
durationMs: 1_000,
});
expect(firstClaim).toMatchObject({ id: operation.id, attempts: 1, leaseOwner: 'worker-a' });
await expect(
repository.pinOperationResolvedCommit?.(operation.id, 'worker-a', 'pinned-commit-a')
).resolves.toBe(true);
await expect(
repository.claimNextOperation(new Date(startedAt.getTime() + 999), {
ownerId: 'worker-b',
durationMs: 1_000,
})
).resolves.toBeNull();
const reclaimed = await repository.claimNextOperation(new Date(startedAt.getTime() + 1_001), {
ownerId: 'worker-b',
durationMs: 1_000,
});
expect(reclaimed).toMatchObject({
id: operation.id,
attempts: 2,
leaseOwner: 'worker-b',
resolvedCommitSha: 'pinned-commit-a',
});
await expect(
repository.pinOperationResolvedCommit?.(operation.id, 'worker-a', 'pinned-commit-a')
).resolves.toBe(false);
await expect(
repository.pinOperationResolvedCommit?.(operation.id, 'worker-b', 'pinned-commit-b')
).resolves.toBe(false);
await expect(
repository.updateProfileForOperation?.(operation.id, 'worker-b', profileName, {
buildStatus: 'RUNNING',
buildError: 'worker-b-marker',
})
).resolves.toMatchObject({ buildStatus: 'RUNNING', buildError: 'worker-b-marker' });
await expect(
repository.updateProfileForOperation?.(operation.id, 'worker-a', profileName, {
buildStatus: 'FAILED',
buildError: 'stale-worker-a',
})
).resolves.toBeNull();
await expect(repository.getProfile(profileName)).resolves.toMatchObject({
buildStatus: 'RUNNING',
buildError: 'worker-b-marker',
});
await expect(repository.renewOperationLease?.(operation.id, 'worker-a', startedAt, 1_000)).resolves.toBe(false);
await expect(repository.renewOperationLease?.(operation.id, 'worker-b', startedAt, 1_000)).resolves.toBe(true);
await expect(
repository.completeOperation(operation.id, 'FAILED', { error: 'stale worker' }, 'worker-a')
).rejects.toThrow('Operation lease lost before completion');
await expect(repository.getOperation(operation.id)).resolves.toMatchObject({
status: 'RUNNING',
leaseOwner: 'worker-b',
});
await expect(repository.requeueOperation(operation.id, 'stale worker', undefined, 'worker-a')).rejects.toThrow(
'Operation lease lost before requeue'
);
await expect(
repository.completeOperation(operation.id, 'SUCCEEDED', { error: null }, 'worker-b')
).resolves.toMatchObject({ status: 'SUCCEEDED' });
});
it('pins retry to the first resolved commit and preserves its install generation', async () => {
const operation = await repository.createOperation({
profileName,
type: 'RESET',
sourceMode: 'BRANCH',
sourceRef: 'main',
payload: { install: { scenarioId: 1010 } },
requestedBy: 'admin-a',
});
const claimed = await repository.claimNextOperation(new Date('2030-01-01T00:00:00.000Z'), {
ownerId: 'worker-a',
durationMs: 1_000,
});
expect(claimed?.id).toBe(operation.id);
await repository.completeOperation(
operation.id,
'FAILED',
{
resolvedCommitSha: 'abcdef0123456789abcdef0123456789abcdef01',
error: 'injected start failure',
},
'worker-a'
);
const retry = await repository.retryOperation(operation.id, 'admin-b');
expect(retry).toMatchObject({
sourceMode: 'COMMIT',
sourceRef: 'abcdef0123456789abcdef0123456789abcdef01',
payload: {
installOperationId: operation.id,
install: { scenarioId: 1010 },
},
});
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { buildRetryOperationPayload, buildRetryOperationSource } from '../src/orchestrator/profileRepository.js';
describe('buildRetryOperationPayload', () => {
it('pins the first failed operation as the install generation', () => {
expect(
buildRetryOperationPayload({ install: { scenarioId: 1010 } }, '11111111-1111-4111-8111-111111111111')
).toEqual({
install: { scenarioId: 1010 },
installOperationId: '11111111-1111-4111-8111-111111111111',
});
});
it('preserves the original install generation across chained retries', () => {
expect(
buildRetryOperationPayload(
{
install: { scenarioId: 903 },
installOperationId: 'original-install-generation',
},
'newer-failed-operation'
)
).toEqual({
install: { scenarioId: 903 },
installOperationId: 'original-install-generation',
});
});
});
describe('buildRetryOperationSource', () => {
it('pins a resolved branch operation to the original commit', () => {
expect(
buildRetryOperationSource({
sourceMode: 'BRANCH',
sourceRef: 'main',
resolvedCommitSha: 'abcdef0123456789abcdef0123456789abcdef01',
})
).toEqual({
sourceMode: 'COMMIT',
sourceRef: 'abcdef0123456789abcdef0123456789abcdef01',
});
});
it('keeps the requested source when resolution never completed', () => {
expect(
buildRetryOperationSource({
sourceMode: 'BRANCH',
sourceRef: 'main',
resolvedCommitSha: null,
})
).toEqual({ sourceMode: 'BRANCH', sourceRef: 'main' });
});
});
@@ -0,0 +1,99 @@
import path from 'node:path';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { describe, expect, it } from 'vitest';
import { seedProfileDatabase } from '../src/orchestrator/seedProfileDatabase.js';
const databaseUrl = process.env.PROFILE_SEED_DATABASE_URL;
const schema = process.env.PROFILE_SEED_DATABASE_SCHEMA ?? 'profile_seed_atomicity';
const describeDatabase = describe.runIf(Boolean(databaseUrl));
const resourceRoot = path.resolve(process.cwd(), '../../resources');
describeDatabase('profile seed atomicity', () => {
it('rolls back the new season when administrator general creation fails', async () => {
if (!databaseUrl) {
throw new Error('PROFILE_SEED_DATABASE_URL is required.');
}
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) {
throw new Error('PROFILE_SEED_DATABASE_SCHEMA must be a safe PostgreSQL identifier.');
}
const connector = createGamePostgresConnector({ url: databaseUrl });
await seedProfileDatabase({
databaseUrl,
scenarioId: 1010,
now: new Date('2034-01-01T00:00:00.000Z'),
installOptions: {
serverId: 'profile-seed-baseline',
installOperationId: 'profile-seed-baseline-operation',
},
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') },
});
await connector.connect();
const prisma = connector.prisma;
const readSnapshot = async () => ({
world: await prisma.worldState.findMany({ orderBy: { id: 'asc' } }),
nations: await prisma.nation.findMany({ orderBy: { id: 'asc' } }),
cities: await prisma.city.findMany({ orderBy: { id: 'asc' } }),
generals: await prisma.general.findMany({ orderBy: { id: 'asc' } }),
history: await prisma.gameHistory.findMany({ orderBy: { id: 'asc' } }),
});
const before = await readSnapshot();
try {
await prisma.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION "${schema}".reject_admin_seed()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.meta ->> 'createdBy' = 'admin-seed' THEN
RAISE EXCEPTION 'injected administrator seed failure';
END IF;
RETURN NEW;
END;
$$
`);
await prisma.$executeRawUnsafe(`
CREATE TRIGGER reject_admin_seed
BEFORE INSERT ON "${schema}"."general"
FOR EACH ROW EXECUTE FUNCTION "${schema}".reject_admin_seed()
`);
await expect(
seedProfileDatabase({
databaseUrl,
scenarioId: 903,
now: new Date('2035-02-02T00:00:00.000Z'),
installOptions: {
serverId: 'profile-seed-failed',
installOperationId: 'profile-seed-failed-operation',
},
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') },
adminUser: {
id: 'profile-seed-admin',
username: 'profile-seed-admin',
displayName: '프로필 관리자',
},
})
).rejects.toThrow('injected administrator seed failure');
expect(await readSnapshot()).toEqual(before);
await expect(prisma.general.findFirst({ where: { userId: 'profile-seed-admin' } })).resolves.toBeNull();
await expect(
prisma.gameHistory.findUnique({ where: { serverId: 'profile-seed-failed' } })
).resolves.toBeNull();
} finally {
await prisma.$executeRawUnsafe(`DROP TRIGGER IF EXISTS reject_admin_seed ON "${schema}"."general"`);
await prisma.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schema}".reject_admin_seed()`);
await prisma.gameHistory.deleteMany({
where: { serverId: { in: ['profile-seed-baseline', 'profile-seed-failed'] } },
});
await connector.disconnect();
}
});
});
@@ -0,0 +1,101 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { describe, expect, it } from 'vitest';
const databaseUrl = process.env.PROFILE_SEED_CLI_DATABASE_URL;
const describeDatabase = describe.runIf(Boolean(databaseUrl));
const workspaceRoot = path.resolve(process.cwd(), '../..');
const runSeedCli = async (requestFile: string): Promise<{ code: number | null; output: string }> =>
new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(workspaceRoot, 'app/gateway-api/dist/index.js')], {
cwd: workspaceRoot,
env: {
...process.env,
DATABASE_URL: databaseUrl,
GATEWAY_ROLE: 'profile-seed',
PROFILE_SEED_REQUEST_FILE: requestFile,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk.toString();
});
child.stderr.on('data', (chunk) => {
output += chunk.toString();
});
child.on('close', (code) => resolve({ code, output }));
});
describeDatabase('selected workspace profile seed CLI', () => {
it('seeds through the built gateway artifact with the serialized operation identity', async () => {
if (!databaseUrl) {
throw new Error('PROFILE_SEED_CLI_DATABASE_URL is required.');
}
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'profile-seed-cli-test-'));
const requestFile = path.join(tempDirectory, 'request.json');
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
await fs.writeFile(
requestFile,
JSON.stringify({
scenarioId: 1010,
tickSeconds: 60,
now: '2036-03-03T00:00:00.000Z',
installOptions: {
serverId: 'selected-cli-seed',
installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit',
},
adminUser: {
id: 'selected-cli-admin',
username: 'selected-cli-admin',
displayName: '선택 CLI 관리자',
},
}),
{ encoding: 'utf8', mode: 0o600 }
);
const result = await runSeedCli(requestFile);
expect(result, result.output).toMatchObject({ code: 0 });
await connector.connect();
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
scenarioCode: '1010',
meta: {
installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit',
},
});
const adminGeneral = await connector.prisma.general.findFirstOrThrow({
where: { userId: 'selected-cli-admin' },
});
expect(adminGeneral).toMatchObject({ meta: { createdBy: 'admin-seed' } });
const history = await connector.prisma.gameHistory.findUniqueOrThrow({
where: { serverId: 'selected-cli-seed' },
});
const retry = await runSeedCli(requestFile);
expect(retry, retry.output).toMatchObject({ code: 0 });
await expect(connector.prisma.worldState.findFirstOrThrow()).resolves.toEqual(world);
await expect(
connector.prisma.general.findFirstOrThrow({ where: { userId: 'selected-cli-admin' } })
).resolves.toEqual(adminGeneral);
await expect(
connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: 'selected-cli-seed' } })
).resolves.toEqual(history);
await expect(
connector.prisma.gameHistory.count({ where: { serverId: 'selected-cli-seed' } })
).resolves.toBe(1);
} finally {
await connector.prisma.gameHistory.deleteMany({ where: { serverId: 'selected-cli-seed' } });
await connector.disconnect();
await fs.rm(tempDirectory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { parseProfileSeedRequest } from '../src/orchestrator/profileSeedCli.js';
describe('parseProfileSeedRequest', () => {
it('accepts the serializable selected-workspace seed contract', () => {
expect(
parseProfileSeedRequest({
scenarioId: 1010,
tickSeconds: 60,
now: '2030-01-01T00:00:00.000Z',
installOptions: {
installOperationId: 'operation-id',
installCommitSha: 'abcdef',
preopenAt: null,
},
adminUser: { id: 'admin', username: 'admin' },
})
).toMatchObject({
scenarioId: 1010,
tickSeconds: 60,
installOptions: { installOperationId: 'operation-id', installCommitSha: 'abcdef' },
});
});
it('rejects an invalid timestamp before touching the database', () => {
expect(() => parseProfileSeedRequest({ scenarioId: 1010, now: 'not-a-date' })).toThrow(
'Profile seed now must be an ISO date-time.'
);
});
});