diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index e072927..686cdf5 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -48,6 +48,8 @@ const BUFF_LABELS: Record = { warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', }; +const POSTGRES_INTEGER_MAX = 2_147_483_647; + const parseBuffRecord = (raw: unknown): Record => { if (typeof raw === 'string') { const parsed = parseJson>(raw); @@ -319,7 +321,7 @@ export const inheritRouter = router({ getLogs: authedProcedure .input( z.object({ - lastId: z.number().int().optional(), + lastId: z.number().int().min(1).max(POSTGRES_INTEGER_MAX).optional(), }) ) .query(async ({ ctx, input }) => { @@ -327,11 +329,10 @@ export const inheritRouter = router({ if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const lastId = input.lastId ?? Number.MAX_SAFE_INTEGER; const logs = await ctx.db.inheritanceLog.findMany({ where: { userId, - id: { lt: lastId }, + ...(input.lastId === undefined ? {} : { id: { lt: input.lastId } }), }, orderBy: { id: 'desc' }, take: 30, diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index da9f6e5..ab9f894 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -95,6 +95,7 @@ const buildContext = (options: { general?: GeneralRow | null; target?: GeneralRow | null; inheritancePoint?: number; + inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; const general = options.general === undefined ? buildGeneral() : options.general; @@ -110,6 +111,7 @@ const buildContext = (options: { const pointUpsert = vi.fn(async () => ({})); const logCreate = vi.fn(async () => ({})); const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); + const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []); const db = { $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), worldState: { @@ -129,7 +131,7 @@ const buildContext = (options: { }, inheritanceLog: { create: logCreate, - findMany: vi.fn(async () => []), + findMany: inheritanceLogFindMany, }, inheritanceUserState: { findUnique: vi.fn(async () => null), @@ -157,7 +159,7 @@ const buildContext = (options: { flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { context, requestCommand, pointUpsert, logCreate, findMany }; + return { context, requestCommand, pointUpsert, logCreate, findMany, inheritanceLogFindMany }; }; describe('inherit router actor and permission boundaries', () => { @@ -189,6 +191,45 @@ describe('inherit router actor and permission boundaries', () => { }); }); + it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { + const createdAt = new Date('2026-07-26T00:00:00Z'); + const fixture = buildContext({ + inheritanceLogs: [{ id: 2_147_483_647, year: 200, month: 4, text: '경계 로그', createdAt }], + }); + + await expect(appRouter.createCaller(fixture.context).inherit.getLogs({})).resolves.toEqual([ + { id: 2_147_483_647, year: 200, month: 4, text: '경계 로그', createdAt }, + ]); + expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + orderBy: { id: 'desc' }, + take: 30, + select: { id: true, year: true, month: true, text: true, createdAt: true }, + }); + }); + + it('uses a bounded cursor for following and empty inheritance-log pages', async () => { + const fixture = buildContext({ inheritanceLogs: [] }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.inherit.getLogs({ lastId: 2_147_483_647 })).resolves.toEqual([]); + expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', id: { lt: 2_147_483_647 } }, + orderBy: { id: 'desc' }, + take: 30, + select: { id: true, year: true, month: true, text: true, createdAt: true }, + }); + }); + + it.each([0, -1, 1.5, 2_147_483_648])('rejects an invalid inheritance-log cursor: %s', async (lastId) => { + const fixture = buildContext({}); + + await expect(appRouter.createCaller(fixture.context).inherit.getLogs({ lastId })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled(); + }); + it('does not dispatch or charge when the authenticated user owns no general', async () => { const fixture = buildContext({ auth: buildAuth('user-2'), diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index acca5da..d77898f 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -37,6 +37,7 @@ type FixtureState = { generalMeQueries?: number; generalLogQueries?: number; ensurePrestartQueries?: number; + ensurePrestartFailure?: 'TIMEOUT' | 'INTERNAL_SERVER_ERROR'; nationNoticeInput?: string; settingMutations: Array>; accessPages: string[]; @@ -188,6 +189,22 @@ const install = async (page: Page, state: FixtureState) => { } if (operation === 'general.ensureDieOnPrestartStatus') { state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1; + if (state.ensurePrestartFailure) { + const isTimeout = state.ensurePrestartFailure === 'TIMEOUT'; + return { + error: { + message: isTimeout + ? '요청 처리 결과를 확인하지 못했습니다.' + : '엔진 transaction을 시작하지 못했습니다.', + code: -32000, + data: { + code: state.ensurePrestartFailure, + httpStatus: isTimeout ? 408 : 500, + path: operation, + }, + }, + }; + } return response({ show: state.dieOnPrestartShow ?? false, available: false, @@ -568,6 +585,35 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac await persistParityArtifact(page, 'core-my-page-mobile', mobile); }); +for (const [label, failure] of [ + ['daemon timeout', 'TIMEOUT'], + ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], +] as const) { + test(`내 정보 기본 출력은 ${label}에도 표시되고 사전 삭제 동작만 비활성화된다`, async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + ensurePrestartFailure: failure, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('my-page'); + + await expect(page.locator('.general-table')).toContainText('검증장수'); + await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect(page.locator('.log-panel').first()).toContainText('기록'); + await expect(page.locator('.error-row')).toHaveCount(0); + + const statusError = page.locator('.prestart-status-error'); + await expect(statusError).toBeVisible(); + await expect(statusError.getByRole('button', { name: '장수 삭제' })).toBeDisabled(); + await expect(statusError.getByRole('button', { name: '상태 재확인' })).toBeEnabled(); + await expect.poll(() => state.ensurePrestartQueries).toBe(1); + }); +} + test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => { const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31'; const state: FixtureState = { diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 8b78c34..7103dde 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -36,6 +36,8 @@ const data = ref(null); const world = ref(null); const selectionPoolStatus = ref(null); const dieOnPrestartStatus = ref(null); +const dieOnPrestartStatusLoading = ref(false); +const dieOnPrestartStatusError = ref(null); const loading = ref(false); const error = ref(null); const screenMode = ref('auto'); @@ -190,21 +192,34 @@ const loadLog = async (type: LogType, beforeId?: number) => { } }; +const loadDieOnPrestartStatus = async () => { + if (dieOnPrestartStatusLoading.value) return; + dieOnPrestartStatusLoading.value = true; + dieOnPrestartStatusError.value = null; + try { + dieOnPrestartStatus.value = await trpc.general.ensureDieOnPrestartStatus.mutate(); + } catch (cause) { + dieOnPrestartStatus.value = null; + dieOnPrestartStatusError.value = errorText(cause); + } finally { + dieOnPrestartStatusLoading.value = false; + } +}; + const loadPage = async (resetImmediateActionIds = true) => { if (loading.value) return; loading.value = true; error.value = null; + void loadDieOnPrestartStatus(); try { - const [general, state, joinConfig, prestartStatus] = await Promise.all([ + const [general, state, joinConfig] = await Promise.all([ trpc.general.me.query(), trpc.world.getState.query() as Promise, trpc.join.getConfig.query(), - trpc.general.ensureDieOnPrestartStatus.mutate(), ]); data.value = general; world.value = state; selectionPoolStatus.value = joinConfig.selectionPool; - dieOnPrestartStatus.value = prestartStatus; if (general) { Object.assign(form, general.settings); selectedIconId.value = @@ -470,6 +485,19 @@ onMounted(() => { 가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)
+
+ 가오픈 기간 내 장수 삭제 상태를 확인하지 못했습니다.
+ {{ dieOnPrestartStatusError }}
+ + +
서버 개시 이전 거병(2턴부터 건국 가능)