Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof load>[0] & { setHeaders: ReturnType<typeof vi.fn> };
```

### Test Stubs

Parameter types **must match** production signature — use domain types (`TaskGrade`), not `string`. Mismatch compiles silently but breaks type safety.
Expand Down
44 changes: 44 additions & 0 deletions e2e/problems_cache.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
14 changes: 13 additions & 1 deletion src/routes/problems/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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' });
}
Comment thread
KATO-Hiro marked this conversation as resolved.

if (tagIds != null) {
return {
taskResults: (await task_crud.getTasksWithTagIds(
Expand Down
107 changes: 107 additions & 0 deletions src/routes/problems/page_server.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof load>[0] & {
setHeaders: ReturnType<typeof vi.fn>;
};
};

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<string, string>;
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<string, string>;
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();
});
});
});
Loading