From e04cd6dbb92434f127ed610b657bbc3a158f4bdc Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:38:51 -0500 Subject: [PATCH] test(e2e): add smoke tests, workflow tests, and Docker integration fixes Add comprehensive e2e test coverage using the Page Object Model pattern with Playwright running in Docker (stub + nectar + tests containers). - Implement POM base class with session cookie, scenario header, and navigation helpers shared across all page objects - Add page objects for all major pages: home, search, abstract, login, register, forgot-password, settings, libraries, notifications, classic-form, paper-form, journals-db, and feedback - Add public page smoke tests (home, search, classic form, paper form, journals DB, login, register, forgot password, feedback variants) - Add protected page smoke tests (libraries, notifications, settings) - Add abstract subpage smoke tests (citations, references, coreads, similar, export citation) - Add workflow tests: anonymous search, abstract tab navigation, auth-gated navigation with login redirect - Add middleware tests: session bootstrap, auth routing, verify routes - Extend stub backend with CORS headers, search/abstract API routes, and configurable bootstrap scenarios - Add Next.js afterFiles rewrites to proxy client-side API calls in Docker, avoiding cross-origin failures from unresolvable hostnames - Suppress Shepherd.js tour dialogs in tests via localStorage fixture - Add ServiceUnavailable component for 5xx errors in abstract pages - Fix empty-query facet requests firing on search page --- e2e-stub/server.js | 182 +++++++++++ e2e/README.md | 52 +++ e2e/docker-compose.yml | 5 +- e2e/fixtures/nectar.fixture.ts | 109 ++++++- e2e/global-setup.ts | 38 +++ e2e/pages/abstract.page.ts | 37 +++ e2e/pages/base.page.ts | 94 ++++++ e2e/pages/classic-form.page.ts | 14 + e2e/pages/feedback.page.ts | 18 ++ e2e/pages/forgot-password.page.ts | 14 + e2e/pages/home.page.ts | 29 ++ e2e/pages/index.ts | 15 + e2e/pages/journals-db.page.ts | 14 + e2e/pages/libraries.page.ts | 14 + e2e/pages/login.page.ts | 45 +++ e2e/pages/notifications.page.ts | 14 + e2e/pages/paper-form.page.ts | 14 + e2e/pages/register.page.ts | 14 + e2e/pages/search.page.ts | 29 ++ e2e/pages/settings.page.ts | 14 + e2e/pages/verify.page.ts | 17 + e2e/playwright.config.ts | 15 +- e2e/tests/middleware/auth-routing.spec.ts | 306 +++++------------- e2e/tests/middleware/mvp.spec.ts | 23 +- .../middleware/session-bootstrap.spec.ts | 115 +++---- e2e/tests/middleware/verify-routes.spec.ts | 116 ++----- e2e/tests/smoke/abstract-pages.spec.ts | 27 ++ e2e/tests/smoke/navigation.spec.ts | 91 ++++++ e2e/tests/smoke/protected-pages.spec.ts | 26 ++ .../workflows/abstract-navigation.spec.ts | 30 ++ .../workflows/auth-gated-navigation.spec.ts | 45 +++ e2e/tests/workflows/search-flow.spec.ts | 23 ++ next.config.mjs | 30 +- .../SearchFacet/useGetFacetData.test.ts | 51 +++ src/components/SearchFacet/useGetFacetData.ts | 2 +- .../ServiceUnavailable.test.tsx | 59 ++++ .../ServiceUnavailable/ServiceUnavailable.tsx | 67 ++++ src/components/ServiceUnavailable/index.ts | 1 + src/lib/serverside/absCanonicalization.ts | 5 +- src/pages/abs/[id]/abstract.tsx | 8 +- src/pages/abs/[id]/citations.tsx | 7 +- src/pages/abs/[id]/coreads.tsx | 7 +- src/pages/abs/[id]/credits.tsx | 7 +- src/pages/abs/[id]/graphics.tsx | 7 +- src/pages/abs/[id]/mentions.tsx | 7 +- src/pages/abs/[id]/metrics.tsx | 7 +- src/pages/abs/[id]/references.tsx | 7 +- src/pages/abs/[id]/similar.tsx | 7 +- src/pages/abs/[id]/toc.tsx | 7 +- 49 files changed, 1464 insertions(+), 421 deletions(-) create mode 100644 e2e/global-setup.ts create mode 100644 e2e/pages/abstract.page.ts create mode 100644 e2e/pages/base.page.ts create mode 100644 e2e/pages/classic-form.page.ts create mode 100644 e2e/pages/feedback.page.ts create mode 100644 e2e/pages/forgot-password.page.ts create mode 100644 e2e/pages/home.page.ts create mode 100644 e2e/pages/index.ts create mode 100644 e2e/pages/journals-db.page.ts create mode 100644 e2e/pages/libraries.page.ts create mode 100644 e2e/pages/login.page.ts create mode 100644 e2e/pages/notifications.page.ts create mode 100644 e2e/pages/paper-form.page.ts create mode 100644 e2e/pages/register.page.ts create mode 100644 e2e/pages/search.page.ts create mode 100644 e2e/pages/settings.page.ts create mode 100644 e2e/pages/verify.page.ts create mode 100644 e2e/tests/smoke/abstract-pages.spec.ts create mode 100644 e2e/tests/smoke/navigation.spec.ts create mode 100644 e2e/tests/smoke/protected-pages.spec.ts create mode 100644 e2e/tests/workflows/abstract-navigation.spec.ts create mode 100644 e2e/tests/workflows/auth-gated-navigation.spec.ts create mode 100644 e2e/tests/workflows/search-flow.spec.ts create mode 100644 src/components/SearchFacet/useGetFacetData.test.ts create mode 100644 src/components/ServiceUnavailable/ServiceUnavailable.test.tsx create mode 100644 src/components/ServiceUnavailable/ServiceUnavailable.tsx create mode 100644 src/components/ServiceUnavailable/index.ts diff --git a/e2e-stub/server.js b/e2e-stub/server.js index fab0c01c0..7f2ad28be 100644 --- a/e2e-stub/server.js +++ b/e2e-stub/server.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports const express = require('express'); const app = express(); @@ -7,6 +8,20 @@ const calls = []; app.use(express.json()); +// CORS headers for cross-origin client-side API calls. +// Must reflect the request origin (not '*') because axios sends withCredentials: true. +app.use((req, res, next) => { + const origin = req.headers.origin || 'http://nectar:8000'; + res.header('Access-Control-Allow-Origin', origin); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Test-Scenario, X-Forwarded-For'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Credentials', 'true'); + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } + next(); +}); + app.get('/accounts/bootstrap', (req, res) => { const scenario = req.headers['x-test-scenario']; @@ -229,6 +244,162 @@ app.use('/link_gateway', (req, res) => { res.status(200).json({ status: 'ok' }); }); +// --- Search API --- +app.get('/search/query', (req, res) => { + const scenario = req.headers['x-test-scenario']; + + calls.push({ + endpoint: '/search/query', + scenario, + query: req.query, + timestamp: Date.now(), + }); + + console.log(`[STUB] Search called with q=${req.query.q}, scenario: ${scenario || 'default'}`); + + // Return minimal search results + res.status(200).json({ + responseHeader: { + status: 0, + QTime: 1, + params: { q: req.query.q || '*:*', rows: req.query.rows || '10', start: '0' }, + }, + response: { + numFound: 1, + start: 0, + docs: [ + { + bibcode: '2024ApJ...test..001A', + title: ['Test Paper: A Study of Testing in Astrophysics'], + author: ['Author, Test A.', 'Writer, Example B.'], + aff: ['Test University', 'Example Institute'], + pubdate: '2024-01-00', + pub: 'The Astrophysical Journal', + citation_count: 5, + read_count: 42, + '[citations]': { num_citations: 5, num_references: 10 }, + property: ['REFEREED', 'ARTICLE'], + abstract: 'This is a test abstract for smoke testing purposes.', + doi: ['10.1234/test.2024.001'], + keyword: ['testing', 'smoke tests', 'astrophysics'], + doctype: 'article', + identifier: ['2024ApJ...test..001A'], + id: '1', + }, + ], + }, + }); +}); + +// --- Stored search / vault --- +app.get('/vault/query/:qid', (req, res) => { + calls.push({ endpoint: `/vault/query/${req.params.qid}`, timestamp: Date.now() }); + res.status(200).json({ qid: req.params.qid, query: 'bibcode:2024ApJ...test..001A', numfound: 1 }); +}); + +app.post('/vault/query', (req, res) => { + calls.push({ endpoint: '/vault/query', timestamp: Date.now() }); + res.status(200).json({ qid: 'test-qid-001' }); +}); + +// --- User settings --- +app.get('/vault/user-data', (req, res) => { + calls.push({ endpoint: '/vault/user-data', timestamp: Date.now() }); + res.status(200).json({}); +}); + +// --- Notifications --- +app.get('/vault/notifications', (req, res) => { + calls.push({ endpoint: '/vault/notifications', timestamp: Date.now() }); + res.status(200).json([]); +}); + +app.use('/vault/notification_query', (req, res) => { + calls.push({ endpoint: req.baseUrl + req.path, timestamp: Date.now() }); + res.status(200).json([]); +}); + +// --- Libraries --- +app.get('/biblib/libraries', (req, res) => { + calls.push({ endpoint: '/biblib/libraries', timestamp: Date.now() }); + res.status(200).json({ libraries: [] }); +}); + +app.get('/biblib/libraries/:id', (req, res) => { + calls.push({ endpoint: `/biblib/libraries/${req.params.id}`, timestamp: Date.now() }); + res.status(200).json({ metadata: { name: 'Test Library', id: req.params.id, num_documents: 0 }, documents: [] }); +}); + +// --- ORCID --- +app.use('/orcid', (req, res) => { + calls.push({ endpoint: req.baseUrl + req.path, timestamp: Date.now() }); + res.status(200).json({}); +}); + +// --- Resolver/objects --- +app.use('/resolver', (req, res) => { + calls.push({ endpoint: req.baseUrl + req.path, timestamp: Date.now() }); + res.status(200).json({ links: { records: [] } }); +}); + +app.get('/objects/query', (req, res) => { + calls.push({ endpoint: '/objects/query', timestamp: Date.now() }); + res.status(200).json({}); +}); + +// --- Graphics --- +app.get('/graphics/:bibcode', (req, res) => { + calls.push({ endpoint: `/graphics/${req.params.bibcode}`, timestamp: Date.now() }); + res.status(200).json({}); +}); + +// --- Metrics --- +app.get('/metrics', (req, res) => { + calls.push({ endpoint: '/metrics', timestamp: Date.now() }); + res.status(200).json({}); +}); + +app.post('/metrics', (req, res) => { + calls.push({ endpoint: '/metrics', timestamp: Date.now() }); + res.status(200).json({}); +}); + +// --- Export --- +app.post('/export/:format', (req, res) => { + calls.push({ endpoint: `/export/${req.params.format}`, timestamp: Date.now() }); + res.status(200).json({ msg: 'Retrieved 0 abstracts, took 0 seconds.', export: '' }); +}); + +// --- Reference resolver --- +app.post('/reference/text', (req, res) => { + calls.push({ endpoint: '/reference/text', timestamp: Date.now() }); + res.status(200).json({ resolved: { bibcode: [] } }); +}); + +// --- Citation helper --- +app.post('/citation_helper', (req, res) => { + calls.push({ endpoint: '/citation_helper', timestamp: Date.now() }); + res.status(200).json({ new: [], recommendations: [] }); +}); + +// --- Author network / paper network / concept cloud --- +app.use('/vis', (req, res) => { + calls.push({ endpoint: req.baseUrl + req.path, timestamp: Date.now() }); + res.status(200).json({ data: {} }); +}); + +// --- Catch-all: return 404 for unknown endpoints so missing stubs are visible --- +app.use((req, res) => { + calls.push({ + endpoint: req.path, + method: req.method, + timestamp: Date.now(), + note: 'catch-all-404', + }); + console.warn(`[STUB] WARNING: Unhandled ${req.method} ${req.path}`); + res.status(404).json({ error: 'not-stubbed', path: req.path }); +}); + app.listen(PORT, () => { console.log(`[STUB] E2E stub backend listening on http://127.0.0.1:${PORT}`); console.log('[STUB] Endpoints:'); @@ -236,6 +407,17 @@ app.listen(PORT, () => { console.log(' - POST /accounts/user/login'); console.log(' - GET /accounts/verify/:token'); console.log(' - ALL /link_gateway/*'); + console.log(' - GET /search/query'); + console.log(' - GET /vault/query/:qid'); + console.log(' - POST /vault/query'); + console.log(' - GET /vault/user-data'); + console.log(' - GET /vault/notifications'); + console.log(' - GET /biblib/libraries'); + console.log(' - GET /resolver/:bibcode/*'); + console.log(' - GET /graphics/:bibcode'); + console.log(' - GET /metrics'); + console.log(' - POST /export/:format'); + console.log(' - ALL (catch-all -> 404)'); console.log(' - GET /__test__/calls'); console.log(' - POST /__test__/reset'); }); diff --git a/e2e/README.md b/e2e/README.md index 061b48c35..1a30da5a3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -141,3 +141,55 @@ pnpm test:e2e:docker lsof -ti:8000 | xargs kill -9 lsof -ti:18080 | xargs kill -9 ``` + +## Page Object Model + +Tests use the Page Object Model (POM) pattern. Page objects live in `e2e/pages/` and encapsulate selectors and actions for each page. + +### Directory structure + +``` +e2e/ +├── fixtures/ +│ ├── nectar.fixture.ts — Playwright fixtures (provides page objects + helpers) +│ └── helpers.ts — Cookie/response utility functions +├── pages/ +│ ├── base.page.ts — Abstract base class (navigation, cookies, scenarios) +│ ├── home.page.ts — Landing page +│ ├── login.page.ts — Login form page +│ ├── search.page.ts — Search results page +│ ├── register.page.ts — Registration page +│ ├── forgot-password.page.ts — Forgot password page +│ ├── verify.page.ts — Email verification page +│ ├── settings.page.ts — User settings page +│ └── index.ts — Barrel export +└── tests/ + ├── middleware/ — Middleware integration tests + └── smoke/ — Smoke/navigation tests +``` + +### Adding a new page object + +1. Create `e2e/pages/my-page.page.ts` extending `BasePage` +2. Set the `path` property (e.g., `/my-page`) +3. Add selectors as private readonly properties +4. Add action methods (e.g., `fillForm`, `submit`) +5. Export from `e2e/pages/index.ts` +6. Add a fixture in `e2e/fixtures/nectar.fixture.ts` + +### Using page objects in tests + +```typescript +import { test, expect } from '../../fixtures/nectar.fixture'; + +test('example', async ({ loginPage, searchPage }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioHeader('bootstrap-anonymous'); + await loginPage.goto(); + + await loginPage.fillCredentials('user@example.com', 'pass'); + await loginPage.submit(); +}); +``` + +Page objects are provided automatically via Playwright fixtures — destructure them in the test signature. diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 303383806..fe0c1b148 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -29,15 +29,16 @@ services: - NODE_TLS_REJECT_UNAUTHORIZED=0 - API_HOST_SERVER=http://stub:18080 - API_HOST_CLIENT=http://stub:18080 - - NEXT_PUBLIC_API_HOST_CLIENT=http://127.0.0.1:18080 + - NEXT_PUBLIC_API_HOST_CLIENT= - BASE_URL=http://stub:18080 + - E2E_API_PROXY=true - NEXT_PUBLIC_BASE_CANONICAL_URL=http://127.0.0.1:8000 - ADS_SESSION_COOKIE_NAME=ads_session - SCIX_SESSION_COOKIE_NAME=scix_session - COOKIE_SECRET=test-secret-must-be-at-least-32-chars-long - AUTH_SECRET=test-auth-secret-must-be-at-least-32-chars - NEXT_PUBLIC_LOG_LEVEL=debug - - RATE_LIMIT_COUNT=100 + - RATE_LIMIT_COUNT=10000 - RATE_LIMIT_MAX=1500 - RATE_LIMIT_TTL=60000 depends_on: diff --git a/e2e/fixtures/nectar.fixture.ts b/e2e/fixtures/nectar.fixture.ts index 28e082f2c..e1628ce48 100644 --- a/e2e/fixtures/nectar.fixture.ts +++ b/e2e/fixtures/nectar.fixture.ts @@ -1,22 +1,119 @@ +/* eslint-disable react-hooks/rules-of-hooks -- Playwright fixture `use` is not React */ import { test as base, expect } from '@playwright/test'; +import { HomePage } from '../pages/home.page'; +import { LoginPage } from '../pages/login.page'; +import { SearchPage } from '../pages/search.page'; +import { RegisterPage } from '../pages/register.page'; +import { ForgotPasswordPage } from '../pages/forgot-password.page'; +import { VerifyPage } from '../pages/verify.page'; +import { SettingsPage } from '../pages/settings.page'; +import { AbstractPage } from '../pages/abstract.page'; +import { ClassicFormPage } from '../pages/classic-form.page'; +import { PaperFormPage } from '../pages/paper-form.page'; +import { JournalsDbPage } from '../pages/journals-db.page'; +import { FeedbackPage } from '../pages/feedback.page'; +import { LibrariesPage } from '../pages/libraries.page'; +import { NotificationsPage } from '../pages/notifications.page'; export type NectarTestContext = { nectarUrl: string; + homePage: HomePage; + loginPage: LoginPage; + searchPage: SearchPage; + registerPage: RegisterPage; + forgotPasswordPage: ForgotPasswordPage; + verifyPage: VerifyPage; + settingsPage: SettingsPage; + abstractPage: AbstractPage; + classicFormPage: ClassicFormPage; + paperFormPage: PaperFormPage; + journalsDbPage: JournalsDbPage; + feedbackPage: FeedbackPage; + librariesPage: LibrariesPage; + notificationsPage: NotificationsPage; setTestScenario: (scenario: string) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- __NEXT_DATA__ is untyped getNextData: () => Promise; assertCookieRewritten: (cookie: string | undefined, expectedValue: string) => void; + resetStub: () => Promise; }; +const STUB_URL = process.env.STUB_URL || 'http://127.0.0.1:18080'; + export const test = base.extend({ nectarUrl: async ({}, use) => { await use(process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'); }, - setTestScenario: async ({ context }, use) => { - let currentScenario: string | null = null; + // Suppress Shepherd.js tour dialogs in all tests by pre-setting the + // localStorage flags that the tour guards check before auto-starting. + page: async ({ page }, use) => { + await page.addInitScript(() => { + localStorage.setItem('seen-landing-tour', 'true'); + localStorage.setItem('seen-results-tour', 'true'); + localStorage.setItem('seen-abstract-tour', 'true'); + }); + await use(page); + }, + + homePage: async ({ page, context, nectarUrl }, use) => { + await use(new HomePage(page, context, nectarUrl)); + }, + + loginPage: async ({ page, context, nectarUrl }, use) => { + await use(new LoginPage(page, context, nectarUrl)); + }, + + searchPage: async ({ page, context, nectarUrl }, use) => { + await use(new SearchPage(page, context, nectarUrl)); + }, + registerPage: async ({ page, context, nectarUrl }, use) => { + await use(new RegisterPage(page, context, nectarUrl)); + }, + + forgotPasswordPage: async ({ page, context, nectarUrl }, use) => { + await use(new ForgotPasswordPage(page, context, nectarUrl)); + }, + + verifyPage: async ({ page, context, nectarUrl }, use) => { + await use(new VerifyPage(page, context, nectarUrl)); + }, + + settingsPage: async ({ page, context, nectarUrl }, use) => { + await use(new SettingsPage(page, context, nectarUrl)); + }, + + abstractPage: async ({ page, context, nectarUrl }, use) => { + await use(new AbstractPage(page, context, nectarUrl)); + }, + + classicFormPage: async ({ page, context, nectarUrl }, use) => { + await use(new ClassicFormPage(page, context, nectarUrl)); + }, + + paperFormPage: async ({ page, context, nectarUrl }, use) => { + await use(new PaperFormPage(page, context, nectarUrl)); + }, + + journalsDbPage: async ({ page, context, nectarUrl }, use) => { + await use(new JournalsDbPage(page, context, nectarUrl)); + }, + + feedbackPage: async ({ page, context, nectarUrl }, use) => { + await use(new FeedbackPage(page, context, nectarUrl)); + }, + + librariesPage: async ({ page, context, nectarUrl }, use) => { + await use(new LibrariesPage(page, context, nectarUrl)); + }, + + notificationsPage: async ({ page, context, nectarUrl }, use) => { + await use(new NotificationsPage(page, context, nectarUrl)); + }, + + setTestScenario: async ({ context }, use) => { await use(async (scenario: string) => { - currentScenario = scenario; await context.route('**/*', async (route) => { const headers = route.request().headers(); if (scenario) { @@ -45,6 +142,12 @@ export const test = base.extend({ expect(cookie).not.toContain('Domain='); }); }, + + resetStub: async ({ request }, use) => { + await use(async () => { + await request.post(`${STUB_URL}/__test__/reset`); + }); + }, }); export { expect }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 000000000..4378f8e38 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,38 @@ +/** + * Warms the Next.js dev server compilation cache before tests run. + * + * In Docker CI, the first browser request to each page triggers + * on-demand compilation of both server and client bundles, which can + * take 30-60s on slow runners — exceeding Playwright action timeouts. + * + * This setup launches a throwaway browser, navigates to key pages, + * and waits for them to fully load. Subsequent test navigations hit + * the already-compiled bundles and load quickly. + */ +import { chromium } from '@playwright/test'; + +async function globalSetup() { + const baseUrl = process.env.BASE_URL || 'http://127.0.0.1:8000'; + + const browser = await chromium.launch({ args: ['--use-gl=egl'] }); + const context = await browser.newContext(); + await context.addCookies([{ name: 'ads_session', value: 'warmup', url: baseUrl }]); + const page = await context.newPage(); + await page.setExtraHTTPHeaders({ 'x-test-scenario': 'bootstrap-anonymous' }); + + const pages = ['/', '/search?p=1&q=test']; + + for (const path of pages) { + const url = `${baseUrl}${path}`; + try { + await page.goto(url, { timeout: 120_000, waitUntil: 'networkidle' }); + console.log(`[warmup] ${url} — loaded`); + } catch { + console.warn(`[warmup] ${url} — timed out, continuing`); + } + } + + await browser.close(); +} + +export default globalSetup; diff --git a/e2e/pages/abstract.page.ts b/e2e/pages/abstract.page.ts new file mode 100644 index 000000000..00f45f48b --- /dev/null +++ b/e2e/pages/abstract.page.ts @@ -0,0 +1,37 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class AbstractPage extends BasePage { + protected readonly path = '/abs'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async gotoAbstract(bibcode: string): Promise { + await this.page.goto(`${this.baseUrl}${this.path}/${bibcode}/abstract`); + } + + async gotoSubpage(bibcode: string, subpage: string): Promise { + await this.page.goto(`${this.baseUrl}${this.path}/${bibcode}/${subpage}`); + } + + async expectVisible(): Promise { + await this.page.locator('article[aria-labelledby="title"]').waitFor({ state: 'visible' }); + } + + async expectNavMenu(): Promise { + await this.page.getByRole('navigation', { name: 'sidebar' }).waitFor({ state: 'visible' }); + } + + async clickNavTab(tabName: string): Promise { + await this.page + .getByRole('navigation', { name: 'sidebar' }) + .getByRole('link', { name: new RegExp(tabName) }) + .click(); + } + + async expectSubviewTitle(text: string | RegExp): Promise { + await this.page.locator('#abstract-subview-title').getByText(text).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/base.page.ts b/e2e/pages/base.page.ts new file mode 100644 index 000000000..e466b4071 --- /dev/null +++ b/e2e/pages/base.page.ts @@ -0,0 +1,94 @@ +import { Page, BrowserContext, Response, expect } from '@playwright/test'; + +export abstract class BasePage { + constructor(protected readonly page: Page, protected readonly context: BrowserContext, readonly baseUrl: string) {} + + protected abstract readonly path: string; + + get url(): string { + return this.page.url(); + } + + buildUrl(params?: string): string { + const base = `${this.baseUrl}${this.path}`; + if (!params) { + return base; + } + const url = new URL(base); + const extra = new URLSearchParams(params.replace(/^\?/, '')); + extra.forEach((value, key) => url.searchParams.set(key, value)); + return url.toString(); + } + + async goto(options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }): Promise { + return this.page.goto(`${this.baseUrl}${this.path}`, options); + } + + async gotoWithParams( + params: string, + options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }, + ): Promise { + return this.page.goto(this.buildUrl(params), options); + } + + async gotoUrl(url: string, options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }): Promise { + return this.page.goto(url, options); + } + + async waitForUrl( + pattern: string | RegExp, + options?: { timeout?: number; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit' }, + ): Promise { + await this.page.waitForURL(pattern, options); + } + + async waitForLoadState(state: 'load' | 'domcontentloaded' | 'networkidle'): Promise { + await this.page.waitForLoadState(state); + } + + async clearCookies(): Promise { + await this.context.clearCookies(); + } + + async addSessionCookie(value: string): Promise { + await this.context.addCookies([{ name: 'ads_session', value, url: this.baseUrl }]); + } + + async setScenarioHeader(scenario: string): Promise { + await this.page.setExtraHTTPHeaders({ + 'x-test-scenario': scenario, + }); + } + + async setScenarioViaRoute(scenario: string): Promise { + await this.context.route('**/*', async (route) => { + const headers = route.request().headers(); + headers['x-test-scenario'] = scenario; + await route.continue({ headers }); + }); + } + + async setExtraHeaders(headers: Record): Promise { + await this.page.setExtraHTTPHeaders(headers); + } + + async interceptRoute(pattern: string, handler: Parameters[1]): Promise { + await this.page.route(pattern, handler); + } + + async getCookies() { + return this.context.cookies(); + } + + urlContains(substring: string): void { + expect(this.page.url()).toContain(substring); + } + + urlEquals(expected: string): void { + expect(this.page.url()).toBe(expected); + } + + urlMatches(pattern: RegExp): void { + expect(this.page.url()).toMatch(pattern); + } +} diff --git a/e2e/pages/classic-form.page.ts b/e2e/pages/classic-form.page.ts new file mode 100644 index 000000000..e67b827ff --- /dev/null +++ b/e2e/pages/classic-form.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class ClassicFormPage extends BasePage { + protected readonly path = '/classic-form'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.locator('[aria-labelledby="form-title"]').waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/feedback.page.ts b/e2e/pages/feedback.page.ts new file mode 100644 index 000000000..8a43ea6b2 --- /dev/null +++ b/e2e/pages/feedback.page.ts @@ -0,0 +1,18 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class FeedbackPage extends BasePage { + protected readonly path = '/feedback/general'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async gotoFeedbackType(type: string): Promise { + await this.page.goto(`${this.baseUrl}/feedback/${type}`); + } + + async expectVisible(): Promise { + await this.page.locator('main').waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/forgot-password.page.ts b/e2e/pages/forgot-password.page.ts new file mode 100644 index 000000000..1995f4d11 --- /dev/null +++ b/e2e/pages/forgot-password.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class ForgotPasswordPage extends BasePage { + protected readonly path = '/user/account/forgotpassword'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.getByRole('heading', { name: /forgot password/i }).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/home.page.ts b/e2e/pages/home.page.ts new file mode 100644 index 000000000..de0366031 --- /dev/null +++ b/e2e/pages/home.page.ts @@ -0,0 +1,29 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class HomePage extends BasePage { + protected readonly path = '/'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async search(query: string): Promise { + // Set the input value and submit in a single synchronous evaluate. + // The search input is a Chakra Combobox with controlled state — using + // Playwright's fill() sets the DOM value, but React re-renders and + // clears it before a separate submit call can run. By setting the + // value and calling form.submit() in one JS execution frame, React + // cannot re-render between them. + await this.page.evaluate((q) => { + const input = document.querySelector('input[name="q"]') as HTMLInputElement; + input.value = q; + const form = document.querySelector('form[action="/search"]') as HTMLFormElement; + form.submit(); + }, query); + } + + async expectVisible(): Promise { + await this.page.getByPlaceholder('all search terms').waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts new file mode 100644 index 000000000..a0a734c83 --- /dev/null +++ b/e2e/pages/index.ts @@ -0,0 +1,15 @@ +export { BasePage } from './base.page'; +export { HomePage } from './home.page'; +export { LoginPage } from './login.page'; +export { SearchPage } from './search.page'; +export { RegisterPage } from './register.page'; +export { ForgotPasswordPage } from './forgot-password.page'; +export { VerifyPage } from './verify.page'; +export { SettingsPage } from './settings.page'; +export { AbstractPage } from './abstract.page'; +export { ClassicFormPage } from './classic-form.page'; +export { PaperFormPage } from './paper-form.page'; +export { JournalsDbPage } from './journals-db.page'; +export { FeedbackPage } from './feedback.page'; +export { LibrariesPage } from './libraries.page'; +export { NotificationsPage } from './notifications.page'; diff --git a/e2e/pages/journals-db.page.ts b/e2e/pages/journals-db.page.ts new file mode 100644 index 000000000..d0a440acf --- /dev/null +++ b/e2e/pages/journals-db.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class JournalsDbPage extends BasePage { + protected readonly path = '/journalsdb'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.getByRole('heading', { name: /journals database/i }).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/libraries.page.ts b/e2e/pages/libraries.page.ts new file mode 100644 index 000000000..301112951 --- /dev/null +++ b/e2e/pages/libraries.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class LibrariesPage extends BasePage { + protected readonly path = '/user/libraries'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.locator('#main-content').waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts new file mode 100644 index 000000000..cbaac47ac --- /dev/null +++ b/e2e/pages/login.page.ts @@ -0,0 +1,45 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class LoginPage extends BasePage { + protected readonly path = '/user/account/login'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async gotoWithNext(nextUrl: string): Promise { + await this.gotoWithParams(`?next=${encodeURIComponent(nextUrl)}`); + } + + async fillCredentials(email: string, password: string): Promise { + // Chakra UI's explicit id="email" breaks FormLabel for/id association, + // so getByLabel doesn't work. Use locator by input name instead. + await this.page.locator('input[name="email"]').fill(email); + await this.page.locator('input[name="password"]').fill(password); + } + + async submit(): Promise { + await this.page.getByRole('button', { name: /submit/i }).click(); + } + + async mockLoginSuccess(): Promise { + await this.interceptRoute('**/api/auth/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + } + + async login(email: string, password: string): Promise { + await this.fillCredentials(email, password); + await this.mockLoginSuccess(); + await this.submit(); + } + + async expectVisible(): Promise { + await this.page.getByRole('heading', { name: /login/i }).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/notifications.page.ts b/e2e/pages/notifications.page.ts new file mode 100644 index 000000000..9771904a3 --- /dev/null +++ b/e2e/pages/notifications.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class NotificationsPage extends BasePage { + protected readonly path = '/user/notifications'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.getByRole('heading', { name: /email notifications/i }).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/paper-form.page.ts b/e2e/pages/paper-form.page.ts new file mode 100644 index 000000000..0c135e076 --- /dev/null +++ b/e2e/pages/paper-form.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class PaperFormPage extends BasePage { + protected readonly path = '/paper-form'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.getByRole('heading', { name: /journal search/i }).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/register.page.ts b/e2e/pages/register.page.ts new file mode 100644 index 000000000..8269f10ef --- /dev/null +++ b/e2e/pages/register.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class RegisterPage extends BasePage { + protected readonly path = '/user/account/register'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.getByRole('heading', { name: /register/i }).waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/search.page.ts b/e2e/pages/search.page.ts new file mode 100644 index 000000000..c33f8f992 --- /dev/null +++ b/e2e/pages/search.page.ts @@ -0,0 +1,29 @@ +import { Page, BrowserContext, Response, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class SearchPage extends BasePage { + protected readonly path = '/search'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async search(query: string): Promise { + await this.page.evaluate((q) => { + const input = document.querySelector('input[name="q"]') as HTMLInputElement; + input.value = q; + const form = document.querySelector('form[action="/search"]') as HTMLFormElement; + form.submit(); + }, query); + } + + async gotoAndExpect(options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }): Promise { + const response = await this.goto(options); + expect(response).not.toBeNull(); + return response!; + } + + async expectVisible(): Promise { + await this.page.getByPlaceholder('all search terms').waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/settings.page.ts b/e2e/pages/settings.page.ts new file mode 100644 index 000000000..5680ca153 --- /dev/null +++ b/e2e/pages/settings.page.ts @@ -0,0 +1,14 @@ +import { Page, BrowserContext } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class SettingsPage extends BasePage { + protected readonly path = '/user/settings'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async expectVisible(): Promise { + await this.page.locator('#main-content').waitFor({ state: 'visible' }); + } +} diff --git a/e2e/pages/verify.page.ts b/e2e/pages/verify.page.ts new file mode 100644 index 000000000..288b23d9d --- /dev/null +++ b/e2e/pages/verify.page.ts @@ -0,0 +1,17 @@ +import { Page, BrowserContext, Response } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class VerifyPage extends BasePage { + protected readonly path = '/user/account/verify/register'; + + constructor(page: Page, context: BrowserContext, baseUrl: string) { + super(page, context, baseUrl); + } + + async gotoWithToken( + token: string, + options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }, + ): Promise { + return this.page.goto(`${this.baseUrl}${this.path}/${token}`, options); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0dcaa0d32..272d63a23 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,16 +1,25 @@ import { defineConfig, devices } from '@playwright/test'; +const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:8000'; + export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: 0, + retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 1 : '50%', reporter: 'list', - timeout: process.env.CI ? 60000 : 30000, + timeout: process.env.CI ? 90000 : 30000, + + // Warm the Next.js dev server compilation cache before tests run. + // First requests trigger on-demand compilation that can exceed action + // timeouts on slow CI runners. + globalSetup: process.env.CI ? './global-setup.ts' : undefined, use: { - baseURL: process.env.BASE_URL || 'http://127.0.0.1:8000', + baseURL: BASE_URL, + actionTimeout: process.env.CI ? 60000 : 15000, + navigationTimeout: process.env.CI ? 90000 : 30000, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', diff --git a/e2e/tests/middleware/auth-routing.spec.ts b/e2e/tests/middleware/auth-routing.spec.ts index 0538576e8..2723bb91e 100644 --- a/e2e/tests/middleware/auth-routing.spec.ts +++ b/e2e/tests/middleware/auth-routing.spec.ts @@ -1,257 +1,129 @@ -import { test, expect } from '@playwright/test'; - -const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'; -const STUB_URL = process.env.STUB_URL || 'http://127.0.0.1:18080'; +import { test, expect } from '../../fixtures/nectar.fixture'; test.describe('Auth Routing (Suite C)', () => { - test.beforeEach(async ({ context, request }) => { - await context.clearCookies(); - await request.post(`${STUB_URL}/__test__/reset`); + test.beforeEach(async ({ loginPage, resetStub }) => { + await loginPage.clearCookies(); + await resetStub(); }); - test('C1: Protected route unauthenticated redirects to login', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'anonymous-session', - url: NECTAR_URL, - }, - ]); - - // Use route interception to add header - ensures header persists through redirects - await page.route('**/*', async (route) => { - const headers = { - ...route.request().headers(), - 'x-test-scenario': 'bootstrap-anonymous', - }; - await route.continue({ headers }); - }); - - // Use waitUntil: 'commit' to avoid timeout waiting for full page load - const response = await page.goto(`${NECTAR_URL}/user/libraries`, { - waitUntil: 'commit', - }); - - // Check the final URL after redirects + test('C1: Protected route unauthenticated redirects to login', async ({ page, loginPage }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioViaRoute('bootstrap-anonymous'); + + const response = await page.goto(`${loginPage.baseUrl}/user/libraries`, { waitUntil: 'commit' }); + const finalUrl = response?.url() || page.url(); expect(finalUrl).toContain('/user/account/login'); expect(finalUrl).toContain('next='); expect(finalUrl).toContain('notify=login-required'); }); - test('C2: Protected route authenticated allows access', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'authenticated-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); - - await page.goto(`${NECTAR_URL}/search`); + test('C2: Protected route authenticated allows access', async ({ settingsPage, searchPage }) => { + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); - const response = await page.goto(`${NECTAR_URL}/user/settings`); + await searchPage.goto(); + const response = await settingsPage.goto(); expect(response?.status()).toBe(200); - expect(page.url()).toContain('/user/settings'); + settingsPage.urlContains('/user/settings'); }); test('C3: Login route authenticated redirects based on next param - valid relative path', async ({ - page, - context, + loginPage, + searchPage, }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'authenticated-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); - - await page.goto(`${NECTAR_URL}/search`); - await page.goto(`${NECTAR_URL}/user/account/login?next=%2Fuser%2Fsettings`); - - expect(page.url()).toContain('/user/settings'); - expect(page.url()).toContain('notify=account-login-success'); - }); + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); - test('C3b: Login route authenticated redirects to home for external next param', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'authenticated-session', - url: NECTAR_URL, - }, - ]); + await searchPage.goto(); + await loginPage.gotoWithNext('/user/settings'); - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); + loginPage.urlContains('/user/settings'); + loginPage.urlContains('notify=account-login-success'); + }); - await page.goto(`${NECTAR_URL}/search`); - await page.goto(`${NECTAR_URL}/user/account/login?next=https%3A%2F%2Fevil.example`); + test('C3b: Login route authenticated redirects to home for external next param', async ({ + loginPage, + searchPage, + }) => { + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); - expect(page.url()).toContain('/?notify=account-login-success'); - }); + await searchPage.goto(); + await loginPage.gotoWithNext('https://evil.example'); - test('C3c: Login route authenticated redirects to home when no next param', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'authenticated-session', - url: NECTAR_URL, - }, - ]); + loginPage.urlContains('/?notify=account-login-success'); + }); - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); + test('C3c: Login route authenticated redirects to home when no next param', async ({ + loginPage, + searchPage, + nectarUrl, + }) => { + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); - await page.goto(`${NECTAR_URL}/search`); - await page.goto(`${NECTAR_URL}/user/account/login`); + await searchPage.goto(); + await loginPage.goto(); - expect(page.url()).toBe(`${NECTAR_URL}/`); + loginPage.urlEquals(`${nectarUrl}/`); }); - test('C4: Register route authenticated redirects to home', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'authenticated-session', - url: NECTAR_URL, - }, - ]); + test('C4: Register route authenticated redirects to home', async ({ registerPage, searchPage, nectarUrl }) => { + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); + + await searchPage.goto(); + await registerPage.goto(); - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); + registerPage.urlEquals(`${nectarUrl}/`); + }); - await page.goto(`${NECTAR_URL}/search`); - await page.goto(`${NECTAR_URL}/user/account/register`); + test('C5: Forgot password route authenticated redirects to home', async ({ page, searchPage, nectarUrl }) => { + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); - expect(page.url()).toBe(`${NECTAR_URL}/`); + await searchPage.goto(); + // Middleware redirects from the legacy /user/forgotpassword path. + // The actual page lives at /user/account/forgotpassword. + await page.goto(`${nectarUrl}/user/forgotpassword`); + + expect(page.url()).toBe(`${nectarUrl}/`); }); - test('C5: Forgot password route authenticated redirects to home', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'authenticated-session', - url: NECTAR_URL, - }, - ]); + test('C6: Login form redirects to next param after successful login', async ({ loginPage }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioHeader('bootstrap-anonymous'); - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); + await loginPage.gotoWithNext('/search?q=test'); - await page.goto(`${NECTAR_URL}/search`); - await page.goto(`${NECTAR_URL}/user/forgotpassword`); + loginPage.urlContains('/user/account/login'); - expect(page.url()).toBe(`${NECTAR_URL}/`); - }); + await loginPage.fillCredentials('test@example.com', 'password123'); + await loginPage.mockLoginSuccess(); + await loginPage.setScenarioHeader('bootstrap-authenticated'); + await loginPage.submit(); - test('C6: Login form redirects to next param after successful login', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'anonymous-session', - url: NECTAR_URL, - }, - ]); - - // start with anonymous session, then switch to authenticated after login - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-anonymous', - }); - - // navigate to login with next param - await page.goto(`${NECTAR_URL}/user/account/login?next=%2Fsearch%3Fq%3Dtest`); - - // verify we're on login page - expect(page.url()).toContain('/user/account/login'); - - // fill in credentials - await page.fill('input[name="email"]', 'test@example.com'); - await page.fill('input[name="password"]', 'password123'); - - // intercept and respond to login request, then switch to authenticated scenario - await page.route('**/api/auth/login', async (route) => { - // simulate successful login by responding to the API call - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - // set authenticated scenario for subsequent requests - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); - - // submit the form - await page.click('button[type="submit"]'); - - // wait for navigation to complete - await page.waitForURL(/\/search/); - - // verify we're redirected to the next URL - expect(page.url()).toContain('/search'); - expect(page.url()).toContain('q=test'); + await loginPage.waitForUrl(/\/search/, { waitUntil: 'commit' }); + + loginPage.urlContains('/search'); + loginPage.urlContains('q=test'); }); - test('C7: Login form falls back to reload when next param is invalid', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'anonymous-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-anonymous', - }); - - // navigate to login with an external (invalid) next param - await page.goto(`${NECTAR_URL}/user/account/login?next=https%3A%2F%2Fevil.example`); - - // fill in credentials - await page.fill('input[name="email"]', 'test@example.com'); - await page.fill('input[name="password"]', 'password123'); - - // intercept and respond to login request - await page.route('**/api/auth/login', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - // set authenticated scenario for subsequent requests - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-authenticated', - }); - - // submit the form - await page.click('button[type="submit"]'); - - // wait a bit for reload to happen - await page.waitForLoadState('networkidle'); - - // should still be on login page (reloaded) or redirected to home by middleware - // since external URLs are blocked, we expect a reload or middleware redirect - const url = page.url(); - expect(url).toMatch(/\/(user\/account\/login|\?notify=account-login-success)?$/); + test('C7: Login form falls back to reload when next param is invalid', async ({ loginPage }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioHeader('bootstrap-anonymous'); + + await loginPage.gotoWithNext('https://evil.example'); + + await loginPage.fillCredentials('test@example.com', 'password123'); + await loginPage.mockLoginSuccess(); + await loginPage.setScenarioHeader('bootstrap-authenticated'); + await loginPage.submit(); + + await loginPage.waitForLoadState('networkidle'); + + loginPage.urlMatches(/\/(user\/account\/login|\?notify=account-login-success)?$/); }); }); diff --git a/e2e/tests/middleware/mvp.spec.ts b/e2e/tests/middleware/mvp.spec.ts index 976bcfc28..606713da2 100644 --- a/e2e/tests/middleware/mvp.spec.ts +++ b/e2e/tests/middleware/mvp.spec.ts @@ -1,31 +1,30 @@ -import { test, expect } from '@playwright/test'; - -const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'; -const STUB_URL = process.env.STUB_URL || 'http://127.0.0.1:18080'; +import { test, expect } from '../../fixtures/nectar.fixture'; test.describe('Middleware MVP', () => { - test.beforeEach(async ({ context, request }) => { - await context.clearCookies(); - await request.post(`${STUB_URL}/__test__/reset`); + test.beforeEach(async ({ searchPage, resetStub }) => { + await searchPage.clearCookies(); + await resetStub(); }); - test('Bootstrap failure redirects to home with error message', async ({ request, context }) => { + test('Bootstrap failure redirects to home with error message', async ({ context, nectarUrl, request }) => { const freshPage = await context.newPage(); await freshPage.setExtraHTTPHeaders({ 'x-test-scenario': 'bootstrap-failure', }); - await freshPage.goto(`${NECTAR_URL}/search`, { waitUntil: 'load' }); - await freshPage.waitForURL('**/?notify=api-connect-failed', { timeout: 5000 }); + await freshPage.goto(`${nectarUrl}/search`, { waitUntil: 'load' }); + await freshPage.waitForURL('**/?notify=api-connect-failed', { + timeout: 5000, + }); expect(freshPage.url()).toContain('/?notify=api-connect-failed'); - const response = await request.get(`${STUB_URL}/__test__/calls`); + const stubUrl = process.env.STUB_URL || 'http://127.0.0.1:18080'; + const response = await request.get(`${stubUrl}/__test__/calls`); const data = await response.json(); expect(data.count).toBeGreaterThan(0); - // Find the bootstrap-failure call (tests run in parallel, so filter by scenario) const failureCall = data.calls.find( (call: { endpoint: string; scenario: string }) => call.endpoint === '/accounts/bootstrap' && call.scenario === 'bootstrap-failure', diff --git a/e2e/tests/middleware/session-bootstrap.spec.ts b/e2e/tests/middleware/session-bootstrap.spec.ts index 36c7eac0b..2129e1f01 100644 --- a/e2e/tests/middleware/session-bootstrap.spec.ts +++ b/e2e/tests/middleware/session-bootstrap.spec.ts @@ -1,32 +1,20 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/nectar.fixture'; import { extractCookie } from '../../fixtures/helpers'; -const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'; -const STUB_URL = process.env.STUB_URL || 'http://127.0.0.1:18080'; - test.describe('Session Bootstrap (Suite B)', () => { - test.beforeEach(async ({ context, request }) => { - await context.clearCookies(); - await request.post(`${STUB_URL}/__test__/reset`); + test.beforeEach(async ({ searchPage, resetStub }) => { + await searchPage.clearCookies(); + await resetStub(); }); - test('B1: Cold start creates sidecar session with cookie rewrite', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'seed-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-rotated-cookie', - }); + test('B1: Cold start creates sidecar session with cookie rewrite', async ({ searchPage }) => { + await searchPage.addSessionCookie('seed-session'); + await searchPage.setScenarioHeader('bootstrap-rotated-cookie'); - const response = await page.goto(`${NECTAR_URL}/search`); - expect(response?.status()).toBe(200); + const response = await searchPage.gotoAndExpect(); + expect(response.status()).toBe(200); - const setCookieHeader = response?.headers()['set-cookie']; + const setCookieHeader = response.headers()['set-cookie']; const adsSessionCookie = extractCookie(setCookieHeader, 'ads_session'); if (adsSessionCookie) { @@ -34,83 +22,54 @@ test.describe('Session Bootstrap (Suite B)', () => { expect(adsSessionCookie).not.toContain('Domain='); } - const cookies = await context.cookies(); - const sessionCookie = cookies.find((c) => c.name === 'ads_session'); - expect(sessionCookie).toBeDefined(); - - const sidecarCookie = cookies.find((c) => c.name === 'scix_session'); - expect(sidecarCookie).toBeDefined(); + const cookies = await searchPage.getCookies(); + expect(cookies.find((c) => c.name === 'ads_session')).toBeDefined(); + expect(cookies.find((c) => c.name === 'scix_session')).toBeDefined(); }); - test('B2: Fast path skips redundant Set-Cookie when unchanged', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'unchanged-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-unchanged-cookie', - }); + test('B2: Fast path skips redundant Set-Cookie when unchanged', async ({ searchPage }) => { + await searchPage.addSessionCookie('unchanged-session'); + await searchPage.setScenarioHeader('bootstrap-unchanged-cookie'); - const response = await page.goto(`${NECTAR_URL}/search`); - expect(response?.status()).toBe(200); + const response = await searchPage.gotoAndExpect(); + expect(response.status()).toBe(200); - const setCookieHeader = response?.headers()['set-cookie']; + const setCookieHeader = response.headers()['set-cookie']; const adsSessionCookie = extractCookie(setCookieHeader, 'ads_session'); - expect(adsSessionCookie).toBeUndefined(); }); test('B3: Force refresh via header triggers bootstrap even with valid session', async ({ - page, - context, + searchPage, + resetStub, request, }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'valid-session', - url: NECTAR_URL, - }, - ]); + await searchPage.addSessionCookie('valid-session'); + await searchPage.goto(); - await page.goto(`${NECTAR_URL}/search`); + await resetStub(); - await request.post(`${STUB_URL}/__test__/reset`); + await searchPage.setExtraHeaders({ 'x-refresh-token': '1' }); + await searchPage.gotoWithParams('?_refresh=1', { waitUntil: 'load' }); + await searchPage.waitForLoadState('networkidle'); - await page.setExtraHTTPHeaders({ - 'x-refresh-token': '1', - }); - - await page.goto(`${NECTAR_URL}/search?_refresh=1`, { waitUntil: 'load' }); - await page.waitForLoadState('networkidle'); - - const response = await request.get(`${STUB_URL}/__test__/calls`); + const stubUrl = process.env.STUB_URL || 'http://127.0.0.1:18080'; + const response = await request.get(`${stubUrl}/__test__/calls`); const data = await response.json(); expect(data.count).toBeGreaterThan(0); expect(data.calls.some((call: { endpoint: string }) => call.endpoint === '/accounts/bootstrap')).toBe(true); }); - test('B4: Bootstrap failure redirects to home with notify param', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'test-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-failure', - }); + test('B4: Bootstrap failure redirects to home with notify param', async ({ searchPage }) => { + await searchPage.addSessionCookie('test-session'); + await searchPage.setScenarioHeader('bootstrap-failure'); - await page.goto(`${NECTAR_URL}/search`, { waitUntil: 'load' }); - await page.waitForURL('**/?notify=api-connect-failed', { timeout: 5000 }); + await searchPage.goto({ waitUntil: 'load' }); + await searchPage.waitForUrl('**/?notify=api-connect-failed', { + timeout: 5000, + }); - expect(page.url()).toContain('/?notify=api-connect-failed'); + searchPage.urlContains('/?notify=api-connect-failed'); }); }); diff --git a/e2e/tests/middleware/verify-routes.spec.ts b/e2e/tests/middleware/verify-routes.spec.ts index 847cb6232..7818adfb5 100644 --- a/e2e/tests/middleware/verify-routes.spec.ts +++ b/e2e/tests/middleware/verify-routes.spec.ts @@ -1,35 +1,23 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '../../fixtures/nectar.fixture'; import { extractCookie } from '../../fixtures/helpers'; -const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'; -const STUB_URL = process.env.STUB_URL || 'http://127.0.0.1:18080'; - test.describe('Verify Routes (Suite D)', () => { - test.beforeEach(async ({ context, request }) => { - await context.clearCookies(); - await request.post(`${STUB_URL}/__test__/reset`); + test.beforeEach(async ({ verifyPage, resetStub }) => { + await verifyPage.clearCookies(); + await resetStub(); }); - test('D1: Verify success redirects to login with propagated Set-Cookie', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'test-session', - url: NECTAR_URL, - }, - ]); - - await page.goto(`${NECTAR_URL}/search`); + test('D1: Verify success redirects to login with propagated Set-Cookie', async ({ verifyPage, searchPage }) => { + await searchPage.addSessionCookie('test-session'); + await searchPage.goto(); - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'verify-success', - }); + await verifyPage.setScenarioHeader('verify-success'); - const response = await page.goto(`${NECTAR_URL}/user/account/verify/register/test-token`); - await page.waitForURL('**/user/account/login?notify=verify-account-success', { timeout: 5000 }); + const response = await verifyPage.gotoWithToken('test-token'); + await verifyPage.waitForUrl('**/user/account/login?notify=verify-account-success', { timeout: 5000 }); - expect(page.url()).toContain('/user/account/login'); - expect(page.url()).toContain('notify=verify-account-success'); + verifyPage.urlContains('/user/account/login'); + verifyPage.urlContains('notify=verify-account-success'); const setCookieHeader = response?.headers()['set-cookie']; const adsSessionCookie = extractCookie(setCookieHeader, 'ads_session'); @@ -40,78 +28,42 @@ test.describe('Verify Routes (Suite D)', () => { } }); - test('D2: Unknown verification token redirects to home with failure', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'test-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'verify-unknown-token', - }); + test('D2: Unknown verification token redirects to home with failure', async ({ verifyPage }) => { + await verifyPage.addSessionCookie('test-session'); + await verifyPage.setScenarioHeader('verify-unknown-token'); - await page.goto(`${NECTAR_URL}/user/account/verify/register/bad-token`); + await verifyPage.gotoWithToken('bad-token'); - expect(page.url()).toContain('/?notify=verify-account-failed'); + verifyPage.urlContains('/?notify=verify-account-failed'); }); - test('D3: Already validated token redirects to home with was-valid message', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'test-session', - url: NECTAR_URL, - }, - ]); + test('D3: Already validated token redirects to home with was-valid message', async ({ verifyPage, searchPage }) => { + await searchPage.addSessionCookie('test-session'); + await searchPage.goto(); - await page.goto(`${NECTAR_URL}/search`); + await verifyPage.setScenarioHeader('verify-already-validated'); - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'verify-already-validated', - }); + await verifyPage.gotoWithToken('already-valid-token'); - await page.goto(`${NECTAR_URL}/user/account/verify/register/already-valid-token`); - - expect(page.url()).toContain('/?notify=verify-account-was-valid'); + verifyPage.urlContains('/?notify=verify-account-was-valid'); }); - test('D4: Verify endpoint 500 error redirects to home with failure', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'test-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'verify-failure', - }); + test('D4: Verify endpoint 500 error redirects to home with failure', async ({ verifyPage }) => { + await verifyPage.addSessionCookie('test-session'); + await verifyPage.setScenarioHeader('verify-failure'); - await page.goto(`${NECTAR_URL}/user/account/verify/register/error-token`); + await verifyPage.gotoWithToken('error-token'); - expect(page.url()).toContain('/?notify=verify-account-failed'); + verifyPage.urlContains('/?notify=verify-account-failed'); }); - test('D5: Missing access token (bootstrap fails) redirects to home with failure', async ({ page, context }) => { - await context.addCookies([ - { - name: 'ads_session', - value: 'test-session', - url: NECTAR_URL, - }, - ]); - - await page.setExtraHTTPHeaders({ - 'x-test-scenario': 'bootstrap-failure', - }); + test('D5: Missing access token (bootstrap fails) redirects to home with failure', async ({ verifyPage }) => { + await verifyPage.addSessionCookie('test-session'); + await verifyPage.setScenarioHeader('bootstrap-failure'); - await page.goto(`${NECTAR_URL}/user/account/verify/register/any-token`); + await verifyPage.gotoWithToken('any-token'); - expect(page.url()).toContain('/?notify='); - expect(page.url()).toMatch(/notify=(verify-account-failed|api-connect-failed)/); + verifyPage.urlContains('/?notify='); + verifyPage.urlMatches(/notify=(verify-account-failed|api-connect-failed)/); }); }); diff --git a/e2e/tests/smoke/abstract-pages.spec.ts b/e2e/tests/smoke/abstract-pages.spec.ts new file mode 100644 index 000000000..5bf28259a --- /dev/null +++ b/e2e/tests/smoke/abstract-pages.spec.ts @@ -0,0 +1,27 @@ +import { test } from '../../fixtures/nectar.fixture'; + +const TEST_BIBCODE = '2024ApJ...test..001A'; + +test.describe('Abstract Page Smoke Tests', () => { + test.beforeEach(async ({ abstractPage, resetStub }) => { + await abstractPage.clearCookies(); + await resetStub(); + await abstractPage.addSessionCookie('authenticated-session'); + await abstractPage.setScenarioHeader('bootstrap-authenticated'); + }); + + test('Abstract page renders with article structure', async ({ abstractPage }) => { + await abstractPage.gotoAbstract(TEST_BIBCODE); + await abstractPage.expectVisible(); + await abstractPage.expectNavMenu(); + }); + + const subpages = ['citations', 'references', 'coreads', 'similar', 'metrics', 'graphics']; + + for (const subpage of subpages) { + test(`Abstract/${subpage} subpage renders`, async ({ abstractPage }) => { + await abstractPage.gotoSubpage(TEST_BIBCODE, subpage); + await abstractPage.expectNavMenu(); + }); + } +}); diff --git a/e2e/tests/smoke/navigation.spec.ts b/e2e/tests/smoke/navigation.spec.ts new file mode 100644 index 000000000..a707e66e8 --- /dev/null +++ b/e2e/tests/smoke/navigation.spec.ts @@ -0,0 +1,91 @@ +import { test } from '../../fixtures/nectar.fixture'; + +test.describe('Public Page Smoke Tests', () => { + test.beforeEach(async ({ homePage, resetStub }) => { + await homePage.clearCookies(); + await resetStub(); + }); + + test('Home page renders search form', async ({ homePage }) => { + await homePage.addSessionCookie('anonymous-session'); + await homePage.setScenarioHeader('bootstrap-anonymous'); + + await homePage.goto(); + await homePage.expectVisible(); + }); + + test('Search page renders for authenticated user', async ({ searchPage }) => { + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); + + await searchPage.gotoAndExpect(); + await searchPage.expectVisible(); + }); + + test('Classic form page renders', async ({ classicFormPage }) => { + await classicFormPage.addSessionCookie('anonymous-session'); + await classicFormPage.setScenarioHeader('bootstrap-anonymous'); + + await classicFormPage.goto(); + await classicFormPage.expectVisible(); + }); + + test('Paper form page renders', async ({ paperFormPage }) => { + await paperFormPage.addSessionCookie('anonymous-session'); + await paperFormPage.setScenarioHeader('bootstrap-anonymous'); + + await paperFormPage.goto(); + await paperFormPage.expectVisible(); + }); + + test('Journals database page renders', async ({ journalsDbPage }) => { + await journalsDbPage.addSessionCookie('anonymous-session'); + await journalsDbPage.setScenarioHeader('bootstrap-anonymous'); + + await journalsDbPage.goto(); + await journalsDbPage.expectVisible(); + }); + + test('Login page renders for anonymous user', async ({ loginPage }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioHeader('bootstrap-anonymous'); + + await loginPage.goto(); + await loginPage.expectVisible(); + }); + + test('Register page renders for anonymous user', async ({ registerPage }) => { + await registerPage.addSessionCookie('anonymous-session'); + await registerPage.setScenarioHeader('bootstrap-anonymous'); + + await registerPage.goto(); + await registerPage.expectVisible(); + }); + + test('Forgot password page renders for anonymous user', async ({ forgotPasswordPage }) => { + await forgotPasswordPage.addSessionCookie('anonymous-session'); + await forgotPasswordPage.setScenarioHeader('bootstrap-anonymous'); + + await forgotPasswordPage.goto(); + await forgotPasswordPage.expectVisible(); + }); +}); + +test.describe('Feedback Page Smoke Tests', () => { + test.beforeEach(async ({ feedbackPage, resetStub }) => { + await feedbackPage.clearCookies(); + await resetStub(); + }); + + const feedbackTypes = ['general', 'missingrecord', 'missingreferences', 'associatedarticles']; + + for (const type of feedbackTypes) { + test(`Feedback/${type} page renders`, async ({ feedbackPage }) => { + await feedbackPage.addSessionCookie('anonymous-session'); + await feedbackPage.setScenarioHeader('bootstrap-anonymous'); + + await feedbackPage.gotoFeedbackType(type); + await feedbackPage.expectVisible(); + }); + } +}); diff --git a/e2e/tests/smoke/protected-pages.spec.ts b/e2e/tests/smoke/protected-pages.spec.ts new file mode 100644 index 000000000..d875e6682 --- /dev/null +++ b/e2e/tests/smoke/protected-pages.spec.ts @@ -0,0 +1,26 @@ +import { test } from '../../fixtures/nectar.fixture'; + +test.describe('Protected Page Smoke Tests', () => { + test.beforeEach(async ({ searchPage, resetStub }) => { + await searchPage.clearCookies(); + await resetStub(); + await searchPage.addSessionCookie('authenticated-session'); + await searchPage.setScenarioHeader('bootstrap-authenticated'); + await searchPage.goto(); + }); + + test('Libraries page renders for authenticated user', async ({ librariesPage }) => { + await librariesPage.goto(); + await librariesPage.expectVisible(); + }); + + test('Settings/application page renders for authenticated user', async ({ settingsPage }) => { + await settingsPage.goto(); + await settingsPage.expectVisible(); + }); + + test('Notifications page renders for authenticated user', async ({ notificationsPage }) => { + await notificationsPage.goto(); + await notificationsPage.expectVisible(); + }); +}); diff --git a/e2e/tests/workflows/abstract-navigation.spec.ts b/e2e/tests/workflows/abstract-navigation.spec.ts new file mode 100644 index 000000000..6a0c9020d --- /dev/null +++ b/e2e/tests/workflows/abstract-navigation.spec.ts @@ -0,0 +1,30 @@ +import { test } from '../../fixtures/nectar.fixture'; + +const TEST_BIBCODE = '2024ApJ...test..001A'; + +test.describe('Abstract Page Navigation Workflow', () => { + test.beforeEach(async ({ abstractPage, resetStub }) => { + await abstractPage.clearCookies(); + await resetStub(); + await abstractPage.addSessionCookie('authenticated-session'); + await abstractPage.setScenarioHeader('bootstrap-authenticated'); + }); + + test('User can navigate between abstract tabs', async ({ abstractPage }) => { + await abstractPage.gotoAbstract(TEST_BIBCODE); + await abstractPage.expectVisible(); + await abstractPage.expectNavMenu(); + + await abstractPage.clickNavTab('Citations'); + await abstractPage.waitForUrl(/\/citations/); + abstractPage.urlContains(`/abs/${TEST_BIBCODE}/citations`); + + await abstractPage.clickNavTab('References'); + await abstractPage.waitForUrl(/\/references/); + abstractPage.urlContains(`/abs/${TEST_BIBCODE}/references`); + + await abstractPage.clickNavTab('Abstract'); + await abstractPage.waitForUrl(/\/abstract/); + abstractPage.urlContains(`/abs/${TEST_BIBCODE}/abstract`); + }); +}); diff --git a/e2e/tests/workflows/auth-gated-navigation.spec.ts b/e2e/tests/workflows/auth-gated-navigation.spec.ts new file mode 100644 index 000000000..bbec208f2 --- /dev/null +++ b/e2e/tests/workflows/auth-gated-navigation.spec.ts @@ -0,0 +1,45 @@ +import { test } from '../../fixtures/nectar.fixture'; + +test.describe('Auth-Gated Navigation Workflow', () => { + test.beforeEach(async ({ loginPage, resetStub }) => { + await loginPage.clearCookies(); + await resetStub(); + }); + + test('Anonymous user accessing protected route is redirected to login with next param', async ({ + page, + loginPage, + }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioViaRoute('bootstrap-anonymous'); + + await page.goto(`${loginPage.baseUrl}/user/libraries`, { + waitUntil: 'commit', + }); + + await loginPage.waitForUrl(/\/user\/account\/login/); + loginPage.urlContains('next='); + loginPage.urlContains('notify=login-required'); + + await loginPage.expectVisible(); + }); + + test('Authenticated user can submit login form and redirect to non-protected destination', async ({ loginPage }) => { + await loginPage.addSessionCookie('anonymous-session'); + await loginPage.setScenarioHeader('bootstrap-anonymous'); + + // Navigate directly to login with a non-protected next destination + await loginPage.gotoWithNext('/search?q=test'); + + loginPage.urlContains('/user/account/login'); + + await loginPage.fillCredentials('test@example.com', 'password123'); + await loginPage.mockLoginSuccess(); + await loginPage.setScenarioHeader('bootstrap-authenticated'); + await loginPage.submit(); + + await loginPage.waitForUrl(/\/search/, { waitUntil: 'commit' }); + loginPage.urlContains('/search'); + loginPage.urlContains('q=test'); + }); +}); diff --git a/e2e/tests/workflows/search-flow.spec.ts b/e2e/tests/workflows/search-flow.spec.ts new file mode 100644 index 000000000..7b8c02c3c --- /dev/null +++ b/e2e/tests/workflows/search-flow.spec.ts @@ -0,0 +1,23 @@ +import { test } from '../../fixtures/nectar.fixture'; + +test.describe('Search Flow Workflow', () => { + test.beforeEach(async ({ homePage, resetStub }) => { + await homePage.clearCookies(); + await resetStub(); + }); + + test('Anonymous user can search from home page and see results', async ({ homePage, searchPage }) => { + await homePage.addSessionCookie('anonymous-session'); + await homePage.setScenarioHeader('bootstrap-anonymous'); + + await homePage.goto(); + await homePage.expectVisible(); + + await homePage.search('black holes'); + + await searchPage.waitForUrl(/\/search/, { waitUntil: 'commit' }); + searchPage.urlContains('q=black'); + + await searchPage.expectVisible(); + }); +}); diff --git a/next.config.mjs b/next.config.mjs index a063a77ea..c3587d09a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -53,8 +53,34 @@ const nextConfig = { }); } - if (beforeFiles.length > 0) { - return { beforeFiles }; + // In e2e Docker tests, proxy client-side API calls through the Next.js + // server to avoid cross-origin issues (the browser in the Playwright + // container cannot resolve Docker service hostnames directly). + const afterFiles = []; + if (process.env.E2E_API_PROXY && process.env.API_HOST_SERVER) { + const apiPrefixes = [ + 'vault', 'accounts', 'biblib', 'resolver', 'graphics', + 'metrics', 'export', 'reference', 'citation_helper', + 'vis', 'orcid', 'objects', + ]; + for (const prefix of apiPrefixes) { + afterFiles.push({ + source: `/${prefix}/:path*`, + destination: `${process.env.API_HOST_SERVER}/${prefix}/:path*`, + }); + } + // /search/query must be explicit to avoid shadowing the /search page + afterFiles.push({ + source: '/search/query', + destination: `${process.env.API_HOST_SERVER}/search/query`, + }); + } + + if (beforeFiles.length > 0 || afterFiles.length > 0) { + return { + ...(beforeFiles.length > 0 ? { beforeFiles } : {}), + ...(afterFiles.length > 0 ? { afterFiles } : {}), + }; } } return {}; diff --git a/src/components/SearchFacet/useGetFacetData.test.ts b/src/components/SearchFacet/useGetFacetData.test.ts new file mode 100644 index 000000000..148fce3a0 --- /dev/null +++ b/src/components/SearchFacet/useGetFacetData.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect, vi, TestContext } from 'vitest'; +import { renderHook, waitFor, createServerListenerMocks, urls } from '@/test-utils'; +import { useGetFacetData } from './useGetFacetData'; +import { defaultQueryParams } from '@/store/slices/search'; +import { FacetField } from '@/api/search/types'; + +vi.mock('@/components/SearchFacet/store/FacetStore', () => ({ + useFacetStore: () => vi.fn(), +})); + +const defaultProps = { + field: 'author_facet_hier' as FacetField, + prefix: '0/', + level: 'root' as const, +}; + +describe('useGetFacetData', () => { + test('does not fire a request when latestQuery.q is empty', async ({ server }: TestContext) => { + const { onRequest } = createServerListenerMocks(server); + renderHook(() => useGetFacetData(defaultProps), { + initialStore: { latestQuery: { ...defaultQueryParams, q: '' } }, + }); + + await new Promise((r) => setTimeout(r, 200)); + const searchRequests = urls(onRequest).filter((u) => u === '/search/query'); + expect(searchRequests).toHaveLength(0); + }); + + test('does not fire a request when latestQuery.q is whitespace-only', async ({ server }: TestContext) => { + const { onRequest } = createServerListenerMocks(server); + renderHook(() => useGetFacetData(defaultProps), { + initialStore: { latestQuery: { ...defaultQueryParams, q: ' ' } }, + }); + + await new Promise((r) => setTimeout(r, 200)); + const searchRequests = urls(onRequest).filter((u) => u === '/search/query'); + expect(searchRequests).toHaveLength(0); + }); + + test('fires a request when latestQuery.q is non-empty', async ({ server }: TestContext) => { + const { onRequest } = createServerListenerMocks(server); + renderHook(() => useGetFacetData(defaultProps), { + initialStore: { latestQuery: { ...defaultQueryParams, q: 'star' } }, + }); + + await waitFor(() => { + const searchRequests = urls(onRequest).filter((u) => u === '/search/query'); + expect(searchRequests.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/components/SearchFacet/useGetFacetData.ts b/src/components/SearchFacet/useGetFacetData.ts index 680ff61ba..81bda3c71 100644 --- a/src/components/SearchFacet/useGetFacetData.ts +++ b/src/components/SearchFacet/useGetFacetData.ts @@ -80,7 +80,7 @@ export const useGetFacetData = (props: IUseGetFacetDataProps) => { }), }, { - enabled, + enabled: enabled && isNonEmptyString(searchQuery?.q?.trim()), keepPreviousData: true, }, ); diff --git a/src/components/ServiceUnavailable/ServiceUnavailable.test.tsx b/src/components/ServiceUnavailable/ServiceUnavailable.test.tsx new file mode 100644 index 000000000..b15eb954d --- /dev/null +++ b/src/components/ServiceUnavailable/ServiceUnavailable.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@/test-utils'; +import { describe, expect, test, vi } from 'vitest'; +import { ServiceUnavailable } from './ServiceUnavailable'; + +const mockReload = vi.fn(); +const mockBack = vi.fn(); + +vi.mock('next/router', () => ({ + useRouter: () => ({ + reload: mockReload, + back: mockBack, + }), +})); + +describe('ServiceUnavailable', () => { + test('renders heading and description', () => { + render(); + + expect(screen.getByText('Temporarily Unavailable')).toBeInTheDocument(); + expect(screen.getByText(/having trouble loading record/)).toBeInTheDocument(); + expect(screen.getByText('2023ApJ...123..456A')).toBeInTheDocument(); + }); + + test('renders Try Again button that reloads the page', async () => { + const { user } = render(); + + const button = screen.getByRole('button', { name: /try again/i }); + expect(button).toBeInTheDocument(); + await user.click(button); + expect(mockReload).toHaveBeenCalled(); + }); + + test('renders Go Back button that navigates back', async () => { + const { user } = render(); + + const button = screen.getByRole('button', { name: /go back/i }); + expect(button).toBeInTheDocument(); + await user.click(button); + expect(mockBack).toHaveBeenCalled(); + }); + + test('displays status code when provided', () => { + render(); + + expect(screen.getByText(/503/)).toBeInTheDocument(); + }); + + test('does not display status code section when omitted', () => { + render(); + + expect(screen.queryByText(/error code/i)).not.toBeInTheDocument(); + }); + + test('has accessible status role', () => { + render(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ServiceUnavailable/ServiceUnavailable.tsx b/src/components/ServiceUnavailable/ServiceUnavailable.tsx new file mode 100644 index 000000000..da0fce89c --- /dev/null +++ b/src/components/ServiceUnavailable/ServiceUnavailable.tsx @@ -0,0 +1,67 @@ +import { Box, Button, Center, Code, Container, Heading, Icon, Text, VStack, useColorModeValue } from '@chakra-ui/react'; +import { WarningIcon } from '@chakra-ui/icons'; +import { useRouter } from 'next/router'; + +interface ServiceUnavailableProps { + recordId: string; + statusCode?: number; +} + +export const ServiceUnavailable = ({ recordId, statusCode }: ServiceUnavailableProps) => { + const cardBg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const router = useRouter(); + + return ( +
+ + + + + + Temporarily Unavailable + + + + We're having trouble loading record{' '} + + {recordId || 'N/A'} + + . The service is temporarily unavailable. + + + + This is usually temporary. Please try again in a moment. + + + + + + + + {statusCode ? ( + + + Error code: {statusCode} + + + ) : null} + + +
+ ); +}; diff --git a/src/components/ServiceUnavailable/index.ts b/src/components/ServiceUnavailable/index.ts new file mode 100644 index 000000000..c25b70cfd --- /dev/null +++ b/src/components/ServiceUnavailable/index.ts @@ -0,0 +1 @@ +export { ServiceUnavailable } from './ServiceUnavailable'; diff --git a/src/lib/serverside/absCanonicalization.ts b/src/lib/serverside/absCanonicalization.ts index 3b1742b31..a4d4d01b0 100644 --- a/src/lib/serverside/absCanonicalization.ts +++ b/src/lib/serverside/absCanonicalization.ts @@ -19,6 +19,7 @@ type AbsProps = { initialDoc?: IDocsEntity | null; isAuthenticated?: boolean; pageError?: string; + statusCode?: number; }; type IncomingGSSPResult = GetServerSidePropsResult; @@ -96,7 +97,7 @@ const absCanonicalize = (viewPathResolver: ViewPathResolver): IncomingGSSP => { context: { bootstrapError: bootstrapResult.error, url: ctx.resolvedUrl }, tags: { feature: 'abs-canonical', stage: 'bootstrap' }, }); - return { props: { pageError: bootstrapResult.error, initialDoc: null } }; + return { props: { pageError: bootstrapResult.error, initialDoc: null, statusCode: 500 } }; } const params = getAbstractParams(requestedId); @@ -127,6 +128,7 @@ const absCanonicalize = (viewPathResolver: ViewPathResolver): IncomingGSSP => { props: { pageError: 'Failed to load abstract data', initialDoc: null, + statusCode: response.status, }, }; } @@ -166,6 +168,7 @@ const absCanonicalize = (viewPathResolver: ViewPathResolver): IncomingGSSP => { props: { pageError: 'Failed to load abstract data', initialDoc: null, + statusCode: 500, }, }; } diff --git a/src/pages/abs/[id]/abstract.tsx b/src/pages/abs/[id]/abstract.tsx index 1d6167d8e..32a02c633 100644 --- a/src/pages/abs/[id]/abstract.tsx +++ b/src/pages/abs/[id]/abstract.tsx @@ -14,6 +14,7 @@ import { useGetAuthors } from '@/components/AllAuthorsModal/useGetAuthors'; import { OrcidActiveIcon } from '@/components/icons/Orcid'; import { AbsLayout } from '@/components/Layout/AbsLayout'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; import { feedbackItems, getAbstractSteps } from '@/components/NavBar'; import { SearchQueryLink } from '@/components/SearchQueryLink'; import { AbstractSources } from '@/components/AbstractSources'; @@ -55,9 +56,10 @@ const safeDecode = (value?: string) => { interface AbstractPageProps { initialDoc?: IDocsEntity | null; isAuthenticated?: boolean; + statusCode?: number; } -const AbstractPage: NextPage = ({ initialDoc, isAuthenticated }) => { +const AbstractPage: NextPage = ({ initialDoc, isAuthenticated, statusCode }) => { const router = useRouter(); const { data } = useGetAbstract({ id: router.query.id as string }); const doc = path(['docs', 0], data) ?? initialDoc ?? undefined; @@ -86,7 +88,9 @@ const AbstractPage: NextPage = ({ initialDoc, isAuthenticated return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( diff --git a/src/pages/abs/[id]/citations.tsx b/src/pages/abs/[id]/citations.tsx index 9a1548015..8299d13a2 100644 --- a/src/pages/abs/[id]/citations.tsx +++ b/src/pages/abs/[id]/citations.tsx @@ -10,9 +10,10 @@ import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalizatio import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; import { feedbackItems } from '@/components/NavBar'; -const CitationsPage: NextPage = () => { +const CitationsPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const pageIndex = router.query.p ? parseInt(router.query.p as string) - 1 : 0; @@ -46,7 +47,9 @@ const CitationsPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/coreads.tsx b/src/pages/abs/[id]/coreads.tsx index 9487b0c7e..ea881586e 100644 --- a/src/pages/abs/[id]/coreads.tsx +++ b/src/pages/abs/[id]/coreads.tsx @@ -11,8 +11,9 @@ import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const CoreadsPage: NextPage = () => { +const CoreadsPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const pageIndex = router.query.p ? parseInt(router.query.p as string) - 1 : 0; @@ -39,7 +40,9 @@ const CoreadsPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/credits.tsx b/src/pages/abs/[id]/credits.tsx index f5f99dc99..df0656f1b 100644 --- a/src/pages/abs/[id]/credits.tsx +++ b/src/pages/abs/[id]/credits.tsx @@ -11,8 +11,9 @@ import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const CreditsPage: NextPage = () => { +const CreditsPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const pageIndex = router.query.p ? parseInt(router.query.p as string) - 1 : 0; @@ -46,7 +47,9 @@ const CreditsPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/graphics.tsx b/src/pages/abs/[id]/graphics.tsx index 1bc6a2a5c..d342fbfdc 100644 --- a/src/pages/abs/[id]/graphics.tsx +++ b/src/pages/abs/[id]/graphics.tsx @@ -15,8 +15,9 @@ import { faImage } from '@fortawesome/free-solid-svg-icons'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const GraphicsPage: NextPage = () => { +const GraphicsPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const { data } = useGetAbstract({ id }); @@ -37,7 +38,9 @@ const GraphicsPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/mentions.tsx b/src/pages/abs/[id]/mentions.tsx index 7e4db3cfb..d36a795c6 100644 --- a/src/pages/abs/[id]/mentions.tsx +++ b/src/pages/abs/[id]/mentions.tsx @@ -11,8 +11,9 @@ import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const MentionsPage: NextPage = () => { +const MentionsPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const pageIndex = router.query.p ? parseInt(router.query.p as string) - 1 : 0; @@ -46,7 +47,9 @@ const MentionsPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/metrics.tsx b/src/pages/abs/[id]/metrics.tsx index 87dd0cc20..2b9a4faab 100644 --- a/src/pages/abs/[id]/metrics.tsx +++ b/src/pages/abs/[id]/metrics.tsx @@ -12,8 +12,9 @@ import { useGetMetrics } from '@/api/metrics/metrics'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const MetricsPage: NextPage = () => { +const MetricsPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const { data } = useGetAbstract({ id }); @@ -37,7 +38,9 @@ const MetricsPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/references.tsx b/src/pages/abs/[id]/references.tsx index 51a346158..a1834788b 100644 --- a/src/pages/abs/[id]/references.tsx +++ b/src/pages/abs/[id]/references.tsx @@ -11,8 +11,9 @@ import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const ReferencesPage: NextPage = () => { +const ReferencesPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const pageIndex = router.query.p ? parseInt(router.query.p as string) - 1 : 0; @@ -46,7 +47,9 @@ const ReferencesPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/similar.tsx b/src/pages/abs/[id]/similar.tsx index 2d5368d0b..a185fa9dd 100644 --- a/src/pages/abs/[id]/similar.tsx +++ b/src/pages/abs/[id]/similar.tsx @@ -11,8 +11,9 @@ import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const SimilarPage: NextPage = () => { +const SimilarPage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; const pageIndex = router.query.p ? parseInt(router.query.p as string) - 1 : 0; @@ -39,7 +40,9 @@ const SimilarPage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <> diff --git a/src/pages/abs/[id]/toc.tsx b/src/pages/abs/[id]/toc.tsx index fef6974e8..af7c6bedc 100644 --- a/src/pages/abs/[id]/toc.tsx +++ b/src/pages/abs/[id]/toc.tsx @@ -11,8 +11,9 @@ import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { feedbackItems } from '@/components/NavBar'; import { RecordNotFound } from '@/components/RecordNotFound'; +import { ServiceUnavailable } from '@/components/ServiceUnavailable'; -const VolumePage: NextPage = () => { +const VolumePage: NextPage<{ statusCode?: number }> = ({ statusCode }) => { const router = useRouter(); const id = router.query.id as string; @@ -37,7 +38,9 @@ const VolumePage: NextPage = () => { return ( - {!doc ? ( + {!doc && statusCode !== undefined && statusCode >= 500 ? ( + + ) : !doc ? ( ) : ( <>