diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 39620b2f1..044a12378 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -26,6 +26,8 @@ paths: E2E files: **must** use `.spec.ts` extension (`.test.ts` not detected). +Route unit tests: `src/routes/**/*.test.ts` is included by `vite.config.ts`. **Never use `+` as a filename prefix** — SvelteKit reserves it and `pnpm check` will error. Name route test files `page_server.test.ts`, not `+page.server.test.ts`. + ## Unit Testing Patterns ### Assertions @@ -131,6 +133,19 @@ test('uses override map when entry exists', () => { }); ``` +### Route load() Unit Tests + +`load` in `+page.server.ts` is a plain async function — call it directly with a mock event. Pass `setHeaders` as a `vi.fn()` spy to assert whether and how headers are set. What unit tests **cannot** verify: whether the header actually reaches the wire, or that `Set-Cookie` is absent (auth mocks bypass that) — cover those in E2E. + +```typescript +const createMockEvent = ({ session = null } = {}) => + ({ + locals: { auth: { validate: vi.fn().mockResolvedValue(session) } }, + url: { searchParams: { get: vi.fn().mockReturnValue(null) } }, + setHeaders: vi.fn(), + }) as unknown as Parameters[0] & { setHeaders: ReturnType }; +``` + ### Test Stubs Parameter types **must match** production signature — use domain types (`TaskGrade`), not `string`. Mismatch compiles silently but breaks type safety. diff --git a/e2e/problems_cache.spec.ts b/e2e/problems_cache.spec.ts new file mode 100644 index 000000000..de61c5c9f --- /dev/null +++ b/e2e/problems_cache.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; + +import { loginAsUser } from './helpers/auth'; + +test.describe('anonymous /problems response', () => { + test('is cache-eligible (cache-control set, no set-cookie)', async ({ page }) => { + const response = await page.goto('/problems'); + + if (!response) { + throw new Error('No response received from /problems'); + } + + const headers = response.headers(); + + // Local dev server (pnpm preview) is not behind Vercel edge, so s-maxage is visible. + expect(headers['cache-control']).toContain('public'); + expect(headers['cache-control']).toContain('max-age=0'); + expect(headers['cache-control']).toContain('s-maxage=300'); + expect(headers['cache-control']).toContain('stale-while-revalidate=600'); + + // set-cookie makes a response ineligible for CDN caching. + // Lucia must not attach a session cookie to anonymous requests. + // If this assertion fails after a Lucia upgrade, verify it does not + // set cookies on unauthenticated requests. + expect(headers['set-cookie']).toBeUndefined(); + }); +}); + +test.describe('logged-in /problems response', () => { + test('is not shared-cached (no CDN cache-control directive)', async ({ page }) => { + await loginAsUser(page); + const response = await page.goto('/problems'); + + if (!response) { + throw new Error('No response received from /problems'); + } + + const headers = response.headers(); + + // Personalized responses must never be shared-cached. + expect(headers['cache-control'] ?? '').not.toContain('public'); + expect(headers['cache-control'] ?? '').not.toContain('s-maxage'); + }); +}); diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index 7c237cca6..901f537dc 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -12,7 +12,7 @@ import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema'; import { BAD_REQUEST } from '$lib/constants/http-response-status-codes'; // 一覧表ページは、ログインしていなくても閲覧できるようにする -export async function load({ locals, url }) { +export async function load({ locals, url, setHeaders }) { const session = await locals.auth.validate(); const params = await url.searchParams; @@ -23,12 +23,24 @@ export async function load({ locals, url }) { // Degrade gracefully if vote stats are unavailable — the problems page must remain accessible. let voteResults = new Map(); + let voteStatsOk = true; + try { voteResults = await getVoteGradeStatistics(); } catch (error) { + voteStatsOk = false; console.error('Failed to load vote statistics:', error); } + // Anonymous responses are identical for all users and contain no per-user + // answer state, so they are safe to cache at the CDN. Logged-in responses + // are personalized and must never be shared-cached. + // Skip caching a degraded response (vote stats failed) to avoid pinning a + // broken page at the edge for the full TTL. + if (session === null && voteStatsOk) { + setHeaders({ 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); + } + if (tagIds != null) { return { taskResults: (await task_crud.getTasksWithTagIds( diff --git a/src/routes/problems/page_server.test.ts b/src/routes/problems/page_server.test.ts new file mode 100644 index 000000000..28ee6d845 --- /dev/null +++ b/src/routes/problems/page_server.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { Roles } from '$lib/types/user'; + +vi.mock('$features/votes/services/vote_statistics', () => ({ + getVoteGradeStatistics: vi.fn(), +})); + +vi.mock('$lib/services/task_results', () => ({ + getTaskResults: vi.fn(), + getTasksWithTagIds: vi.fn(), +})); + +import * as voteStatsModule from '$features/votes/services/vote_statistics'; +import * as taskCrud from '$lib/services/task_results'; +import { load } from './+page.server'; + +const mockGetVoteGradeStatistics = vi.mocked(voteStatsModule.getVoteGradeStatistics); +const mockGetTaskResults = vi.mocked(taskCrud.getTaskResults); +const mockGetTasksWithTagIds = vi.mocked(taskCrud.getTasksWithTagIds); + +type MockSession = { user: { userId: string; username: string; role: Roles } } | null; + +const createMockEvent = ({ + session = null, + tagIds = null, +}: { + session?: MockSession; + tagIds?: string | null; +} = {}) => { + const setHeaders = vi.fn(); + const locals = { + auth: { validate: vi.fn().mockResolvedValue(session) }, + user: session + ? { + id: session.user.userId, + name: session.user.username, + role: session.user.role, + atcoder_name: '', + is_validated: false, + } + : undefined, + }; + const url = { searchParams: { get: vi.fn().mockReturnValue(tagIds) } }; + + return { locals, url, setHeaders } as unknown as Parameters[0] & { + setHeaders: ReturnType; + }; +}; + +const LOGGED_IN_SESSION: MockSession = { + user: { userId: 'user-abc123', username: 'testuser', role: Roles.USER }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetTaskResults.mockResolvedValue([]); + mockGetTasksWithTagIds.mockResolvedValue([]); + mockGetVoteGradeStatistics.mockResolvedValue(new Map()); +}); + +describe('load() cache-control behaviour', () => { + describe('sets cache-control', () => { + test('anonymous users get a public shared-cache header when vote stats succeed', async () => { + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).toHaveBeenCalledOnce(); + const headerArg = event.setHeaders.mock.calls[0][0] as Record; + expect(headerArg['Cache-Control']).toBe( + 'public, max-age=0, s-maxage=300, stale-while-revalidate=600', + ); + }); + + test('anonymous users with tagIds also get a public shared-cache header', async () => { + const event = createMockEvent({ session: null, tagIds: 'abc,dp' }); + + await load(event); + + expect(event.setHeaders).toHaveBeenCalledOnce(); + const headerArg = event.setHeaders.mock.calls[0][0] as Record; + expect(headerArg['Cache-Control']).toBe( + 'public, max-age=0, s-maxage=300, stale-while-revalidate=600', + ); + }); + }); + + describe('does not set cache-control', () => { + test('logged-in users — personalized response must never be shared-cached', async () => { + const event = createMockEvent({ session: LOGGED_IN_SESSION }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + + test('degraded response when vote stats fail — avoids pinning a broken page at the CDN', async () => { + mockGetVoteGradeStatistics.mockRejectedValue(new Error('DB timeout')); + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + }); +});