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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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`.
Binary file added assets/brand/archive/gitgud-cyber-cat-backup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion e2e/backend-failure.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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
Expand Down Expand Up @@ -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'
});
}
});
65 changes: 65 additions & 0 deletions e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
39 changes: 39 additions & 0 deletions e2e/server-authorization.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
16 changes: 15 additions & 1 deletion e2e/submit-codeforces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/);
Expand Down
12 changes: 12 additions & 0 deletions e2e/support/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ export async function setProviderMode(mode: ProviderMode): Promise<void> {
}
}

export type MutationMode = 'success' | 'null' | 'error' | 'delayed-success' | 'delayed-error';
export async function setMutationMode(mode: MutationMode): Promise<void> {
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<number> {
Expand Down
8 changes: 4 additions & 4 deletions e2e/support/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading