fix: 실제 행동 기준으로 접속 중 국가를 집계
This commit is contained in:
@@ -810,7 +810,7 @@ export const generalRouter = router({
|
|||||||
const [onlineAccess, ownNation, latestVote] = await Promise.all([
|
const [onlineAccess, ownNation, latestVote] = await Promise.all([
|
||||||
ctx.db.generalAccessLog.findMany({
|
ctx.db.generalAccessLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
lastRefresh: {
|
lastActionAt: {
|
||||||
gte: scoreStartedAt,
|
gte: scoreStartedAt,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -868,8 +868,9 @@ export const generalRouter = router({
|
|||||||
onlineByNation.set(general.nationId, bucket);
|
onlineByNation.set(general.nationId, bucket);
|
||||||
}
|
}
|
||||||
const onlineNations = [...onlineByNation.entries()]
|
const onlineNations = [...onlineByNation.entries()]
|
||||||
|
.filter(([nationId]) => nationId > 0)
|
||||||
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
|
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
|
||||||
.map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`)
|
.map(([nationId]) => `【${nationNames.get(nationId) ?? `세력 ${nationId}`}】`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
const myOnlineGenerals = onlineGenerals
|
const myOnlineGenerals = onlineGenerals
|
||||||
.filter((general) => general.nationId === me.nationId)
|
.filter((general) => general.nationId === me.nationId)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../context.js';
|
||||||
|
|
||||||
|
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a completed, authenticated user mutation without changing the Ref
|
||||||
|
* refresh counters. Page loads and read-model refreshes never call this path.
|
||||||
|
*/
|
||||||
|
export const recordGeneralActivity = async (
|
||||||
|
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||||
|
now = new Date()
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const user = ctx.auth?.user;
|
||||||
|
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const written = await ctx.db.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
INSERT INTO general_access_log (
|
||||||
|
general_id,
|
||||||
|
user_id,
|
||||||
|
last_action_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
${user.id},
|
||||||
|
${now}
|
||||||
|
FROM "general"
|
||||||
|
WHERE user_id = ${user.id}
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
ON CONFLICT (general_id) DO UPDATE SET
|
||||||
|
user_id = EXCLUDED.user_id,
|
||||||
|
last_action_at = GREATEST(
|
||||||
|
general_access_log.last_action_at,
|
||||||
|
EXCLUDED.last_action_at
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return written > 0;
|
||||||
|
};
|
||||||
+54
-10
@@ -16,6 +16,7 @@ import {
|
|||||||
type GeneralAccessEndpoint,
|
type GeneralAccessEndpoint,
|
||||||
} from './services/generalAccess.js';
|
} from './services/generalAccess.js';
|
||||||
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
|
||||||
|
import { recordGeneralActivity } from './services/generalActivity.js';
|
||||||
|
|
||||||
const t = initTRPC.context<GameApiContext>().create();
|
const t = initTRPC.context<GameApiContext>().create();
|
||||||
|
|
||||||
@@ -41,6 +42,21 @@ const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
||||||
|
const result = await next();
|
||||||
|
if (type !== 'mutation' || !result.ok || ctx.generalAccessTracking !== true) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recordGeneralActivity(ctx);
|
||||||
|
} catch {
|
||||||
|
// 활동 표시는 업무 transaction보다 약한 보조 기록이다. 배포 중 schema
|
||||||
|
// 전환이나 일시 DB 오류가 이미 완료된 사용자 mutation을 실패시키지 않는다.
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||||
return next();
|
return next();
|
||||||
@@ -146,7 +162,10 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
export const authedProcedure: typeof procedure = t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.use(generalActivityMiddleware)
|
||||||
|
.use(inputEventMiddleware);
|
||||||
|
|
||||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||||
@@ -154,16 +173,20 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle
|
|||||||
export const accessAuthedProcedure: typeof procedure = t.procedure
|
export const accessAuthedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(generalAccessEndpointMiddleware)
|
.use(generalAccessEndpointMiddleware)
|
||||||
|
.use(generalActivityMiddleware)
|
||||||
.use(inputEventMiddleware);
|
.use(inputEventMiddleware);
|
||||||
|
|
||||||
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
|
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
|
||||||
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
|
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
|
||||||
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
|
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
|
||||||
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
export const engineAuthedProcedure: typeof procedure = t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
export const engineProcedure: typeof procedure = t.procedure;
|
export const engineProcedure: typeof procedure = t.procedure;
|
||||||
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
|
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(generalAccessEndpointMiddleware);
|
.use(generalAccessEndpointMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
|
|
||||||
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||||
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||||
@@ -171,22 +194,43 @@ export const sessionActivityProcedure = t.procedure;
|
|||||||
|
|
||||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
|
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(generalAccessLimitMiddleware);
|
.use(generalAccessLimitMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure
|
export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(deferredGeneralAccessLimitMiddleware);
|
.use(deferredGeneralAccessLimitMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
|
||||||
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
|
||||||
export const accessInputProcedure: typeof procedure.input = (input) =>
|
export const accessInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
|
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
|
||||||
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
|
t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.input(input)
|
||||||
|
.use(generalAccessEndpointMiddleware)
|
||||||
|
.use(generalActivityMiddleware)
|
||||||
|
.use(inputEventMiddleware);
|
||||||
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
|
t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.input(input)
|
||||||
|
.use(generalAccessEndpointMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
|
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
|
t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.input(input)
|
||||||
|
.use(generalAccessEndpointMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
|
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware);
|
t.procedure
|
||||||
|
.use(requireAuthMiddleware)
|
||||||
|
.input(input)
|
||||||
|
.use(generalAccessLimitMiddleware)
|
||||||
|
.use(generalActivityMiddleware);
|
||||||
|
|||||||
@@ -427,9 +427,7 @@ integration('general access tracking persistence', () => {
|
|||||||
if (!initialContext?.sourceRevision) {
|
if (!initialContext?.sourceRevision) {
|
||||||
throw new Error('dashboard snapshot did not include its source revision');
|
throw new Error('dashboard snapshot did not include its source revision');
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||||
db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })
|
|
||||||
).resolves.toBeNull();
|
|
||||||
expect(redisEval).toHaveBeenCalledTimes(1);
|
expect(redisEval).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
|
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
|
||||||
@@ -482,15 +480,22 @@ integration('general access tracking persistence', () => {
|
|||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
refresh: 2,
|
refresh: 2,
|
||||||
refreshTotal: 2,
|
refreshTotal: 2,
|
||||||
|
lastActionAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
|
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
|
||||||
await expect(
|
const completedActionAccess = await db.generalAccessLog.findUniqueOrThrow({
|
||||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
where: { generalId: endpointGeneralId },
|
||||||
).resolves.toMatchObject({
|
});
|
||||||
|
expect(completedActionAccess).toMatchObject({
|
||||||
refresh: 2,
|
refresh: 2,
|
||||||
refreshTotal: 2,
|
refreshTotal: 2,
|
||||||
});
|
});
|
||||||
|
expect(completedActionAccess.lastActionAt).toBeInstanceOf(Date);
|
||||||
|
await expect(dashboardCaller.general.getFrontStatus()).resolves.toMatchObject({
|
||||||
|
onlineNations: expect.not.stringContaining('재야'),
|
||||||
|
onlineGenerals: expect.stringContaining('접속경계'),
|
||||||
|
});
|
||||||
|
|
||||||
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
|
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -501,6 +506,7 @@ integration('general access tracking persistence', () => {
|
|||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
refresh: 3,
|
refresh: 3,
|
||||||
refreshTotal: 3,
|
refreshTotal: 3,
|
||||||
|
lastActionAt: completedActionAccess.lastActionAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminCaller = endpointBoundaryRouter.createCaller({
|
const adminCaller = endpointBoundaryRouter.createCaller({
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
import { authedProcedure, router, sessionActivityProcedure } from '../src/trpc.js';
|
||||||
|
|
||||||
|
const auth = (roles: string[] = ['user']): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2026-08-19T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||||
|
sessionId: 'activity-session',
|
||||||
|
user: {
|
||||||
|
id: 'activity-user',
|
||||||
|
username: 'activity-user',
|
||||||
|
displayName: '활동 사용자',
|
||||||
|
roles,
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token = auth()) =>
|
||||||
|
({
|
||||||
|
auth: token,
|
||||||
|
db: { $executeRaw: executeRaw },
|
||||||
|
generalAccessTracking: true,
|
||||||
|
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
||||||
|
}) as unknown as GameApiContext;
|
||||||
|
|
||||||
|
const activityRouter = router({
|
||||||
|
read: authedProcedure.query(() => 'read'),
|
||||||
|
act: authedProcedure.mutation(() => 'acted'),
|
||||||
|
rejected: authedProcedure.mutation(() => {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'rejected' });
|
||||||
|
}),
|
||||||
|
pageRefresh: sessionActivityProcedure.mutation(() => 'refreshed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('general action tracking', () => {
|
||||||
|
it('records only a completed authenticated mutation, not reads, page refreshes, or rejected actions', async () => {
|
||||||
|
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||||
|
const caller = activityRouter.createCaller(buildContext(executeRaw));
|
||||||
|
|
||||||
|
await expect(caller.read()).resolves.toBe('read');
|
||||||
|
await expect(caller.pageRefresh()).resolves.toBe('refreshed');
|
||||||
|
await expect(caller.rejected()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(executeRaw).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect(caller.act()).resolves.toBe('acted');
|
||||||
|
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||||
|
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||||
|
expect(statement.sql).toContain('INSERT INTO general_access_log');
|
||||||
|
expect(statement.sql).toContain('last_action_at');
|
||||||
|
expect(statement.sql).toContain('FROM "general"');
|
||||||
|
expect(statement.values).toContain('activity-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark admin mutations and never overturns a completed action when presence persistence fails', async () => {
|
||||||
|
const adminWrite = vi.fn(async (_query: unknown) => 1);
|
||||||
|
await expect(activityRouter.createCaller(buildContext(adminWrite, auth(['admin']))).act()).resolves.toBe(
|
||||||
|
'acted'
|
||||||
|
);
|
||||||
|
expect(adminWrite).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const failedWrite = vi.fn(async (_query: unknown) => Promise.reject(new Error('presence unavailable')));
|
||||||
|
await expect(activityRouter.createCaller(buildContext(failedWrite)).act()).resolves.toBe('acted');
|
||||||
|
expect(failedWrite).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
|
|||||||
{ id: 7, name: '유비', nationId: 2 },
|
{ id: 7, name: '유비', nationId: 2 },
|
||||||
{ id: 8, name: '관우', nationId: 2 },
|
{ id: 8, name: '관우', nationId: 2 },
|
||||||
{ id: 9, name: '조조', nationId: 3 },
|
{ id: 9, name: '조조', nationId: 3 },
|
||||||
|
{ id: 10, name: '재야장수', nationId: 0 },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
@@ -46,7 +47,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
generalAccessLog: {
|
generalAccessLog: {
|
||||||
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]),
|
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }, { generalId: 10 }]),
|
||||||
},
|
},
|
||||||
nation: {
|
nation: {
|
||||||
findUnique: vi.fn(async () => ({
|
findUnique: vi.fn(async () => ({
|
||||||
@@ -81,7 +82,7 @@ describe('general.getFrontStatus', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => {
|
it('returns action-based current-turn online data without listing the free nation', async () => {
|
||||||
const context = buildContext();
|
const context = buildContext();
|
||||||
const caller = appRouter.createCaller(context);
|
const caller = appRouter.createCaller(context);
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ describe('general.getFrontStatus', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
serverId: 'che_260819_front',
|
serverId: 'che_260819_front',
|
||||||
onlineUserCount: 3,
|
onlineUserCount: 4,
|
||||||
onlineNations: '【촉】, 【위】',
|
onlineNations: '【촉】, 【위】',
|
||||||
onlineGenerals: '유비, 관우',
|
onlineGenerals: '유비, 관우',
|
||||||
nationNotice: '<p>북벌 준비</p>',
|
nationNotice: '<p>북벌 준비</p>',
|
||||||
@@ -102,7 +103,7 @@ describe('general.getFrontStatus', () => {
|
|||||||
});
|
});
|
||||||
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
|
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
lastRefresh: {
|
lastActionAt: {
|
||||||
gte: new Date('2026-07-26T10:00:00.000Z'),
|
gte: new Date('2026-07-26T10:00:00.000Z'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260818001000_add_game_cancellation_operation',
|
gatewaySchemaHead: '20260819000000_backfill_profile_release_source',
|
||||||
gameSchemaHead: '20260818010000_add_legacy_battle_result_logs',
|
gameSchemaHead: '20260819000000_add_general_last_action',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -271,12 +271,14 @@ model GeneralAccessLog {
|
|||||||
generalId Int @unique @map("general_id")
|
generalId Int @unique @map("general_id")
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
lastRefresh DateTime? @map("last_refresh")
|
lastRefresh DateTime? @map("last_refresh")
|
||||||
|
lastActionAt DateTime? @map("last_action_at")
|
||||||
refresh Int @default(0)
|
refresh Int @default(0)
|
||||||
refreshTotal Int @default(0) @map("refresh_total")
|
refreshTotal Int @default(0) @map("refresh_total")
|
||||||
refreshScore Int @default(0) @map("refresh_score")
|
refreshScore Int @default(0) @map("refresh_score")
|
||||||
refreshScoreTotal Int @default(0) @map("refresh_score_total")
|
refreshScoreTotal Int @default(0) @map("refresh_score_total")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([lastActionAt])
|
||||||
@@map("general_access_log")
|
@@map("general_access_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "general_access_log"
|
||||||
|
ADD COLUMN "last_action_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX "general_access_log_last_action_at_idx"
|
||||||
|
ON "general_access_log"("last_action_at");
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
|
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source",
|
||||||
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
|
"gameSchemaHead": "20260819000000_add_general_last_action",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const accessToken = `ga_${randomUUID()}`;
|
|||||||
let postgres: ReturnType<typeof createGamePostgresConnector>;
|
let postgres: ReturnType<typeof createGamePostgresConnector>;
|
||||||
let redis: RedisConnector;
|
let redis: RedisConnector;
|
||||||
let prisma: GamePrismaClient;
|
let prisma: GamePrismaClient;
|
||||||
let fixture: { generalId: number; nationId: number; userId: string; voteId: number } | null = null;
|
let fixture: { generalId: number; freeGeneralId: number; nationId: number; userId: string; voteId: number } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
|
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
@@ -49,7 +50,9 @@ test.beforeAll(async () => {
|
|||||||
]);
|
]);
|
||||||
const nationId = (nationMax._max.id ?? 0) + 10_000;
|
const nationId = (nationMax._max.id ?? 0) + 10_000;
|
||||||
const generalId = (generalMax._max.id ?? 0) + 10_000;
|
const generalId = (generalMax._max.id ?? 0) + 10_000;
|
||||||
|
const freeGeneralId = generalId + 1;
|
||||||
const userId = `main-front-status-${randomUUID()}`;
|
const userId = `main-front-status-${randomUUID()}`;
|
||||||
|
const freeUserId = `main-front-status-free-${randomUUID()}`;
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO nation (id, name, color, meta)
|
INSERT INTO nation (id, name, color, meta)
|
||||||
@@ -64,19 +67,35 @@ test.beforeAll(async () => {
|
|||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time)
|
INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time)
|
||||||
VALUES (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()})
|
VALUES
|
||||||
|
(${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()}),
|
||||||
|
(${freeGeneralId}, ${freeUserId}, ${'재야행동검증장수'}, ${0}, ${0}, ${new Date()})
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
await prisma.generalAccessLog.create({
|
const actionAt = new Date();
|
||||||
data: {
|
await prisma.generalAccessLog.createMany({
|
||||||
generalId,
|
data: [
|
||||||
userId,
|
{
|
||||||
lastRefresh: new Date(),
|
generalId,
|
||||||
refresh: 1,
|
userId,
|
||||||
refreshTotal: 1,
|
lastRefresh: actionAt,
|
||||||
refreshScore: 1,
|
lastActionAt: actionAt,
|
||||||
refreshScoreTotal: 1,
|
refresh: 1,
|
||||||
},
|
refreshTotal: 1,
|
||||||
|
refreshScore: 1,
|
||||||
|
refreshScoreTotal: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
generalId: freeGeneralId,
|
||||||
|
userId: freeUserId,
|
||||||
|
lastRefresh: actionAt,
|
||||||
|
lastActionAt: actionAt,
|
||||||
|
refresh: 1,
|
||||||
|
refreshTotal: 1,
|
||||||
|
refreshScore: 1,
|
||||||
|
refreshScoreTotal: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
const poll = await prisma.votePoll.create({
|
const poll = await prisma.votePoll.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -92,7 +111,7 @@ test.beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
fixture = { generalId, nationId, userId, voteId: poll.id };
|
fixture = { generalId, freeGeneralId, nationId, userId, voteId: poll.id };
|
||||||
|
|
||||||
const issuedAt = new Date();
|
const issuedAt = new Date();
|
||||||
await redis.client.set(
|
await redis.client.set(
|
||||||
@@ -118,14 +137,15 @@ test.beforeAll(async () => {
|
|||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
if (fixture) {
|
if (fixture) {
|
||||||
await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } });
|
await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } });
|
||||||
await prisma.generalAccessLog.deleteMany({ where: { generalId: fixture.generalId } });
|
const generalIds = [fixture.generalId, fixture.freeGeneralId];
|
||||||
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM general WHERE id = ${fixture.generalId}`);
|
await prisma.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||||
|
await prisma.general.deleteMany({ where: { id: { in: generalIds } } });
|
||||||
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`);
|
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`);
|
||||||
const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([
|
const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([
|
||||||
prisma.general.count({ where: { id: fixture.generalId } }),
|
prisma.general.count({ where: { id: { in: generalIds } } }),
|
||||||
prisma.nation.count({ where: { id: fixture.nationId } }),
|
prisma.nation.count({ where: { id: fixture.nationId } }),
|
||||||
prisma.votePoll.count({ where: { id: fixture.voteId } }),
|
prisma.votePoll.count({ where: { id: fixture.voteId } }),
|
||||||
prisma.generalAccessLog.count({ where: { generalId: fixture.generalId } }),
|
prisma.generalAccessLog.count({ where: { generalId: { in: generalIds } } }),
|
||||||
]);
|
]);
|
||||||
expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]);
|
expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]);
|
||||||
}
|
}
|
||||||
@@ -146,6 +166,33 @@ const installMainFixture = async (
|
|||||||
},
|
},
|
||||||
failStatus: () => boolean
|
failStatus: () => boolean
|
||||||
) => {
|
) => {
|
||||||
|
const generalContext = {
|
||||||
|
general: {
|
||||||
|
id: fixture?.generalId ?? 1,
|
||||||
|
name: '현황검증장수',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: fixture?.nationId ?? 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
stats: { leadership: 55, strength: 55, intelligence: 55 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
injury: 0,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
city: null,
|
||||||
|
nation: null,
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ token }) => {
|
({ token }) => {
|
||||||
window.localStorage.setItem('sammo-game-token', token);
|
window.localStorage.setItem('sammo-game-token', token);
|
||||||
@@ -155,35 +202,28 @@ const installMainFixture = async (
|
|||||||
);
|
);
|
||||||
await page.route('**/che/api/trpc/**', async (route) => {
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
const results = operationNames(route).map((operation) => {
|
const results = operationNames(route).map((operation) => {
|
||||||
if (operation === 'general.me') {
|
if (operation === 'dashboard.getContextBundleDelta') {
|
||||||
return response({
|
return response({
|
||||||
general: {
|
context: {
|
||||||
id: fixture?.generalId ?? 1,
|
kind: 'snapshot',
|
||||||
name: '현황검증장수',
|
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||||
npcState: 0,
|
data: generalContext,
|
||||||
nationId: fixture?.nationId ?? 1,
|
},
|
||||||
cityId: 1,
|
commandTable: {
|
||||||
troopId: 0,
|
kind: 'snapshot',
|
||||||
picture: null,
|
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||||||
imageServer: 0,
|
data: { general: [], nation: [] },
|
||||||
officerLevel: 0,
|
},
|
||||||
stats: { leadership: 55, strength: 55, intelligence: 55 },
|
boardAccess: {
|
||||||
gold: 1000,
|
kind: 'snapshot',
|
||||||
rice: 1000,
|
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
|
||||||
crew: 0,
|
data: { canMeeting: false, canSecret: false, permission: 0 },
|
||||||
train: 0,
|
|
||||||
atmos: 0,
|
|
||||||
injury: 0,
|
|
||||||
experience: 0,
|
|
||||||
dedication: 0,
|
|
||||||
items: { horse: null, weapon: null, book: null, item: null },
|
|
||||||
},
|
},
|
||||||
city: null,
|
|
||||||
nation: null,
|
|
||||||
settings: {},
|
|
||||||
penalties: {},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'general.me') {
|
||||||
|
return response(generalContext);
|
||||||
|
}
|
||||||
if (operation === 'general.getFrontStatus') {
|
if (operation === 'general.getFrontStatus') {
|
||||||
return failStatus()
|
return failStatus()
|
||||||
? errorResponse(operation, '상단 현황을 불러오지 못했습니다.')
|
? errorResponse(operation, '상단 현황을 불러오지 못했습니다.')
|
||||||
@@ -214,6 +254,15 @@ const installMainFixture = async (
|
|||||||
levelMap: { 7: '수도' },
|
levelMap: { 7: '수도' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'world.getState') {
|
||||||
|
return response({
|
||||||
|
currentYear: 190,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 60,
|
||||||
|
config: { npcMode: 0, const: {}, environment: {} },
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (operation === 'world.getMap') {
|
if (operation === 'world.getMap') {
|
||||||
return response({
|
return response({
|
||||||
year: 190,
|
year: 190,
|
||||||
@@ -280,6 +329,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
).result.data;
|
).result.data;
|
||||||
|
expect(liveStatus.onlineUserCount).toBe(2);
|
||||||
|
expect(liveStatus.onlineNations).toContain('【검증국】');
|
||||||
|
expect(liveStatus.onlineNations).not.toContain('재야');
|
||||||
expect(liveStatus.onlineGenerals).toContain('현황검증장수');
|
expect(liveStatus.onlineGenerals).toContain('현황검증장수');
|
||||||
expect(liveStatus.nationNotice).toContain(marker);
|
expect(liveStatus.nationNotice).toContain(marker);
|
||||||
expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false });
|
expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false });
|
||||||
@@ -337,7 +389,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
|
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
|
||||||
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
|
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
|
||||||
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
|
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
|
||||||
expect(desktop.backgroundImage).toContain('/image/game/back_walnut.jpg');
|
expect(desktop.backgroundImage).toContain('/game/back_walnut.jpg');
|
||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
await expect(status).toHaveCSS('width', '500px');
|
await expect(status).toHaveCSS('width', '500px');
|
||||||
@@ -349,7 +401,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0);
|
for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0);
|
||||||
|
|
||||||
failStatus = true;
|
failStatus = true;
|
||||||
await page.getByRole('button', { name: '새로고침', exact: true }).click();
|
await page.getByRole('button', { name: '갱 신', exact: true }).click();
|
||||||
await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible();
|
await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible();
|
||||||
await expect(status).toContainText(marker);
|
await expect(status).toContainText(marker);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user