fix: 갱신 비용에 따라 접속 점수를 기록

revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
This commit is contained in:
2026-08-17 11:10:38 +00:00
parent a80d58f761
commit 19629fe3cc
18 changed files with 643 additions and 96 deletions
+31 -3
View File
@@ -15,6 +15,8 @@ const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
const realtimeAccessGrantHeader = 'x-sammo-realtime-access-grant';
const fixtureRealtimeAccessGrant = 'fixture-realtime-grant';
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
@@ -63,6 +65,7 @@ type NavigationFixture = {
boardAccessKind: string | null;
}>;
dashboardRequests?: DashboardBundleInput[];
dashboardGrantHeaders?: Array<string | null>;
};
type JsonPatchOperation = {
@@ -384,7 +387,13 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
}
if (operation === 'dashboard.getContextBundleDelta') {
state.generalMeCalls += 1;
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
const refreshGrant = route.request().headers()[realtimeAccessGrantHeader] ?? null;
(state.dashboardGrantHeaders ??= []).push(refreshGrant);
if (
state.accessLimitAfterCalls !== undefined &&
state.generalMeCalls > state.accessLimitAfterCalls &&
refreshGrant !== fixtureRealtimeAccessGrant
) {
return errorResponse(
operation,
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
@@ -588,7 +597,15 @@ const installRealtimeHarness = async (page: Page) => {
configurable: true,
value: (type: string, payload: unknown) => {
TestEventSource.latest?.dispatchEvent(
new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) })
new MessageEvent(type, {
data: JSON.stringify({
type,
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
? { refreshGrant: 'fixture-realtime-grant' }
: {}),
...((payload as object) ?? {}),
}),
})
);
},
});
@@ -2387,6 +2404,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
.toBe(true);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders).toContain(null);
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
@@ -2396,6 +2414,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders?.at(-1)).toBe(fixtureRealtimeAccessGrant);
await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]');
@@ -2708,7 +2727,15 @@ test('access limit stops automatic main refresh and closes realtime until a manu
.toBe(true);
const operationsBeforeLimit = state.operations.length;
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
await page.evaluate(
(invalidation) => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelInvalidated',
{ invalidation, refreshGrant: 'expired-grant' }
);
},
readModelInvalidation({ records: true, map: true })
);
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
await expect
@@ -2731,6 +2758,7 @@ test('access limit stops automatic main refresh and closes realtime until a manu
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
expect(state.dashboardGrantHeaders?.at(-1)).toBeNull();
});
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {