fix: 실제 행동 기준으로 접속 중 국가를 집계
This commit is contained in:
@@ -427,9 +427,7 @@ integration('general access tracking persistence', () => {
|
||||
if (!initialContext?.sourceRevision) {
|
||||
throw new Error('dashboard snapshot did not include its source revision');
|
||||
}
|
||||
await expect(
|
||||
db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toBeNull();
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
|
||||
expect(redisEval).toHaveBeenCalledTimes(1);
|
||||
|
||||
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
|
||||
@@ -482,15 +480,22 @@ integration('general access tracking persistence', () => {
|
||||
).resolves.toMatchObject({
|
||||
refresh: 2,
|
||||
refreshTotal: 2,
|
||||
lastActionAt: null,
|
||||
});
|
||||
|
||||
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
const completedActionAccess = await db.generalAccessLog.findUniqueOrThrow({
|
||||
where: { generalId: endpointGeneralId },
|
||||
});
|
||||
expect(completedActionAccess).toMatchObject({
|
||||
refresh: 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({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -501,6 +506,7 @@ integration('general access tracking persistence', () => {
|
||||
).resolves.toMatchObject({
|
||||
refresh: 3,
|
||||
refreshTotal: 3,
|
||||
lastActionAt: completedActionAccess.lastActionAt,
|
||||
});
|
||||
|
||||
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: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
{ id: 10, name: '재야장수', nationId: 0 },
|
||||
]),
|
||||
},
|
||||
worldState: {
|
||||
@@ -46,7 +47,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
|
||||
})),
|
||||
},
|
||||
generalAccessLog: {
|
||||
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]),
|
||||
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }, { generalId: 10 }]),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
@@ -81,7 +82,7 @@ describe('general.getFrontStatus', () => {
|
||||
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 caller = appRouter.createCaller(context);
|
||||
|
||||
@@ -89,7 +90,7 @@ describe('general.getFrontStatus', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: 'che_260819_front',
|
||||
onlineUserCount: 3,
|
||||
onlineUserCount: 4,
|
||||
onlineNations: '【촉】, 【위】',
|
||||
onlineGenerals: '유비, 관우',
|
||||
nationNotice: '<p>북벌 준비</p>',
|
||||
@@ -102,7 +103,7 @@ describe('general.getFrontStatus', () => {
|
||||
});
|
||||
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
lastRefresh: {
|
||||
lastActionAt: {
|
||||
gte: new Date('2026-07-26T10:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user