fix: align legacy general access call boundaries

This commit is contained in:
2026-07-31 07:33:26 +00:00
parent 821da19731
commit 98a3cf514f
34 changed files with 909 additions and 177 deletions
+2
View File
@@ -71,6 +71,7 @@ export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext {
requestId?: string;
generalAccessTracking?: boolean;
db: DatabaseClient;
redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport;
@@ -102,6 +103,7 @@ export const createGameApiContext = (options: {
}): GameApiContext => {
return {
requestId: options.requestId,
generalAccessTracking: true,
db: options.db,
redis: options.redis,
turnDaemon: options.turnDaemon,
+9 -4
View File
@@ -4,7 +4,13 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { getDexLevel } from '@sammo-ts/logic';
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
import {
accessAuthedInputProcedure,
accessReadOnlyAuthedInputProcedure,
authedProcedure,
readOnlyAuthedProcedure,
router,
} from '../../trpc.js';
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
import {
@@ -56,7 +62,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
};
export const battleRouter = router({
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
@@ -169,8 +175,7 @@ export const battleRouter = router({
generalsByNation,
};
}),
getGeneralDetail: authedProcedure
.input(
getGeneralDetail: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
})
+2 -3
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { appendInheritanceLog, readInheritancePoint, setInheritancePoint } from '../../services/inheritance.js';
import { getMyGeneral } from '../shared/general.js';
@@ -30,8 +30,7 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
};
export const bettingRouter = router({
getList: authedProcedure
.input(z.object({ req: z.literal('bettingNation').optional() }).optional())
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
.query(async ({ ctx, input }) => {
requireUserId(ctx.auth);
await getMyGeneral(ctx);
+4 -6
View File
@@ -5,7 +5,7 @@ import { promises as fs } from 'fs';
import { randomUUID } from 'crypto';
import sharp, { type WebpOptions } from 'sharp';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
@@ -107,7 +107,7 @@ export const boardRouter = router({
canSecret: permission >= 2,
};
}),
getArticles: authedProcedure.input(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
getArticles: accessAuthedInputProcedure(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
const { general, permission } = await getBoardActor(ctx);
assertBoardAccess(permission, input.isSecret);
@@ -159,8 +159,7 @@ export const boardRouter = router({
})),
}));
}),
writeArticle: authedProcedure
.input(
writeArticle: accessAuthedInputProcedure(
z.object({
isSecret: z.boolean(),
title: z.string().trim().max(250),
@@ -189,8 +188,7 @@ export const boardRouter = router({
return { id: post.id };
}),
writeComment: authedProcedure
.input(
writeComment: accessAuthedInputProcedure(
z.object({
postId: z.number().int().positive(),
content: z.string().trim().max(2000),
+6 -10
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -28,7 +28,7 @@ const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' |
};
export const diplomacyRouter = router({
getLetters: authedProcedure.query(async ({ ctx }) => {
getLetters: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -98,8 +98,7 @@ export const diplomacyRouter = router({
permission,
};
}),
sendLetter: authedProcedure
.input(
sendLetter: accessAuthedInputProcedure(
z.object({
destNationId: z.number().int().positive(),
prevId: z.number().int().positive().nullable().optional(),
@@ -217,8 +216,7 @@ export const diplomacyRouter = router({
return { id: created.id };
}),
respondLetter: authedProcedure
.input(
respondLetter: accessAuthedInputProcedure(
z.object({
letterId: z.number().int().positive(),
agree: z.boolean(),
@@ -288,8 +286,7 @@ export const diplomacyRouter = router({
return { ok: true };
}),
rollbackLetter: authedProcedure
.input(z.object({ letterId: z.number().int().positive() }))
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -324,8 +321,7 @@ export const diplomacyRouter = router({
return { ok: true };
}),
destroyLetter: authedProcedure
.input(z.object({ letterId: z.number().int().positive() }))
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
+14 -10
View File
@@ -5,7 +5,14 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import {
accessAuthedProcedure,
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
authedProcedure,
router,
} from '../../trpc.js';
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
import { resolveAccessWindows } from '../../services/generalAccess.js';
import { getMyGeneral } from '../shared/general.js';
@@ -283,7 +290,7 @@ export const generalRouter = router({
penalties,
};
}),
ensureDieOnPrestartStatus: engineAuthedProcedure.mutation(async ({ ctx }) => {
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -319,14 +326,11 @@ export const generalRouter = router({
availableAt: result.availableAt ?? null,
};
}),
dieOnPrestart: engineAuthedProcedure
.input(zImmediateActionInput)
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
buildNationCandidate: engineAuthedProcedure
.input(zImmediateActionInput)
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
instantRetreat: engineAuthedProcedure
.input(zImmediateActionInput)
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'instantRetreat')),
vacation: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
@@ -342,7 +346,7 @@ export const generalRouter = router({
}
return { ok: true };
}),
setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => {
setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
@@ -459,7 +463,7 @@ export const generalRouter = router({
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
getFrontStatus: authedProcedure.query(async ({ ctx }) => {
getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
+2 -3
View File
@@ -4,7 +4,7 @@ import { asRecord } from '@sammo-ts/common';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -388,8 +388,7 @@ export const messagesRouter = router({
...messageBuckets,
};
}),
send: authedProcedure
.input(
send: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
mailbox: z.number().int(),
@@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server';
import { LogCategory } from '@sammo-ts/infra';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, formatDateTime, resolveNationPermission } from '../shared.js';
export const getBattleCenter = authedProcedure.query(async ({ ctx }) => {
export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -1,12 +1,12 @@
import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { resolveSecretPermission } from '../../shared/secretPermission.js';
import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js';
import { assertNationAccess } from '../shared.js';
export const getChiefCenter = authedProcedure.query(async ({ ctx }) => {
export const getChiefCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -18,7 +18,7 @@ const experienceLevel = (experience: number): number =>
const dedicationLevel = (dedication: number): number =>
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
assertNationAccess(general);
@@ -2,11 +2,11 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, checkSecretMaxPermission, mapGeneralList, resolveChiefStatMin } from '../shared.js';
export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../shared.js';
@@ -25,7 +25,7 @@ const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
const defenceTrainText = (value: number): string =>
value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△';
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const nation = await ctx.db.nation.findUnique({
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome, type NationIncomeContext } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -29,7 +29,7 @@ import {
type NationStratRow,
} from '../shared.js';
export const getStratFinan = authedProcedure.query(async ({ ctx }) => {
export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
+4 -4
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
import type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js';
@@ -537,7 +537,7 @@ export const npcRouter = router({
permissionLevel,
};
}),
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
setNationPolicy: accessAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
@@ -700,7 +700,7 @@ export const npcRouter = router({
return { ok: true };
}),
setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
setNationPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
@@ -753,7 +753,7 @@ export const npcRouter = router({
return { ok: true };
}),
setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
setGeneralPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
if (general.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
+2 -3
View File
@@ -8,7 +8,7 @@ import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js';
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
type WorldTrendSnapshot = {
@@ -477,8 +477,7 @@ export const publicRouter = router({
intelligence: general.intel,
}));
}),
getNpcList: procedure
.input(
getNpcList: accessInputProcedure(
z
.object({
sort: z.number().int().min(1).max(8).catch(1).optional(),
+3 -5
View File
@@ -6,7 +6,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { authedProcedure, procedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff';
@@ -81,8 +81,7 @@ const loadUniqueItems = () => {
};
export const rankingRouter = router({
getBestGeneral: authedProcedure
.input(
getBestGeneral: accessAuthedInputProcedure(
z
.object({
view: z.enum(['user', 'npc']).optional(),
@@ -369,8 +368,7 @@ export const rankingRouter = router({
}
return Array.from(seasonMap.values());
}),
getHallOfFame: procedure
.input(
getHallOfFame: accessInputProcedure(
z.object({
season: z.number().int(),
scenario: z.number().int().optional(),
+2 -2
View File
@@ -7,7 +7,7 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
const hasAdminRole = (roles: string[], profileName: string): boolean => {
@@ -127,7 +127,7 @@ export const tournamentRouter = router({
return store.getState();
}),
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
getSnapshot: authedProcedure.query(async ({ ctx }) => {
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx);
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const [state, participants, matches, bets] = await Promise.all([
+2 -2
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
const troopNameSchema = z
@@ -33,7 +33,7 @@ const assertCommandResult = <T extends 'troopCreate' | 'troopJoin' | 'troopExit'
};
export const troopRouter = router({
getList: authedProcedure.query(async ({ ctx }) => {
getList: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
if (me.nationId <= 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
+2 -3
View File
@@ -1,7 +1,7 @@
import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import { authedProcedure } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
@@ -204,8 +204,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
});
});
export const getGeneralDirectory = authedProcedure
.input(z.object({ sort: zDirectorySort }).optional())
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional())
.query(async ({ ctx, input }) => {
await getMyGeneral(ctx);
const sort = input?.sort ?? 9;
+2 -3
View File
@@ -2,8 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { procedure, router } from '../../trpc.js';
import { authedProcedure } from '../../trpc.js';
import { accessAuthedProcedure, authedProcedure, procedure, router } from '../../trpc.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
@@ -71,7 +70,7 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
export const worldRouter = router({
getNationDirectory,
getGeneralDirectory,
getGlobalInfo: authedProcedure.query(async ({ ctx }) => {
getGlobalInfo: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const [nations, cities, diplomacy, map] = await Promise.all([
ctx.db.nation.findMany({ where: { level: { gt: 0 } } }),
+21
View File
@@ -7,6 +7,10 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import {
generalAccessEndpointWeights,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -24,6 +28,13 @@ const zServerId = z.string().trim().min(1).max(64);
const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex');
const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
if (ctx.generalAccessTracking !== true) {
return;
}
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
};
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
@@ -257,6 +268,10 @@ export const yearbookRouter = router({
}
const targetProfileName = input.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash);
if (isCurrentProfile && !shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
const isCurrent =
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
@@ -280,6 +295,9 @@ export const yearbookRouter = router({
if (input.hash && input.hash === hash) {
return { notModified: true, hash };
}
if (shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
return { notModified: false, hash, data };
}
@@ -317,6 +335,9 @@ export const yearbookRouter = router({
if (input.hash && input.hash === hash) {
return { notModified: true, hash };
}
if (shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
}
return { notModified: false, hash, data };
}),
});
+70 -35
View File
@@ -4,59 +4,86 @@ import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
export const accessPages = [
'front-info',
'nation-info',
'nation-cities',
'global-info',
'nation-list',
'general-list',
'current-city',
'diplomacy',
'nation-generals',
'nation-personnel',
'nation-finance',
'battle-center',
'board',
'best-general',
'hall-of-fame',
'dynasty',
'yearbook',
'nation-betting',
'traffic',
'npc-list',
'my-page',
'npc-control',
'tournament',
'betting',
] as const;
export type AccessPage = (typeof accessPages)[number];
export const accessPageWeights: Record<AccessPage, number> = {
'front-info': 1,
'nation-info': 1,
'nation-cities': 1,
'global-info': 1,
'nation-list': 2,
'general-list': 2,
'current-city': 1,
diplomacy: 1,
'nation-generals': 1,
'nation-personnel': 1,
'nation-finance': 1,
'battle-center': 1,
board: 1,
'best-general': 1,
'hall-of-fame': 1,
dynasty: 1,
yearbook: 1,
'nation-betting': 1,
traffic: 1,
'npc-list': 2,
'my-page': 1,
'npc-control': 1,
tournament: 1,
betting: 1,
};
export const generalAccessEndpointWeights = {
'world.getGeneralDirectory': 2,
'public.getNpcList': 2,
'ranking.getBestGeneral': 1,
'ranking.getHallOfFame': 1,
'tournament.getSnapshot': 1,
'nation.getSecretGeneralList': 1,
'nation.getPersonnelInfo': 1,
'nation.getGeneralList': 1,
'general.ensureDieOnPrestartStatus': 1,
'nation.getStratFinan': 1,
'board.getArticles': 1,
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
'nation.getChiefCenter': 1,
'troop.getList': 1,
'board.writeArticle': 1,
'board.writeComment': 1,
'diplomacy.sendLetter': 1,
'diplomacy.respondLetter': 1,
'diplomacy.rollbackLetter': 1,
'diplomacy.destroyLetter': 1,
'general.buildNationCandidate': 1,
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
'npc.setGeneralPriority': 0,
'battle.simulate': 0,
} as const satisfies Record<string, 0 | 1 | 2>;
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
export const resolveGeneralAccessEndpointWeight = (
path: string,
input: unknown,
currentProfileName: string
): 0 | 1 | 2 | null | undefined => {
const weight = generalAccessEndpointWeights[path as GeneralAccessEndpoint];
if (weight === undefined) {
return undefined;
}
if (path === 'yearbook.getHistory') {
const requestedServer =
typeof input === 'object' && input !== null && 'serverID' in input
? (input as { serverID?: unknown }).serverID
: undefined;
if (requestedServer !== undefined && requestedServer !== currentProfileName) {
return null;
}
}
return weight;
};
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
@@ -262,7 +289,16 @@ export const recordGeneralAccess = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
export const recordGeneralAccessWeight = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
weight: number,
now = new Date()
): Promise<boolean> => {
if (!Number.isInteger(weight) || weight < 0) {
throw new RangeError('General access weight must be a non-negative integer.');
}
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return false;
@@ -296,7 +332,6 @@ export const recordGeneralAccess = async (
return false;
}
const weight = accessPageWeights[page];
const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
await upsertGeneralAccess(ctx.db, {
+49
View File
@@ -5,6 +5,7 @@ import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from './context.js';
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
import { recordGeneralAccessWeight, resolveGeneralAccessEndpointWeight } from './services/generalAccess.js';
const t = initTRPC.context<GameApiContext>().create();
@@ -67,14 +68,48 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
}
});
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
// Router 단위 테스트의 부분 DB mock은 명시적으로 opt-in할 때만 계측한다.
if (ctx.generalAccessTracking !== true) {
return next();
}
const weight = resolveGeneralAccessEndpointWeight(path, input, ctx.profile.name);
if (weight === undefined) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `General access endpoint is not registered: ${path}`,
});
}
if (weight === null) {
return next();
}
await recordGeneralAccessWeight(ctx, weight);
return next();
});
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
export const accessProcedure: typeof procedure = t.procedure
.use(generalAccessEndpointMiddleware)
.use(inputEventMiddleware);
export const accessAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware)
.use(inputEventMiddleware);
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
@@ -83,3 +118,17 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
@@ -1,14 +1,58 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { TRPCError } from '@trpc/server';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 9_980_071;
const secondGeneralId = generalId + 1;
const rollbackGeneralId = generalId + 2;
const zeroWeightGeneralId = generalId + 3;
const endpointGeneralId = generalId + 4;
const endpointUserId = `access-endpoint-user-${endpointGeneralId}`;
const scenarioCode = `traffic-period-${generalId}`;
const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`;
const yearbookProfile = `access-profile-${endpointGeneralId}`;
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: `${yearbookProfile}:default`,
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `access-session-${endpointGeneralId}`,
user: {
id: endpointUserId,
username: endpointUserId,
displayName: endpointUserId,
roles,
},
sanctions: {},
});
const endpointBoundaryRouter = router({
world: router({
getGeneralDirectory: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).query(() => ({
ok: true,
})),
}),
general: router({
setMySetting: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => ({
ok: true,
})),
}),
board: router({
writeArticle: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' });
}),
}),
});
integration('general access tracking persistence', () => {
let db: GamePrismaClient;
@@ -21,8 +65,13 @@ integration('general access tracking persistence', () => {
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.generalAccessLog.deleteMany({
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
where: {
generalId: {
in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId],
},
},
});
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.worldState.deleteMany({ where: { scenarioCode } });
const world = await db.worldState.create({
data: {
@@ -35,12 +84,47 @@ integration('general access tracking persistence', () => {
},
});
worldStateId = world.id;
await db.general.deleteMany({ where: { id: endpointGeneralId } });
await db.general.create({
data: {
id: endpointGeneralId,
userId: endpointUserId,
name: '접속경계',
turnTime: new Date('2026-07-26T03:00:00.000Z'),
},
});
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.yearbookHistory.create({
data: {
profileName: yearbookProfile,
sourceId: 1,
year: 184,
month: 12,
map: {
year: 184,
month: 12,
startYear: 180,
cityList: [],
nationList: [],
},
nations: [],
globalHistory: ['기록'],
globalAction: ['행동'],
},
});
});
afterAll(async () => {
await db.generalAccessLog.deleteMany({
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
where: {
generalId: {
in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId],
},
},
});
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.general.deleteMany({ where: { id: endpointGeneralId } });
await db.worldState.deleteMany({ where: { id: worldStateId } });
await closeDb?.();
});
@@ -233,4 +317,151 @@ integration('general access tracking persistence', () => {
refreshTotal: 2_147_483_647,
});
});
it('records weight zero membership and last refresh without changing counters', async () => {
const now = new Date('2026-07-26T03:40:00.000Z');
await upsertGeneralAccess(db, {
worldStateId,
year: 186,
month: 2,
tickSeconds: 600,
generalId: zeroWeightGeneralId,
userId: 'zero-weight-user',
now,
periodStartedAt: now,
scoreStartedAt: now,
weight: 0,
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: zeroWeightGeneralId } })
).resolves.toMatchObject({
userId: 'zero-weight-user',
lastRefresh: now,
refresh: 0,
refreshTotal: 0,
refreshScore: 0,
refreshScoreTotal: 0,
});
await expect(
db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 186,
month: 2,
},
},
include: { generals: true },
})
).resolves.toMatchObject({
refresh: 0,
online: 1,
generals: [
{
generalId: zeroWeightGeneralId,
userId: 'zero-weight-user',
refresh: 0,
lastRefresh: now,
},
],
});
});
it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => {
const context = {
auth: endpointAuth(),
db,
generalAccessTracking: true,
requestId: endpointRequestPrefix,
profile: {
id: `${yearbookProfile}:default`,
name: yearbookProfile,
scenario: 'default',
},
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 2,
refreshTotal: 2,
});
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 2,
refreshTotal: 2,
});
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'business rejected',
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 3,
refreshTotal: 3,
});
const adminCaller = endpointBoundaryRouter.createCaller({
...context,
auth: endpointAuth(['admin']),
});
await expect(adminCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 3,
refreshTotal: 3,
});
const yearbookCaller = appRouter.createCaller({
...context,
requestId: `${endpointRequestPrefix}-yearbook`,
});
const first = await yearbookCaller.yearbook.getHistory({ year: 184, month: 12 });
expect(first.notModified).toBe(false);
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 4,
refreshTotal: 4,
});
const cached = await yearbookCaller.yearbook.getHistory({
year: 184,
month: 12,
hash: first.hash,
});
expect(cached).toEqual({ notModified: true, hash: first.hash });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 4,
refreshTotal: 4,
});
await yearbookCaller.yearbook.getHistory({
year: 184,
month: 12,
hash: 'stale-hash',
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 5,
refreshTotal: 5,
});
});
});
+176 -4
View File
@@ -1,8 +1,19 @@
import { describe, expect, it, vi } from 'vitest';
import { TRPCError } from '@trpc/server';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import { accessPageWeights, recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
import {
accessPageWeights,
generalAccessEndpointWeights,
recordGeneralAccess,
recordGeneralAccessWeight,
resolveGeneralAccessEndpointWeight,
resolveAccessWindows,
} from '../src/services/generalAccess.js';
const auth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
@@ -48,9 +59,56 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
};
describe('general access tracking', () => {
it('uses the legacy weight two for both global directory pages', () => {
it('owns every migrated Ref call at one explicit server endpoint weight', () => {
expect(generalAccessEndpointWeights).toEqual({
'world.getGeneralDirectory': 2,
'public.getNpcList': 2,
'ranking.getBestGeneral': 1,
'ranking.getHallOfFame': 1,
'tournament.getSnapshot': 1,
'nation.getSecretGeneralList': 1,
'nation.getPersonnelInfo': 1,
'nation.getGeneralList': 1,
'general.ensureDieOnPrestartStatus': 1,
'nation.getStratFinan': 1,
'board.getArticles': 1,
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
'nation.getChiefCenter': 1,
'troop.getList': 1,
'board.writeArticle': 1,
'board.writeComment': 1,
'diplomacy.sendLetter': 1,
'diplomacy.respondLetter': 1,
'diplomacy.rollbackLetter': 1,
'diplomacy.destroyLetter': 1,
'general.buildNationCandidate': 1,
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
'npc.setGeneralPriority': 0,
'battle.simulate': 0,
});
});
it('counts only current-profile yearbook reads like Ref Global.GetHistory', () => {
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
});
it('keeps the legacy route weight while endpoint-owned directories use the server map', () => {
expect(accessPageWeights['nation-list']).toBe(2);
expect(accessPageWeights['general-list']).toBe(2);
expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2);
});
it('uses the latest processed game turn as the traffic period and score window', () => {
@@ -68,7 +126,7 @@ describe('general access tracking', () => {
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
const now = new Date('2026-07-26T03:05:00.000Z');
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
expect(findGeneral).toHaveBeenCalledWith({
where: { userId: 'user-7' },
orderBy: { id: 'asc' },
@@ -105,6 +163,120 @@ describe('general access tracking', () => {
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
});
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
const { db, executeRaw, queryRaw, transaction } = buildDb();
const now = new Date('2026-07-26T03:06:00.000Z');
await expect(recordGeneralAccessWeight({ auth: auth(), db }, 0, now)).resolves.toBe(true);
expect(transaction).toHaveBeenCalledTimes(1);
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
});
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
const fixture = buildDb();
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf(
RangeError
);
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, 0.5)).rejects.toBeInstanceOf(
RangeError
);
expect(fixture.findGeneral).not.toHaveBeenCalled();
});
it('parses input, commits access, and only then opens the failing business event boundary', async () => {
const events: string[] = [];
let transactionCount = 0;
const transactionClient = {
$queryRaw: vi.fn(async () => [{ id: 41 }]),
$executeRaw: vi.fn(async () => 1),
inputEvent: {
update: vi.fn(async () => ({})),
},
};
const db = {
general: {
findFirst: vi.fn(async () => ({
id: 7,
userId: 'user-7',
})),
},
worldState: {
findFirst: vi.fn(async () => ({
id: 3,
currentYear: 185,
currentMonth: 4,
tickSeconds: 600,
meta: {
opentime: '2026-07-25T00:00:00.000Z',
lastTurnTime: '2026-07-26T03:00:00.000Z',
},
})),
},
inputEvent: {
create: vi.fn(async () => {
events.push('input-event-create');
return {};
}),
update: vi.fn(async () => {
events.push('input-event-failed');
return {};
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
$transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => {
transactionCount += 1;
events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction');
return callback(transactionClient);
}),
};
const trackedRouter = router({
board: router({
writeArticle: accessAuthedInputProcedure(
z.object({
value: z.string().transform((value) => {
events.push('input-parse');
return value;
}),
})
).mutation(() => {
events.push('resolver');
throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' });
}),
}),
});
const context = {
auth: auth(),
db,
generalAccessTracking: true,
requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' },
} as unknown as GameApiContext;
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
message: 'business rejected',
});
expect(events).toEqual([
'input-parse',
'access-transaction',
'input-event-create',
'business-transaction',
'resolver',
'input-event-failed',
]);
events.length = 0;
transactionCount = 0;
await expect(
trackedRouter.createCaller(context).board.writeArticle({ value: 7 } as never)
).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(events).toEqual([]);
});
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
const anonymous = buildDb();
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
+78 -4
View File
@@ -161,9 +161,9 @@ const install = async (
);
await page.route('**/che/api/trpc/**', async (route) => {
const requestBody = route.request().postDataJSON() as
| Record<string, { json?: { page?: unknown }; page?: unknown }>
| undefined;
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') {
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
}
@@ -191,7 +191,8 @@ const install = async (
};
}
const sort = parseSort(route);
const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
const rows =
sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
return response({ sort, generals: rows });
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -200,6 +201,30 @@ const install = async (
});
};
const installAccessBoundary = async (page: Page, accessPages: string[]) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_access_boundary');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/**', (route) => route.abort('failed'));
await page.route('**/che/api/trpc/**', async (route) => {
const requestBody = route.request().postDataJSON() as
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조회자' } });
if (operation === 'public.recordAccess') {
const payload = requestBody?.[String(operationIndex)];
const pageName = payload?.json?.page ?? payload?.page;
if (typeof pageName === 'string') accessPages.push(pageName);
return response({ recorded: true });
}
return response({});
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
const accessPages: string[] = [];
await install(page, 'general', accessPages);
@@ -283,7 +308,8 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
).toBe(65);
}
}
await expect.poll(() => accessPages).toEqual(expect.arrayContaining(['nation-list', 'general-list']));
await expect.poll(() => accessPages).toContain('nation-list');
expect(accessPages).not.toContain('general-list');
const header = page.locator('.general-table thead td').first();
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
@@ -345,3 +371,51 @@ test('an authenticated account without a general is redirected away from both di
await expect(page).toHaveURL(/\/che\/join$/);
}
});
test('route access belongs only to the eight Ref page boundaries', async ({ page }) => {
const accessPages: string[] = [];
await installAccessBoundary(page, accessPages);
const retained = [
['nation/info', 'nation-info'],
['nation/cities', 'nation-cities'],
['nation-list', 'nation-list'],
['current-city', 'current-city'],
['dynasty', 'dynasty'],
['dynasty/1', 'dynasty'],
['traffic', 'traffic'],
['npc-control', 'npc-control'],
] as const;
for (const [path, pageName] of retained) {
const before = accessPages.length;
await page.goto(path);
await expect.poll(() => accessPages.length).toBe(before + 1);
expect(accessPages.at(-1)).toBe(pageName);
}
const endpointOwned = [
'./',
'global-info',
'general-list',
'diplomacy',
'nation/generals',
'nation/personnel',
'nation/finance',
'battle-center',
'board',
'board/secret',
'best-general',
'hall-of-fame',
'yearbook',
'nation-betting',
'npc-list',
'my-page',
'tournament',
'betting',
];
for (const path of endpointOwned) {
const before = accessPages.length;
await page.goto(path);
await page.waitForTimeout(50);
expect(accessPages).toHaveLength(before);
}
});
+68 -7
View File
@@ -26,12 +26,16 @@ type FixtureState = {
instantRetreatEnabled?: boolean;
instantRetreatAttempts?: number;
instantRetreatInputs?: Array<Record<string, unknown>>;
buildNationCandidateEnabled?: boolean;
buildNationCandidateAttempts?: number;
buildNationCandidateInputs?: Array<Record<string, unknown>>;
dieOnPrestartShow?: boolean;
dieOnPrestartAvailableAt?: string;
dieOnPrestartAttempts?: number;
dieOnPrestartInputs?: Array<Record<string, unknown>>;
generalMeQueries?: number;
generalLogQueries?: number;
ensurePrestartQueries?: number;
nationNoticeInput?: string;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
@@ -47,7 +51,7 @@ const myGeneral = (state: FixtureState) => ({
id: 7,
name: '검증장수',
npcState: 0,
nationId: 1,
nationId: state.buildNationCandidateEnabled ? 0 : 1,
cityId: 1,
troopId: 0,
picture: null,
@@ -165,12 +169,14 @@ const install = async (page: Page, state: FixtureState) => {
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
return response(myGeneral(state));
}
if (operation === 'general.ensureDieOnPrestartStatus')
if (operation === 'general.ensureDieOnPrestartStatus') {
state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1;
return response({
show: state.dieOnPrestartShow ?? false,
available: false,
availableAt: state.dieOnPrestartAvailableAt ?? null,
});
}
if (operation === 'general.getFrontStatus')
return response({
onlineUserCount: 1,
@@ -196,7 +202,9 @@ const install = async (page: Page, state: FixtureState) => {
},
meta: {
turntime: '2026-01-01T00:00:00.000Z',
opentime: '2025-12-01T00:00:00.000Z',
opentime: state.buildNationCandidateEnabled
? '2026-02-01T00:00:00.000Z'
: '2025-12-01T00:00:00.000Z',
autorun_user: {},
},
});
@@ -261,6 +269,20 @@ const install = async (page: Page, state: FixtureState) => {
}
return response({ ok: true });
}
if (operation === 'general.buildNationCandidate') {
state.buildNationCandidateInputs?.push(jsonInput);
state.buildNationCandidateAttempts = (state.buildNationCandidateAttempts ?? 0) + 1;
if (state.buildNationCandidateAttempts === 1) {
return {
error: {
message: '요청 처리 결과를 확인하지 못했습니다.',
code: -32000,
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
},
};
}
return response({ ok: true });
}
if (operation === 'general.dieOnPrestart') {
state.dieOnPrestartInputs?.push(jsonInput);
state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1;
@@ -395,7 +417,8 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect.poll(() => state.accessPages).toContain('my-page');
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
expect(state.accessPages).not.toContain('my-page');
const noDefenceOption = page.locator('option[value="999"]');
await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]');
await expect(page.locator('#defence_train option')).toHaveText([
@@ -533,6 +556,7 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
instantRetreatInputs: [],
generalMeQueries: 0,
generalLogQueries: 0,
ensurePrestartQueries: 0,
settingMutations: [],
accessPages: [],
};
@@ -547,18 +571,21 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
const instantRetreatButton = page.getByRole('button', { name: '접경 귀환' });
await expect(instantRetreatButton).toBeVisible();
await expect.poll(() => state.generalMeQueries).toBe(1);
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(1);
await expect
.poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.')))
.toBe(true);
expect(state.generalMeQueries).toBe(1);
await expect.poll(() => state.generalMeQueries).toBe(2);
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(2);
await expect.poll(() => state.generalMeQueries).toBe(2);
await expect.poll(() => state.generalLogQueries).toBe(8);
await expect.poll(() => state.generalMeQueries).toBe(3);
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
await expect.poll(() => state.generalLogQueries).toBe(12);
await page.evaluate(() => new Promise<void>((resolveFrame) => requestAnimationFrame(() => resolveFrame())));
await instantRetreatButton.click();
@@ -690,6 +717,40 @@ test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 time
});
});
test('사전 거병은 timeout reload 뒤 같은 ID를 재시도하고 성공 reload 뒤 새 ID를 만든다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
buildNationCandidateEnabled: true,
buildNationCandidateAttempts: 0,
buildNationCandidateInputs: [],
ensurePrestartQueries: 0,
settingMutations: [],
accessPages: [],
};
page.on('dialog', (dialog) => dialog.accept());
await install(page, state);
await page.goto('my-page');
const build = page.getByRole('button', { name: '사전 거병' });
await expect(build).toBeVisible();
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
await build.click();
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(1);
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
await build.click();
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(2);
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
await build.click();
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(3);
const requestIds = state.buildNationCandidateInputs?.map((input) => input.clientRequestId);
expect(requestIds?.[1]).toBe(requestIds?.[0]);
expect(requestIds?.[2]).not.toBe(requestIds?.[1]);
});
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, head);
-18
View File
@@ -41,32 +41,14 @@ import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
const accessPageByRouteName = {
home: 'front-info',
'nation-info': 'nation-info',
'nation-cities': 'nation-cities',
'global-info': 'global-info',
'nation-list': 'nation-list',
'general-list': 'general-list',
'current-city': 'current-city',
diplomacy: 'diplomacy',
'nation-generals': 'nation-generals',
'nation-personnel': 'nation-personnel',
'nation-finance': 'nation-finance',
'battle-center': 'battle-center',
board: 'board',
'board-secret': 'board',
'best-general': 'best-general',
'hall-of-fame': 'hall-of-fame',
'dynasty-list': 'dynasty',
'dynasty-detail': 'dynasty',
yearbook: 'yearbook',
'nation-betting': 'nation-betting',
traffic: 'traffic',
'npc-list': 'npc-list',
'my-page': 'my-page',
'npc-control': 'npc-control',
tournament: 'tournament',
betting: 'betting',
} as const;
const routes = [
+2 -1
View File
@@ -86,9 +86,10 @@ const placeBet = async (targetId: number) => {
try {
await trpc.tournament.placeBet.mutate({ targetId, amount });
message.value = '베팅이 등록되었습니다.';
await load();
} catch (value) {
message.value = errorText(value);
} finally {
await load();
}
};
</script>
@@ -106,10 +106,7 @@ watch([selectedSeason, selectedScenario], () => {
void loadHall();
});
onMounted(async () => {
await loadOptions();
await loadHall();
});
onMounted(loadOptions);
</script>
<template>
+24 -12
View File
@@ -187,7 +187,7 @@ const loadLog = async (type: LogType, beforeId?: number) => {
}
};
const loadPage = async () => {
const loadPage = async (resetImmediateActionIds = true) => {
if (loading.value) return;
loading.value = true;
error.value = null;
@@ -206,7 +206,9 @@ const loadPage = async () => {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
resetImmediateActionRequestIds();
if (resetImmediateActionIds) {
resetImmediateActionRequestIds();
}
} catch (cause) {
error.value = errorText(cause);
} finally {
@@ -224,13 +226,17 @@ const saveSettings = async () => {
}
};
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
const confirmMutation = async (message: string, mutation: () => Promise<unknown>, reloadAfterFailure = false) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
if (reloadAfterFailure) {
const code = asRecord(asRecord(cause).data).code;
await loadPage(code !== 'TIMEOUT');
}
}
};
@@ -288,7 +294,7 @@ onMounted(() => {
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
<button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button>
</div>
<div v-if="error" class="error-row">{{ error }}</div>
@@ -430,10 +436,13 @@ onMounted(() => {
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate({
clientRequestId: immediateActionRequestIds.buildNationCandidate,
})
confirmMutation(
'거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?',
() =>
trpc.general.buildNationCandidate.mutate({
clientRequestId: immediateActionRequestIds.buildNationCandidate,
}),
true
)
"
>
@@ -445,10 +454,13 @@ onMounted(() => {
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () =>
trpc.general.instantRetreat.mutate({
clientRequestId: immediateActionRequestIds.instantRetreat,
})
confirmMutation(
'아군 접경으로 이동할까요?',
() =>
trpc.general.instantRetreat.mutate({
clientRequestId: immediateActionRequestIds.instantRetreat,
}),
true
)
"
>
@@ -102,9 +102,10 @@ const join = async () => {
try {
await trpc.tournament.join.mutate();
actionMessage.value = '참가 신청이 반영되었습니다.';
await load();
} catch (value) {
actionMessage.value = errorText(value);
} finally {
await load();
}
};
@@ -66,13 +66,35 @@ const bettingSummary = {
myAmount: 50,
};
type FixtureState = {
betCalls: number;
joinCalls: number;
snapshotQueries: number;
summaryQueries: number;
rankingQueries: number;
accessCalls: number;
stage: number;
myGeneralId: number;
};
const fixtureByPage = new WeakMap<Page, FixtureState>();
const installFixture = async (page: Page) => {
const state: FixtureState = {
betCalls: 0,
joinCalls: 0,
snapshotQueries: 0,
summaryQueries: 0,
rankingQueries: 0,
accessCalls: 0,
stage: snapshot.state.stage,
myGeneralId: 64,
};
fixtureByPage.set(page, state);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_visual');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
let betCalls = 0;
let joinCalls = 0;
await page.route('**/image/game/**', (route) =>
route.fulfill({
status: 200,
@@ -82,16 +104,32 @@ const installFixture = async (page: Page) => {
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') {
return response({ userId: 'tournament-user' });
}
if (operation === 'lobby.info') {
return response({ myGeneral: { id: 64, name: '내장수' } });
return response({ myGeneral: { id: state.myGeneralId, name: '내장수' } });
}
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') {
return response({ general: { id: 64, name: '내장수' }, nation: null, city: null });
return response({ general: { id: state.myGeneralId, name: '내장수' }, nation: null, city: null });
}
if (operation === 'tournament.getSnapshot') {
state.snapshotQueries += 1;
return response({
...snapshot,
state: {
...snapshot.state,
stage: state.stage,
},
});
}
if (operation === 'tournament.getBettingSummary') {
state.summaryQueries += 1;
return response(bettingSummary);
}
if (operation === 'tournament.getSnapshot') return response(snapshot);
if (operation === 'tournament.getBettingSummary') return response(bettingSummary);
if (operation === 'tournament.getRankings') {
state.rankingQueries += 1;
return response(
[
['tt', '전 력 전', '종합'],
@@ -123,17 +161,21 @@ const installFixture = async (page: Page) => {
if (operation === 'tournament.getAdminStatus')
return errorResponse(operation, 'Admin permission is required.');
if (operation === 'tournament.join') {
joinCalls += 1;
return joinCalls === 1
state.joinCalls += 1;
return state.joinCalls === 1
? errorResponse(operation, '금이 부족합니다.')
: response({ ok: true, count: 64 });
}
if (operation === 'tournament.placeBet') {
betCalls += 1;
return betCalls === 1
state.betCalls += 1;
return state.betCalls === 1
? errorResponse(operation, '500금까지만 베팅 가능합니다.')
: response({ ok: true });
}
if (operation === 'public.recordAccess') {
state.accessCalls += 1;
return response({ recorded: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({
@@ -204,11 +246,33 @@ test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ p
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(2000);
});
test('tournament reloads its snapshot after failed and successful joins without route access', async ({ page }) => {
const state = fixtureByPage.get(page)!;
state.stage = 1;
state.myGeneralId = 999;
await page.goto(`${gameUrl}/che/tournament`);
const join = page.getByRole('button', { name: '참가', exact: true });
await expect(join).toBeVisible();
await expect.poll(() => state.snapshotQueries).toBe(1);
await join.click();
await expect(page.getByRole('status')).toHaveText('금이 부족합니다.');
await expect.poll(() => state.snapshotQueries).toBe(2);
await join.click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
await expect.poll(() => state.snapshotQueries).toBe(3);
expect(state.summaryQueries).toBe(3);
expect(state.accessCalls).toBe(0);
});
test('betting keeps the 1120px and 16 by 70px layout and retains a failed selection', async ({ page }) => {
const state = fixtureByPage.get(page)!;
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`${gameUrl}/che/betting`);
await expect(page.getByText('베 팅 장')).toBeVisible();
await expect(page.locator('.names span')).toHaveCount(16);
await expect.poll(() => state.snapshotQueries).toBe(1);
const geometry = await page.locator('#tournament-betting-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
@@ -251,7 +315,12 @@ test('betting keeps the 1120px and 16 by 70px layout and retains a failed select
await bet.click();
await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.');
await expect(select).toHaveValue('500');
await expect.poll(() => state.snapshotQueries).toBe(2);
await bet.click();
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
await expect.poll(() => state.snapshotQueries).toBe(3);
expect(state.summaryQueries).toBe(3);
expect(state.rankingQueries).toBe(3);
expect(state.accessCalls).toBe(0);
});
@@ -16,7 +16,9 @@ const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const passwordPublicKey = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicExponent: 0x10001,
}).publicKey.export({ format: 'pem', type: 'spki' }).toString();
})
.publicKey.export({ format: 'pem', type: 'spki' })
.toString();
const response = (data: unknown) => ({ result: { data } });
@@ -154,9 +156,7 @@ const installGatewayFixture = async (page: Page): Promise<void> => {
return {
available: true,
normalizedValue:
fieldInput?.field === 'username'
? fieldInput.value?.toLowerCase()
: fieldInput?.value,
fieldInput?.field === 'username' ? fieldInput.value?.toLowerCase() : fieldInput?.value,
message: '사용할 수 있습니다.',
};
}
@@ -216,15 +216,31 @@ const installGatewayFixture = async (page: Page): Promise<void> => {
});
};
const installHallFixture = async (page: Page): Promise<void> => {
type HallFixtureCalls = {
options: number;
hallInputs: unknown[];
};
const installHallFixture = async (page: Page): Promise<HallFixtureCalls> => {
const calls: HallFixtureCalls = {
options: 0,
hallInputs: [],
};
await installImages(page);
await page.route('**/che/api/trpc/**', async (route) => {
await fulfillOperations(route, (operation) => {
if (operation === 'ranking.getHallOfFameOptions') return fixture.game.hallOptions;
if (operation === 'ranking.getHallOfFame') return fixture.game.hall;
await fulfillOperations(route, (operation, input) => {
if (operation === 'ranking.getHallOfFameOptions') {
calls.options += 1;
return fixture.game.hallOptions;
}
if (operation === 'ranking.getHallOfFame') {
calls.hallInputs.push(input);
return fixture.game.hall;
}
throw new Error(`Unhandled hall fixture operation: ${operation}`);
});
});
return calls;
};
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
@@ -730,8 +746,10 @@ test.describe('best general legacy parity', () => {
});
test.describe('hall of fame legacy parity', () => {
let hallCalls: HallFixtureCalls;
test.beforeEach(async ({ page }) => {
await installHallFixture(page);
hallCalls = await installHallFixture(page);
});
for (const viewport of [
@@ -742,6 +760,9 @@ test.describe('hall of fame legacy parity', () => {
await page.setViewportSize(viewport);
await page.goto('http://127.0.0.1:15102/che/hall-of-fame');
await expect(page.getByText('유비')).toBeVisible();
await expect.poll(() => hallCalls.options).toBe(1);
await expect.poll(() => hallCalls.hallInputs.length).toBe(1);
expect(hallCalls.hallInputs[0]).toEqual({ season: 1 });
await expect(page.locator('.rankView')).toHaveCount(2);
if (artifactRoot) {
await page.screenshot({
@@ -807,14 +828,19 @@ test.describe('hall of fame legacy parity', () => {
await expect(scenario).toBeFocused();
await scenario.selectOption('scenario:1:22');
await expect(scenario).toHaveValue('scenario:1:22');
await expect.poll(() => hallCalls.hallInputs.length).toBe(2);
expect(hallCalls.hallInputs[1]).toEqual({ season: 1, scenario: 22 });
expect(hallCalls.options).toBe(1);
});
}
test('keeps the selected scenario after a hall API error', async ({ page }) => {
await page.goto('http://127.0.0.1:15102/che/hall-of-fame');
await expect(page.getByText('유비')).toBeVisible();
let failedHallRequests = 0;
await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('ranking.getHallOfFame')) {
failedHallRequests += 1;
await route.fulfill({
status: 500,
contentType: 'application/json',
@@ -829,6 +855,9 @@ test.describe('hall of fame legacy parity', () => {
await expect(page.getByRole('alert')).toBeVisible();
await expect(scenario).toHaveValue('scenario:1:22');
await expect(page.getByText('유비')).toBeVisible();
await expect.poll(() => failedHallRequests).toBe(1);
await page.waitForTimeout(250);
expect(failedHallRequests).toBe(1);
});
});