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
161 changes: 61 additions & 100 deletions e2e/bounty-application.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
*/

import { test, expect, type Page } from "@playwright/test";
import {
makeSession,
stubAuth,
seedSessionCookie,
LEADERBOARD_STUBS,
} from "./helpers/mocks";

// Must be a valid UUID (all hex chars) so toBountyIdBigInt() in
// use-competition-bounty.ts can parse it without throwing ContestError("tx_failed").
Expand Down Expand Up @@ -104,16 +110,11 @@ const MOCK_BOUNTY_FRAGMENT = {
};

// Session includes walletAddress so handleJoin() passes the wallet guard.
const MOCK_SESSION = {
user: {
id: "user-e2e-tester",
name: "E2E Tester",
email: "e2e@test.com",
image: null,
walletAddress: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGYWDOUALPIF5JD4PI21JQ",
},
session: { token: "fake-e2e-token" },
};
const MOCK_SESSION = makeSession(
"user-e2e-tester",
"E2E Tester",
"e2e@test.com",
);

type ContestContracts = {
claimBounty: (args: {
Expand All @@ -122,7 +123,7 @@ type ContestContracts = {
}) => Promise<{ txHash: string }>;
};

async function setupMocks(page: Page) {
export async function setupMocks(page: Page) {
// Inject successful contract client by default
await page.addInitScript(() => {
(globalThis as { __claimBountyCalls?: number }).__claimBountyCalls = 0;
Expand All @@ -148,27 +149,7 @@ async function setupMocks(page: Page) {
};
});

await page.route("**/api/auth/**", async (route) => {
const url = new URL(route.request().url());
// better-auth's getSession endpoint is `/api/auth/get-session` — match
// both shapes so the session mock catches it.
if (
url.pathname.endsWith("/get-session") ||
url.pathname.endsWith("/session")
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(MOCK_SESSION),
});
} else {
await route.fulfill({
status: 200,
contentType: "application/json",
body: "{}",
});
}
});
await stubAuth(page, MOCK_SESSION);

await page.route("**/api/graphql", async (route) => {
let body: {
Expand All @@ -184,78 +165,58 @@ async function setupMocks(page: Page) {
/* ignore */
}

switch (body.operationName) {
case "Bounties":
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
bounties: {
bounties: [
MOCK_BOUNTY_FRAGMENT,
MOCK_MULTI_WINNER_BOUNTY_FRAGMENT,
],
total: 2,
limit: 20,
offset: 0,
},
},
}),
});
return;
case "Bounty": {
const requestedId = body.variables?.id;
const bountyData =
requestedId === BOUNTY_ID_MULTI
? MOCK_MULTI_WINNER_BOUNTY_FRAGMENT
: MOCK_BOUNTY_FRAGMENT;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { bounty: { ...bountyData, submissions: [] } },
}),
});
return;
}
case "TopContributors":
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { topContributors: [] } }),
});
return;
case "Leaderboard":
case "GetLeaderboardUser":
case "LeaderboardUser":
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
leaderboard: { contributors: [], total: 0, limit: 10, offset: 0 },
userLeaderboard: null,
const op = body.operationName ?? "";

if (op === "Bounties") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
bounties: {
bounties: [
MOCK_BOUNTY_FRAGMENT,
MOCK_MULTI_WINNER_BOUNTY_FRAGMENT,
],
total: 2,
limit: 20,
offset: 0,
},
}),
});
return;
default:
await route.abort("failed");
},
}),
});
return;
}

if (op === "Bounty") {
const requestedId = body.variables?.id;
const bountyData =
requestedId === BOUNTY_ID_MULTI
? MOCK_MULTI_WINNER_BOUNTY_FRAGMENT
: MOCK_BOUNTY_FRAGMENT;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { bounty: { ...bountyData, submissions: [] } },
}),
});
return;
}

if (op in LEADERBOARD_STUBS) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: LEADERBOARD_STUBS[op] }),
});
return;
}

await route.abort("failed");
});

await page.context().addCookies([
{
name: "boundless_auth.session_token",
value: "fake-e2e-token",
domain: "localhost",
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
await seedSessionCookie(page);
}

test.describe("Bounty application flow", () => {
Expand Down
122 changes: 122 additions & 0 deletions e2e/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Shared e2e mock helpers.
*
* Provides the baseline auth + GraphQL route stubs used across spec files.
* Each spec can register additional page.route() calls *after* calling these
* helpers to override specific operations (Playwright matches the first
* registered handler that accepts the request).
*/

import type { Page } from "@playwright/test";

export const WALLET_ADDRESS =
"GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGYWDOUALPIF5JD4PI21JQ";

export interface MockSession {
user: {
id: string;
name: string;
email: string;
image: string | null;
walletAddress: string;
};
session: { token: string };
}

export function makeSession(
userId: string,
name: string,
email: string,
): MockSession {
return {
user: {
id: userId,
name,
email,
image: null,
walletAddress: WALLET_ADDRESS,
},
session: { token: "fake-e2e-token" },
};
}

/** Stub all /api/auth/** routes to return the given session object. */
export async function stubAuth(page: Page, session: object): Promise<void> {
await page.route("**/api/auth/**", async (route) => {
const url = new URL(route.request().url());
if (
url.pathname.endsWith("/get-session") ||
url.pathname.endsWith("/session")
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(session),
});
} else {
await route.fulfill({
status: 200,
contentType: "application/json",
body: "{}",
});
}
});
}

/**
* Stub /api/graphql for a specific set of operations.
* Pass a map of operationName → response data; any unmatched operation is aborted.
*/
export async function stubGraphQL(
page: Page,
handlers: Record<string, unknown>,
): Promise<void> {
await page.route("**/api/graphql", async (route) => {
let body: { operationName?: string } = {};
try {
body = JSON.parse(route.request().postData() ?? "{}") as {
operationName?: string;
};
} catch {
/* ignore */
}
const op = body.operationName ?? "";
if (op in handlers) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: handlers[op] }),
});
} else {
await route.abort("failed");
}
});
}

/** Returns the hostname from BASE_URL (or 'localhost' as fallback). */
export function baseUrlHostname(): string {
return new URL(process.env.BASE_URL ?? "http://localhost:3000").hostname;
}

/** Seed the session cookie against the correct host. */
export async function seedSessionCookie(page: Page): Promise<void> {
await page.context().addCookies([
{
name: "boundless_auth.session_token",
value: "fake-e2e-token",
domain: baseUrlHostname(),
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
}

/** Null-response stubs for leaderboard operations that every detail page queries. */
export const LEADERBOARD_STUBS: Record<string, unknown> = {
TopContributors: {},
Leaderboard: {},
GetLeaderboardUser: {},
LeaderboardUser: {},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading