fix: serialize profile reset operations
This commit is contained in:
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user