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
49 changes: 48 additions & 1 deletion src/features/votes/server/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, VotedGradeStatistics> =>
new Map([
Expand Down Expand Up @@ -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());
Expand All @@ -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);
});
});
23 changes: 18 additions & 5 deletions src/features/votes/server/cache.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, VotedGradeStatistics>>(VOTE_STATS_TTL_MS);
const voteStatsCache = new Cache<Map<string, VotedGradeStatistics>>(VOTE_STATS_TTL_MS);
const allTasksWithVoteInfoCache = new Cache<TaskWithVoteInfo[]>(VOTE_STATS_TTL_MS);

export function getCachedVoteStats(
fetchFn: () => Promise<Map<string, VotedGradeStatistics>>,
): Promise<Map<string, VotedGradeStatistics>> {
return cache.getOrFetch(KEY, fetchFn);
return voteStatsCache.getOrFetch(VOTE_STATS_KEY, fetchFn);
}

export function getCachedAllTasksWithVoteInfo(
fetchFn: () => Promise<TaskWithVoteInfo[]>,
): Promise<TaskWithVoteInfo[]> {
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();
}
1 change: 1 addition & 0 deletions src/features/votes/services/vote_statistics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('$lib/server/database', () => ({

vi.mock('$features/votes/server/cache', () => ({
getCachedVoteStats: (fetchFn: () => Promise<unknown>) => fetchFn(),
getCachedAllTasksWithVoteInfo: (fetchFn: () => Promise<unknown>) => fetchFn(),
}));

import prisma from '$lib/server/database';
Expand Down
7 changes: 6 additions & 1 deletion src/features/votes/services/vote_statistics.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -37,6 +37,10 @@ export async function getVoteGradeStatisticsForTaskIds(
}

export async function getAllTasksWithVoteInfo(): Promise<TaskWithVoteInfo[]> {
return getCachedAllTasksWithVoteInfo(fetchAllTasksWithVoteInfo);
}

async function fetchAllTasksWithVoteInfo(): Promise<TaskWithVoteInfo[]> {
const [allTasks, stats, counters] = await Promise.all([
prisma.task.findMany({ orderBy: { task_id: 'desc' } }),
prisma.votedGradeStatistics.findMany(),
Expand All @@ -45,6 +49,7 @@ export async function getAllTasksWithVoteInfo(): Promise<TaskWithVoteInfo[]> {

const statsMap = new Map(stats.map((s) => [s.taskId, s]));
const totalsMap = new Map<string, number>();

for (const c of counters) {
totalsMap.set(c.taskId, (totalsMap.get(c.taskId) ?? 0) + c.count);
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/services/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -189,6 +191,7 @@ export async function createTask(
});

invalidateTaskCaches();
invalidateVoteCaches();
console.log(task);
}

Expand All @@ -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') {
Expand Down
22 changes: 19 additions & 3 deletions src/routes/votes/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
104 changes: 104 additions & 0 deletions src/routes/votes/page_server.test.ts
Original file line number Diff line number Diff line change
@@ -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<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();
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<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 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);
});
});
29 changes: 29 additions & 0 deletions src/test/lib/services/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn> } };
Expand All @@ -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', () => {
Expand All @@ -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);
Expand Down
Loading