diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..17484bd --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,20 @@ +# Project context + +## Domain vocabulary + +- **Problem**: a tracked competitive-programming problem from Codeforces or Kattis. +- **Contest**: a tracked programming contest; participation is a per-user marker. +- **Reaction**: a current user's like or dislike. Repeating the same reaction undoes it. +- **Solved**: a per-user marker on a problem, independent of the aggregate `solved` count. +- **Actor**: the signed-in browser user and resolved admin role. +- **Leaderboard entry**: a public ranked user returned by `get_leaderboard`. + +## Intentional interaction differences + +- Problem reactions update optimistically; an ordinary successful RPC response with no row leaves that optimistic state in place. +- Contest reactions update only after the server returns the updated contest. +- Problem solved state updates optimistically and reloads the actor's solved set after a failed write. +- Contest participation updates only after the write succeeds. +- Public list read failures are logged server-side and render the existing safe empty table or shell. + +Public problem, contest, and leaderboard reads live in `src/lib/queries`. Current-user reads remain browser-side. Browser identity/session ownership lives in `src/lib/auth/currentActor.ts`; bearer authorization for protected server handlers lives in `src/lib/server/authorization.ts`. diff --git a/assets/brand/archive/gitgud-cyber-cat-backup.png b/assets/brand/archive/gitgud-cyber-cat-backup.png new file mode 100644 index 0000000..7f219ea Binary files /dev/null and b/assets/brand/archive/gitgud-cyber-cat-backup.png differ diff --git a/e2e/backend-failure.spec.ts b/e2e/backend-failure.spec.ts index a30e7d3..9e7ac72 100644 --- a/e2e/backend-failure.spec.ts +++ b/e2e/backend-failure.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; +import { MOCK_URL } from './support/constants.ts'; import { waitForShell } from './support/harness.ts'; import { setScenario } from './support/scenario.ts'; @@ -9,7 +10,7 @@ test.beforeEach(async () => { // Explicit backend-failure coverage (the `error` scenario: every Supabase read // replies 500). // -// Observed product behavior: the data services (fetchProblems / fetchContests / +// Observed product behavior: the domain queries (fetchProblems / fetchContests / // fetchLeaderboard) catch the backend error, log it server-side, and return an // empty result, so the SSR page degrades to a safe empty shell rather than // crashing or blanking. There is no user-facing error banner on the initial @@ -45,3 +46,18 @@ test('leaderboard backend failure renders the table shell with no rows', async ( await expect(page.getByRole('columnheader', { name: /Rank/i })).toBeVisible(); await expect(page.locator('table tbody tr')).toHaveCount(0); }); + +test('actor feedback and participation reads honor the error scenario', async ({ request }) => { + for (const table of [ + 'user_problem_feedback', + 'user_contest_feedback', + 'user_contest_participation' + ]) { + const response = await request.get(`${MOCK_URL}/rest/v1/${table}`); + expect(response.status()).toBe(500); + expect(await response.json()).toMatchObject({ + code: 'PGRST500', + message: 'mock backend failure' + }); + } +}); diff --git a/e2e/interactions.spec.ts b/e2e/interactions.spec.ts new file mode 100644 index 0000000..368ddc0 --- /dev/null +++ b/e2e/interactions.spec.ts @@ -0,0 +1,65 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { resetMockStore, seedMemberSession, setMutationMode } from './support/auth.ts'; +import { CONTESTS, PROBLEMS } from './support/fixtures.ts'; +import { setScenario } from './support/scenario.ts'; + +const problem = PROBLEMS[0]; +const contest = CONTESTS[0]; + +function rowFor(page: Page, name: string): Locator { + return page.getByRole('link', { name, exact: true }).locator('xpath=ancestor::tr'); +} + +test.beforeEach(async ({ page }) => { + await setScenario('data'); + await resetMockStore(); + await seedMemberSession(page); +}); + +test('problem reactions stay optimistic when the RPC returns null', async ({ page }) => { + await setMutationMode('null'); + await page.goto('/'); + + const row = rowFor(page, problem.name); + const like = row.getByRole('button', { name: `Like, ${problem.likes} likes` }); + await like.click(); + + await expect( + row.getByRole('button', { name: `Like (liked), ${problem.likes + 1} likes` }) + ).toBeVisible(); +}); + +test('contest reactions wait for server confirmation', async ({ page }) => { + await setMutationMode('delayed-success'); + await page.goto('/contests'); + + const row = rowFor(page, contest.name); + await row.getByRole('button', { name: `Like, ${contest.likes} likes` }).click(); + + await expect(row.getByRole('button', { name: `Like, ${contest.likes} likes` })).toBeVisible(); + await expect( + row.getByRole('button', { name: `Like (liked), ${contest.likes + 1} likes` }) + ).toBeVisible(); +}); + +test('problem solved is optimistic and reloads after a failed write', async ({ page }) => { + await setMutationMode('delayed-error'); + await page.goto('/'); + + const row = rowFor(page, problem.name); + await row.getByRole('button', { name: 'Mark as solved' }).click(); + + await expect(row.getByRole('button', { name: 'Mark as unsolved' })).toBeVisible(); + await expect(row.getByRole('button', { name: 'Mark as solved' })).toBeVisible(); +}); + +test('contest participation waits for server confirmation', async ({ page }) => { + await setMutationMode('delayed-success'); + await page.goto('/contests'); + + const row = rowFor(page, contest.name); + await row.getByRole('button', { name: 'Mark as participated' }).click(); + + await expect(row.getByRole('button', { name: 'Mark as participated' })).toBeVisible(); + await expect(row.getByRole('button', { name: 'Mark as not participated' })).toBeVisible(); +}); diff --git a/e2e/server-authorization.spec.ts b/e2e/server-authorization.spec.ts new file mode 100644 index 0000000..b8f6cbc --- /dev/null +++ b/e2e/server-authorization.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; +import { MEMBER_USER } from './support/constants.ts'; +import { setScenario } from './support/scenario.ts'; + +test.beforeEach(async () => { + await setScenario('data'); +}); + +test('admin handler preserves authentication and authorization responses', async ({ request }) => { + const missing = await request.post('/api/codeforces/problems', { data: { problems: [] } }); + expect(missing.status()).toBe(401); + expect(await missing.json()).toEqual({ error: 'Authentication required' }); + + const invalid = await request.post('/api/codeforces/problems', { + headers: { Authorization: 'Bearer invalid-token' }, + data: { problems: [] } + }); + expect(invalid.status()).toBe(401); + expect(await invalid.json()).toEqual({ error: 'Invalid or expired session' }); + + const forbidden = await request.post('/api/codeforces/problems', { + headers: { Authorization: `Bearer ${MEMBER_USER.accessToken}` }, + data: { problems: [{ contestId: '1000', index: 'A' }] } + }); + expect(forbidden.status()).toBe(403); + expect(await forbidden.json()).toEqual({ error: 'Admin privileges required' }); +}); + +test('user handler preserves missing and invalid session responses', async ({ request }) => { + const missing = await request.get('/api/codeforces/user-solves?handle=tourist'); + expect(missing.status()).toBe(401); + expect(await missing.json()).toEqual({ error: 'Authentication required' }); + + const invalid = await request.get('/api/codeforces/user-solves?handle=tourist', { + headers: { Authorization: 'Bearer invalid-token' } + }); + expect(invalid.status()).toBe(401); + expect(await invalid.json()).toEqual({ error: 'Invalid or expired session' }); +}); diff --git a/e2e/submit-codeforces.spec.ts b/e2e/submit-codeforces.spec.ts index 79bc1cc..9eb099c 100644 --- a/e2e/submit-codeforces.spec.ts +++ b/e2e/submit-codeforces.spec.ts @@ -83,11 +83,25 @@ test.describe('admin authorization gate', () => { await seedMemberSession(page); await gotoSubmit(page); - await expect(page.getByText(/Only admins can submit/i)).toBeVisible(); + await expect(page.getByText('Only admins can submit problems.', { exact: true })).toBeVisible(); await expect(page.getByLabel(/Problem URLs/i)).toHaveCount(0); await expect(previewButton(page)).toHaveCount(0); }); + test('an admin lookup failure is distinct from a non-admin result', async ({ page }) => { + await setScenario('error'); + await seedAdminSession(page); + await gotoSubmit(page); + + await expect( + page.getByText('Failed to verify your permissions. Please try again later.', { exact: true }) + ).toBeVisible(); + await expect(page.getByText('Only admins can submit problems.', { exact: true })).toHaveCount( + 0 + ); + await expect(page.getByLabel(/Problem URLs/i)).toHaveCount(0); + }); + test('an anonymous visitor is redirected home', async ({ page }) => { await page.goto('/submit/codeforces'); await page.waitForURL(/\/$/); diff --git a/e2e/support/auth.ts b/e2e/support/auth.ts index fada49a..634d848 100644 --- a/e2e/support/auth.ts +++ b/e2e/support/auth.ts @@ -89,6 +89,18 @@ export async function setProviderMode(mode: ProviderMode): Promise { } } +export type MutationMode = 'success' | 'null' | 'error' | 'delayed-success' | 'delayed-error'; +export async function setMutationMode(mode: MutationMode): Promise { + const res = await fetch(`${MOCK_URL}/__control/mutation`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }) + }); + if (!res.ok) { + throw new Error(`failed to set mutation mode '${mode}': HTTP ${res.status}`); + } +} + // Read the number of user_solved_problems write attempts the mock has seen this // run. Used to assert that a read-only preview performs ZERO writes. export async function getSolvedWriteAttempts(): Promise { diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts index 6ab170c..9c1dda7 100644 --- a/e2e/support/fixtures.ts +++ b/e2e/support/fixtures.ts @@ -1,7 +1,7 @@ // Representative Supabase fixtures for the deterministic Playwright suite. // // These rows mirror the exact snake_case shape the app's data services read -// from Supabase (see src/lib/services/{problem,contest,leaderboard}.ts) and are +// from Supabase (see src/lib/queries/*Queries.ts) and are // served by the mock Supabase server (mock-supabase.ts) so the suite can assert // real rendered rows, links, filters, and sorts without touching production // data. Each fixture maps 1:1 to a Supabase endpoint: @@ -15,7 +15,7 @@ // scores (drives the default score sort and the difficulty sort), and multiple // recommenders (drives the author filter). -// Column shape matches PROBLEM_COLUMNS in src/lib/services/problem.ts. +// Column shape matches PROBLEM_COLUMNS in src/lib/queries/problemQueries.ts. export type ProblemRow = { id: string; name: string; @@ -31,7 +31,7 @@ export type ProblemRow = { type: string | null; }; -// Column shape matches CONTEST_COLUMNS in src/lib/services/contest.ts. +// Column shape matches CONTEST_COLUMNS in src/lib/queries/contestQueries.ts. export type ContestRow = { id: string; name: string; @@ -46,7 +46,7 @@ export type ContestRow = { type: string | null; }; -// Shape matches LeaderboardRecord in src/lib/services/leaderboard.ts (the +// Shape matches the record in src/lib/queries/leaderboardQueries.ts (the // get_leaderboard RPC return rows). export type LeaderboardRow = { user_id: string; diff --git a/e2e/support/mock-supabase.ts b/e2e/support/mock-supabase.ts index 7d1bc6e..2307a79 100644 --- a/e2e/support/mock-supabase.ts +++ b/e2e/support/mock-supabase.ts @@ -42,7 +42,8 @@ // POST /__control/scenario body {"scenario":"data"|"empty"|"error"} // POST /__control/reset resets the in-memory insert store + provider mode // POST /__control/provider body {"mode":"ok"|"fail"|"notfound", ...fixtures} -// GET /__control/scenario -> {"scenario","inserted":{...},"provider":"..."} +// POST /__control/mutation selects success/null/delayed/error writes +// GET /__control/scenario returns current mode and isolated write counts // // Every write lands ONLY in this process's in-memory maps and is wiped by // /__control/reset (called per scenario), so tests never touch a real store and @@ -54,6 +55,7 @@ import { ADMIN_USER, MEMBER_USER } from './constants.ts'; type Scenario = 'data' | 'empty' | 'error'; type ProviderMode = 'ok' | 'fail' | 'notfound' | 'malformed' | 'ratelimited'; +type MutationMode = 'success' | 'null' | 'error' | 'delayed-success' | 'delayed-error'; // Server-wide current scenario, switched via the control endpoint. let scenario: Scenario = (process.env.MOCK_SCENARIO as Scenario) || 'data'; @@ -66,10 +68,9 @@ const USERS = [ADMIN_USER, MEMBER_USER]; const userByToken = new Map(USERS.map((u) => [u.accessToken, u])); const roleByUserId = new Map(USERS.map((u) => [u.id, u.role])); -// --- In-memory insert store (isolated, reset per scenario) ------------------- -// Only URLs written during a run live here; existence checks and inserts read -// and write these, never the read fixtures, so a submit test's writes are fully -// isolated and deterministic. +// --- In-memory stores (isolated, reset per scenario) ------------------------ +// Submission and interaction writes live only here, so tests never mutate the +// read fixtures or any external data. let insertedProblems: { id: string; url: string; name: string }[] = []; let insertedContests: { id: string; url: string; name: string }[] = []; let insertSeq = 0; @@ -78,9 +79,15 @@ let insertSeq = 0; // Idempotent (a repeat insert of an existing pair is a unique violation the app // treats as already-solved) and reset per scenario so imports never leak. let solvedByUser = new Map>(); +let participationByUser = new Map>(); +let problemFeedbackByUser = new Map>(); +let contestFeedbackByUser = new Map>(); +let problemRows = PROBLEMS.map((row) => ({ ...row })); +let contestRows = CONTESTS.map((row) => ({ ...row })); // Total user_solved_problems write attempts (POST) seen this run, so a spec can // assert that preview performs ZERO writes. let solvedWriteAttempts = 0; +let mutationMode: MutationMode = 'success'; // --- Provider (Codeforces/Kattis) stub mode ---------------------------------- // The server-side app endpoints (/api/codeforces/problems, /api/kattis) fetch @@ -95,24 +102,37 @@ function resetState(): void { insertedContests = []; insertSeq = 0; solvedByUser = new Map(); + participationByUser = new Map(); + problemFeedbackByUser = new Map(); + contestFeedbackByUser = new Map(); + problemRows = PROBLEMS.map((row) => ({ ...row })); + contestRows = CONTESTS.map((row) => ({ ...row })); solvedWriteAttempts = 0; + mutationMode = 'success'; provider = 'ok'; } +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,PATCH,OPTIONS' +}; + function sendJson(res: http.ServerResponse, status: number, body: unknown): void { const payload = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), - // The Supabase JS client sends these on every request; echoing permissive - // CORS keeps any (non-SSR) client-side call from failing on preflight. - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS' + ...CORS_HEADERS }); res.end(payload); } +function sendNoContent(res: http.ServerResponse): void { + res.writeHead(204, CORS_HEADERS); + res.end(); +} + // A PostgREST-style error body. The data services read `error.message`, so a // realistic shape keeps the failure path faithful to production. function sendError(res: http.ServerResponse): void { @@ -138,8 +158,8 @@ function bearerToken(req: http.IncomingMessage): string { } function payloadFor(pathname: string): unknown[] | null { - if (pathname === '/rest/v1/problems') return PROBLEMS; - if (pathname === '/rest/v1/contests') return CONTESTS; + if (pathname === '/rest/v1/problems') return problemRows; + if (pathname === '/rest/v1/contests') return contestRows; if (pathname === '/rest/v1/rpc/get_leaderboard') return LEADERBOARD; return null; } @@ -382,12 +402,24 @@ function handleSolvedRead(req: http.IncomingMessage, res: http.ServerResponse, u sendJson(res, 200, rows); } +async function waitForMutation(res: http.ServerResponse): Promise { + if (mutationMode === 'delayed-success' || mutationMode === 'delayed-error') { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (mutationMode === 'error' || mutationMode === 'delayed-error') { + sendError(res); + return false; + } + return true; +} + async function handleSolvedInsert( req: http.IncomingMessage, res: http.ServerResponse, body: string ): Promise { solvedWriteAttempts++; + if (!(await waitForMutation(res))) return; const caller = userByToken.get(bearerToken(req)); if (!caller) { sendJson(res, 401, { code: 401, msg: 'invalid claim: missing sub claim' }); @@ -426,6 +458,120 @@ async function handleSolvedInsert( sendJson(res, 201, inserted); } +async function handleSolvedDelete( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL +): Promise { + if (!(await waitForMutation(res))) return; + const caller = userByToken.get(bearerToken(req)); + if (!caller) { + sendJson(res, 401, { code: 401, msg: 'invalid claim: missing sub claim' }); + return; + } + solvedByUser.get(caller.id)?.delete(eqFilter(url, 'problem_id') || ''); + sendNoContent(res); +} + +function handleActorRows( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + kind: 'problem-feedback' | 'contest-feedback' | 'participation' +): void { + const userId = eqFilter(url, 'user_id') || userByToken.get(bearerToken(req))?.id; + if (!userId) { + sendJson(res, 200, []); + return; + } + if (kind === 'participation') { + const rows = Array.from(participationByUser.get(userId) || []).map((contest_id) => ({ + contest_id + })); + sendJson(res, 200, rows); + return; + } + const feedback = + kind === 'problem-feedback' + ? problemFeedbackByUser.get(userId) + : contestFeedbackByUser.get(userId); + const idKey = kind === 'problem-feedback' ? 'problem_id' : 'contest_id'; + const entries = feedback ? Array.from(feedback.entries()) : []; + sendJson( + res, + 200, + entries.map(([id, feedback_type]) => ({ [idKey]: id, feedback_type })) + ); +} + +async function handleParticipationWrite( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + body: string +): Promise { + if (!(await waitForMutation(res))) return; + const caller = userByToken.get(bearerToken(req)); + if (!caller) { + sendJson(res, 401, { code: 401, msg: 'invalid claim: missing sub claim' }); + return; + } + const set = participationByUser.get(caller.id) || new Set(); + if (req.method === 'DELETE') { + set.delete(eqFilter(url, 'contest_id') || ''); + } else { + const record = JSON.parse(body || '{}') as { contest_id?: string }; + if (record.contest_id) set.add(record.contest_id); + } + participationByUser.set(caller.id, set); + if (req.method === 'DELETE') sendNoContent(res); + else sendJson(res, 201, []); +} + +async function handleFeedbackRpc( + req: http.IncomingMessage, + res: http.ServerResponse, + body: string, + kind: 'problem' | 'contest' +): Promise { + if (!(await waitForMutation(res))) return; + if (mutationMode === 'null') { + sendJson(res, 200, null); + return; + } + const caller = userByToken.get(bearerToken(req)); + if (!caller) { + sendJson(res, 401, { code: 401, msg: 'invalid claim: missing sub claim' }); + return; + } + const input = JSON.parse(body || '{}') as { + p_problem_id?: string; + p_contest_id?: string; + p_is_like?: boolean; + }; + const id = kind === 'problem' ? input.p_problem_id : input.p_contest_id; + const next = input.p_is_like ? 'like' : 'dislike'; + const byUser = kind === 'problem' ? problemFeedbackByUser : contestFeedbackByUser; + const feedback = byUser.get(caller.id) || new Map(); + const previous = id ? feedback.get(id) : undefined; + const rows = kind === 'problem' ? problemRows : contestRows; + const row = rows.find((candidate) => candidate.id === id); + if (!id || !row) { + sendJson(res, 200, []); + return; + } + if (previous === next) { + feedback.delete(id); + row[next === 'like' ? 'likes' : 'dislikes']--; + } else { + if (previous) row[previous === 'like' ? 'likes' : 'dislikes']--; + feedback.set(id, next); + row[next === 'like' ? 'likes' : 'dislikes']++; + } + byUser.set(caller.id, feedback); + sendJson(res, 200, [{ ...row }]); +} + function handleKattisPage(res: http.ServerResponse): void { if (provider === 'fail') { res.writeHead(500, { 'Content-Type': 'text/plain' }); @@ -463,6 +609,7 @@ const server = http.createServer(async (req, res) => { sendJson(res, 200, { scenario, provider, + mutationMode, inserted: { problems: insertedProblems.length, contests: insertedContests.length }, solvedWriteAttempts }); @@ -490,12 +637,32 @@ const server = http.createServer(async (req, res) => { provider = next; } } catch { - // Ignore malformed payloads; keep current provider mode. + // Ignore malformed control payloads. } sendJson(res, 200, { provider }); return; } + if (url.pathname === '/__control/mutation') { + const body = await readBody(req); + try { + const next = (JSON.parse(body || '{}') as { mode?: MutationMode }).mode; + if ( + next === 'success' || + next === 'null' || + next === 'error' || + next === 'delayed-success' || + next === 'delayed-error' + ) { + mutationMode = next; + } + } catch { + // Ignore malformed control payloads. + } + sendJson(res, 200, { mutationMode }); + return; + } + // Auth (GoTrue) ------------------------------------------------------------- if (url.pathname === '/auth/v1/authorize') { await readBody(req); @@ -537,13 +704,18 @@ const server = http.createServer(async (req, res) => { return; } - // user_solved_problems: read (GET) and idempotent user-scoped insert (POST). + // Current-actor interaction reads and isolated writes. if (url.pathname === '/rest/v1/user_solved_problems') { if (req.method === 'POST') { const body = await readBody(req); await handleSolvedInsert(req, res, body); return; } + if (req.method === 'DELETE') { + await readBody(req); + await handleSolvedDelete(req, res, url); + return; + } await readBody(req); if (scenario === 'error') { sendError(res); @@ -552,6 +724,45 @@ const server = http.createServer(async (req, res) => { handleSolvedRead(req, res, url); return; } + if (url.pathname === '/rest/v1/user_problem_feedback') { + await readBody(req); + if (scenario === 'error') { + sendError(res); + return; + } + handleActorRows(req, res, url, 'problem-feedback'); + return; + } + if (url.pathname === '/rest/v1/user_contest_feedback') { + await readBody(req); + if (scenario === 'error') { + sendError(res); + return; + } + handleActorRows(req, res, url, 'contest-feedback'); + return; + } + if (url.pathname === '/rest/v1/user_contest_participation') { + const body = await readBody(req); + if (req.method === 'POST' || req.method === 'DELETE') { + await handleParticipationWrite(req, res, url, body); + } else if (scenario === 'error') { + sendError(res); + } else { + handleActorRows(req, res, url, 'participation'); + } + return; + } + if (url.pathname === '/rest/v1/rpc/update_problem_feedback') { + const body = await readBody(req); + await handleFeedbackRpc(req, res, body, 'problem'); + return; + } + if (url.pathname === '/rest/v1/rpc/update_contest_feedback') { + const body = await readBody(req); + await handleFeedbackRpc(req, res, body, 'contest'); + return; + } if (url.pathname.startsWith('/problems/')) { // Kattis problem page (open.kattis.com/problems/) redirected here. await readBody(req); diff --git a/src/lib/auth/currentActor.ts b/src/lib/auth/currentActor.ts new file mode 100644 index 0000000..d7b8010 --- /dev/null +++ b/src/lib/auth/currentActor.ts @@ -0,0 +1,120 @@ +import { get, writable } from 'svelte/store'; +import type { Session, User } from '@supabase/supabase-js'; +import { supabase } from '$lib/services/database'; + +export type CurrentActor = { + session: Session | null; + user: User | null; + isAdmin: boolean; + adminCheckFailed: boolean; + initialized: boolean; +}; + +const initialActor: CurrentActor = { + session: null, + user: null, + isAdmin: false, + adminCheckFailed: false, + initialized: false +}; + +export const currentActor = writable(initialActor); + +let authSubscription: { unsubscribe: () => void } | null = null; +let bootstrapPromise: Promise | null = null; +let actorVersion = 0; + +type AdminLookup = Pick; + +async function lookupAdmin(userId: string): Promise { + try { + const { data, error } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', userId) + .maybeSingle(); + + if (error) { + console.error('Error checking admin status:', error); + return { isAdmin: false, adminCheckFailed: true }; + } + return { isAdmin: data?.role === 'admin', adminCheckFailed: false }; + } catch (error) { + console.error('Failed to check admin status:', error); + return { isAdmin: false, adminCheckFailed: true }; + } +} + +async function applySession(session: Session | null): Promise { + const version = ++actorVersion; + const user = session?.user ?? null; + const base: CurrentActor = { + session, + user, + isAdmin: false, + adminCheckFailed: false, + initialized: true + }; + currentActor.set(base); + if (!user) return base; + + const next = { ...base, ...(await lookupAdmin(user.id)) }; + if (version === actorVersion) currentActor.set(next); + return next; +} + +async function bootstrap(): Promise { + const { data } = await supabase.auth.getSession(); + const actor = await applySession(data.session); + const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => { + const actor = get(currentActor); + if (actor.initialized && actor.session?.access_token === session?.access_token) { + currentActor.set({ ...actor, session }); + return; + } + void applySession(session); + }); + authSubscription = listener.subscription; + return actor; +} + +export function resolveCurrentActor(): Promise { + bootstrapPromise ??= bootstrap(); + return bootstrapPromise; +} + +export async function startCurrentActor(): Promise<() => void> { + await resolveCurrentActor(); + return stopCurrentActor; +} + +export function stopCurrentActor(): void { + authSubscription?.unsubscribe(); + authSubscription = null; + bootstrapPromise = null; + actorVersion++; + currentActor.set(initialActor); +} + +export function getCurrentActor(): CurrentActor { + return get(currentActor); +} + +export async function signInWithGithub(): Promise { + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { redirectTo: `${window.location.origin}/auth/callback` } + }); + if (error) { + console.error('Error signing in with Github:', error); + throw error; + } +} + +export async function signOut(): Promise { + const { error } = await supabase.auth.signOut(); + if (error) { + console.error('Error signing out:', error); + throw error; + } +} diff --git a/src/lib/collections/contestCollection.ts b/src/lib/collections/contestCollection.ts new file mode 100644 index 0000000..b46710e --- /dev/null +++ b/src/lib/collections/contestCollection.ts @@ -0,0 +1,151 @@ +import type { Contest } from '../queries/contestQueries.ts'; +import { + cycleTableState, + getSortedAuthors, + nextSortDirection, + sortByDifficulty, + sortByScore, + type SortDirection +} from '../utils/table.ts'; + +export type ParticipationFilter = 'all' | 'participated' | 'not-participated'; +export type ContestTypeFilter = 'all' | 'icpc' | 'codeforces'; + +const PARTICIPATION_FILTER_STATES = ['all', 'participated', 'not-participated'] as const; +const TYPE_FILTER_STATES = ['all', 'icpc', 'codeforces'] as const; + +type ContestCollectionState = { + sourceItems: readonly Contest[]; + participatedContestIds: ReadonlySet; + selectedAuthor: string | null; + participationFilter: ParticipationFilter; + typeFilter: ContestTypeFilter; + difficultySortDirection: SortDirection; +}; + +export type CreateContestCollectionOptions = { + items?: readonly Contest[]; + participatedContestIds?: ReadonlySet; + selectedAuthor?: string | null; + participationFilter?: ParticipationFilter; + typeFilter?: ContestTypeFilter; + difficultySortDirection?: SortDirection; + preserveSourceOrder?: boolean; +}; + +function sortByDefaultScore(items: readonly Contest[]): Contest[] { + return sortByScore(items, 'desc'); +} + +export class ContestCollection { + readonly sourceItems: readonly Contest[]; + readonly participatedContestIds: ReadonlySet; + readonly selectedAuthor: string | null; + readonly participationFilter: ParticipationFilter; + readonly typeFilter: ContestTypeFilter; + readonly difficultySortDirection: SortDirection; + + constructor(options: CreateContestCollectionOptions = {}) { + this.sourceItems = options.preserveSourceOrder + ? [...(options.items ?? [])] + : sortByDefaultScore(options.items ?? []); + this.participatedContestIds = new Set(options.participatedContestIds ?? []); + this.selectedAuthor = options.selectedAuthor ?? null; + this.participationFilter = options.participationFilter ?? 'all'; + this.typeFilter = options.typeFilter ?? 'all'; + this.difficultySortDirection = options.difficultySortDirection ?? null; + } + + get sortKey(): 'score' | 'difficulty' { + return this.difficultySortDirection === null ? 'score' : 'difficulty'; + } + + get availableAuthors(): string[] { + return getSortedAuthors(this.withoutAuthorFilter); + } + + get rows(): Contest[] { + const filtered = this.selectedAuthor + ? this.withoutAuthorFilter.filter((contest) => contest.addedBy === this.selectedAuthor) + : this.withoutAuthorFilter; + return this.difficultySortDirection === null + ? [...filtered] + : sortByDifficulty(filtered, this.difficultySortDirection, sortByDefaultScore); + } + + withSourceItems(items: readonly Contest[]): ContestCollection { + return this.copy({ sourceItems: sortByDefaultScore(items) }); + } + + replaceSourceItem(item: Contest): ContestCollection { + return this.copy({ + sourceItems: this.sourceItems.map((contest) => (contest.id === item.id ? item : contest)) + }); + } + + withParticipatedContestIds(ids: ReadonlySet): ContestCollection { + return this.copy({ participatedContestIds: new Set(ids) }); + } + + selectAuthor(author: string | null): ContestCollection { + return this.copy({ selectedAuthor: author }); + } + + cycleParticipationFilter(): ContestCollection { + return this.copy({ + participationFilter: cycleTableState(this.participationFilter, PARTICIPATION_FILTER_STATES) + }); + } + + cycleTypeFilter(): ContestCollection { + return this.copy({ typeFilter: cycleTableState(this.typeFilter, TYPE_FILTER_STATES) }); + } + + cycleDifficultySort(): ContestCollection { + const direction = nextSortDirection(this.difficultySortDirection); + return this.copy({ + sourceItems: direction === null ? sortByDefaultScore(this.sourceItems) : this.sourceItems, + difficultySortDirection: direction + }); + } + + resetFilters(): ContestCollection { + return this.copy({ + sourceItems: sortByDefaultScore(this.sourceItems), + selectedAuthor: null, + participationFilter: 'all', + typeFilter: 'all', + difficultySortDirection: null + }); + } + + private get withoutAuthorFilter(): Contest[] { + return this.sourceItems.filter((contest) => { + if (this.participationFilter !== 'all' && contest.id) { + const participated = this.participatedContestIds.has(contest.id); + if (this.participationFilter === 'participated' ? !participated : participated) { + return false; + } + } + + if (this.typeFilter === 'icpc' && contest.type !== 'ICPC') return false; + if (this.typeFilter === 'codeforces' && contest.type === 'ICPC') return false; + return true; + }); + } + + private copy(changes: Partial): ContestCollection { + return new ContestCollection({ + items: changes.sourceItems ?? this.sourceItems, + participatedContestIds: changes.participatedContestIds ?? this.participatedContestIds, + selectedAuthor: 'selectedAuthor' in changes ? changes.selectedAuthor : this.selectedAuthor, + participationFilter: changes.participationFilter ?? this.participationFilter, + typeFilter: changes.typeFilter ?? this.typeFilter, + difficultySortDirection: + changes.difficultySortDirection === undefined + ? this.difficultySortDirection + : changes.difficultySortDirection, + preserveSourceOrder: true + }); + } +} diff --git a/src/lib/collections/problemCollection.ts b/src/lib/collections/problemCollection.ts new file mode 100644 index 0000000..5763ec3 --- /dev/null +++ b/src/lib/collections/problemCollection.ts @@ -0,0 +1,196 @@ +import type { Problem } from '../queries/problemQueries.ts'; +import { + cycleTableState, + getSortedAuthors, + nextSortDirection, + sortByDifficulty, + sortByScore, + type SortDirection +} from '../utils/table.ts'; + +export const PROBLEM_TOPICS = [ + 'graph', + 'array', + 'string', + 'math', + 'tree', + 'queries', + 'geometry', + 'misc' +] as const; +export const NEW_PROBLEM_TOPIC = 'NEW'; + +export type ProblemTopic = (typeof PROBLEM_TOPICS)[number] | typeof NEW_PROBLEM_TOPIC; +export type SolvedFilter = 'all' | 'solved' | 'unsolved'; +export type ProblemSourceFilter = 'all' | Problem['source']; + +const SOLVED_FILTER_STATES = ['all', 'solved', 'unsolved'] as const; +const SOURCE_FILTER_STATES = ['all', 'codeforces', 'kattis'] as const; + +type ProblemCollectionState = { + sourceItems: readonly Problem[]; + solvedProblemIds: ReadonlySet; + defaultSolvedFilter: SolvedFilter; + selectedTopic: ProblemTopic | null; + selectedAuthor: string | null; + sourceFilter: ProblemSourceFilter; + solvedFilter: SolvedFilter; + difficultySortDirection: SortDirection; +}; + +export type CreateProblemCollectionOptions = { + items?: readonly Problem[]; + solvedProblemIds?: ReadonlySet; + defaultSolvedFilter?: SolvedFilter; + selectedTopic?: ProblemTopic | null; + selectedAuthor?: string | null; + sourceFilter?: ProblemSourceFilter; + solvedFilter?: SolvedFilter; + difficultySortDirection?: SortDirection; + preserveSourceOrder?: boolean; +}; + +function sortByDefaultScore(items: readonly Problem[]): Problem[] { + return sortByScore(items, 'desc'); +} + +export class ProblemCollection { + readonly sourceItems: readonly Problem[]; + readonly solvedProblemIds: ReadonlySet; + readonly defaultSolvedFilter: SolvedFilter; + readonly selectedTopic: ProblemTopic | null; + readonly selectedAuthor: string | null; + readonly sourceFilter: ProblemSourceFilter; + readonly solvedFilter: SolvedFilter; + readonly difficultySortDirection: SortDirection; + + constructor(options: CreateProblemCollectionOptions = {}) { + this.sourceItems = options.preserveSourceOrder + ? [...(options.items ?? [])] + : sortByDefaultScore(options.items ?? []); + this.solvedProblemIds = new Set(options.solvedProblemIds ?? []); + this.defaultSolvedFilter = options.defaultSolvedFilter ?? 'all'; + this.selectedTopic = options.selectedTopic ?? null; + this.selectedAuthor = options.selectedAuthor ?? null; + this.sourceFilter = options.sourceFilter ?? 'all'; + this.solvedFilter = options.solvedFilter ?? this.defaultSolvedFilter; + this.difficultySortDirection = options.difficultySortDirection ?? null; + } + + get topicOptions(): readonly string[] { + return PROBLEM_TOPICS; + } + + get sortKey(): 'score' | 'difficulty' { + return this.difficultySortDirection === null ? 'score' : 'difficulty'; + } + + get availableAuthors(): string[] { + return getSortedAuthors(this.withoutAuthorFilter); + } + + get rows(): Problem[] { + const filtered = this.selectedAuthor + ? this.withoutAuthorFilter.filter((problem) => problem.addedBy === this.selectedAuthor) + : this.withoutAuthorFilter; + return this.difficultySortDirection === null + ? [...filtered] + : sortByDifficulty(filtered, this.difficultySortDirection, sortByDefaultScore); + } + + withSourceItems(items: readonly Problem[]): ProblemCollection { + return this.copy({ sourceItems: sortByDefaultScore(items) }); + } + + updateSourceItem(id: string, update: (problem: Problem) => Problem): ProblemCollection { + return this.copy({ + sourceItems: this.sourceItems.map((problem) => + problem.id === id ? update(problem) : problem + ) + }); + } + + withSolvedProblemIds(ids: ReadonlySet): ProblemCollection { + return this.copy({ solvedProblemIds: new Set(ids) }); + } + + selectTopic(topic: ProblemTopic | null): ProblemCollection { + return this.copy({ selectedTopic: topic }); + } + + selectAuthor(author: string | null): ProblemCollection { + return this.copy({ selectedAuthor: author }); + } + + cycleSolvedFilter(): ProblemCollection { + return this.copy({ + solvedFilter: cycleTableState(this.solvedFilter, SOLVED_FILTER_STATES) + }); + } + + cycleSourceFilter(): ProblemCollection { + return this.copy({ + sourceFilter: cycleTableState(this.sourceFilter, SOURCE_FILTER_STATES) + }); + } + + cycleDifficultySort(): ProblemCollection { + const direction = nextSortDirection(this.difficultySortDirection); + return this.copy({ + sourceItems: direction === null ? sortByDefaultScore(this.sourceItems) : this.sourceItems, + difficultySortDirection: direction + }); + } + + resetFilters(): ProblemCollection { + return this.copy({ + sourceItems: sortByDefaultScore(this.sourceItems), + selectedTopic: null, + selectedAuthor: null, + sourceFilter: 'all', + solvedFilter: this.defaultSolvedFilter, + difficultySortDirection: null + }); + } + + private get withoutAuthorFilter(): Problem[] { + return this.sourceItems.filter((problem) => { + if (this.selectedTopic === NEW_PROBLEM_TOPIC && problem.type) return false; + if (this.selectedTopic === 'misc' && problem.type && problem.type !== 'misc') { + return false; + } + if ( + this.selectedTopic && + this.selectedTopic !== NEW_PROBLEM_TOPIC && + this.selectedTopic !== 'misc' && + problem.type !== this.selectedTopic + ) { + return false; + } + + if (this.solvedFilter !== 'all') { + const isSolved = !!problem.id && this.solvedProblemIds.has(problem.id); + if (this.solvedFilter === 'solved' ? !isSolved : isSolved) return false; + } + + return this.sourceFilter === 'all' || problem.source === this.sourceFilter; + }); + } + + private copy(changes: Partial): ProblemCollection { + return new ProblemCollection({ + items: changes.sourceItems ?? this.sourceItems, + solvedProblemIds: changes.solvedProblemIds ?? this.solvedProblemIds, + defaultSolvedFilter: changes.defaultSolvedFilter ?? this.defaultSolvedFilter, + selectedTopic: 'selectedTopic' in changes ? changes.selectedTopic : this.selectedTopic, + selectedAuthor: 'selectedAuthor' in changes ? changes.selectedAuthor : this.selectedAuthor, + sourceFilter: changes.sourceFilter ?? this.sourceFilter, + solvedFilter: changes.solvedFilter ?? this.solvedFilter, + difficultySortDirection: + changes.difficultySortDirection === undefined + ? this.difficultySortDirection + : changes.difficultySortDirection, + preserveSourceOrder: true + }); + } +} diff --git a/src/lib/components/ContestTable.svelte b/src/lib/components/ContestTable.svelte index 43193db..e6ffe60 100644 --- a/src/lib/components/ContestTable.svelte +++ b/src/lib/components/ContestTable.svelte @@ -1,23 +1,15 @@ diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index b7732b3..974681b 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,4 +1,4 @@ -import { fetchProblems } from '$lib/services/problem'; +import { fetchProblems } from '$lib/queries/problemQueries'; import type { PageServerLoad } from './$types'; // Server-only load: SSR ships the initial problems in the HTML and the diff --git a/src/routes/api/codeforces/problems/+server.ts b/src/routes/api/codeforces/problems/+server.ts index b204734..1ed0776 100644 --- a/src/routes/api/codeforces/problems/+server.ts +++ b/src/routes/api/codeforces/problems/+server.ts @@ -1,6 +1,4 @@ import { json } from '@sveltejs/kit'; -import { createClient } from '@supabase/supabase-js'; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { env as publicEnv } from '$env/dynamic/public'; import { fetchProblemsetCatalog, @@ -9,6 +7,7 @@ import { type CodeforcesProblemsetProblem, type ProblemRef } from '$lib/services/codeforcesProblemset'; +import { requireAdmin } from '$lib/server/authorization'; import type { RequestHandler } from './$types'; // In-memory cache of the Codeforces problemset catalog. The catalog is large @@ -32,54 +31,14 @@ async function getCatalog(): Promise { return catalog; } -/** - * Verify the request carries a valid Supabase session for an admin user. - * Uses the caller's own access token (not a service-role secret): the token - * validates the user and RLS lets that user read only their own role row. - * Returns null when authorized, or an error response when not. - */ -async function requireAdmin(request: Request): Promise { - const authHeader = request.headers.get('authorization') || ''; - const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - if (!token) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { headers: { Authorization: `Bearer ${token}` } }, - auth: { persistSession: false, autoRefreshToken: false } - }); - - const { - data: { user }, - error: userError - } = await supabase.auth.getUser(token); - if (userError || !user) { - return json({ error: 'Invalid or expired session' }, { status: 401 }); - } - - const { data: roleRow, error: roleError } = await supabase - .from('user_roles') - .select('role') - .eq('user_id', user.id) - .single(); - if (roleError || roleRow?.role !== 'admin') { - return json({ error: 'Admin privileges required' }, { status: 403 }); - } - - return null; -} - /** * Resolve Codeforces problem metadata for a batch of problems through the * anonymous problemset.problems API. Admin-gated so it is not an open proxy, * and returns only the requested metadata. */ export const POST: RequestHandler = async ({ request }) => { - const denied = await requireAdmin(request); - if (denied) { - return denied; - } + const auth = await requireAdmin(request); + if (!auth.authorized) return auth.response; let body: unknown; try { diff --git a/src/routes/api/codeforces/user-solves/+server.ts b/src/routes/api/codeforces/user-solves/+server.ts index 08f388c..d8719aa 100644 --- a/src/routes/api/codeforces/user-solves/+server.ts +++ b/src/routes/api/codeforces/user-solves/+server.ts @@ -10,6 +10,7 @@ import { normalizeHandle, type TrackedProblem } from '$lib/services/codeforcesSolves'; +import { requireUser } from '$lib/server/authorization'; import type { RequestHandler } from './$types'; // Bound the upstream fetch so a slow or hostile Codeforces cannot pin a server @@ -21,35 +22,6 @@ const FETCH_TIMEOUT_MS = 15_000; // headroom while refusing pathological payloads. const MAX_RESPONSE_BYTES = 25 * 1024 * 1024; -/** - * Verify the request carries a valid Supabase session for an authenticated app - * user (any signed-in user, not just admins). Uses the caller's own access - * token — never a service-role secret — so all downstream work runs as that - * user under RLS. Returns the user id when authorized, or an error response. - */ -async function requireUser(request: Request): Promise<{ userId: string } | Response> { - const authHeader = request.headers.get('authorization') || ''; - const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - if (!token) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { headers: { Authorization: `Bearer ${token}` } }, - auth: { persistSession: false, autoRefreshToken: false } - }); - - const { - data: { user }, - error: userError - } = await supabase.auth.getUser(token); - if (userError || !user) { - return json({ error: 'Invalid or expired session' }, { status: 401 }); - } - - return { userId: user.id }; -} - /** * Read the raw body of an upstream response with a hard byte cap so an oversized * payload is refused rather than buffered in full. Returns null when the cap is @@ -86,9 +58,7 @@ async function readBounded(response: Response): Promise { */ export const GET: RequestHandler = async ({ url, request, fetch }) => { const auth = await requireUser(request); - if (auth instanceof Response) { - return auth; - } + if (!auth.authorized) return auth.response; const handle = normalizeHandle(url.searchParams.get('handle') || ''); if (!handle) { diff --git a/src/routes/api/kattis/+server.ts b/src/routes/api/kattis/+server.ts index 8abc464..36ba7be 100644 --- a/src/routes/api/kattis/+server.ts +++ b/src/routes/api/kattis/+server.ts @@ -1,6 +1,9 @@ import { env as publicEnv } from '$env/dynamic/public'; import { json } from '@sveltejs/kit'; -import { buildCanonicalKattisProblemUrl, parseKattisProblemId } from '$lib/services/kattisUrl'; +import { + buildCanonicalKattisProblemUrl, + parseKattisProblemId +} from '$lib/providers/kattis/ingestion'; import type { RequestHandler } from './$types'; const FETCH_TIMEOUT_MS = 10_000; diff --git a/src/routes/contests/+page.server.ts b/src/routes/contests/+page.server.ts index 1e85886..f9fad45 100644 --- a/src/routes/contests/+page.server.ts +++ b/src/routes/contests/+page.server.ts @@ -1,4 +1,4 @@ -import { fetchContests } from '$lib/services/contest'; +import { fetchContests } from '$lib/queries/contestQueries'; import type { PageServerLoad } from './$types'; // Server-only load: SSR ships the initial contests in the HTML and the diff --git a/src/routes/contests/+page.svelte b/src/routes/contests/+page.svelte index c7773f0..e266515 100644 --- a/src/routes/contests/+page.svelte +++ b/src/routes/contests/+page.svelte @@ -1,32 +1,20 @@ @@ -314,16 +128,21 @@ onMount(() => {
(collection = collection.cycleDifficultySort())} + onAuthorFilter={(author) => (collection = collection.selectAuthor(author))} + onParticipatedFilter={() => (collection = collection.cycleParticipationFilter())} + onTypeFilter={() => (collection = collection.cycleTypeFilter())} />
diff --git a/src/routes/leaderboard/+page.server.ts b/src/routes/leaderboard/+page.server.ts index 4d666a9..8babe82 100644 --- a/src/routes/leaderboard/+page.server.ts +++ b/src/routes/leaderboard/+page.server.ts @@ -1,4 +1,4 @@ -import { fetchLeaderboard } from '$lib/services/leaderboard'; +import { fetchLeaderboard } from '$lib/queries/leaderboardQueries'; import type { PageServerLoad } from './$types'; // Server-only load: SSR ships the initial leaderboard rows in the HTML and the diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index 0424681..02570d0 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -1,7 +1,9 @@ - + diff --git a/tests/codeforces-ingestion.test.ts b/tests/codeforces-ingestion.test.ts new file mode 100644 index 0000000..5748b7d --- /dev/null +++ b/tests/codeforces-ingestion.test.ts @@ -0,0 +1,199 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + codeforcesProblemAliases, + createCodeforcesIngestion, + extractCodeforcesEntries, + parseCodeforcesProblemUrl, + type CodeforcesIngestionDependencies +} from '../src/lib/providers/codeforces/ingestion.ts'; + +const now = '2026-01-01T00:00:00.000Z'; + +function ingestion(overrides: Partial = {}) { + return createCodeforcesIngestion({ + checkProblem: async () => ({ duplicate: false }), + checkContest: async () => ({ duplicate: false }), + resolveProblemBatch: async (refs) => + new Map( + refs.map((ref) => [ + `${ref.contestId}:${ref.index}`, + { + problem: { + ...ref, + name: `Problem ${ref.index}`, + tags: ['math'], + rating: 1200 + } + } + ]) + ), + fetchJson: async () => ({ status: 'OK', result: [] }), + now: () => now, + ...overrides + }); +} + +test('extracts, canonicalizes, deduplicates, and orders problems before contests', () => { + assert.deepEqual( + extractCodeforcesEntries( + 'https://codeforces.com/contest/2 https://mirror.codeforces.com/contest/1/problem/A\n' + + 'https://codeforces.com/problemset/problem/1/A https://codeforces.com/gym/3/problem/B' + ), + [ + { kind: 'problem', url: 'https://codeforces.com/contest/1/problem/A' }, + { kind: 'problem', url: 'https://codeforces.com/gym/3/problem/B' }, + { kind: 'contest', url: 'https://codeforces.com/contest/2' } + ] + ); +}); + +test('per-paste regular problems use one catalog request and exact drafts', async () => { + let requests = 0; + const service = ingestion({ + resolveProblemBatch: async (refs: Array<{ contestId: string; index: string }>) => { + requests++; + return new Map( + refs.map((ref) => [ + `${ref.contestId}:${ref.index}`, + { problem: { ...ref, name: `Name ${ref.index}`, tags: ['dp'], rating: 1700 } } + ]) + ); + } + }); + const entries = service.extract( + 'https://codeforces.com/contest/1/problem/A https://codeforces.com/contest/1/problem/B' + ); + const first = await service.resolve(entries[0], 'alice'); + const second = await service.resolve(entries[1], 'alice'); + assert.equal(requests, 1); + assert.deepEqual(first, { + valid: true, + kind: 'problem', + label: 'CF 1A - Name A', + url: entries[0].url, + payload: { + name: 'Name A', + tags: ['dp'], + difficulty: 1700, + url: entries[0].url, + solved: 0, + dateAdded: now, + addedBy: 'alice', + addedByUrl: 'https://codeforces.com/profile/alice', + likes: 0, + dislikes: 0 + } + }); + assert.equal(second.valid, true); +}); + +test('regular problem aliases are handed to the duplicate boundary', async () => { + let checkedAliases: readonly string[] | undefined; + const service = ingestion({ + checkProblem: async (_url, aliases) => { + checkedAliases = aliases; + return { duplicate: false }; + } + }); + const [entry] = service.extract('https://codeforces.com/contest/42/problem/C'); + await service.resolve(entry, ''); + assert.deepEqual(checkedAliases, ['https://codeforces.com/problemset/problem/42/C']); + + const info = parseCodeforcesProblemUrl(entry.url); + assert.ok(info); + assert.deepEqual(codeforcesProblemAliases(info), checkedAliases); +}); + +test('gym fallback and contest drafts preserve defaults', async () => { + const service = ingestion({ + fetchJson: async (url: string) => + url.includes('contest.list') + ? { + status: 'OK', + result: [{ id: 9, name: 'Codeforces Round', durationSeconds: 7200 }] + } + : { status: 'OK', result: { problems: [] } } + }); + const entries = service.extract( + 'https://codeforces.com/gym/7/problem/A https://codeforces.com/contest/9' + ); + const gym = await service.resolve(entries[0], ''); + const contest = await service.resolve(entries[1], ''); + assert.deepEqual(gym, { + valid: true, + kind: 'problem', + label: 'GYM 7A - Problem A from Gym Contest 7', + url: 'https://codeforces.com/gym/7/problem/A', + payload: { + name: 'Problem A from Gym Contest 7', + tags: ['gym'], + url: 'https://codeforces.com/gym/7/problem/A', + solved: 0, + dateAdded: now, + addedBy: 'tourist', + addedByUrl: 'https://codeforces.com/profile/tourist', + likes: 0, + dislikes: 0 + } + }); + assert.deepEqual(contest, { + valid: true, + kind: 'contest', + label: 'Codeforces Round', + url: 'https://codeforces.com/contest/9', + payload: { + name: 'Codeforces Round', + url: 'https://codeforces.com/contest/9', + durationSeconds: 7200, + difficulty: undefined, + addedBy: 'tourist', + addedByUrl: 'https://codeforces.com/profile/tourist', + likes: 0, + dislikes: 0, + type: 'Codeforces' + } + }); +}); + +test('thrown catalog failures become formatted invalid rows', async () => { + const service = ingestion({ + resolveProblemBatch: async () => { + throw new Error('catalog offline'); + } + }); + const [entry] = service.extract('https://codeforces.com/contest/1/problem/A'); + assert.deepEqual(await service.resolve(entry, ''), { + valid: false, + kind: 'problem', + label: 'CF 1A', + url: entry.url, + reason: 'catalog offline' + }); +}); + +test('missing catalog entries, provider failures, and duplicates become invalid rows', async () => { + const missing = ingestion({ resolveProblemBatch: async () => new Map() }); + const [entry] = missing.extract('https://codeforces.com/contest/1/problem/A'); + const missingRow = await missing.resolve(entry, ''); + assert.match(missingRow.valid ? '' : missingRow.reason, /not found/); + + const failed = ingestion({ fetchJson: async () => ({ status: 'FAILED' }) }); + const [gym] = failed.extract('https://codeforces.com/gym/1/problem/A'); + const failedRow = await failed.resolve(gym, ''); + assert.equal(failedRow.valid, false); + + const duplicate = ingestion({ + checkProblem: async () => ({ + duplicate: true, + message: 'Problem already exists in database (with alternate URL)' + }) + }); + const [problem] = duplicate.extract('https://codeforces.com/contest/1/problem/A'); + const duplicateRow = await duplicate.resolve(problem, ''); + assert.equal(duplicateRow.valid, false); + assert.equal( + duplicateRow.valid ? '' : duplicateRow.reason, + 'Problem already exists in database (with alternate URL)' + ); +}); diff --git a/tests/codeforces.test.ts b/tests/codeforces.test.ts index 3b06b35..2a6d4eb 100644 --- a/tests/codeforces.test.ts +++ b/tests/codeforces.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for Codeforces problem URL parsing and problemset-based metadata + * Unit tests for Codeforces ingestion URL parsing and problemset metadata * resolution. Run with: `node --test tests/` * * These tests exercise the pure, dependency-free helpers only. They use a @@ -13,10 +13,10 @@ import { PROBLEMSET_API_URL, resolveFromCatalog, validateProblemRef, - parseProblemUrl, type CodeforcesProblemsetProblem, type FetchLike } from '../src/lib/services/codeforcesProblemset.ts'; +import { parseCodeforcesProblemUrl as parseProblemUrl } from '../src/lib/providers/codeforces/ingestion.ts'; // The five problems from the user repro report. const REPRO = [ diff --git a/tests/collection-ownership.test.ts b/tests/collection-ownership.test.ts new file mode 100644 index 0000000..fabef99 --- /dev/null +++ b/tests/collection-ownership.test.ts @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const readSource = (path: string) => readFileSync(join(directory, '..', path), 'utf8'); + +const problemTable = readSource('src/lib/components/ProblemTable.svelte'); +const contestTable = readSource('src/lib/components/ContestTable.svelte'); +const problemDisplay = readSource('src/lib/components/ProblemDisplay.svelte'); +const contestPage = readSource('src/routes/contests/+page.svelte'); + +test('catalog tables are controlled renderers without collection decisions', () => { + for (const source of [problemTable, contestTable]) { + assert.doesNotMatch(source, /createEventDispatcher/); + assert.doesNotMatch(source, /\.filter\(\(problem|\.filter\(\(contest/); + assert.doesNotMatch(source, /cycleTableState|nextSortDirection|sortByDifficulty|sortByScore/); + } + assert.doesNotMatch(contestTable, /filteredContests/); +}); + +test('catalog callers delegate collection decisions to domain owners', () => { + assert.match(problemDisplay, /new ProblemCollection/); + assert.match(contestPage, /new ContestCollection/); + assert.doesNotMatch(problemDisplay, /getProblemsWithoutAuthorFilter|filterProblems\(/); + assert.doesNotMatch(contestPage, /getContestsWithoutAuthorFilter|updateFilters\(/); +}); diff --git a/tests/contest-collection.test.ts b/tests/contest-collection.test.ts new file mode 100644 index 0000000..8fdd6e4 --- /dev/null +++ b/tests/contest-collection.test.ts @@ -0,0 +1,168 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Contest } from '../src/lib/queries/contestQueries.ts'; +import { ContestCollection } from '../src/lib/collections/contestCollection.ts'; + +const contests: Contest[] = [ + contest('b', 'cf-bob', 'bob', 'Codeforces', 4, 1, 3), + contest('a', 'cf-alice', 'alice', 'Codeforces', 4, 1, 1), + contest('c', 'icpc-alice', 'alice', 'ICPC', 2, 0, undefined), + contest('d', 'other-carol', 'carol', undefined, 0, 1, 5) +]; + +function contest( + id: string, + name: string, + addedBy: string, + type: string | undefined, + likes: number, + dislikes: number, + difficulty: number | undefined +): Contest { + return { + id, + name, + url: `https://example.com/${id}`, + durationSeconds: 7200, + difficulty, + dateAdded: '', + addedBy, + addedByUrl: '', + likes, + dislikes, + type + }; +} + +function ids(collection: ContestCollection): string[] { + return collection.rows.map((item) => item.id ?? ''); +} + +test('contest collection applies every current filter together', () => { + const participatedContestIds = new Set(['a', 'c']); + const participationFilters = ['all', 'participated', 'not-participated'] as const; + const typeFilters = ['all', 'icpc', 'codeforces'] as const; + const authors = [null, 'alice', 'bob'] as const; + + for (const participationFilter of participationFilters) { + for (const typeFilter of typeFilters) { + for (const selectedAuthor of authors) { + const collection = new ContestCollection({ + items: contests, + participatedContestIds, + participationFilter, + typeFilter, + selectedAuthor + }); + const expected = contests + .filter((contest) => { + const participated = !!contest.id && participatedContestIds.has(contest.id); + const participationMatches = + participationFilter === 'all' || + !contest.id || + (participationFilter === 'participated' ? participated : !participated); + const typeMatches = + typeFilter === 'all' || + (typeFilter === 'icpc' ? contest.type === 'ICPC' : contest.type !== 'ICPC'); + return participationMatches && typeMatches; + }) + .filter((contest) => !selectedAuthor || contest.addedBy === selectedAuthor) + .sort((left, right) => { + const score = right.likes - right.dislikes - (left.likes - left.dislikes); + return score || (left.id ?? '').localeCompare(right.id ?? ''); + }) + .map((contest) => contest.id); + + assert.deepEqual(ids(collection), expected); + } + } + } +}); + +test('contest type semantics classify every non-ICPC row as Codeforces', () => { + let collection = new ContestCollection({ items: contests }); + collection = collection.cycleTypeFilter(); + assert.deepEqual(ids(collection), ['c']); + collection = collection.cycleTypeFilter(); + assert.deepEqual(ids(collection), ['a', 'b', 'd']); +}); + +test('contest author options derive after participation and type filters but ignore author', () => { + let collection = new ContestCollection({ + items: contests, + participatedContestIds: new Set(['a', 'c']) + }); + collection = collection.cycleParticipationFilter().cycleTypeFilter(); + assert.deepEqual(collection.availableAuthors, ['alice']); + + collection = collection.selectAuthor('bob'); + assert.deepEqual(collection.availableAuthors, ['alice']); + assert.deepEqual(collection.rows, []); +}); + +test('contest participation filter cycles in the existing order', () => { + let collection = new ContestCollection({ + items: contests, + participatedContestIds: new Set(['a', 'c']) + }); + collection = collection.cycleParticipationFilter(); + assert.deepEqual(ids(collection), ['a', 'c']); + collection = collection.cycleParticipationFilter(); + assert.deepEqual(ids(collection), ['b', 'd']); + collection = collection.cycleParticipationFilter(); + assert.equal(collection.participationFilter, 'all'); +}); + +test('contest difficulty sorting cycles asc, desc, then stable default score order', () => { + const tiedDifficulty = contests.map((contest) => + contest.id === 'a' || contest.id === 'b' ? { ...contest, difficulty: 2 } : contest + ); + assert.deepEqual( + ids(new ContestCollection({ items: tiedDifficulty }).cycleDifficultySort()).slice(1, 3), + ['a', 'b'] + ); + + let collection = new ContestCollection({ items: contests }); + assert.equal(collection.sortKey, 'score'); + assert.deepEqual(ids(collection), ['a', 'b', 'c', 'd']); + collection = collection.cycleDifficultySort(); + assert.equal(collection.sortKey, 'difficulty'); + assert.deepEqual(ids(collection), ['c', 'a', 'b', 'd']); + collection = collection.cycleDifficultySort(); + assert.deepEqual(ids(collection), ['d', 'b', 'a', 'c']); + collection = collection.cycleDifficultySort(); + assert.equal(collection.sortKey, 'score'); + assert.deepEqual(ids(collection), ['a', 'b', 'c', 'd']); +}); + +test('contest filter reset restores all filters and default ordering', () => { + let collection = new ContestCollection({ items: contests }); + collection = collection.cycleParticipationFilter().cycleTypeFilter().selectAuthor('alice'); + collection = collection.cycleDifficultySort().resetFilters(); + + assert.equal(collection.selectedAuthor, null); + assert.equal(collection.participationFilter, 'all'); + assert.equal(collection.typeFilter, 'all'); + assert.equal(collection.difficultySortDirection, null); + assert.deepEqual(ids(collection), ['a', 'b', 'c', 'd']); +}); + +test('contest collection handles empty input and never mutates supplied source data', () => { + const source = contests.map((contest) => ({ ...contest })); + const originalOrder = source.map((item) => item.id); + const originalLikes = source[0].likes; + const collection = new ContestCollection({ items: source }); + const updated = collection.replaceSourceItem({ ...source[0], likes: originalLikes + 1 }); + + assert.deepEqual(new ContestCollection().rows, []); + assert.deepEqual(new ContestCollection().availableAuthors, []); + assert.notEqual(collection.sourceItems, source); + collection.cycleTypeFilter().cycleDifficultySort(); + assert.deepEqual( + source.map((item) => item.id), + originalOrder + ); + assert.equal(source[0].likes, originalLikes); + assert.equal(updated.sourceItems.find((item) => item.id === 'b')?.likes, originalLikes + 1); + assert.deepEqual(ids(updated), ['a', 'b', 'c', 'd']); +}); diff --git a/tests/contest-engagement-controller.test.ts b/tests/contest-engagement-controller.test.ts new file mode 100644 index 0000000..e91af27 --- /dev/null +++ b/tests/contest-engagement-controller.test.ts @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ContestCollection } from '../src/lib/collections/contestCollection.ts'; +import { createContestEngagementController } from '../src/lib/contests/contestEngagementController.ts'; +import type { + ContestEngagementGateway, + ContestFeedback +} from '../src/lib/contests/contestEngagementGateway.ts'; +import type { Contest } from '../src/lib/queries/contestQueries.ts'; + +type Actor = { user: { id: string } | null }; + +function actorStore(initial: Actor) { + let actor = initial; + const listeners = new Set<(value: Actor) => void>(); + return { + subscribe(listener: (value: Actor) => void) { + listeners.add(listener); + listener(actor); + return () => listeners.delete(listener); + }, + set(next: Actor) { + actor = next; + for (const listener of listeners) listener(actor); + }, + get listenerCount() { + return listeners.size; + } + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function contest(likes = 2, dislikes = 1): Contest { + return { + id: 'c', + name: 'Contest', + url: 'https://example.com/c', + durationSeconds: 7200, + dateAdded: '', + addedBy: 'author', + addedByUrl: '', + likes, + dislikes + }; +} + +function setup( + options: Partial = {}, + initialActor: Actor = { user: null } +) { + const actor = actorStore(initialActor); + let collection = new ContestCollection({ items: [contest()] }); + const gateway: ContestEngagementGateway = { + loadFeedback: async () => ({}), + loadParticipatedContestIds: async () => new Set(), + updateFeedback: async () => null, + setParticipation: async () => true, + ...options + }; + const controller = createContestEngagementController({ + actor, + gateway, + getCollection: () => collection, + setCollection: (next) => (collection = next) + }); + controller.start(); + return { actor, controller, collection: () => collection }; +} + +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +test('contest controller anonymous actions are exact no-ops', async () => { + let writes = 0; + const context = setup({ + updateFeedback: async () => { + writes++; + return contest(3, 1); + }, + setParticipation: async () => { + writes++; + return true; + } + }); + await context.controller.react('c', true); + await context.controller.setParticipation('c', true); + assert.equal(writes, 0); + assert.equal(context.collection().sourceItems[0].likes, 2); + assert.deepEqual(context.controller.state.feedback, {}); + assert.deepEqual([...context.controller.state.participatedContestIds], []); +}); + +test('contest controller loads, clears, and reloads on actor changes', async () => { + let actorId = 'one'; + const context = setup({ + loadFeedback: async () => ({ c: actorId === 'one' ? 'like' : 'dislike' }), + loadParticipatedContestIds: async () => new Set([actorId]) + }); + context.actor.set({ user: { id: 'one' } }); + await settle(); + assert.equal(context.controller.state.feedback.c, 'like'); + assert.deepEqual([...context.controller.state.participatedContestIds], ['one']); + context.actor.set({ user: null }); + assert.deepEqual(context.controller.state.feedback, {}); + assert.deepEqual([...context.collection().participatedContestIds], []); + actorId = 'two'; + context.actor.set({ user: { id: 'two' } }); + await settle(); + assert.equal(context.controller.state.feedback.c, 'dislike'); +}); + +test('contest reaction waits for success and null or rejection changes nothing', async () => { + const pending = deferred(); + const context = setup({ updateFeedback: () => pending.promise }, { user: { id: 'actor' } }); + await settle(); + const action = context.controller.react('c', true); + assert.equal(context.collection().sourceItems[0].likes, 2); + assert.deepEqual(context.controller.state.feedback, {}); + pending.resolve(contest(3, 1)); + await action; + assert.equal(context.collection().sourceItems[0].likes, 3); + assert.equal(context.controller.state.feedback.c, 'like'); + + const nullContext = setup({ updateFeedback: async () => null }, { user: { id: 'actor' } }); + await settle(); + await nullContext.controller.react('c', true); + assert.equal(nullContext.collection().sourceItems[0].likes, 2); + assert.deepEqual(nullContext.controller.state.feedback, {}); + + const failureContext = setup( + { updateFeedback: async () => Promise.reject(new Error('failed')) }, + { user: { id: 'actor' } } + ); + await settle(); + await failureContext.controller.react('c', true); + assert.equal(failureContext.collection().sourceItems[0].likes, 2); +}); + +test('contest participation waits for persistence success', async () => { + const pending = deferred(); + const context = setup({ setParticipation: () => pending.promise }, { user: { id: 'actor' } }); + await settle(); + const action = context.controller.setParticipation('c', true); + assert.equal(context.controller.state.participatedContestIds.has('c'), false); + pending.resolve(true); + await action; + assert.equal(context.controller.state.participatedContestIds.has('c'), true); + assert.equal(context.collection().participatedContestIds.has('c'), true); + + const failed = setup({ setParticipation: async () => false }, { user: { id: 'actor' } }); + await settle(); + await failed.controller.setParticipation('c', true); + assert.equal(failed.controller.state.participatedContestIds.has('c'), false); +}); + +test('contest controller ignores stale loads and disposed completions', async () => { + const firstFeedback = deferred(); + const secondFeedback = deferred(); + const firstParticipation = deferred>(); + const secondParticipation = deferred>(); + const feedbackLoads = [firstFeedback, secondFeedback]; + const participationLoads = [firstParticipation, secondParticipation]; + const context = setup({ + loadFeedback: () => feedbackLoads.shift()!.promise, + loadParticipatedContestIds: () => participationLoads.shift()!.promise + }); + context.actor.set({ user: { id: 'one' } }); + context.actor.set({ user: { id: 'two' } }); + firstFeedback.resolve({ c: 'like' }); + firstParticipation.resolve(new Set(['one'])); + await settle(); + assert.deepEqual(context.controller.state.feedback, {}); + context.controller.dispose(); + assert.equal(context.actor.listenerCount, 0); + await context.controller.react('c', true); + await context.controller.setParticipation('c', true); + secondFeedback.resolve({ c: 'dislike' }); + secondParticipation.resolve(new Set(['two'])); + await settle(); + assert.deepEqual(context.controller.state.feedback, {}); + assert.deepEqual([...context.controller.state.participatedContestIds], []); +}); diff --git a/tests/engagement-architecture.test.ts b/tests/engagement-architecture.test.ts new file mode 100644 index 0000000..2a1276e --- /dev/null +++ b/tests/engagement-architecture.test.ts @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; + +const root = new URL('../', import.meta.url); +const source = (path: string) => readFile(new URL(path, root), 'utf8'); + +async function assertMissing(path: string) { + await assert.rejects(access(new URL(path, root)), { code: 'ENOENT' }, path); +} + +test('problem and contest shells delegate engagement ownership', async () => { + const [problem, contest, problemTable, contestTable] = await Promise.all([ + source('src/lib/components/ProblemDisplay.svelte'), + source('src/routes/contests/+page.svelte'), + source('src/lib/components/ProblemTable.svelte'), + source('src/lib/components/ContestTable.svelte') + ]); + assert.match(problem, /createProblemEngagementController/); + assert.match(contest, /createContestEngagementController/); + for (const contents of [problem, contest, problemTable, contestTable]) { + assert.doesNotMatch(contents, /\.rpc\s*\(|\.insert\s*\(|\.delete\s*\(/); + assert.doesNotMatch(contents, /currentActor\.subscribe|\$currentActor/); + assert.doesNotMatch(contents, /currentFeedback|isUndo/); + assert.doesNotMatch( + contents, + /likes:\s*problem\.likes\s*[+-]|dislikes:\s*problem\.dislikes\s*[+-]/ + ); + } +}); + +test('legacy service facades are deleted', async () => { + await Promise.all( + ['auth', 'leaderboard', 'problem', 'contest'].map((service) => + assertMissing(`src/lib/services/${service}.ts`) + ) + ); +}); + +test('gateway composition is the only engagement layer with direct database access', async () => { + const paths = [ + 'src/lib/problems/problemEngagementController.ts', + 'src/lib/contests/contestEngagementController.ts', + 'src/lib/engagement/reactionTransition.ts' + ]; + for (const path of paths) { + const contents = await source(path); + assert.doesNotMatch(contents, /services\/database|supabase/); + } + assert.match( + await source('src/lib/problems/problemEngagementGateway.supabase.ts'), + /services\/database/ + ); + assert.match( + await source('src/lib/contests/contestEngagementGateway.supabase.ts'), + /services\/database/ + ); +}); diff --git a/tests/engagement-gateway.test.ts b/tests/engagement-gateway.test.ts new file mode 100644 index 0000000..8e66678 --- /dev/null +++ b/tests/engagement-gateway.test.ts @@ -0,0 +1,160 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createProblemEngagementGateway, + type ProblemEngagementClient +} from '../src/lib/problems/problemEngagementGateway.ts'; +import { + createContestEngagementGateway, + type ContestEngagementClient +} from '../src/lib/contests/contestEngagementGateway.ts'; +type Call = { name: string; value: unknown }; +type DeleteResponse = { data: null; error: { code?: string } | null }; +type DeleteResult = PromiseLike & { + eq: (column: string, value: string) => DeleteResult; +}; + +function deleteQuery(calls: Call[], error: { code?: string } | null = null): DeleteResult { + const promise = Promise.resolve({ data: null, error }); + const result: DeleteResult = { + eq(column: string, value: string): DeleteResult { + calls.push({ name: `eq:${column}`, value }); + return result; + }, + then: promise.then.bind(promise) + }; + return result; +} + +test('problem gateway preserves RPC params and solved persistence', async () => { + const calls: Call[] = []; + let insertError: { code?: string } | null = null; + const client: ProblemEngagementClient = { + from: (table) => ({ + insert: async (value) => { + calls.push({ name: `insert:${table}`, value }); + return { data: null, error: insertError }; + }, + delete: () => { + calls.push({ name: `delete:${table}`, value: null }); + return deleteQuery(calls); + } + }), + rpc: async (name, value) => { + calls.push({ name, value }); + return { data: [{ id: 'p' }], error: null }; + } + }; + const gateway = createProblemEngagementGateway({ + client, + getCurrentUser: () => ({ id: 'actor' }), + loadFeedback: async () => ({}), + loadSolvedProblemIds: async () => new Set(), + mapProblemRecord: (record) => ({ + id: record.id, + name: 'mapped', + tags: [], + url: '', + solved: 0, + dateAdded: '', + addedBy: '', + addedByUrl: '', + likes: 0, + dislikes: 0, + source: 'codeforces' + }) + }); + assert.equal((await gateway.updateFeedback('p', true))?.name, 'mapped'); + assert.deepEqual(calls[0], { + name: 'update_problem_feedback', + value: { p_problem_id: 'p', p_is_like: true } + }); + assert.equal(await gateway.setSolved('p', true), true); + assert.deepEqual(calls[1].value, { user_id: 'actor', problem_id: 'p' }); + assert.equal(await gateway.setSolved('p', false), true); + assert.deepEqual(calls.slice(3, 5), [ + { name: 'eq:user_id', value: 'actor' }, + { name: 'eq:problem_id', value: 'p' } + ]); + insertError = { code: '23505' }; + assert.equal(await gateway.setSolved('p', true), true); + insertError = { code: 'write-failed' }; + assert.equal(await gateway.setSolved('p', true), false); +}); + +test('problem gateway maps null/error RPC to null', async () => { + let rpcError: { code?: string } | null = null; + const client: ProblemEngagementClient = { + from: () => ({ + insert: async () => ({ data: null, error: null }), + delete: () => deleteQuery([]) + }), + rpc: async () => ({ data: null, error: rpcError }) + }; + const gateway = createProblemEngagementGateway({ + client, + getCurrentUser: () => ({ id: 'actor' }), + loadFeedback: async () => ({}), + loadSolvedProblemIds: async () => new Set(), + mapProblemRecord: () => { + throw new Error('must not map'); + } + }); + assert.equal(await gateway.updateFeedback('p', true), null); + rpcError = { code: 'rpc-failed' }; + assert.equal(await gateway.updateFeedback('p', true), null); +}); + +test('contest gateway preserves RPC params and participation persistence', async () => { + const calls: Call[] = []; + let insertError: { code?: string } | null = null; + const client: ContestEngagementClient = { + from: (table) => ({ + insert: async (value) => { + calls.push({ name: `insert:${table}`, value }); + return { data: null, error: insertError }; + }, + delete: () => { + calls.push({ name: `delete:${table}`, value: null }); + return deleteQuery(calls); + } + }), + rpc: async (name, value) => { + calls.push({ name, value }); + return { data: [{ id: 'c' }], error: null }; + } + }; + const gateway = createContestEngagementGateway({ + client, + getCurrentUser: () => ({ id: 'actor' }), + loadFeedback: async () => ({}), + loadParticipatedContestIds: async () => new Set(), + mapContestRecord: (record) => ({ + id: record.id, + name: 'mapped', + url: '', + durationSeconds: 0, + dateAdded: '', + addedBy: '', + addedByUrl: '', + likes: 0, + dislikes: 0 + }) + }); + assert.equal((await gateway.updateFeedback('c', false))?.name, 'mapped'); + assert.deepEqual(calls[0], { + name: 'update_contest_feedback', + value: { p_contest_id: 'c', p_is_like: false } + }); + assert.equal(await gateway.setParticipation('c', true), true); + assert.deepEqual(calls[1].value, { user_id: 'actor', contest_id: 'c' }); + assert.equal(await gateway.setParticipation('c', false), true); + assert.deepEqual(calls.slice(3, 5), [ + { name: 'eq:user_id', value: 'actor' }, + { name: 'eq:contest_id', value: 'c' } + ]); + insertError = { code: '23505' }; + assert.equal(await gateway.setParticipation('c', true), true); + insertError = { code: 'write-failed' }; + assert.equal(await gateway.setParticipation('c', true), false); +}); diff --git a/tests/kattis-ingestion.test.ts b/tests/kattis-ingestion.test.ts new file mode 100644 index 0000000..53614ce --- /dev/null +++ b/tests/kattis-ingestion.test.ts @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createKattisIngestion, + extractKattisEntries, + mapKattisDifficulty, + parseKattisProblemId, + type KattisIngestionDependencies +} from '../src/lib/providers/kattis/ingestion.ts'; + +const now = '2026-01-01T00:00:00.000Z'; + +function ingestion(overrides: Partial = {}) { + return createKattisIngestion({ + checkProblem: async () => ({ duplicate: false }), + fetchPage: async () => '', + parsePage: () => ({ name: 'Two Stones', rating: 1 }), + now: () => now, + logError: () => {}, + ...overrides + }); +} + +test('accepts IDs and URLs, canonicalizes, and deduplicates', () => { + assert.deepEqual( + extractKattisEntries( + 'hello https://open.kattis.com/problems/hello open.kattis.com/problems/twostones' + ), + [ + { kind: 'problem', url: 'https://open.kattis.com/problems/hello' }, + { kind: 'problem', url: 'https://open.kattis.com/problems/twostones' } + ] + ); + assert.equal(parseKattisProblemId('https://evil.com/problems/hello'), null); +}); + +test('maps 1-10 ratings to 800-3500', () => { + assert.equal(mapKattisDifficulty(1), 800); + assert.equal(mapKattisDifficulty(5), 2000); + assert.equal(mapKattisDifficulty(10), 3500); +}); + +test('page metadata produces exact defaults, submitter profile, and label', async () => { + const service = ingestion(); + const [entry] = service.extract('hello'); + assert.deepEqual(await service.resolve(entry, 'alice'), { + valid: true, + kind: 'problem', + label: 'Two Stones', + url: entry.url, + payload: { + name: 'Two Stones', + tags: [], + difficulty: 800, + url: entry.url, + solved: 0, + dateAdded: now, + addedBy: 'alice', + addedByUrl: 'https://open.kattis.com/users/alice', + likes: 0, + dislikes: 0 + } + }); +}); + +test('fetch or parse failure logs and remains a valid title-cased fallback row', async () => { + const logs: unknown[][] = []; + const service = ingestion({ + fetchPage: async () => { + throw new Error('offline'); + }, + logError: (...values: unknown[]) => logs.push(values) + }); + const [entry] = service.extract('customscontrols'); + const row = await service.resolve(entry, ''); + assert.equal(row.valid, true); + assert.equal(row.valid && row.label, 'Customscontrols'); + assert.equal(row.valid && row.payload.difficulty, undefined); + assert.equal(row.valid && row.payload.addedByUrl, 'https://open.kattis.com/users/'); + assert.equal(logs[0][0], 'Error fetching Kattis problem HTML:'); +}); + +test('duplicate checks are read-only and ingestion exposes no persistence write', async () => { + let checks = 0; + const service = ingestion({ + checkProblem: async () => { + checks++; + return { duplicate: true, message: 'Problem already exists in database' }; + } + }); + const [entry] = service.extract('hello'); + const row = await service.resolve(entry, ''); + assert.equal(checks, 1); + assert.equal(row.valid, false); + assert.equal('commit' in service, false); +}); diff --git a/tests/kattis-url.test.ts b/tests/kattis-url.test.ts index da36c38..4ac2b0a 100644 --- a/tests/kattis-url.test.ts +++ b/tests/kattis-url.test.ts @@ -3,7 +3,7 @@ * `/api/kattis` proxy against SSRF / open-proxy abuse. Run with: * `node --test tests/` * - * These tests exercise the pure, dependency-free helpers in `kattisUrl.ts` + * These tests exercise the pure, dependency-free Kattis ingestion helpers * only. They never hit the network, a browser, Supabase, or any production * data. Their purpose is to lock in exactly which inputs the proxy will treat * as a canonical Kattis problem reference (and therefore fetch) and which it @@ -16,7 +16,7 @@ import { parseKattisProblemId, buildCanonicalKattisProblemUrl, KATTIS_HOST -} from '../src/lib/services/kattisUrl.ts'; +} from '../src/lib/providers/kattis/ingestion.ts'; // --- valid inputs: bare ids and canonical / submitter-friendly URLs ---------- // Each maps to a problem id; the proxy rebuilds the canonical URL from it. diff --git a/tests/problem-collection.test.ts b/tests/problem-collection.test.ts new file mode 100644 index 0000000..0ac9ac4 --- /dev/null +++ b/tests/problem-collection.test.ts @@ -0,0 +1,198 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Problem } from '../src/lib/queries/problemQueries.ts'; +import { + NEW_PROBLEM_TOPIC, + PROBLEM_TOPICS, + ProblemCollection +} from '../src/lib/collections/problemCollection.ts'; + +const problems: Problem[] = [ + problem('b', 'graph-cf-bob', 'bob', 'graph', 'codeforces', 4, 1, 2000), + problem('a', 'graph-cf-alice', 'alice', 'graph', 'codeforces', 4, 1, 1000), + problem('c', 'misc-kattis-alice', 'alice', 'misc', 'kattis', 2, 0, undefined), + problem('d', 'new-kattis-carol', 'carol', undefined, 'kattis', 0, 1, 3000) +]; + +function problem( + id: string, + name: string, + addedBy: string, + type: string | undefined, + source: Problem['source'], + likes: number, + dislikes: number, + difficulty: number | undefined +): Problem { + return { + id, + name, + tags: type ? [type] : [], + difficulty, + url: `https://example.com/${id}`, + solved: 0, + dateAdded: '', + addedBy, + addedByUrl: '', + likes, + dislikes, + source, + type + }; +} + +function ids(collection: ProblemCollection): string[] { + return collection.rows.map((item) => item.id ?? ''); +} + +test('problem collection applies every current filter together', () => { + const solvedProblemIds = new Set(['a', 'c']); + const topics = [null, 'graph', 'misc', NEW_PROBLEM_TOPIC] as const; + const solvedFilters = ['all', 'solved', 'unsolved'] as const; + const sourceFilters = ['all', 'codeforces', 'kattis'] as const; + const authors = [null, 'alice', 'bob'] as const; + + for (const selectedTopic of topics) { + for (const solvedFilter of solvedFilters) { + for (const sourceFilter of sourceFilters) { + for (const selectedAuthor of authors) { + const collection = new ProblemCollection({ + items: problems, + solvedProblemIds, + selectedTopic, + solvedFilter, + sourceFilter, + selectedAuthor + }); + const expected = problems + .filter((problem) => { + const topicMatches = + selectedTopic === null || + (selectedTopic === NEW_PROBLEM_TOPIC + ? !problem.type + : selectedTopic === 'misc' + ? !problem.type || problem.type === 'misc' + : problem.type === selectedTopic); + const solved = !!problem.id && solvedProblemIds.has(problem.id); + const solvedMatches = + solvedFilter === 'all' || (solvedFilter === 'solved' ? solved : !solved); + const sourceMatches = sourceFilter === 'all' || problem.source === sourceFilter; + return topicMatches && solvedMatches && sourceMatches; + }) + .filter((problem) => !selectedAuthor || problem.addedBy === selectedAuthor) + .sort((left, right) => { + const score = right.likes - right.dislikes - (left.likes - left.dislikes); + return score || (left.id ?? '').localeCompare(right.id ?? ''); + }) + .map((problem) => problem.id); + + assert.deepEqual(ids(collection), expected); + } + } + } + } +}); + +test('problem topic semantics distinguish NEW and include untyped rows in misc', () => { + const collection = new ProblemCollection({ items: problems }); + assert.deepEqual(ids(collection.selectTopic(NEW_PROBLEM_TOPIC)), ['d']); + assert.deepEqual(ids(collection.selectTopic('misc')), ['c', 'd']); + assert.deepEqual(collection.topicOptions, PROBLEM_TOPICS); +}); + +test('problem author options derive after all non-author filters', () => { + let collection = new ProblemCollection({ + items: problems, + solvedProblemIds: new Set(['a', 'c']) + }); + collection = collection.cycleSolvedFilter(); + collection = collection.cycleSourceFilter(); + assert.deepEqual(collection.availableAuthors, ['alice']); + + collection = collection.selectAuthor('bob'); + assert.deepEqual(collection.availableAuthors, ['alice']); + assert.deepEqual(collection.rows, []); +}); + +test('problem solved and source filters cycle in the existing order', () => { + let collection = new ProblemCollection({ + items: problems, + solvedProblemIds: new Set(['a', 'c']) + }); + collection = collection.cycleSolvedFilter(); + assert.deepEqual(ids(collection), ['a', 'c']); + collection = collection.cycleSolvedFilter(); + assert.deepEqual(ids(collection), ['b', 'd']); + collection = collection.cycleSolvedFilter(); + assert.equal(collection.solvedFilter, 'all'); + + collection = collection.cycleSourceFilter(); + assert.deepEqual(ids(collection), ['a', 'b']); + collection = collection.cycleSourceFilter(); + assert.deepEqual(ids(collection), ['c', 'd']); + collection = collection.cycleSourceFilter(); + assert.equal(collection.sourceFilter, 'all'); +}); + +test('problem difficulty sorting cycles asc, desc, then stable default score order', () => { + const tiedDifficulty = problems.map((problem) => + problem.id === 'a' || problem.id === 'b' ? { ...problem, difficulty: 1500 } : problem + ); + assert.deepEqual( + ids(new ProblemCollection({ items: tiedDifficulty }).cycleDifficultySort()).slice(1, 3), + ['a', 'b'] + ); + + let collection = new ProblemCollection({ items: problems }); + assert.equal(collection.sortKey, 'score'); + assert.deepEqual(ids(collection), ['a', 'b', 'c', 'd']); + collection = collection.cycleDifficultySort(); + assert.equal(collection.sortKey, 'difficulty'); + assert.deepEqual(ids(collection), ['c', 'a', 'b', 'd']); + collection = collection.cycleDifficultySort(); + assert.deepEqual(ids(collection), ['d', 'b', 'a', 'c']); + collection = collection.cycleDifficultySort(); + assert.equal(collection.sortKey, 'score'); + assert.deepEqual(ids(collection), ['a', 'b', 'c', 'd']); +}); + +test('problem filter reset restores its configured default and default ordering', () => { + let collection = new ProblemCollection({ + items: problems, + solvedProblemIds: new Set(['a', 'c']), + defaultSolvedFilter: 'solved' + }); + collection = collection.selectTopic('graph').selectAuthor('alice').cycleSourceFilter(); + collection = collection.cycleDifficultySort(); + collection = collection.resetFilters(); + + assert.equal(collection.selectedTopic, null); + assert.equal(collection.selectedAuthor, null); + assert.equal(collection.sourceFilter, 'all'); + assert.equal(collection.solvedFilter, 'solved'); + assert.equal(collection.difficultySortDirection, null); + assert.deepEqual(ids(collection), ['a', 'c']); +}); + +test('problem collection handles empty input and never mutates supplied source data', () => { + const source = problems.map((problem) => ({ ...problem })); + const originalOrder = source.map((item) => item.id); + const originalLikes = source[0].likes; + const collection = new ProblemCollection({ items: source }); + const updated = collection.updateSourceItem('b', (problem) => ({ + ...problem, + likes: problem.likes + 1 + })); + + assert.deepEqual(new ProblemCollection().rows, []); + assert.deepEqual(new ProblemCollection().availableAuthors, []); + assert.notEqual(collection.sourceItems, source); + collection.selectTopic('graph').cycleDifficultySort(); + assert.deepEqual( + source.map((item) => item.id), + originalOrder + ); + assert.equal(source[0].likes, originalLikes); + assert.equal(updated.sourceItems.find((item) => item.id === 'b')?.likes, originalLikes + 1); + assert.deepEqual(ids(updated), ['a', 'b', 'c', 'd']); +}); diff --git a/tests/problem-engagement-controller.test.ts b/tests/problem-engagement-controller.test.ts new file mode 100644 index 0000000..6914e0b --- /dev/null +++ b/tests/problem-engagement-controller.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ProblemCollection } from '../src/lib/collections/problemCollection.ts'; +import { createProblemEngagementController } from '../src/lib/problems/problemEngagementController.ts'; +import type { + ProblemEngagementGateway, + ProblemFeedback +} from '../src/lib/problems/problemEngagementGateway.ts'; +import type { Problem } from '../src/lib/queries/problemQueries.ts'; + +type Actor = { user: { id: string } | null }; + +function actorStore(initial: Actor) { + let actor = initial; + const listeners = new Set<(value: Actor) => void>(); + return { + subscribe(listener: (value: Actor) => void) { + listeners.add(listener); + listener(actor); + return () => listeners.delete(listener); + }, + set(next: Actor) { + actor = next; + for (const listener of listeners) listener(actor); + }, + get listenerCount() { + return listeners.size; + } + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function problem(likes = 2, dislikes = 1): Problem { + return { + id: 'p', + name: 'Problem', + tags: [], + url: 'https://example.com/p', + solved: 0, + dateAdded: '', + addedBy: 'author', + addedByUrl: '', + likes, + dislikes, + source: 'codeforces' + }; +} + +function setup( + options: Partial = {}, + initialActor: Actor = { user: null } +) { + const actor = actorStore(initialActor); + let collection = new ProblemCollection({ items: [problem()] }); + const errors: string[] = []; + const calls: string[] = []; + const gateway: ProblemEngagementGateway = { + loadFeedback: async () => ({}), + loadSolvedProblemIds: async () => new Set(), + updateFeedback: async () => null, + setSolved: async () => true, + ...options + }; + const controller = createProblemEngagementController({ + actor, + gateway, + getCollection: () => collection, + setCollection: (next) => { + collection = next; + calls.push('collection'); + }, + applySolvedToCollection: true, + reportError: (message) => errors.push(message) + }); + controller.subscribe(() => calls.push('state')); + controller.start(); + return { actor, controller, errors, calls, collection: () => collection }; +} + +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +test('problem controller preserves anonymous messages and performs no writes', async () => { + let writes = 0; + const context = setup({ + updateFeedback: async () => { + writes++; + return null; + }, + setSolved: async () => { + writes++; + return true; + } + }); + await context.controller.react('p', true); + await context.controller.setSolved('p', true); + assert.equal(writes, 0); + assert.deepEqual(context.errors, [ + 'You must be signed in to like or dislike problems', + 'You must be signed in to mark problems as solved' + ]); +}); + +test('problem controller loads, clears, and reloads on actor changes', async () => { + let actorId = 'one'; + const context = setup({ + loadFeedback: async () => ({ p: actorId === 'one' ? 'like' : 'dislike' }), + loadSolvedProblemIds: async () => new Set([actorId]) + }); + context.actor.set({ user: { id: 'one' } }); + await settle(); + assert.equal(context.controller.state.feedback.p, 'like'); + assert.deepEqual([...context.controller.state.solvedProblemIds], ['one']); + + context.actor.set({ user: null }); + assert.deepEqual(context.controller.state.feedback, {}); + assert.deepEqual([...context.controller.state.solvedProblemIds], []); + assert.deepEqual([...context.collection().solvedProblemIds], []); + + actorId = 'two'; + context.actor.set({ user: { id: 'two' } }); + await settle(); + assert.equal(context.controller.state.feedback.p, 'dislike'); + assert.deepEqual([...context.controller.state.solvedProblemIds], ['two']); +}); + +test('problem reactions update before RPC and null preserves optimism', async () => { + const pending = deferred(); + const context = setup({ updateFeedback: () => pending.promise }, { user: { id: 'actor' } }); + await settle(); + context.calls.length = 0; + const action = context.controller.react('p', true); + assert.equal(context.collection().sourceItems[0].likes, 3); + assert.equal(context.controller.state.feedback.p, 'like'); + assert.deepEqual(context.calls, ['collection', 'state']); + pending.resolve(null); + await action; + assert.equal(context.collection().sourceItems[0].likes, 3); + assert.equal(context.controller.state.feedback.p, 'like'); +}); + +test('thrown problem reaction reloads actor feedback and solved state', async () => { + let load = 0; + const context = setup( + { + loadFeedback: async (): Promise => (++load === 1 ? {} : { p: 'dislike' }), + loadSolvedProblemIds: async () => new Set(load > 1 ? ['server'] : []), + updateFeedback: async () => { + throw new Error('rpc threw'); + } + }, + { user: { id: 'actor' } } + ); + await settle(); + await context.controller.react('p', true); + assert.equal(context.controller.state.feedback.p, 'dislike'); + assert.deepEqual([...context.controller.state.solvedProblemIds], ['server']); +}); + +test('problem solved successful add/remove keeps duplicate success optimistic', async () => { + const writes: Array<{ problemId: string; isSolved: boolean }> = []; + const context = setup( + { + setSolved: async (problemId, isSolved) => { + writes.push({ problemId, isSolved }); + return true; + } + }, + { user: { id: 'actor' } } + ); + await settle(); + await context.controller.setSolved('p', true); + assert.equal(context.controller.state.solvedProblemIds.has('p'), true); + await context.controller.setSolved('p', false); + assert.equal(context.controller.state.solvedProblemIds.has('p'), false); + assert.deepEqual(writes, [ + { problemId: 'p', isSolved: true }, + { problemId: 'p', isSolved: false } + ]); +}); + +test('problem solved changes optimistically and failed writes reconcile', async () => { + const pending = deferred(); + let loads = 0; + const context = setup( + { + loadSolvedProblemIds: async () => { + loads++; + return new Set(); + }, + setSolved: () => pending.promise + }, + { user: { id: 'actor' } } + ); + await settle(); + const action = context.controller.setSolved('p', true); + assert.equal(context.controller.state.solvedProblemIds.has('p'), true); + assert.equal(context.collection().solvedProblemIds.has('p'), true); + pending.resolve(false); + await action; + assert.equal(loads, 2); + assert.equal(context.controller.state.solvedProblemIds.has('p'), false); + + await context.controller.setSolved('p', false); + assert.equal(context.controller.state.solvedProblemIds.has('p'), false); +}); + +test('problem controller ignores stale actor loads and all loads after dispose', async () => { + const firstFeedback = deferred(); + const secondFeedback = deferred(); + const firstSolved = deferred>(); + const secondSolved = deferred>(); + const feedbackLoads = [firstFeedback, secondFeedback]; + const solvedLoads = [firstSolved, secondSolved]; + const context = setup({ + loadFeedback: () => feedbackLoads.shift()!.promise, + loadSolvedProblemIds: () => solvedLoads.shift()!.promise + }); + context.actor.set({ user: { id: 'one' } }); + context.actor.set({ user: { id: 'two' } }); + firstFeedback.resolve({ p: 'like' }); + firstSolved.resolve(new Set(['one'])); + await settle(); + assert.deepEqual(context.controller.state.feedback, {}); + + context.controller.dispose(); + assert.equal(context.actor.listenerCount, 0); + await context.controller.react('p', true); + await context.controller.setSolved('p', true); + secondFeedback.resolve({ p: 'dislike' }); + secondSolved.resolve(new Set(['two'])); + await settle(); + assert.deepEqual(context.controller.state.feedback, {}); + assert.deepEqual([...context.controller.state.solvedProblemIds], []); +}); diff --git a/tests/reaction-transition.test.ts b/tests/reaction-transition.test.ts new file mode 100644 index 0000000..46d5609 --- /dev/null +++ b/tests/reaction-transition.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { transitionReaction } from '../src/lib/engagement/reactionTransition.ts'; + +test('reaction transition handles new likes and dislikes', () => { + assert.deepEqual(transitionReaction({ likes: 2, dislikes: 3, reaction: null }, 'like'), { + likes: 3, + dislikes: 3, + reaction: 'like' + }); + assert.deepEqual(transitionReaction({ likes: 2, dislikes: 3, reaction: null }, 'dislike'), { + likes: 2, + dislikes: 4, + reaction: 'dislike' + }); +}); + +test('reaction transition handles switches and undo', () => { + assert.deepEqual(transitionReaction({ likes: 2, dislikes: 3, reaction: 'like' }, 'dislike'), { + likes: 1, + dislikes: 4, + reaction: 'dislike' + }); + assert.deepEqual(transitionReaction({ likes: 2, dislikes: 3, reaction: 'dislike' }, 'like'), { + likes: 3, + dislikes: 2, + reaction: 'like' + }); + assert.deepEqual(transitionReaction({ likes: 2, dislikes: 3, reaction: 'like' }, 'like'), { + likes: 1, + dislikes: 3, + reaction: null + }); + assert.deepEqual(transitionReaction({ likes: 2, dislikes: 3, reaction: 'dislike' }, 'dislike'), { + likes: 2, + dislikes: 2, + reaction: null + }); +}); + +test('reaction transition floors counts and leaves input immutable', () => { + const input = Object.freeze({ likes: 0, dislikes: 0, reaction: 'like' as const }); + const result = transitionReaction(input, 'dislike'); + assert.deepEqual(result, { likes: 0, dislikes: 1, reaction: 'dislike' }); + assert.deepEqual(transitionReaction({ likes: -2, dislikes: -3, reaction: null }, 'like'), { + likes: 0, + dislikes: 0, + reaction: 'like' + }); + assert.deepEqual(input, { likes: 0, dislikes: 0, reaction: 'like' }); + assert.notEqual(result, input); +}); diff --git a/tests/settings-a11y.test.ts b/tests/settings-a11y.test.ts index f93e493..3920c26 100644 --- a/tests/settings-a11y.test.ts +++ b/tests/settings-a11y.test.ts @@ -214,16 +214,16 @@ test('the settings init has no fixed timeout / artificial delay', () => { ); }); -test('the settings init gates on the resolved session (getSession) and redirects anonymous visitors', () => { +test('the settings init gates on the resolved current actor and redirects anonymous visitors', () => { assert.match( SETTINGS, - /supabase\.auth\.getSession\(\)/, - 'init must resolve the session via getSession()' + /import\s*\{[^}]*resolveCurrentActor[^}]*\}\s*from '\$lib\/auth\/currentActor';/, + 'init must resolve auth through the currentActor module' ); - // Anonymous visitors are redirected home once the session resolves. + // Anonymous visitors are redirected home once the actor resolves. assert.match( SETTINGS, - /if \(!currentUser\)\s*\{\s*\/\/[\s\S]*?goto\(resolve\('\/'\)\)/, - 'init must redirect home when no session is present' + /const actor = await resolveCurrentActor\(\);[\s\S]*?if \(!actor\.user\)\s*\{\s*\/\/[\s\S]*?goto\(resolve\('\/'\)\)/, + 'init must await the current actor and redirect home when no user is present' ); }); diff --git a/tests/submission-persistence.test.ts b/tests/submission-persistence.test.ts new file mode 100644 index 0000000..687df37 --- /dev/null +++ b/tests/submission-persistence.test.ts @@ -0,0 +1,210 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createSubmissionPersistence, + type SubmissionClient +} from '../src/lib/submit/submissionPersistence.ts'; +import type { ContestDraft, ProblemDraft } from '../src/lib/submit/types.ts'; + +type Response = { data: unknown; error: { message: string } | null }; + +function client(responses: Response[]) { + const calls: Array<{ table: string; operation: string; value: unknown }> = []; + const next = () => responses.shift() as Response; + const mock: SubmissionClient = { + from: (table) => ({ + select: () => ({ + eq: async (_column, value) => { + calls.push({ table, operation: 'select', value }); + return next() as { data: { id?: string }[] | null; error: { message: string } | null }; + } + }), + insert: (draft) => { + calls.push({ table, operation: 'insert', value: draft }); + return { + select: () => ({ + single: async () => + next() as { data: { id?: string } | null; error: { message: string } | null } + }) + }; + } + }) + }; + return { mock, calls }; +} + +const problem: ProblemDraft = { + name: 'Two Stones', + tags: ['math'], + difficulty: 800, + url: 'https://codeforces.com/contest/1/problem/A', + solved: 0, + dateAdded: '2026-01-01T00:00:00.000Z', + addedBy: 'tourist', + addedByUrl: 'https://codeforces.com/profile/tourist', + likes: 0, + dislikes: 0 +}; + +const contest: ContestDraft = { + name: 'Round', + url: 'https://codeforces.com/contest/1', + durationSeconds: 7200, + difficulty: 3, + addedBy: 'tourist', + addedByUrl: 'https://codeforces.com/profile/tourist', + likes: 0, + dislikes: 0, + type: 'Codeforces' +}; + +test('equivalent problem URL checks preserve canonical and alternate duplicate messages', async () => { + const canonical = client([{ data: [{ id: 'p' }], error: null }]); + assert.deepEqual( + await createSubmissionPersistence(canonical.mock).checkEquivalentProblemUrls('canonical', [ + 'alternate' + ]), + { duplicate: true, message: 'Problem already exists in database' } + ); + + const alternate = client([ + { data: [], error: null }, + { data: [{ id: 'p' }], error: null } + ]); + assert.deepEqual( + await createSubmissionPersistence(alternate.mock).checkEquivalentProblemUrls('canonical', [ + 'alternate' + ]), + { duplicate: true, message: 'Problem already exists in database (with alternate URL)' } + ); +}); + +test('contest duplicate and database errors retain current result shapes', async () => { + const duplicate = client([{ data: [{ id: 'c' }], error: null }]); + assert.deepEqual(await createSubmissionPersistence(duplicate.mock).checkContest('contest'), { + duplicate: true, + message: 'Contest already exists in database' + }); + + const failed = client([{ data: null, error: { message: 'denied' } }]); + assert.deepEqual(await createSubmissionPersistence(failed.mock).checkContest('contest'), { + duplicate: false, + error: 'Error checking if contest exists: denied', + message: 'Error checking if contest exists: denied' + }); +}); + +test('problem insert maps snake case, includes optional difficulty, and returns id', async () => { + const mock = client([ + { data: [], error: null }, + { data: { id: 'problem-id' }, error: null } + ]); + assert.deepEqual(await createSubmissionPersistence(mock.mock).insertProblem(problem), { + success: true, + id: 'problem-id' + }); + assert.deepEqual(mock.calls[1], { + table: 'problems', + operation: 'insert', + value: { + name: 'Two Stones', + tags: ['math'], + difficulty: 800, + url: problem.url, + solved: 0, + date_added: problem.dateAdded, + added_by: 'tourist', + added_by_url: problem.addedByUrl, + likes: 0, + dislikes: 0, + type: undefined + } + }); +}); + +test('optional difficulty is omitted and insert database errors are surfaced', async () => { + const mock = client([ + { data: [], error: null }, + { data: null, error: { message: 'write denied' } } + ]); + const result = await createSubmissionPersistence(mock.mock).insertProblem({ + ...problem, + difficulty: undefined + }); + assert.deepEqual(result, { success: false, message: 'Database error: write denied' }); + assert.equal( + Object.prototype.hasOwnProperty.call(mock.calls[1].value as object, 'difficulty'), + false + ); +}); + +test('contest insert maps snake case and returns id', async () => { + const mock = client([ + { data: [], error: null }, + { data: { id: 'contest-id' }, error: null } + ]); + assert.deepEqual(await createSubmissionPersistence(mock.mock).insertContest(contest), { + success: true, + id: 'contest-id' + }); + assert.deepEqual(mock.calls[1].value, { + name: 'Round', + url: contest.url, + type: 'Codeforces', + duration_seconds: 7200, + difficulty: 3, + added_by: 'tourist', + added_by_url: contest.addedByUrl, + likes: 0, + dislikes: 0 + }); +}); + +test('contest insert omits undefined difficulty', async () => { + const mock = client([ + { data: [], error: null }, + { data: { id: 'contest-id' }, error: null } + ]); + await createSubmissionPersistence(mock.mock).insertContest({ + ...contest, + difficulty: undefined + }); + assert.equal( + Object.prototype.hasOwnProperty.call(mock.calls[1].value as object, 'difficulty'), + false + ); +}); + +test('duplicate checks run again on insert and prevent writes', async () => { + const mock = client([{ data: [{ id: 'existing' }], error: null }]); + assert.deepEqual(await createSubmissionPersistence(mock.mock).insertProblem(problem), { + success: false, + message: 'Problem already exists in database' + }); + assert.equal( + mock.calls.some((call) => call.operation === 'insert'), + false + ); +}); + +test('problem query errors and thrown database failures preserve messages', async () => { + const queryFailure = client([{ data: null, error: { message: 'read denied' } }]); + assert.deepEqual( + await createSubmissionPersistence(queryFailure.mock).checkEquivalentProblemUrls('problem'), + { + duplicate: false, + error: 'Database query error: read denied', + message: 'Database query error: read denied' + } + ); + + const throwing: SubmissionClient = { + from: () => { + throw new Error('client unavailable'); + } + }; + assert.deepEqual(await createSubmissionPersistence(throwing).insertContest(contest), { + success: false, + message: 'client unavailable' + }); +}); diff --git a/tests/submit-architecture.test.ts b/tests/submit-architecture.test.ts new file mode 100644 index 0000000..ad6e1e4 --- /dev/null +++ b/tests/submit-architecture.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { access, readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const root = new URL('../', import.meta.url); + +async function source(path: string) { + return readFile(new URL(path, root), 'utf8'); +} + +async function assertMissing(path: string) { + await assert.rejects(access(new URL(path, root)), { code: 'ENOENT' }, path); +} + +async function typescriptFiles(path: string): Promise { + const directory = new URL(path, root); + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const relative = join(path, entry.name); + return entry.isDirectory() + ? typescriptFiles(`${relative}/`) + : entry.name.endsWith('.ts') + ? [relative] + : []; + }) + ); + return nested.flat(); +} + +const databaseImport = + /(?:from\s*['"][^'"]*(?:supabase|services\/database)[^'"]*['"]|import\s*['"][^'"]*(?:supabase|services\/database)[^'"]*['"])/i; +const directDatabaseAccess = /\.(?:from|insert|rpc)\s*\(/; + +async function assertNoDatabaseAccess(paths: string[]) { + for (const path of paths) { + const contents = await source(path); + assert.doesNotMatch(contents, databaseImport, path); + assert.doesNotMatch(contents, directDatabaseAccess, path); + } +} + +test('only the submit provider composition root may import Supabase', async () => { + const submitProviders = await typescriptFiles('src/lib/submit/providers/'); + await assertNoDatabaseAccess(submitProviders.filter((path) => !path.endsWith('/index.ts'))); + await assertNoDatabaseAccess(await typescriptFiles('src/lib/providers/')); + + const compositionRoot = await source('src/lib/submit/providers/index.ts'); + assert.match(compositionRoot, /createSubmissionPersistence\s*\(/); + assert.doesNotMatch(compositionRoot, directDatabaseAccess); +}); + +test('route shell delegates workflow state and keeps no sequencing implementation', async () => { + const route = await source('src/routes/submit/+page.svelte'); + assert.match(route, /createSubmissionWorkflow/); + assert.doesNotMatch(route, /rowSeq|handlePattern|for \(let i = 0; i < rows\.length/); + assert.doesNotMatch(route, /rows\s*=\s*rows\.filter|status:\s*'committing'/); +}); + +test('obsolete component provider factory and form contracts are deleted', async () => { + await assertMissing('src/lib/components/submitForm.ts'); + await assertMissing('src/lib/components/submitProviders.ts'); + await assertMissing('src/lib/services/codeforces.ts'); + await assertMissing('src/lib/services/kattis.ts'); + await assertMissing('src/lib/services/kattisUrl.ts'); +}); diff --git a/tests/submit-workflow.test.ts b/tests/submit-workflow.test.ts new file mode 100644 index 0000000..c4b5a17 --- /dev/null +++ b/tests/submit-workflow.test.ts @@ -0,0 +1,329 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSubmissionWorkflow, providerFromUrl } from '../src/lib/submit/workflow.ts'; +import type { + ExtractedEntry, + ProviderAdapter, + ProviderAdapters, + ResolvedItem +} from '../src/lib/submit/types.ts'; + +const draft = { + name: 'A', + tags: [], + url: 'https://example.test/a', + solved: 0, + dateAdded: '2026-01-01T00:00:00.000Z', + addedBy: 'tourist', + addedByUrl: 'https://example.test/tourist', + likes: 0, + dislikes: 0 +}; + +function adapter(overrides: Partial = {}): ProviderAdapter { + return { + id: 'codeforces', + name: 'Codeforces', + icon: '', + placeholder: '', + help: '', + extract: (text) => + text + .trim() + .split(/\s+/) + .filter(Boolean) + .map((url) => ({ kind: 'problem' as const, url })), + resolve: async (entry) => ({ + valid: true, + kind: 'problem', + label: entry.url, + url: entry.url, + payload: { ...draft, url: entry.url } + }), + commit: async () => ({ success: true }), + ...overrides + }; +} + +function adapters(codeforces = adapter(), kattis = adapter({ id: 'kattis', name: 'Kattis' })) { + return { codeforces, kattis } as ProviderAdapters; +} + +test('initializes from a valid route provider and defaults invalid routes', () => { + assert.equal(providerFromUrl(new URL('https://gitgud.test/submit?provider=kattis')), 'kattis'); + assert.equal(providerFromUrl(new URL('https://gitgud.test/submit?provider=unknown')), undefined); + assert.equal(createSubmissionWorkflow(adapters(), 'kattis').getState().provider, 'kattis'); + assert.equal(createSubmissionWorkflow(adapters()).getState().provider, 'codeforces'); +}); + +test('owns source, links, review, complete stages and derived counts', async () => { + const workflow = createSubmissionWorkflow(adapters()); + assert.equal(workflow.getState().stage, 'source'); + workflow.setPasted('one two'); + assert.equal(workflow.getState().stage, 'links'); + await workflow.resolveEntries({ authorized: true }); + assert.equal(workflow.getState().stage, 'review'); + assert.deepEqual([workflow.getState().validCount, workflow.getState().invalidCount], [2, 0]); + await workflow.confirmAdd(); + assert.equal(workflow.getState().stage, 'complete'); + assert.equal(workflow.getState().addedCount, 2); + workflow.setPasted('one two'); + assert.equal(workflow.getState().stage, 'links'); + assert.equal(workflow.getState().rows.length, 0); +}); + +test('resolve performs zero writes and confirm writes valid rows sequentially', async () => { + const events: string[] = []; + const source = adapter({ + resolve: async (entry) => { + events.push(`resolve:${entry.url}`); + return { + valid: entry.url !== 'bad', + kind: 'problem', + label: entry.url, + url: entry.url, + ...(entry.url === 'bad' ? { reason: 'bad' } : { payload: { ...draft, url: entry.url } }) + } as ResolvedItem; + }, + commit: async (item) => { + events.push(`commit:${item.url}`); + return { success: true }; + } + }); + const workflow = createSubmissionWorkflow(adapters(source)); + workflow.setPasted('one bad two'); + await workflow.resolveEntries({ authorized: true }); + assert.deepEqual(events, ['resolve:one', 'resolve:bad', 'resolve:two']); + assert.deepEqual([workflow.getState().validCount, workflow.getState().invalidCount], [2, 1]); + await workflow.confirmAdd(); + assert.deepEqual(events.slice(3), ['commit:one', 'commit:two']); +}); + +test('resolving and committing remain sequential with accurate flags', async () => { + let activeResolves = 0; + let maxResolves = 0; + let activeCommits = 0; + let maxCommits = 0; + const workflow = createSubmissionWorkflow( + adapters( + adapter({ + resolve: async (entry) => { + activeResolves++; + maxResolves = Math.max(maxResolves, activeResolves); + await Promise.resolve(); + activeResolves--; + return { + valid: true, + kind: 'problem', + label: entry.url, + url: entry.url, + payload: { ...draft, url: entry.url } + }; + }, + commit: async () => { + activeCommits++; + maxCommits = Math.max(maxCommits, activeCommits); + await Promise.resolve(); + activeCommits--; + return { success: true }; + } + }) + ) + ); + workflow.setPasted('one two'); + const resolving = workflow.resolveEntries({ authorized: true }); + assert.equal(workflow.getState().resolving, true); + await resolving; + assert.equal(workflow.getState().resolving, false); + const committing = workflow.confirmAdd(); + assert.equal(workflow.getState().committing, true); + await committing; + assert.equal(workflow.getState().committing, false); + assert.deepEqual([maxResolves, maxCommits], [1, 1]); +}); + +test('route provider sync preserves previews while user switching resets them', async () => { + const workflow = createSubmissionWorkflow(adapters()); + workflow.setPasted('one'); + await workflow.resolveEntries({ authorized: true }); + const sequence = workflow.getState().sequence; + + workflow.syncProviderFromRoute('kattis'); + assert.equal(workflow.getState().provider, 'kattis'); + assert.equal(workflow.getState().rows.length, 1); + assert.equal(workflow.getState().sequence, sequence); + workflow.syncProviderFromRoute('kattis'); + assert.equal(workflow.getState().sequence, sequence); + + workflow.selectProvider('codeforces'); + assert.equal(workflow.getState().provider, 'codeforces'); + assert.equal(workflow.getState().rows.length, 0); + workflow.setPasted('two'); + assert.equal(workflow.getState().stage, 'links'); +}); + +test('invalid duplicate rows are excluded from writes', async () => { + let commits = 0; + const workflow = createSubmissionWorkflow( + adapters( + adapter({ + resolve: async (entry) => + entry.url === 'duplicate' + ? { + valid: false, + kind: 'problem', + label: entry.url, + url: entry.url, + reason: 'Problem already exists in database' + } + : { + valid: true, + kind: 'problem', + label: entry.url, + url: entry.url, + payload: { ...draft, url: entry.url } + }, + commit: async () => ((commits += 1), { success: true }) + }) + ) + ); + workflow.setPasted('duplicate valid'); + await workflow.resolveEntries({ authorized: true }); + await workflow.confirmAdd(); + assert.equal(commits, 1); +}); + +test('removal changes the commit set', async () => { + const committed: string[] = []; + const workflow = createSubmissionWorkflow( + adapters(adapter({ commit: async (item) => (committed.push(item.url), { success: true }) })) + ); + workflow.setPasted('one two'); + await workflow.resolveEntries({ authorized: true }); + workflow.removeRow(workflow.getState().rows[0].id); + await workflow.confirmAdd(); + assert.deepEqual(committed, ['two']); +}); + +test('thrown adapter errors become row failures without stopping the sequence', async () => { + const workflow = createSubmissionWorkflow( + adapters( + adapter({ + resolve: async (entry) => { + if (entry.url === 'one') throw new Error('resolve exploded'); + return { + valid: true, + kind: 'problem', + label: entry.url, + url: entry.url, + payload: { ...draft, url: entry.url } + }; + }, + commit: async () => { + throw new Error('commit exploded'); + } + }) + ) + ); + workflow.setPasted('one two'); + await workflow.resolveEntries({ authorized: true }); + const failedItem = workflow.getState().rows[0].item; + assert.equal(failedItem.valid, false); + if (failedItem.valid) assert.fail('expected failed resolution'); + assert.match(failedItem.reason, /resolve exploded/); + await workflow.confirmAdd(); + assert.equal(workflow.getState().committedFailures, 1); + assert.equal(workflow.getState().rows[1].message, 'commit exploded'); +}); + +test('stale resolve results cannot restore a reset preview', async () => { + let release!: (item: ResolvedItem) => void; + const pending = new Promise((resolve) => (release = resolve)); + const workflow = createSubmissionWorkflow(adapters(adapter({ resolve: async () => pending }))); + workflow.setPasted('one'); + const resolving = workflow.resolveEntries({ authorized: true }); + workflow.setPasted('two'); + release({ + valid: true, + kind: 'problem', + label: 'one', + url: 'one', + payload: { ...draft, url: 'one' } + }); + assert.equal(await resolving, 'stale'); + assert.equal(workflow.getState().rows.length, 0); + assert.equal(workflow.getState().pasted, 'two'); +}); + +test('provider switches invalidate in-flight resolution', async () => { + let release!: (item: ResolvedItem) => void; + const pending = new Promise((resolve) => (release = resolve)); + const workflow = createSubmissionWorkflow(adapters(adapter({ resolve: async () => pending }))); + workflow.setPasted('one'); + const resolving = workflow.resolveEntries({ authorized: true }); + workflow.selectProvider('kattis'); + release({ + valid: true, + kind: 'problem', + label: 'one', + url: 'one', + payload: { ...draft, url: 'one' } + }); + assert.equal(await resolving, 'stale'); + assert.equal(workflow.getState().provider, 'kattis'); + assert.equal(workflow.getState().rows.length, 0); +}); + +test('stale commit results cannot restore cleared rows', async () => { + let release!: () => void; + const pending = new Promise((resolve) => (release = resolve)); + const workflow = createSubmissionWorkflow( + adapters( + adapter({ + commit: async () => { + await pending; + return { success: true }; + } + }) + ) + ); + workflow.setPasted('one'); + await workflow.resolveEntries({ authorized: true }); + const committing = workflow.confirmAdd(); + workflow.resetPreview(); + release(); + assert.equal(await committing, 'stale'); + assert.equal(workflow.getState().rows.length, 0); + assert.equal(workflow.getState().done, false); +}); + +test('clear/start-another and handle validation preserve exact messages', async () => { + const workflow = createSubmissionWorkflow(adapters()); + workflow.setHandle('!'); + workflow.setPasted('one'); + assert.equal(await workflow.resolveEntries({ authorized: true }), 'invalid-handle'); + assert.equal(workflow.getState().inlineError, 'Invalid Codeforces handle format.'); + workflow.setHandle('valid_handle'); + workflow.setPasted(''); + assert.equal(await workflow.resolveEntries({ authorized: true }), 'no-entries'); + assert.equal( + workflow.getState().inlineError, + 'No valid Codeforces URLs found. Enter at least one valid URL.' + ); + workflow.setPasted('one'); + await workflow.resolveEntries({ authorized: true }); + workflow.startAnother(); + assert.equal(workflow.getState().stage, 'source'); + assert.equal(workflow.getState().pasted, ''); + assert.equal(workflow.getState().rows.length, 0); +}); + +test('unauthorized resolve does not call the adapter', async () => { + let extracted = false; + const workflow = createSubmissionWorkflow( + adapters(adapter({ extract: (): ExtractedEntry[] => ((extracted = true), []) })) + ); + workflow.setPasted('one'); + assert.equal(await workflow.resolveEntries({ authorized: false }), 'unauthorized'); + assert.equal(extracted, false); +});