대시보드 벌점을 read-model source와 outbox에서 제외하고 수동 projection의 가점을 Redis에 합산한다. 5초 worker가 월별 traffic과 접속 점수를 멱등 PostgreSQL batch로 반영하며 메인 제한 gate는 Redis TTL marker만 조회한다.
141 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|