feat(admin): apply durable runtime clock shifts
This commit is contained in:
@@ -860,12 +860,27 @@ export const adminRouter = router({
|
||||
profiles: router({
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
const profiles = await ctx.profiles.listProfiles();
|
||||
const runtimeActions = await ctx.prisma.gatewayRuntimeAction.findMany({
|
||||
where: {
|
||||
profileName: { in: profiles.map((profile) => profile.profileName) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const runtimeActionsByProfile = new Map<string, typeof runtimeActions>();
|
||||
for (const action of runtimeActions) {
|
||||
const bucket = runtimeActionsByProfile.get(action.profileName) ?? [];
|
||||
if (bucket.length < 10) {
|
||||
bucket.push(action);
|
||||
runtimeActionsByProfile.set(action.profileName, bucket);
|
||||
}
|
||||
}
|
||||
const runtimeStates = await ctx.orchestrator.listRuntimeStates(
|
||||
profiles.map((profile) => profile.profileName)
|
||||
);
|
||||
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
||||
return profiles.map((profile) => ({
|
||||
...profile,
|
||||
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
|
||||
runtime: runtimeMap.get(profile.profileName) ?? {
|
||||
profileName: profile.profileName,
|
||||
apiRunning: false,
|
||||
@@ -1251,6 +1266,12 @@ export const adminRouter = router({
|
||||
message: 'scheduledAt is required for scheduled reset.',
|
||||
});
|
||||
}
|
||||
if (input.action !== 'RESET_SCHEDULED' && input.scheduledAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'scheduledAt is supported only for scheduled reset.',
|
||||
});
|
||||
}
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) {
|
||||
throw new TRPCError({
|
||||
@@ -1307,6 +1328,36 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === 'OPEN_SURVEY') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '설문은 게임 내 설문 관리 화면에서 생성해 주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === 'ACCELERATE' || input.action === 'DELAY') {
|
||||
try {
|
||||
const runtimeAction = await ctx.prisma.gatewayRuntimeAction.create({
|
||||
data: {
|
||||
profileName: input.profileName,
|
||||
action: input.action,
|
||||
durationMinutes: input.durationMinutes,
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
},
|
||||
});
|
||||
return { ok: true, action: runtimeAction };
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
RESUME: 'RUNNING',
|
||||
PAUSE: 'PAUSED',
|
||||
|
||||
@@ -12,7 +12,11 @@ import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = async (
|
||||
createOperation: GatewayProfileRepository['createOperation'],
|
||||
options: { adminRoles?: string[]; firstUserIsAdmin?: boolean } = {}
|
||||
options: {
|
||||
adminRoles?: string[];
|
||||
firstUserIsAdmin?: boolean;
|
||||
runtimeActionCreateError?: unknown;
|
||||
} = {}
|
||||
) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const admin = await users.createUser({
|
||||
@@ -28,6 +32,7 @@ const buildCaller = async (
|
||||
});
|
||||
const session = await sessions.createSession({ ...admin, roles: adminRoles });
|
||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||
const flushes: Array<{ userId: string; reason?: string }> = [];
|
||||
const profile = {
|
||||
profileName: 'che:2',
|
||||
@@ -104,10 +109,29 @@ const buildCaller = async (
|
||||
appUser: {
|
||||
findFirst: async () => ({ id: options.firstUserIsAdmin === false ? 'bootstrap-user' : admin.id }),
|
||||
},
|
||||
gatewayRuntimeAction: {
|
||||
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||
if (options.runtimeActionCreateError) {
|
||||
throw options.runtimeActionCreateError;
|
||||
}
|
||||
createdRuntimeActions.push(data);
|
||||
return {
|
||||
id: '68f1f0e4-3b95-4aeb-9925-c7e93caf1ba7',
|
||||
...data,
|
||||
status: 'REQUESTED',
|
||||
detail: null,
|
||||
handler: null,
|
||||
handledAt: null,
|
||||
scheduledAt: null,
|
||||
createdAt: new Date('2026-07-30T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-07-30T01:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as GatewayPrismaClient,
|
||||
})
|
||||
);
|
||||
return { caller, createdInputs, users, admin, flushes };
|
||||
return { caller, createdInputs, createdRuntimeActions, users, admin, flushes };
|
||||
};
|
||||
|
||||
describe('admin operation API', () => {
|
||||
@@ -152,6 +176,90 @@ describe('admin operation API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin runtime clock action API', () => {
|
||||
const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => {
|
||||
throw new Error('not used');
|
||||
};
|
||||
|
||||
it('creates a first-class clock action owned by the authenticated administrator', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
|
||||
const result = await harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'ACCELERATE',
|
||||
durationMinutes: 15,
|
||||
reason: '운영 일정 조정',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
action: {
|
||||
action: 'ACCELERATE',
|
||||
durationMinutes: 15,
|
||||
status: 'REQUESTED',
|
||||
},
|
||||
});
|
||||
expect(harness.createdRuntimeActions).toEqual([
|
||||
{
|
||||
profileName: 'che:2',
|
||||
action: 'ACCELERATE',
|
||||
durationMinutes: 15,
|
||||
reason: '운영 일정 조정',
|
||||
requestedBy: harness.admin.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a conflict when another clock action is still pending', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
runtimeActionCreateError: { code: 'P2002' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'DELAY',
|
||||
durationMinutes: 5,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a scheduled clock shift instead of silently applying it immediately', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'ACCELERATE',
|
||||
durationMinutes: 15,
|
||||
scheduledAt: '2026-07-31T01:00:00.000Z',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'scheduledAt is supported only for scheduled reset.',
|
||||
});
|
||||
expect(harness.createdRuntimeActions).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects OPEN_SURVEY instead of reporting a false success', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'OPEN_SURVEY',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '설문은 게임 내 설문 관리 화면에서 생성해 주세요.',
|
||||
});
|
||||
expect(harness.createdRuntimeActions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin role non-escalation', () => {
|
||||
const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => {
|
||||
throw new Error('not used');
|
||||
|
||||
Reference in New Issue
Block a user