diff --git a/src/features/votes/server/cache.test.ts b/src/features/votes/server/cache.test.ts index adf2a92f3..7775076ca 100644 --- a/src/features/votes/server/cache.test.ts +++ b/src/features/votes/server/cache.test.ts @@ -2,7 +2,26 @@ import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from 'vit import type { VotedGradeStatistics } from '@prisma/client'; import { TaskGrade } from '$lib/types/task'; -import { getCachedVoteStats, invalidateVoteCaches, disposeVoteCaches } from './cache'; +import type { TaskWithVoteInfo } from '$features/votes/services/vote_statistics'; +import { + getCachedVoteStats, + getCachedAllTasksWithVoteInfo, + invalidateVoteCaches, + disposeVoteCaches, +} from './cache'; + +const makeTasksWithVoteInfo = (): TaskWithVoteInfo[] => [ + { + task_id: 'abc408_d', + contest_id: 'abc408', + title: 'D - Flip Cards', + grade: TaskGrade.PENDING, + task_table_index: 'D', + estimatedGrade: TaskGrade.Q1, + voteTotal: 12, + }, +]; +const mockTasksFn = () => vi.fn().mockResolvedValue(makeTasksWithVoteInfo()); const makeStats = (): Map => new Map([ @@ -41,6 +60,26 @@ describe('getCachedVoteStats', () => { }); }); +describe('getCachedAllTasksWithVoteInfo', () => { + beforeEach(() => invalidateVoteCaches()); + afterEach(() => vi.restoreAllMocks()); + + test('delegates to fetchFn and returns fetched value', async () => { + const fetchFn = mockTasksFn(); + const result = await getCachedAllTasksWithVoteInfo(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result[0].task_id).toBe('abc408_d'); + expect(result[0].voteTotal).toBe(12); + }); + + test('returns cached value on subsequent calls', async () => { + const fetchFn = mockTasksFn(); + await getCachedAllTasksWithVoteInfo(fetchFn); + await getCachedAllTasksWithVoteInfo(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + describe('invalidateVoteCaches', () => { beforeEach(() => invalidateVoteCaches()); afterEach(() => vi.restoreAllMocks()); @@ -52,4 +91,12 @@ describe('invalidateVoteCaches', () => { await getCachedVoteStats(fetchFn); expect(fetchFn).toHaveBeenCalledTimes(2); }); + + test('clears all tasks with vote info cache', async () => { + const fetchFn = mockTasksFn(); + await getCachedAllTasksWithVoteInfo(fetchFn); + invalidateVoteCaches(); + await getCachedAllTasksWithVoteInfo(fetchFn); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/features/votes/server/cache.ts b/src/features/votes/server/cache.ts index 1b82831fe..ebecf1030 100644 --- a/src/features/votes/server/cache.ts +++ b/src/features/votes/server/cache.ts @@ -1,21 +1,34 @@ import { Cache } from '$lib/clients/cache'; + import type { VotedGradeStatistics } from '@prisma/client'; +import type { TaskWithVoteInfo } from '$features/votes/services/vote_statistics'; + const VOTE_STATS_TTL_MS = 10 * 60 * 1000; -const KEY = 'vote_grade_statistics'; +const VOTE_STATS_KEY = 'vote_grade_statistics'; +const ALL_TASKS_WITH_VOTE_INFO_KEY = 'all_tasks_with_vote_info'; -const cache = new Cache>(VOTE_STATS_TTL_MS); +const voteStatsCache = new Cache>(VOTE_STATS_TTL_MS); +const allTasksWithVoteInfoCache = new Cache(VOTE_STATS_TTL_MS); export function getCachedVoteStats( fetchFn: () => Promise>, ): Promise> { - return cache.getOrFetch(KEY, fetchFn); + return voteStatsCache.getOrFetch(VOTE_STATS_KEY, fetchFn); +} + +export function getCachedAllTasksWithVoteInfo( + fetchFn: () => Promise, +): Promise { + return allTasksWithVoteInfoCache.getOrFetch(ALL_TASKS_WITH_VOTE_INFO_KEY, fetchFn); } export function invalidateVoteCaches(): void { - cache.delete(KEY); + voteStatsCache.delete(VOTE_STATS_KEY); + allTasksWithVoteInfoCache.delete(ALL_TASKS_WITH_VOTE_INFO_KEY); } export function disposeVoteCaches(): void { - cache.dispose(); + voteStatsCache.dispose(); + allTasksWithVoteInfoCache.dispose(); } diff --git a/src/features/votes/services/vote_statistics.test.ts b/src/features/votes/services/vote_statistics.test.ts index 22c6b51dd..729dddeb2 100644 --- a/src/features/votes/services/vote_statistics.test.ts +++ b/src/features/votes/services/vote_statistics.test.ts @@ -32,6 +32,7 @@ vi.mock('$lib/server/database', () => ({ vi.mock('$features/votes/server/cache', () => ({ getCachedVoteStats: (fetchFn: () => Promise) => fetchFn(), + getCachedAllTasksWithVoteInfo: (fetchFn: () => Promise) => fetchFn(), })); import prisma from '$lib/server/database'; diff --git a/src/features/votes/services/vote_statistics.ts b/src/features/votes/services/vote_statistics.ts index 83898ca62..60db5299f 100644 --- a/src/features/votes/services/vote_statistics.ts +++ b/src/features/votes/services/vote_statistics.ts @@ -1,5 +1,5 @@ import { default as prisma } from '$lib/server/database'; -import { getCachedVoteStats } from '$features/votes/server/cache'; +import { getCachedVoteStats, getCachedAllTasksWithVoteInfo } from '$features/votes/server/cache'; import type { VotedGradeStatistics, VotedGradeCounter, TaskGrade } from '@prisma/client'; @@ -37,6 +37,10 @@ export async function getVoteGradeStatisticsForTaskIds( } export async function getAllTasksWithVoteInfo(): Promise { + return getCachedAllTasksWithVoteInfo(fetchAllTasksWithVoteInfo); +} + +async function fetchAllTasksWithVoteInfo(): Promise { const [allTasks, stats, counters] = await Promise.all([ prisma.task.findMany({ orderBy: { task_id: 'desc' } }), prisma.votedGradeStatistics.findMany(), @@ -45,6 +49,7 @@ export async function getAllTasksWithVoteInfo(): Promise { const statsMap = new Map(stats.map((s) => [s.taskId, s])); const totalsMap = new Map(); + for (const c of counters) { totalsMap.set(c.taskId, (totalsMap.get(c.taskId) ?? 0) + c.count); } diff --git a/src/lib/services/tasks.ts b/src/lib/services/tasks.ts index e4ddc99df..81a8017e6 100644 --- a/src/lib/services/tasks.ts +++ b/src/lib/services/tasks.ts @@ -16,6 +16,8 @@ import { getCachedMergedTasksMap, invalidateTaskCaches, } from '$lib/server/tasks/cache'; +import { invalidateVoteCaches } from '$features/votes/server/cache'; + import { classifyContest } from '$lib/utils/contest'; import { createContestTaskPairKey } from '$lib/utils/contest_task_pair'; @@ -189,6 +191,7 @@ export async function createTask( }); invalidateTaskCaches(); + invalidateVoteCaches(); console.log(task); } @@ -210,6 +213,7 @@ export async function updateTask(task_id: string, task_grade: TaskGrade): Promis }); invalidateTaskCaches(); + invalidateVoteCaches(); console.log(task); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { diff --git a/src/routes/votes/+page.server.ts b/src/routes/votes/+page.server.ts index 8633f1238..7a3b551b1 100644 --- a/src/routes/votes/+page.server.ts +++ b/src/routes/votes/+page.server.ts @@ -1,9 +1,25 @@ import type { PageServerLoad } from './$types'; -import { getAllTasksWithVoteInfo } from '$features/votes/services/vote_statistics'; +import { + getAllTasksWithVoteInfo, + type TaskWithVoteInfo, +} from '$features/votes/services/vote_statistics'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, setHeaders }) => { const session = await locals.auth.validate(); - const tasks = await getAllTasksWithVoteInfo(); + + let tasks: TaskWithVoteInfo[] = []; + let fetchFailed = false; + + try { + tasks = await getAllTasksWithVoteInfo(); + } catch (error) { + fetchFailed = true; + console.error('Failed to load tasks with vote info:', error); + } + + if (session === null && !fetchFailed) { + setHeaders({ 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); + } return { tasks, diff --git a/src/routes/votes/page_server.test.ts b/src/routes/votes/page_server.test.ts new file mode 100644 index 000000000..b7d9a1767 --- /dev/null +++ b/src/routes/votes/page_server.test.ts @@ -0,0 +1,104 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { Roles } from '$lib/types/user'; + +vi.mock('$features/votes/services/vote_statistics', () => ({ + getAllTasksWithVoteInfo: vi.fn(), +})); + +import * as voteStatsModule from '$features/votes/services/vote_statistics'; +import { load } from './+page.server'; + +const mockGetAllTasksWithVoteInfo = vi.mocked(voteStatsModule.getAllTasksWithVoteInfo); + +type MockSession = { user: { userId: string; username: string; role: Roles } } | null; + +const createMockEvent = ({ session = null }: { session?: MockSession } = {}) => { + const setHeaders = vi.fn(); + const locals = { + auth: { validate: vi.fn().mockResolvedValue(session) }, + }; + + return { locals, 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(); + mockGetAllTasksWithVoteInfo.mockResolvedValue([]); +}); + +describe('load() cache-control behaviour', () => { + describe('sets cache-control', () => { + test('anonymous users get a public shared-cache header when data fetch succeeds', 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', + ); + }); + }); + + 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 data fetch fails — avoids pinning a broken page at the CDN', async () => { + mockGetAllTasksWithVoteInfo.mockRejectedValue(new Error('DB timeout')); + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + }); +}); + +describe('load() return data', () => { + test('returns tasks and isLoggedIn for anonymous users', async () => { + mockGetAllTasksWithVoteInfo.mockResolvedValue([]); + const event = createMockEvent({ session: null }); + + const result = await load(event); + + expect(result).toBeDefined(); + expect(result!.tasks).toEqual([]); + expect(result!.isLoggedIn).toBe(false); + }); + + test('returns tasks and isLoggedIn for logged-in users', async () => { + mockGetAllTasksWithVoteInfo.mockResolvedValue([]); + const event = createMockEvent({ session: LOGGED_IN_SESSION }); + + const result = await load(event); + + expect(result).toBeDefined(); + expect(result!.tasks).toEqual([]); + expect(result!.isLoggedIn).toBe(true); + }); + + test('returns empty tasks array when data fetch fails (degraded)', async () => { + mockGetAllTasksWithVoteInfo.mockRejectedValue(new Error('DB timeout')); + const event = createMockEvent({ session: null }); + + const result = await load(event); + + expect(result).toBeDefined(); + expect(result!.tasks).toEqual([]); + expect(result!.isLoggedIn).toBe(false); + }); +}); diff --git a/src/test/lib/services/tasks.test.ts b/src/test/lib/services/tasks.test.ts index 62427f247..e9e65012e 100644 --- a/src/test/lib/services/tasks.test.ts +++ b/src/test/lib/services/tasks.test.ts @@ -19,7 +19,12 @@ vi.mock('$lib/server/tasks/cache', () => ({ invalidateTaskCaches: vi.fn(), })); +vi.mock('$features/votes/server/cache', () => ({ + invalidateVoteCaches: vi.fn(), +})); + import db from '$lib/server/database'; +import { invalidateVoteCaches } from '$features/votes/server/cache'; describe('updateTask', () => { const mockDb = db as unknown as { task: { update: ReturnType } }; @@ -44,6 +49,18 @@ describe('updateTask', () => { data: { grade: TaskGrade.Q2 }, }); }); + + test('invalidates vote caches after successful update', async () => { + mockDb.task.update.mockResolvedValue({ + id: '1', + task_id: 'abc450_d', + grade: TaskGrade.Q2, + }); + + await updateTask('abc450_d', TaskGrade.Q2); + + expect(invalidateVoteCaches).toHaveBeenCalledOnce(); + }); }); describe('error cases', () => { @@ -60,6 +77,18 @@ describe('updateTask', () => { expect(result).toBeNull(); }); + test('does not invalidate vote caches when task is not found (P2025)', async () => { + const error = new Prisma.PrismaClientKnownRequestError('Record to update not found.', { + code: 'P2025', + clientVersion: '5.0.0', + }); + mockDb.task.update.mockRejectedValue(error); + + await updateTask('nonexistent_task', TaskGrade.Q1); + + expect(invalidateVoteCaches).not.toHaveBeenCalled(); + }); + test('re-throws non-P2025 errors', async () => { const error = new Error('Database connection error'); mockDb.task.update.mockRejectedValue(error);