164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
resolveUpcomingResetAnnouncement,
|
|
resolveUpcomingResetAnnouncements,
|
|
shouldExposeUpcomingReset,
|
|
} from '../src/lobby/profileStatusService.js';
|
|
import type { GatewayOperationRecord } from '../src/orchestrator/profileRepository.js';
|
|
|
|
const buildOperation = (status: GatewayOperationRecord['status'] = 'QUEUED'): GatewayOperationRecord => ({
|
|
id: '11111111-1111-4111-8111-111111111111',
|
|
profileName: 'che:2',
|
|
type: 'RESET',
|
|
status,
|
|
sourceMode: 'BRANCH',
|
|
sourceRef: 'private/source-ref',
|
|
payload: {
|
|
install: { scenarioId: 1010 },
|
|
requestedSource: 'CURRENT',
|
|
publicAnnouncement: {
|
|
enabled: true,
|
|
scenarioId: 1010,
|
|
scenarioTitle: '황건적의 난',
|
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
|
preopenAt: '2026-08-27T05:30:00.000Z',
|
|
openAt: '2026-08-27T11:00:00.000Z',
|
|
turnTermMinutes: 60,
|
|
fictionMode: '가상',
|
|
npcMode: 1,
|
|
defaultStatTotal: 70,
|
|
otherTextInfo: '랜덤 임관',
|
|
autorunUser: {
|
|
limitMinutes: 1440,
|
|
options: ['develop', 'battle'],
|
|
},
|
|
requestedBy: 'must-not-leak',
|
|
reason: 'must-not-leak',
|
|
},
|
|
},
|
|
reason: 'private reason',
|
|
requestedBy: 'admin-id',
|
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
|
error: 'private error',
|
|
createdAt: '2026-08-23T00:00:00.000Z',
|
|
updatedAt: '2026-08-23T00:00:00.000Z',
|
|
});
|
|
|
|
describe('resolveUpcomingResetAnnouncement', () => {
|
|
it('projects only the public snapshot while the delayed build is queued', () => {
|
|
const result = resolveUpcomingResetAnnouncement(buildOperation(), new Date('2026-08-27T04:00:00.000Z'));
|
|
|
|
expect(result).toEqual({
|
|
phase: 'SCHEDULED',
|
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
|
preopenAt: '2026-08-27T05:30:00.000Z',
|
|
openAt: '2026-08-27T11:00:00.000Z',
|
|
scenarioId: 1010,
|
|
scenarioTitle: '황건적의 난',
|
|
turnTermMinutes: 60,
|
|
fictionMode: '가상',
|
|
npcMode: 1,
|
|
defaultStatTotal: 70,
|
|
otherTextInfo: '랜덤 임관',
|
|
autorunUser: {
|
|
limitMinutes: 1440,
|
|
options: ['develop', 'battle'],
|
|
},
|
|
});
|
|
expect(result).not.toHaveProperty('sourceRef');
|
|
expect(result).not.toHaveProperty('requestedBy');
|
|
expect(result).not.toHaveProperty('reason');
|
|
expect(result).not.toHaveProperty('error');
|
|
});
|
|
|
|
it('moves from preparation to a truthful delay state without changing the profile lifecycle', () => {
|
|
expect(
|
|
resolveUpcomingResetAnnouncement(buildOperation('RUNNING'), new Date('2026-08-27T05:10:00.000Z'))
|
|
).toMatchObject({ phase: 'PREPARING' });
|
|
expect(
|
|
resolveUpcomingResetAnnouncement(buildOperation('RUNNING'), new Date('2026-08-27T05:31:00.000Z'))
|
|
).toMatchObject({ phase: 'DELAYED' });
|
|
});
|
|
|
|
it('keeps a completed build ready for RESERVED handoff and removes cancelled or failed announcements', () => {
|
|
const succeeded = buildOperation('SUCCEEDED');
|
|
expect(resolveUpcomingResetAnnouncement(succeeded, new Date('2026-08-27T05:20:00.000Z'))).toMatchObject({
|
|
phase: 'READY',
|
|
});
|
|
expect(shouldExposeUpcomingReset(succeeded, 'RESERVED')).toBe(true);
|
|
expect(shouldExposeUpcomingReset(succeeded, 'PREOPEN')).toBe(false);
|
|
expect(shouldExposeUpcomingReset(succeeded, 'RUNNING')).toBe(false);
|
|
for (const status of ['CANCELLED', 'FAILED'] as const) {
|
|
expect(
|
|
resolveUpcomingResetAnnouncement(buildOperation(status), new Date('2026-08-27T04:00:00.000Z'))
|
|
).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('keeps an unpublished snapshot hidden until a completed build is ready', () => {
|
|
const unpublished = buildOperation();
|
|
unpublished.payload = {
|
|
publicAnnouncement: {
|
|
...(unpublished.payload as { publicAnnouncement: Record<string, unknown> }).publicAnnouncement,
|
|
enabled: false,
|
|
scheduledAt: null,
|
|
},
|
|
};
|
|
expect(resolveUpcomingResetAnnouncement(unpublished, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
|
unpublished.status = 'SUCCEEDED';
|
|
expect(resolveUpcomingResetAnnouncement(unpublished, new Date('2026-08-27T04:00:00.000Z'))).toMatchObject({
|
|
phase: 'READY',
|
|
scheduledAt: null,
|
|
});
|
|
|
|
const incomplete = buildOperation();
|
|
incomplete.payload = { publicAnnouncement: { enabled: true, scenarioTitle: '황건적의 난' } };
|
|
expect(resolveUpcomingResetAnnouncement(incomplete, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
|
});
|
|
|
|
it('does not restore an older published notice after a newer reset replaces it', () => {
|
|
const olderPublished = buildOperation('SUCCEEDED');
|
|
const newerImmediate = {
|
|
...buildOperation('SUCCEEDED'),
|
|
id: '22222222-2222-4222-8222-222222222222',
|
|
payload: {
|
|
install: {
|
|
scenarioId: 1011,
|
|
preopenAt: '2026-09-01T05:30:00.000Z',
|
|
openAt: '2026-09-01T11:00:00.000Z',
|
|
},
|
|
},
|
|
scheduledAt: undefined,
|
|
createdAt: '2026-08-27T01:00:00.000Z',
|
|
updatedAt: '2026-08-27T01:00:00.000Z',
|
|
} satisfies GatewayOperationRecord;
|
|
|
|
const result = resolveUpcomingResetAnnouncements(
|
|
[newerImmediate, olderPublished],
|
|
new Map([['che:2', 'RESERVED']]),
|
|
new Date('2026-08-27T04:00:00.000Z')
|
|
);
|
|
|
|
expect(result.has('che:2')).toBe(false);
|
|
});
|
|
|
|
it('treats a newer cancelled reset as the announcement boundary', () => {
|
|
const olderPublished = buildOperation('SUCCEEDED');
|
|
const newerCancelled = {
|
|
...buildOperation('CANCELLED'),
|
|
id: '33333333-3333-4333-8333-333333333333',
|
|
createdAt: '2026-08-27T01:00:00.000Z',
|
|
updatedAt: '2026-08-27T01:00:00.000Z',
|
|
} satisfies GatewayOperationRecord;
|
|
|
|
const result = resolveUpcomingResetAnnouncements(
|
|
[newerCancelled, olderPublished],
|
|
new Map([['che:2', 'RESERVED']]),
|
|
new Date('2026-08-27T04:00:00.000Z')
|
|
);
|
|
|
|
expect(result.has('che:2')).toBe(false);
|
|
});
|
|
});
|