새 초기화가 이전 예약 공지를 대체하면 과거 publicAnnouncement로 되돌아가지 않도록 프로필별 최신 RESET을 공지 경계로 사용한다. 취소된 최신 작업과 즉시 초기화 회귀 테스트를 추가한다.
153 lines
6.0 KiB
TypeScript
153 lines
6.0 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('fails closed for an unpublished or incomplete snapshot', () => {
|
|
const unpublished = buildOperation();
|
|
unpublished.payload = { publicAnnouncement: { enabled: false } };
|
|
expect(resolveUpcomingResetAnnouncement(unpublished, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
|
|
|
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);
|
|
});
|
|
});
|