Files
core2026/app/game-api/test/deferredGeneralAccess.test.ts
T
Hide_D 5bc1e3473f perf: 접속 점수를 Redis 배치로 지연 반영
대시보드 벌점을 read-model source와 outbox에서 제외하고 수동 projection의 가점을 Redis에 합산한다. 5초 worker가 월별 traffic과 접속 점수를 멱등 PostgreSQL batch로 반영하며 메인 제한 gate는 Redis TTL marker만 조회한다.
2026-08-17 14:50:34 +00:00

141 lines
5.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
DeferredGeneralAccessWorker,
enqueueDeferredGeneralAccess,
flushDeferredGeneralAccessBatch,
getDeferredGeneralAccessLimit,
} from '../src/services/deferredGeneralAccess.js';
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
version: 1,
profile: 'hwe:default',
issuedAt: '2026-08-17T00:00:00.000Z',
expiresAt: '2026-08-18T00:00:00.000Z',
sessionId: 'deferred-access-session',
user: {
id: 'deferred-user',
username: 'deferred-user',
displayName: '지연 접속 사용자',
roles,
},
sanctions: {},
});
describe('deferred general access', () => {
it('adds a server-owned access weight with one Redis script and skips admins', async () => {
const evalScript = vi.fn(
async (_script: string, _options: { keys: string[]; arguments: string[] }) => 1
);
const now = new Date('2026-08-17T03:00:00.000Z');
await expect(
enqueueDeferredGeneralAccess({ eval: evalScript }, 'hwe:default', auth(), 7, 1, now)
).resolves.toBe(true);
expect(evalScript).toHaveBeenCalledTimes(1);
expect(evalScript.mock.calls[0]?.[1]).toMatchObject({
keys: ['sammo:game:general-access:pending:hwe:default'],
arguments: ['7', 'deferred-user', '1', String(now.getTime()), String(24 * 60 * 60 * 1_000)],
});
await expect(
enqueueDeferredGeneralAccess({ eval: evalScript }, 'hwe:default', auth(['admin']), 7, 1, now)
).resolves.toBe(false);
expect(evalScript).toHaveBeenCalledTimes(1);
});
it('reads only an unexpired Redis limit marker', async () => {
const nextAccessAt = '2026-08-17T03:10:00.000Z';
await expect(
getDeferredGeneralAccessLimit(
{ get: vi.fn(async () => JSON.stringify({ nextAccessAt })) },
'hwe:default',
auth(),
new Date('2026-08-17T03:05:00.000Z')
)
).resolves.toEqual({ nextAccessAt: new Date(nextAccessAt) });
await expect(
getDeferredGeneralAccessLimit(
{ get: vi.fn(async () => JSON.stringify({ nextAccessAt })) },
'hwe:default',
auth(),
new Date(nextAccessAt)
)
).resolves.toBeNull();
});
it('flushes an aggregated batch with one PostgreSQL write statement and no read-model journal', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [
{
generalId: 7,
userId: 'deferred-user',
refreshScore: 2,
nextAccessAt: new Date('2026-08-17T03:10:00.000Z'),
},
]);
const db = {
worldState: {
findFirst: vi.fn(async () => ({
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
meta: { lastTurnTime: '2026-08-17T03:00:00.000Z', refreshLimit: 50 },
})),
},
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => 1),
};
await expect(
flushDeferredGeneralAccessBatch(db as never, 'batch-1', [
{
generalId: 7,
userId: 'deferred-user',
weight: 2,
lastRefresh: new Date('2026-08-17T03:05:00.000Z'),
},
])
).resolves.toMatchObject({ refreshLimit: 50, states: [{ refreshScore: 2 }] });
expect(queryRaw).toHaveBeenCalledTimes(1);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql).toContain('INSERT INTO "general_access_batch"');
expect(statement.sql).toContain('jsonb_to_recordset');
expect(statement.sql).toContain('INSERT INTO "traffic_period"');
expect(statement.sql).toContain('INSERT INTO "traffic_period_general"');
expect(statement.sql).toContain('INSERT INTO "general_access_log"');
expect(statement.sql).not.toContain('read_model_revision');
expect(statement.sql).not.toContain('read_model_outbox');
});
it('drops a rotated batch when profile status is unavailable instead of penalizing it later', async () => {
const del = vi.fn(async (_key: string) => 1);
const hGetAll = vi.fn(async (_key: string) => ({}));
const redis = {
scanIterator: async function* (_options: { MATCH: string; COUNT: number }) {
yield [];
},
eval: vi.fn(async () => 1),
hGetAll,
get: vi.fn(async () => null),
set: vi.fn(async () => 'OK'),
del,
};
const worker = new DeferredGeneralAccessWorker(
{} as never,
redis,
'hwe:default',
{ get: async () => Promise.reject(new Error('gateway unavailable')) },
{ createBatchId: () => 'status-failure-batch' }
);
await worker.flushOnce();
expect(del).toHaveBeenCalledWith('sammo:game:general-access:batch:hwe:default:status-failure-batch');
expect(hGetAll).not.toHaveBeenCalled();
});
});