Track traffic by game month
This commit is contained in:
@@ -6,30 +6,54 @@ import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 9_980_071;
|
||||
const secondGeneralId = generalId + 1;
|
||||
const rollbackGeneralId = generalId + 2;
|
||||
const scenarioCode = `traffic-period-${generalId}`;
|
||||
|
||||
integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await db.generalAccessLog.deleteMany({
|
||||
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode,
|
||||
currentYear: 185,
|
||||
currentMonth: 3,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
worldStateId = world.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await db.generalAccessLog.deleteMany({
|
||||
where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } },
|
||||
});
|
||||
await db.worldState.deleteMany({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
||||
it('atomically increments one game-month bucket and opens a new bucket at the next month', async () => {
|
||||
const firstWindow = {
|
||||
worldStateId,
|
||||
year: 185,
|
||||
month: 3,
|
||||
generalId,
|
||||
userId: 'access-user-a',
|
||||
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
periodStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
};
|
||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||
@@ -50,13 +74,37 @@ integration('general access tracking persistence', () => {
|
||||
refreshScore: 22,
|
||||
refreshScoreTotal: 22,
|
||||
});
|
||||
const firstPeriod = await db.trafficPeriod.findUniqueOrThrow({
|
||||
where: {
|
||||
worldStateId_year_month: {
|
||||
worldStateId,
|
||||
year: 185,
|
||||
month: 3,
|
||||
},
|
||||
},
|
||||
include: { generals: { orderBy: { generalId: 'asc' } } },
|
||||
});
|
||||
expect(firstPeriod).toMatchObject({
|
||||
refresh: 22,
|
||||
online: 1,
|
||||
generals: [
|
||||
{
|
||||
generalId,
|
||||
userId: 'access-user-a',
|
||||
refresh: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await upsertGeneralAccess(db, {
|
||||
worldStateId,
|
||||
year: 185,
|
||||
month: 4,
|
||||
generalId,
|
||||
userId: 'access-user-b',
|
||||
now: new Date('2026-07-27T00:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
now: new Date('2026-07-26T03:15:00.000Z'),
|
||||
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
@@ -67,5 +115,89 @@ integration('general access tracking persistence', () => {
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 23,
|
||||
});
|
||||
|
||||
await upsertGeneralAccess(db, {
|
||||
worldStateId,
|
||||
year: 185,
|
||||
month: 4,
|
||||
generalId: secondGeneralId,
|
||||
userId: 'access-user-c',
|
||||
now: new Date('2026-07-26T03:15:01.000Z'),
|
||||
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
weight: 1,
|
||||
});
|
||||
await expect(
|
||||
db.trafficPeriod.findUniqueOrThrow({
|
||||
where: {
|
||||
worldStateId_year_month: {
|
||||
worldStateId,
|
||||
year: 185,
|
||||
month: 4,
|
||||
},
|
||||
},
|
||||
include: { generals: { orderBy: { generalId: 'asc' } } },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
refresh: 2,
|
||||
online: 2,
|
||||
generals: [
|
||||
{
|
||||
generalId,
|
||||
userId: 'access-user-b',
|
||||
refresh: 1,
|
||||
},
|
||||
{
|
||||
generalId: secondGeneralId,
|
||||
userId: 'access-user-c',
|
||||
refresh: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back the period and member rows when the legacy access update fails', async () => {
|
||||
await db.generalAccessLog.create({
|
||||
data: {
|
||||
generalId: rollbackGeneralId,
|
||||
userId: 'rollback-user',
|
||||
lastRefresh: new Date('2026-07-26T03:19:00.000Z'),
|
||||
refresh: 1,
|
||||
refreshTotal: 2_147_483_647,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
upsertGeneralAccess(db, {
|
||||
worldStateId,
|
||||
year: 186,
|
||||
month: 1,
|
||||
generalId: rollbackGeneralId,
|
||||
userId: 'rollback-user',
|
||||
now: new Date('2026-07-26T03:20:00.000Z'),
|
||||
periodStartedAt: new Date('2026-07-26T03:20:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:20:00.000Z'),
|
||||
weight: 1,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
db.trafficPeriod.findUnique({
|
||||
where: {
|
||||
worldStateId_year_month: {
|
||||
worldStateId,
|
||||
year: 186,
|
||||
month: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: rollbackGeneralId } })
|
||||
).resolves.toMatchObject({
|
||||
refreshTotal: 2_147_483_647,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,17 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
|
||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
|
||||
const transaction = vi.fn(
|
||||
async (
|
||||
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
|
||||
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
|
||||
);
|
||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||
const findWorld = vi.fn(async () => ({
|
||||
id: 3,
|
||||
currentYear: 185,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
meta: {
|
||||
opentime: '2026-07-25T00:00:00.000Z',
|
||||
@@ -31,11 +40,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
},
|
||||
}));
|
||||
const db = {
|
||||
$executeRaw: executeRaw,
|
||||
$transaction: transaction,
|
||||
general: { findFirst: findGeneral },
|
||||
worldState: { findFirst: findWorld },
|
||||
} as unknown as DatabaseClient;
|
||||
return { db, executeRaw, findGeneral, findWorld };
|
||||
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
|
||||
};
|
||||
|
||||
describe('general access tracking', () => {
|
||||
@@ -44,19 +53,19 @@ describe('general access tracking', () => {
|
||||
expect(accessPageWeights['general-list']).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves the UTC day and latest processed turn windows', () => {
|
||||
it('uses the latest processed game turn as the traffic period and score window', () => {
|
||||
expect(
|
||||
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||
const { db, executeRaw, findGeneral } = buildDb();
|
||||
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||
@@ -65,22 +74,28 @@ describe('general access tracking', () => {
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
|
||||
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(statement.values).toEqual([
|
||||
7,
|
||||
'user-7',
|
||||
now,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
new Date('2026-07-26T00:00:00.000Z'),
|
||||
new Date('2026-07-26T03:00:00.000Z'),
|
||||
]);
|
||||
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
|
||||
expect(periodStatement.sql).toContain('ON CONFLICT (world_state_id, year, month) DO UPDATE');
|
||||
expect(periodStatement.values).toEqual([3, 185, 4, new Date('2026-07-26T03:00:00.000Z'), now, 2]);
|
||||
|
||||
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
|
||||
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
|
||||
expect(memberStatement.sql).toContain('SET online = traffic_period.online');
|
||||
|
||||
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
||||
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(accessStatement.values).toContain(7);
|
||||
expect(accessStatement.values).toContain('user-7');
|
||||
expect(accessStatement.values).toContain(2);
|
||||
expect(accessStatement.values).toContain(now);
|
||||
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||
@@ -96,10 +111,10 @@ describe('general access tracking', () => {
|
||||
await expect(
|
||||
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||
).resolves.toBe(false);
|
||||
expect(future.executeRaw).not.toHaveBeenCalled();
|
||||
expect(future.transaction).not.toHaveBeenCalled();
|
||||
|
||||
const united = buildDb({ isUnited: 2 });
|
||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(united.executeRaw).not.toHaveBeenCalled();
|
||||
expect(united.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,17 +44,50 @@ const buildContext = (): GameApiContext => {
|
||||
generalAccessLog: {
|
||||
aggregate: async () => ({
|
||||
_sum: {
|
||||
refresh: 12,
|
||||
refreshScoreTotal: 21,
|
||||
},
|
||||
}),
|
||||
count: async (args: { where: { lastRefresh: { gte: Date } } }) => {
|
||||
expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z'));
|
||||
return 2;
|
||||
},
|
||||
findMany: async () => [
|
||||
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 },
|
||||
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 },
|
||||
{ generalId: 7, refreshScoreTotal: 15 },
|
||||
{ generalId: 8, refreshScoreTotal: 6 },
|
||||
],
|
||||
},
|
||||
trafficPeriod: {
|
||||
findUnique: async () => ({
|
||||
id: 12,
|
||||
worldStateId: 1,
|
||||
year: 185,
|
||||
month: 3,
|
||||
startedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
|
||||
refresh: 12,
|
||||
online: 0,
|
||||
_count: { generals: 2 },
|
||||
}),
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 11,
|
||||
worldStateId: 1,
|
||||
year: 185,
|
||||
month: 2,
|
||||
startedAt: new Date('2026-07-26T02:50:00.000Z'),
|
||||
lastRefresh: new Date('2026-07-26T02:50:00.000Z'),
|
||||
refresh: 30,
|
||||
online: 5,
|
||||
_count: { generals: 0 },
|
||||
},
|
||||
],
|
||||
aggregate: async () => ({
|
||||
_max: {
|
||||
refresh: 30,
|
||||
online: 5,
|
||||
},
|
||||
}),
|
||||
},
|
||||
trafficPeriodGeneral: {
|
||||
findMany: async () => [
|
||||
{ generalId: 7, refresh: 9 },
|
||||
{ generalId: 8, refresh: 3 },
|
||||
],
|
||||
},
|
||||
general: {
|
||||
@@ -95,13 +128,14 @@ describe('public.getTraffic', () => {
|
||||
month: 2,
|
||||
refresh: 30,
|
||||
online: 5,
|
||||
date: '2026-07-26 02:50:00',
|
||||
date: '2026-07-26T02:50:00.000Z',
|
||||
});
|
||||
expect(result.history[1]).toMatchObject({
|
||||
year: 185,
|
||||
month: 3,
|
||||
refresh: 12,
|
||||
online: 2,
|
||||
date: '2026-07-26T03:05:00.000Z',
|
||||
});
|
||||
expect(result.maxRefresh).toBe(30);
|
||||
expect(result.maxOnline).toBe(5);
|
||||
|
||||
Reference in New Issue
Block a user