From 8b798f7bb7de997628e538dfc24a51ff14587759 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Tue, 1 Sep 2026 19:39:47 -0400 Subject: [PATCH 1/2] Adding additional feed tests --- components/Auth/screens/ForgotPassword.tsx | 8 +- components/Auth/screens/Login.tsx | 1 + components/Auth/screens/Signup.tsx | 3 + .../items/FeedItemGrantWithApplicants.tsx | 1 + components/Funding/GrantSortAndFilters.tsx | 1 + components/Funding/ProposalSortAndFilters.tsx | 1 + components/Funding/ProposalWorkCard.tsx | 2 +- components/menus/UserMenu.tsx | 2 + smoke/auth.spec.ts | 84 +++++++++++++++++- smoke/fund.spec.ts | 86 +++++++++++++++++++ smoke/helpers/api.ts | 58 +++++++++++++ smoke/helpers/auth.ts | 15 ++++ 12 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 smoke/fund.spec.ts create mode 100644 smoke/helpers/api.ts diff --git a/components/Auth/screens/ForgotPassword.tsx b/components/Auth/screens/ForgotPassword.tsx index ef40bf126..11b649523 100644 --- a/components/Auth/screens/ForgotPassword.tsx +++ b/components/Auth/screens/ForgotPassword.tsx @@ -117,10 +117,16 @@ export default function ForgotPassword({ autoComplete="email" className="w-full p-3 border rounded" ref={emailInputRef} + data-testid="auth-forgot-email-input" /> - diff --git a/components/Auth/screens/Login.tsx b/components/Auth/screens/Login.tsx index 9db617cf0..fbe934a0d 100644 --- a/components/Auth/screens/Login.tsx +++ b/components/Auth/screens/Login.tsx @@ -240,6 +240,7 @@ export default function Login({ onClick={onForgotPassword} disabled={isLoading} className="text-rhBlue-500 hover:text-rhBlue-600 text-sm" + data-testid="auth-forgot-password" > Forgot your password? diff --git a/components/Auth/screens/Signup.tsx b/components/Auth/screens/Signup.tsx index 869a789e0..48db9b947 100644 --- a/components/Auth/screens/Signup.tsx +++ b/components/Auth/screens/Signup.tsx @@ -92,6 +92,7 @@ export default function Signup({ placeholder="Full name (e.g. John Smith)" className="w-full p-3 border rounded mb-4" ref={fullNameInputRef} + data-testid="auth-signup-name-input" />
@@ -101,6 +102,7 @@ export default function Signup({ onChange={(e) => setPassword(e.target.value)} placeholder="Password" className="w-full p-3 border rounded pr-12" + data-testid="auth-signup-password-input" /> diff --git a/components/Feed/items/FeedItemGrantWithApplicants.tsx b/components/Feed/items/FeedItemGrantWithApplicants.tsx index 96b79d9bf..c54b18586 100644 --- a/components/Feed/items/FeedItemGrantWithApplicants.tsx +++ b/components/Feed/items/FeedItemGrantWithApplicants.tsx @@ -171,6 +171,7 @@ export const FeedItemGrantWithApplicants: FC = 'bg-white rounded-[14px] overflow-hidden border border-gray-200 shadow-[0_1px_3px_rgba(0,0,0,0.04)]', className )} + data-testid="grant-card" > {/* Frosted header */} diff --git a/components/Funding/GrantSortAndFilters.tsx b/components/Funding/GrantSortAndFilters.tsx index 854f7b483..4475f29a6 100644 --- a/components/Funding/GrantSortAndFilters.tsx +++ b/components/Funding/GrantSortAndFilters.tsx @@ -38,6 +38,7 @@ function SortDropdown({ aria-haspopup="listbox" aria-expanded={isOpen} className="inline-flex items-center gap-1.5 -mr-2 min-h-[44px] px-2 rounded-lg text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer touch-manipulation" + data-testid="feed-sort-trigger" > {selectedLabel} {isOpen ? : } diff --git a/components/Funding/ProposalSortAndFilters.tsx b/components/Funding/ProposalSortAndFilters.tsx index b91a74115..2cab9dfd1 100644 --- a/components/Funding/ProposalSortAndFilters.tsx +++ b/components/Funding/ProposalSortAndFilters.tsx @@ -34,6 +34,7 @@ function SortDropdown() { aria-haspopup="listbox" aria-expanded={isOpen} className="inline-flex items-center gap-1.5 -mr-2 min-h-[44px] px-2 rounded-lg text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer touch-manipulation" + data-testid="feed-sort-trigger" > {selectedLabel} {isOpen ? : } diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx index 2fcf39f43..a3b5e5317 100644 --- a/components/Funding/ProposalWorkCard.tsx +++ b/components/Funding/ProposalWorkCard.tsx @@ -91,7 +91,7 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate }; return ( -
+
@@ -446,6 +447,7 @@ export default function UserMenu({ AuthSharingService.signOutFromBothApps()} className="w-full px-4 py-2" + data-testid="user-menu-signout" >
diff --git a/smoke/auth.spec.ts b/smoke/auth.spec.ts index 1aaa6d23e..447de8808 100644 --- a/smoke/auth.spec.ts +++ b/smoke/auth.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; -import { submitCredentials } from './helpers/auth'; +import { mockApiPost } from './helpers/api'; +import { logIn, smokeCredentials, submitCredentials, uniqueEmail } from './helpers/auth'; test('a user can log in from the sign-in page', async ({ page }) => { await page.goto('/auth/signin'); @@ -36,3 +37,84 @@ test('a user can log in through the auth modal', async ({ page }) => { await expect(page.getByTestId('user-menu-button').first()).toBeVisible(); await expect(loginButton).toHaveCount(0); }); + +test('a user can request a password reset from the auth modal', async ({ page }) => { + // Mocked so that a run cannot mail a real inbox or trip rate limiting on the + // endpoint. The request is still asserted below, so what the form sends stays + // covered; only Django's own handling of it is out of scope. + const resetRequests = await mockApiPost(page, '/api/auth/password-reset/'); + + await page.goto('/'); + await page.getByTestId('topbar-login-button').click(); + + // The reset link lives on the password step, which check_account only opens + // for an account that exists and is verified, so this has to be driven as the + // smoke user rather than an arbitrary address. + const { email } = smokeCredentials(); + await page.getByTestId('auth-email-input').fill(email); + await page.getByTestId('auth-email-continue').click(); + await page.getByTestId('auth-forgot-password').click(); + + // The screen carries the address over from the email step, so a user who got + // here through the modal never retypes it. + await expect(page.getByTestId('auth-forgot-email-input')).toHaveValue(email); + await page.getByTestId('auth-forgot-submit').click(); + + await expect(page.getByRole('heading', { name: 'Request submitted' })).toBeVisible(); + await expect(page.getByText(`password reset link to ${email}`)).toBeVisible(); + expect(resetRequests).toEqual([{ body: { email } }]); +}); + +test('a user can register a new account with an email address', async ({ page }) => { + // Mocked: a real registration would leave an account behind on every pull + // request that no one can ever verify, since CI has no inbox to confirm from. + const registrations = await mockApiPost(page, '/api/auth/register/', { + status: 201, + body: { id: 1 }, + }); + + const email = uniqueEmail(); + await page.goto('/'); + await page.getByTestId('topbar-login-button').click(); + + // check_account is deliberately left unmocked. A freshly generated address + // genuinely has no account, and it is that real response which decides + // between the login and signup screens. + await page.getByTestId('auth-email-input').fill(email); + await page.getByTestId('auth-email-continue').click(); + await expect(page.getByRole('heading', { name: 'Create your account' })).toBeVisible(); + + await page.getByTestId('auth-signup-name-input').fill('Smoke Tester'); + await page.getByTestId('auth-signup-password-input').fill('SmokeTest!2468'); + await page.getByTestId('auth-signup-submit').click(); + + await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible(); + await expect(page.getByText(`verification link to ${email}`)).toBeVisible(); + + // With the response faked, the request is the only real evidence the form + // works: in particular that the single name field was split into the two + // Django expects. + expect(registrations).toHaveLength(1); + expect(registrations[0].body).toMatchObject({ + email, + password1: 'SmokeTest!2468', + password2: 'SmokeTest!2468', + first_name: 'Smoke', + last_name: 'Tester', + }); +}); + +test('a user can log out from the user menu', async ({ page }) => { + await logIn(page); + await page.goto('/'); + + await page.getByTestId('user-menu-button').first().click(); + await page.getByTestId('user-menu-signout').click(); + + // Signing out also clears the token shared with the other app and redirects + // home, but the observable result is the top bar reverting to its + // logged-out controls. Asserting that rather than the URL keeps this + // independent of the redirect flakiness noted above. + await expect(page.getByTestId('topbar-login-button')).toBeVisible(); + await expect(page.getByTestId('user-menu-button')).toHaveCount(0); +}); diff --git a/smoke/fund.spec.ts b/smoke/fund.spec.ts new file mode 100644 index 000000000..efc92b8ed --- /dev/null +++ b/smoke/fund.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Page, type Request } from '@playwright/test'; + +const GRANT_FEED = '/api/grant_feed/'; +const FUNDING_FEED = '/api/funding_feed/'; + +/** + * A sort change refetches immediately, unlike a first page load, so these waits + * are capped well below the test timeout. A broken control then reports in + * seconds instead of consuming the whole budget, twice over once CI retries. + */ +const SORT_CHANGE_TIMEOUT = 15_000; + +/** + * Neither feed writes its sort to the URL, and neither card renders a date in + * any machine-readable form, so the request the frontend builds is the only + * place the chosen order is observable. Matching on pathname keeps this working + * whichever origin NEXT_PUBLIC_API_URL points at. + */ +function feedRequest(pathname: string, ordering: string) { + return (request: Request) => { + const url = new URL(request.url()); + return url.pathname === pathname && url.searchParams.get('ordering') === ordering; + }; +} + +/** Opens a feed's sort dropdown and picks an option by its visible label. */ +async function selectSort(page: Page, label: string) { + await page.getByTestId('feed-sort-trigger').click(); + await page.getByRole('option', { name: label }).click(); +} + +test('the RFP feed renders results from the API', async ({ page }) => { + await page.goto('/fund'); + + // As with the activity feed, a failed fetch is swallowed in favour of the + // empty state rather than an error page, so assert that state is absent too. + await expect(page.getByTestId('grant-card').first()).toBeVisible(); + await expect(page.getByText('No open awards right now')).toHaveCount(0); +}); + +test('the RFP feed can be sorted by newest', async ({ page }) => { + const initialNewest = page.waitForRequest(feedRequest(GRANT_FEED, 'newest')); + await page.goto('/fund'); + await initialNewest; + await expect(page.getByTestId('grant-card').first()).toBeVisible(); + + // This feed already defaults to newest, so selecting it on a fresh page would + // pass even with the dropdown wired to nothing. Sorting away and back is what + // makes the assertion depend on the control actually driving the query. + const byAmount = page.waitForRequest(feedRequest(GRANT_FEED, 'amount_raised'), { + timeout: SORT_CHANGE_TIMEOUT, + }); + await selectSort(page, 'Highest amount'); + await byAmount; + + const backToNewest = page.waitForRequest(feedRequest(GRANT_FEED, 'newest'), { + timeout: SORT_CHANGE_TIMEOUT, + }); + await selectSort(page, 'Newest'); + await backToNewest; + + await expect(page.getByTestId('grant-card').first()).toBeVisible(); +}); + +test('the proposals feed renders results from the API', async ({ page }) => { + await page.goto('/fund/proposals'); + + await expect(page.getByTestId('proposal-card').first()).toBeVisible(); + await expect(page.getByText('No proposals submitted yet')).toHaveCount(0); +}); + +test('the proposals feed can be sorted by newest', async ({ page }) => { + // Unlike the RFP feed this one defaults to 'best', so choosing Newest is + // already a real change of order and needs no round trip to mean something. + const initialBest = page.waitForRequest(feedRequest(FUNDING_FEED, 'best')); + await page.goto('/fund/proposals'); + await initialBest; + + const byNewest = page.waitForRequest(feedRequest(FUNDING_FEED, 'newest'), { + timeout: SORT_CHANGE_TIMEOUT, + }); + await selectSort(page, 'Newest'); + await byNewest; + + await expect(page.getByTestId('proposal-card').first()).toBeVisible(); +}); diff --git a/smoke/helpers/api.ts b/smoke/helpers/api.ts new file mode 100644 index 000000000..3dfe25cf9 --- /dev/null +++ b/smoke/helpers/api.ts @@ -0,0 +1,58 @@ +import type { Page } from '@playwright/test'; + +/** + * The Django API is on a different origin to the app, and ApiClient sends + * `Content-Type: application/json`, which is not CORS-safelisted. That means + * every POST is preceded by a preflight the browser will not skip, and a + * fulfilled response is discarded unless it authorises the app's origin. + */ +const CORS_HEADERS = { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*', + 'access-control-allow-methods': '*', +}; + +/** A request captured by {@link mockApiPost}, for asserting what was sent. */ +export interface CapturedRequest { + body: Record; +} + +/** + * Intercepts POSTs to an API path and answers them without reaching Django. + * + * For endpoints whose side effects are unacceptable in a suite that runs + * against a shared environment on every pull request: registering would leave + * behind an account nothing can verify, and a password reset would mail a real + * inbox and risk tripping rate limits. The outgoing request is still recorded, + * so the payload the frontend builds stays covered even though the response is + * fabricated. + * + * Matches on pathname alone, so it holds wherever NEXT_PUBLIC_API_URL points. + * The returned array is appended to as requests arrive. + */ +export async function mockApiPost( + page: Page, + pathname: string, + response: { status?: number; body?: unknown } = {} +): Promise { + const captured: CapturedRequest[] = []; + + await page.route( + (url) => url.pathname === pathname, + async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 204, headers: CORS_HEADERS }); + return; + } + + captured.push({ body: route.request().postDataJSON() }); + await route.fulfill({ + status: response.status ?? 200, + headers: { ...CORS_HEADERS, 'content-type': 'application/json' }, + body: JSON.stringify(response.body ?? {}), + }); + } + ); + + return captured; +} diff --git a/smoke/helpers/auth.ts b/smoke/helpers/auth.ts index 4ee4b5e29..2e0a5d29f 100644 --- a/smoke/helpers/auth.ts +++ b/smoke/helpers/auth.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { expect, type Page } from '@playwright/test'; function requiredEnv(name: string): string { @@ -19,6 +20,20 @@ export function smokeCredentials() { }; } +/** + * An address for the registration spec that no account can already own. + * + * example.com is reserved by RFC 2606 and can never receive mail, so nothing + * reaches a real person even if a request escapes the mock. Randomised per + * call rather than per run because a retry that reused an address would find + * the account from the previous attempt: check_account would report it as + * existing and route the modal into the login flow, and the test would pass + * while exercising the wrong screen. + */ +export function uniqueEmail(): string { + return `rh-smoke-${randomUUID()}@example.com`; +} + /** * Drives the two-step credential form, which is shared by the /auth/signin * route and the auth modal. The caller is responsible for opening it, so this From 4dbd54023e2479f55dec4eabbc8572275f32ce44 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Wed, 2 Sep 2026 14:23:13 -0400 Subject: [PATCH 2/2] Apply to RFP via proposal spec --- .env.example | 8 +- components/Notebook/NoteEditorLayout.tsx | 1 + .../components/FundingSection.tsx | 1 + .../components/TopicsSection.tsx | 2 +- .../components/WorkImageSection.tsx | 1 + components/Notebook/PublishingForm/index.tsx | 1 + components/modals/ApplyToGrantModal.tsx | 2 + components/modals/ConfirmPublishModal.tsx | 2 + .../work/WorkHeader/WorkHeaderGrant.tsx | 1 + smoke/helpers/auth.ts | 12 +- smoke/helpers/env.ts | 17 ++ smoke/helpers/fixtures.ts | 19 +++ smoke/proposal.spec.ts | 152 ++++++++++++++++++ 13 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 smoke/helpers/env.ts create mode 100644 smoke/helpers/fixtures.ts create mode 100644 smoke/proposal.spec.ts diff --git a/.env.example b/.env.example index 522e2ba05..2346067bd 100755 --- a/.env.example +++ b/.env.example @@ -51,6 +51,10 @@ GA_MEASUREMENT_ID='example' SMOKE_BASE_URL=http://localhost:3000 # Credentials for a dedicated smoke-test account. Keep real values in -# .env.development, which is gitignored - never commit them here. +# .env.development SMOKE_USER_EMAIL= -SMOKE_USER_PASSWORD= \ No newline at end of file +SMOKE_USER_PASSWORD= + +# Content the specs navigate to. Ids only - /grant/ and /proposal/ +SMOKE_GRANT_ID=33 +SMOKE_PROPOSAL_ID=5 diff --git a/components/Notebook/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx index fad9b7625..2e407969b 100644 --- a/components/Notebook/NoteEditorLayout.tsx +++ b/components/Notebook/NoteEditorLayout.tsx @@ -334,6 +334,7 @@ export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutPr
{activeTab === 'document' && (