chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -50,7 +50,7 @@ type Route = {
|
||||
detailLinkSelector?: string;
|
||||
/** Extra wait after navigation (ms) for content to settle. */
|
||||
settleMs?: number;
|
||||
/** If true, skip — useful for known-broken or out-of-scope routes. */
|
||||
/** If true, skip - useful for known-broken or out-of-scope routes. */
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
@@ -194,7 +194,7 @@ async function ensureAdminExists(request: APIRequestContext) {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!signUp.ok()) {
|
||||
// Already exists — verify sign-in works
|
||||
// Already exists - verify sign-in works
|
||||
const signIn = await request.post('/api/auth/sign-in/email', {
|
||||
headers,
|
||||
data: { email: ADMIN.email, password: ADMIN.password },
|
||||
@@ -249,7 +249,7 @@ async function captureRoute(
|
||||
status: 'error',
|
||||
error: msg.split('\n')[0],
|
||||
});
|
||||
console.log(` ✗ ${route.path} — ${msg.split('\n')[0]}`);
|
||||
console.log(` ✗ ${route.path} - ${msg.split('\n')[0]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ async function captureRoute(
|
||||
status: 'error',
|
||||
error: msg.split('\n')[0],
|
||||
});
|
||||
console.log(` ✗ ${route.path} detail — ${msg.split('\n')[0]}`);
|
||||
console.log(` ✗ ${route.path} detail - ${msg.split('\n')[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,7 +339,7 @@ async function writeIndex(allByViewport: Map<string, Capture[]>): Promise<void>
|
||||
await fs.writeFile(path.join(OUT_ROOT, 'index.md'), lines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
test('mobile audit — every page at iPhone viewports', async ({ browser, request }) => {
|
||||
test('mobile audit - every page at iPhone viewports', async ({ browser, request }) => {
|
||||
test.setTimeout(1_800_000);
|
||||
|
||||
await fs.mkdir(OUT_ROOT, { recursive: true });
|
||||
|
||||
@@ -14,7 +14,7 @@ async function createYachtViaApi(page: import('@playwright/test').Page, name: st
|
||||
data: {
|
||||
name,
|
||||
ownerType: 'client',
|
||||
// ownerId left blank intentionally — UI flow seeds an owner via
|
||||
// ownerId left blank intentionally - UI flow seeds an owner via
|
||||
// existing client list in the form. For the API-driven path here we
|
||||
// need a real client; fetch one if available.
|
||||
},
|
||||
@@ -32,12 +32,12 @@ test.describe('destructive: yacht archive', () => {
|
||||
// Build a unique name so we can find the row deterministically.
|
||||
const name = `ARCHIVE-ME-${Date.now()}`;
|
||||
|
||||
// Create via API. The endpoint may require ownerId — if so, skip with a
|
||||
// Create via API. The endpoint may require ownerId - if so, skip with a
|
||||
// clear message rather than fail (this test depends on a seed fixture
|
||||
// that doesn't yet exist in the global setup).
|
||||
const created = await createYachtViaApi(page, name);
|
||||
if (!created.ok()) {
|
||||
test.skip(true, `yacht create returned ${created.status()} — fixture not seeded`);
|
||||
test.skip(true, `yacht create returned ${created.status()} - fixture not seeded`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('exhaustive: yachts', () => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
|
||||
test('list page — every visible button/link is clickable without errors', async ({ page }) => {
|
||||
test('list page - every visible button/link is clickable without errors', async ({ page }) => {
|
||||
await navigateTo(page, '/yachts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -17,7 +17,7 @@ test.describe('exhaustive: yachts', () => {
|
||||
expect(result.clicked).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('detail page — every tab + button (skipping destructive)', async ({ page }) => {
|
||||
test('detail page - every tab + button (skipping destructive)', async ({ page }) => {
|
||||
await navigateTo(page, '/yachts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('exhaustive: yachts', () => {
|
||||
const result = await clickEverythingOnPage(page);
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
} else {
|
||||
test.skip(true, 'no yachts seeded — list is empty');
|
||||
test.skip(true, 'no yachts seeded - list is empty');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('exhaustive: companies', () => {
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('detail page — every tab and button', async ({ page }) => {
|
||||
test('detail page - every tab and button', async ({ page }) => {
|
||||
await navigateTo(page, '/companies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
|
||||
test('berths list — reservation-related affordances are clickable', async ({ page }) => {
|
||||
test('berths list - reservation-related affordances are clickable', async ({ page }) => {
|
||||
await navigateTo(page, '/berths');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('berth detail — reservations tab opens and is interactive', async ({ page }) => {
|
||||
test('berth detail - reservations tab opens and is interactive', async ({ page }) => {
|
||||
await navigateTo(page, '/berths');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('exhaustive: client detail (refactored)', () => {
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('detail page — every tab and button (yachts / memberships / reservations / etc.)', async ({
|
||||
test('detail page - every tab and button (yachts / memberships / reservations / etc.)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await navigateTo(page, '/clients');
|
||||
@@ -31,7 +31,7 @@ test.describe('exhaustive: client detail (refactored)', () => {
|
||||
await page.waitForURL(new RegExp(`/${PORT_SLUG}/clients/[^/]+`), { timeout: 10_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Walk every tab — the refactor added yachts/memberships/reservations tabs.
|
||||
// Walk every tab - the refactor added yachts/memberships/reservations tabs.
|
||||
const tabs = await page.getByRole('tab').all();
|
||||
for (const tab of tabs) {
|
||||
if (await tab.isVisible({ timeout: 250 }).catch(() => false)) {
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('exhaustive: berths with reservations panel', () => {
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('detail page — every tab including reservations', async ({ page }) => {
|
||||
test('detail page - every tab including reservations', async ({ page }) => {
|
||||
await navigateTo(page, '/berths');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('exhaustive: client portal', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Some portal pages redirect to login if the user has no client linkage.
|
||||
// Skip rather than fail in that case — portal coverage requires its own
|
||||
// Skip rather than fail in that case - portal coverage requires its own
|
||||
// seeded portal fixture.
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'portal page redirected to login (no portal user seeded)');
|
||||
|
||||
@@ -19,7 +19,7 @@ import { login, apiHeaders, getPortId } from '../smoke/helpers';
|
||||
const SOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_SOCKET_URL ?? process.env.SOCKET_URL ?? 'http://localhost:3000';
|
||||
|
||||
test.describe('Alert engine — socket fanout', () => {
|
||||
test.describe('Alert engine - socket fanout', () => {
|
||||
test.skip(
|
||||
!process.env.RUN_ALERT_ENGINE_REALAPI,
|
||||
'RUN_ALERT_ENGINE_REALAPI not set (opt-in; emits real events)',
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('Documenso cancel pathway', () => {
|
||||
// Seed a minimal client to ensure a doc can be created. Real cancel
|
||||
// testing assumes either an existing in-flight doc or the wizard flow
|
||||
// has already produced one. We probe the hub for an in-flight doc and
|
||||
// skip if none — this lets the spec run as a smoke check rather than
|
||||
// skip if none - this lets the spec run as a smoke check rather than
|
||||
// a fixture-dependent integration.
|
||||
const list = await page.request.get(
|
||||
'/api/v1/documents?tab=awaiting_them&signatureOnly=true&limit=1',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { login, apiHeaders } from '../smoke/helpers';
|
||||
/**
|
||||
* Real-API end-to-end test for the Documenso documenso-template pathway.
|
||||
*
|
||||
* This spec exercises the SEND side of the integration only — it creates a
|
||||
* This spec exercises the SEND side of the integration only - it creates a
|
||||
* fully-loaded interest, fires generate-and-sign, and asserts both
|
||||
* (a) the CRM persisted a documensoId on the documents row, and
|
||||
* (b) Documenso itself returns 200 for the freshly-created document.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { login, apiHeaders } from '../smoke/helpers';
|
||||
const SMTP_HOST = process.env.SMTP_HOST;
|
||||
const OTHER_PORT_FILE_ID = process.env.PHASE_A_CROSS_PORT_FILE_ID;
|
||||
|
||||
test.describe('Email attachments — port isolation', () => {
|
||||
test.describe('Email attachments - port isolation', () => {
|
||||
test.skip(!SMTP_HOST || !OTHER_PORT_FILE_ID, 'cross-port fixture not configured');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -49,7 +49,7 @@ test.describe('MinIO file lifecycle', () => {
|
||||
const listBody = (await list.json()) as { data: Array<{ id: string }> };
|
||||
expect(listBody.data.find((f) => f.id === fileId)).toBeDefined();
|
||||
|
||||
// Download — assert byte-equality
|
||||
// Download - assert byte-equality
|
||||
const dlRes = await page.request.get(`/api/v1/files/${fileId}/download`, { headers });
|
||||
expect(dlRes.ok()).toBe(true);
|
||||
const dlBody = await dlRes.body();
|
||||
|
||||
@@ -22,7 +22,7 @@ import { login, apiHeaders } from '../smoke/helpers';
|
||||
* routes back to the same inbox via standard +addressing.
|
||||
*
|
||||
* NOTE: this test bypasses EMAIL_REDIRECT_TO by sending to the IMAP user
|
||||
* directly. If EMAIL_REDIRECT_TO is set, the redirect still applies — but
|
||||
* directly. If EMAIL_REDIRECT_TO is set, the redirect still applies - but
|
||||
* the redirect target is also IMAP_USER in our dev setup, so it works out.
|
||||
*/
|
||||
|
||||
@@ -129,7 +129,7 @@ test.describe('Portal activation: SMTP + IMAP round-trip', () => {
|
||||
expect(inviteRes.ok(), `invite: ${inviteRes.status()} ${await inviteRes.text()}`).toBe(true);
|
||||
|
||||
// ─── 3-4. Poll IMAP and extract the activation token ─────────────────────
|
||||
// Match on the +addressed recipient — that's what's preserved in the TO
|
||||
// Match on the +addressed recipient - that's what's preserved in the TO
|
||||
// header even after the mailserver delivers it back to IMAP_USER's inbox.
|
||||
const token = await fetchActivationToken({
|
||||
recipientEmail: recipientEmail.toLowerCase(),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { login, apiHeaders, getPortId } from '../smoke/helpers';
|
||||
*
|
||||
* 1. Admin save + test-connection round-trip: writes a real OpenAI key
|
||||
* to the global OCR config, calls /admin/ocr-settings/test (which
|
||||
* sends a 1×1 pixel PNG — essentially free in tokens), and asserts
|
||||
* sends a 1×1 pixel PNG - essentially free in tokens), and asserts
|
||||
* the provider responds 2xx. Validates the auth + key-storage path.
|
||||
*
|
||||
* 2. Real receipt parse: when REALAPI_RECEIPT_FIXTURE is set to an
|
||||
@@ -24,7 +24,7 @@ import { login, apiHeaders, getPortId } from '../smoke/helpers';
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const RECEIPT_FIXTURE = process.env.REALAPI_RECEIPT_FIXTURE;
|
||||
|
||||
test.describe('Receipt OCR — real provider', () => {
|
||||
test.describe('Receipt OCR - real provider', () => {
|
||||
test.skip(!OPENAI_API_KEY, 'OPENAI_API_KEY not configured');
|
||||
|
||||
test('admin can save an OpenAI key and the test endpoint passes', async ({ page }) => {
|
||||
@@ -110,7 +110,7 @@ test.describe('Receipt OCR — real provider', () => {
|
||||
};
|
||||
};
|
||||
expect(body.data.source).toBe('ai');
|
||||
// Confidence must be a valid number 0..1 — provider should always emit it.
|
||||
// Confidence must be a valid number 0..1 - provider should always emit it.
|
||||
expect(body.data.parsed.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(body.data.parsed.confidence).toBeLessThanOrEqual(1);
|
||||
// Amount, if present, should be non-negative.
|
||||
|
||||
@@ -13,8 +13,8 @@ import { login, apiHeaders } from '../smoke/helpers';
|
||||
* and verifies the attachment bytes round-trip end-to-end.
|
||||
*
|
||||
* Requires:
|
||||
* SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS — outbound transport
|
||||
* IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASS — inbound for verification
|
||||
* SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS - outbound transport
|
||||
* IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASS - inbound for verification
|
||||
*/
|
||||
|
||||
const SMTP_HOST = process.env.SMTP_HOST;
|
||||
|
||||
@@ -47,14 +47,16 @@ test.describe('Auth & Permissions', () => {
|
||||
// The PermissionGate hides the button once permissions resolve
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check if PermissionGate is working — viewer has clients.create = false
|
||||
// Check if PermissionGate is working - viewer has clients.create = false
|
||||
const newClientBtn = page.getByRole('button', { name: /new client/i });
|
||||
const isVisible = await newClientBtn.isVisible().catch(() => false);
|
||||
|
||||
// If button is visible, this is an application bug — PermissionGate not enforcing
|
||||
// If button is visible, this is an application bug - PermissionGate not enforcing
|
||||
// We log and soft-fail: the permission IS enforced server-side (API 403)
|
||||
if (isVisible) {
|
||||
console.warn('⚠️ APP BUG: New Client button visible to viewer — PermissionGate not enforcing client-side');
|
||||
console.warn(
|
||||
'⚠️ APP BUG: New Client button visible to viewer - PermissionGate not enforcing client-side',
|
||||
);
|
||||
// Verify server-side enforcement: clicking should fail
|
||||
await newClientBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
@@ -66,16 +68,25 @@ test.describe('Auth & Permissions', () => {
|
||||
await login(page, 'sales_agent');
|
||||
|
||||
// Navigate to a URL with a non-existent port slug
|
||||
// App Router will match [portSlug] dynamically — the behavior depends on
|
||||
// App Router will match [portSlug] dynamically - the behavior depends on
|
||||
// whether the layout's server component can resolve the port
|
||||
await page.goto('/non-existent-port/clients');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should see 404, error, empty state, or redirect
|
||||
const is404 = await page.locator('text=404').isVisible().catch(() => false);
|
||||
const isNotFound = await page.getByText(/not found/i).isVisible().catch(() => false);
|
||||
const is404 = await page
|
||||
.locator('text=404')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const isNotFound = await page
|
||||
.getByText(/not found/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const isLogin = page.url().includes('/login');
|
||||
const isError = await page.getByText(/error|no port/i).isVisible().catch(() => false);
|
||||
const isError = await page
|
||||
.getByText(/error|no port/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// If none of these are true, it means the app loaded without a valid port
|
||||
// context. This is acceptable as long as it doesn't crash.
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe('Interest Pipeline', () => {
|
||||
await clientTrigger.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the popover to load initial options (no search needed — they load on mount)
|
||||
// Wait for the popover to load initial options (no search needed - they load on mount)
|
||||
// The options API returns all clients for this port
|
||||
const cmdItems = page.locator('[cmdk-item]');
|
||||
await expect(cmdItems.first()).toBeVisible({ timeout: 10_000 });
|
||||
@@ -116,7 +116,7 @@ test.describe('Interest Pipeline', () => {
|
||||
const rowCount = await rows.count();
|
||||
|
||||
if (rowCount > 0) {
|
||||
// Click the first row — it may navigate or open a link
|
||||
// Click the first row - it may navigate or open a link
|
||||
const firstRow = rows.first();
|
||||
const link = firstRow.locator('a').first();
|
||||
|
||||
@@ -131,11 +131,11 @@ test.describe('Interest Pipeline', () => {
|
||||
// Check if we navigated to a detail page
|
||||
const url = page.url();
|
||||
if (url.includes('/interests/')) {
|
||||
// We're on the detail page — look for content
|
||||
// We're on the detail page - look for content
|
||||
const content = page.getByText(/pipeline|stage|notes|activity|client/i);
|
||||
await expect(content.first()).toBeVisible({ timeout: 10_000 });
|
||||
} else {
|
||||
// The table rows don't navigate — this is fine for a smoke test
|
||||
// The table rows don't navigate - this is fine for a smoke test
|
||||
console.log(' ℹ Table rows do not navigate to detail pages');
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
|
||||
import { login, navigateTo } from './helpers';
|
||||
|
||||
/**
|
||||
* Smoke spec — Documents Hub aggregated view (Wave 11.B rebuild)
|
||||
* Smoke spec - Documents Hub aggregated view (Wave 11.B rebuild)
|
||||
*
|
||||
* Exercises the structural layout of the new DocumentsHub:
|
||||
* - FolderTreeSidebar with system-root folders (Clients / Companies / Yachts)
|
||||
@@ -12,11 +12,11 @@ import { login, navigateTo } from './helpers';
|
||||
* renders the AggregatedSection blocks
|
||||
*
|
||||
* Note: This spec intentionally avoids asserting on seeded file/workflow
|
||||
* counts — those vary by seed state. The integration tests (Tasks 8 + 9)
|
||||
* counts - those vary by seed state. The integration tests (Tasks 8 + 9)
|
||||
* already verify service correctness; this spec guards UI-level rendering.
|
||||
*/
|
||||
|
||||
test.describe('Documents hub — aggregated view', () => {
|
||||
test.describe('Documents hub - aggregated view', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
@@ -64,7 +64,7 @@ test.describe('Documents hub — aggregated view', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click the Clients system root folder button (the label button inside FolderRow).
|
||||
// The chevron button and the label button are siblings — we target the button
|
||||
// The chevron button and the label button are siblings - we target the button
|
||||
// that contains the text "Clients".
|
||||
const clientsBtn = page
|
||||
.locator('aside button')
|
||||
@@ -85,7 +85,7 @@ test.describe('Documents hub — aggregated view', () => {
|
||||
// Create a fresh client via the API so we have a guaranteed entity with a
|
||||
// folder. ensureEntityFolder is called by the createClient service hook.
|
||||
// page.request shares the browser context cookie jar (session cookie from
|
||||
// login()) — the bare `request` fixture is an isolated API context that
|
||||
// login()) - the bare `request` fixture is an isolated API context that
|
||||
// does not carry the session cookie and would 401.
|
||||
const res = await page.request.post('/api/v1/clients', {
|
||||
data: {
|
||||
@@ -95,13 +95,13 @@ test.describe('Documents hub — aggregated view', () => {
|
||||
},
|
||||
});
|
||||
// A non-2xx here means smoke setup is broken (port cookie / seed) or the
|
||||
// clients API regressed. Fail loud rather than skip green — a silent skip
|
||||
// clients API regressed. Fail loud rather than skip green - a silent skip
|
||||
// masked an infra failure for weeks in the audit window. Playwright doesn't
|
||||
// expose vitest's `expect.fail`, so we throw a plain Error which the
|
||||
// runner promotes to a failing test the same way.
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Client create returned ${res.status()} ${await res.text()} — entity sub-folder assertion cannot proceed`,
|
||||
`Client create returned ${res.status()} ${await res.text()} - entity sub-folder assertion cannot proceed`,
|
||||
);
|
||||
}
|
||||
const { data: client } = (await res.json()) as {
|
||||
|
||||
@@ -4,11 +4,11 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* Smoke spec — Upload a file into an entity folder (Wave 11.B)
|
||||
* Smoke spec - Upload a file into an entity folder (Wave 11.B)
|
||||
*
|
||||
* Strategy: Option B (API-fixture approach).
|
||||
*
|
||||
* The EntityFolderView's upload button is not wired into the new hub UI —
|
||||
* The EntityFolderView's upload button is not wired into the new hub UI -
|
||||
* the hub focuses on aggregated read + signing. We therefore drive the
|
||||
* upload path via the API directly (`page.request`) and then assert that
|
||||
* the file surfaces in the entity folder view.
|
||||
@@ -17,7 +17,7 @@ import fs from 'fs';
|
||||
* entity view after upload) rather than a specific UI affordance.
|
||||
*/
|
||||
|
||||
test.describe('Documents hub — upload into entity folder', () => {
|
||||
test.describe('Documents hub - upload into entity folder', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
@@ -27,7 +27,7 @@ test.describe('Documents hub — upload into entity folder', () => {
|
||||
|
||||
// 1. Create a client.
|
||||
// page.request shares the browser context cookie jar (session cookie from
|
||||
// login()) — the bare `request` fixture is an isolated API context that
|
||||
// login()) - the bare `request` fixture is an isolated API context that
|
||||
// does not carry the session cookie and would 401.
|
||||
const clientRes = await page.request.post('/api/v1/clients', {
|
||||
headers,
|
||||
@@ -40,7 +40,7 @@ test.describe('Documents hub — upload into entity folder', () => {
|
||||
// Playwright doesn't expose vitest's `expect.fail`; throw to fail loud.
|
||||
if (!clientRes.ok()) {
|
||||
throw new Error(
|
||||
`Client create returned ${clientRes.status()} ${await clientRes.text()} — upload test cannot proceed`,
|
||||
`Client create returned ${clientRes.status()} ${await clientRes.text()} - upload test cannot proceed`,
|
||||
);
|
||||
}
|
||||
const { data: client } = (await clientRes.json()) as {
|
||||
@@ -114,10 +114,10 @@ test.describe('Documents hub — upload into entity folder', () => {
|
||||
test('file uploaded with folderId (entity folder) auto-sets client_id', async ({ page }) => {
|
||||
const headers = await apiHeaders(page);
|
||||
|
||||
// 1. Create a client — ensureEntityFolder is triggered server-side when
|
||||
// 1. Create a client - ensureEntityFolder is triggered server-side when
|
||||
// the first file is uploaded with clientId or directly by the create hook.
|
||||
// page.request shares the browser context cookie jar (session cookie from
|
||||
// login()) — the bare `request` fixture is an isolated API context that
|
||||
// login()) - the bare `request` fixture is an isolated API context that
|
||||
// does not carry the session cookie and would 401.
|
||||
const clientRes = await page.request.post('/api/v1/clients', {
|
||||
headers,
|
||||
@@ -129,7 +129,7 @@ test.describe('Documents hub — upload into entity folder', () => {
|
||||
});
|
||||
if (!clientRes.ok()) {
|
||||
throw new Error(
|
||||
`Client create returned ${clientRes.status()} ${await clientRes.text()} — folderId test cannot proceed`,
|
||||
`Client create returned ${clientRes.status()} ${await clientRes.text()} - folderId test cannot proceed`,
|
||||
);
|
||||
}
|
||||
const { data: client } = (await clientRes.json()) as {
|
||||
@@ -151,11 +151,11 @@ test.describe('Documents hub — upload into entity folder', () => {
|
||||
clientId: client.id,
|
||||
},
|
||||
});
|
||||
// Seed upload failing means the files API is broken — fail loud so the
|
||||
// Seed upload failing means the files API is broken - fail loud so the
|
||||
// infra regression surfaces in CI instead of staying green-skipped.
|
||||
if (!seedUpload.ok()) {
|
||||
throw new Error(
|
||||
`Seed upload returned ${seedUpload.status()} ${await seedUpload.text()} — folderId test cannot proceed`,
|
||||
`Seed upload returned ${seedUpload.status()} ${await seedUpload.text()} - folderId test cannot proceed`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,11 +168,11 @@ test.describe('Documents hub — upload into entity folder', () => {
|
||||
);
|
||||
if (!listRes.ok()) {
|
||||
throw new Error(
|
||||
`File list returned ${listRes.status()} ${await listRes.text()} — folderId test cannot proceed`,
|
||||
`File list returned ${listRes.status()} ${await listRes.text()} - folderId test cannot proceed`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Navigate and verify — folder view shows the client entity sections.
|
||||
// 4. Navigate and verify - folder view shows the client entity sections.
|
||||
await navigateTo(page, '/documents');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
|
||||
@@ -8,8 +8,14 @@ test.describe('Error Handling', () => {
|
||||
await page.goto(`/${PORT_SLUG}/this-page-does-not-exist`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const is404 = await page.getByText('404').isVisible().catch(() => false);
|
||||
const isNotFound = await page.getByText(/not found/i).isVisible().catch(() => false);
|
||||
const is404 = await page
|
||||
.getByText('404')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const isNotFound = await page
|
||||
.getByText(/not found/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(is404 || isNotFound).toBeTruthy();
|
||||
});
|
||||
@@ -41,11 +47,23 @@ test.describe('Error Handling', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should show 404, not found, error, or empty state — but NOT crash
|
||||
const is404 = await page.getByText('404').isVisible().catch(() => false);
|
||||
const isNotFound = await page.getByText(/not found/i).isVisible().catch(() => false);
|
||||
const isError = await page.getByText(/error|not exist/i).isVisible().catch(() => false);
|
||||
const noData = await page.getByText(/no client|loading/i).isVisible().catch(() => false);
|
||||
// Should show 404, not found, error, or empty state - but NOT crash
|
||||
const is404 = await page
|
||||
.getByText('404')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const isNotFound = await page
|
||||
.getByText(/not found/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const isError = await page
|
||||
.getByText(/error|not exist/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const noData = await page
|
||||
.getByText(/no client|loading/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// At minimum, the page should not be a blank crash
|
||||
const body = await page.locator('body').textContent();
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe('Global Search', () => {
|
||||
await searchInput.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Recent section may or may not be present — storage backed, best-effort
|
||||
// Recent section may or may not be present - storage backed, best-effort
|
||||
const recentHeader = page.getByText('Recent', { exact: true });
|
||||
await recentHeader
|
||||
.first()
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe('Notifications', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The notification creation is async via socket — we verify the bell in the next test
|
||||
// The notification creation is async via socket - we verify the bell in the next test
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ test.describe('Notifications', () => {
|
||||
await firstNotif.click();
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Go back and check — the unread indicator should be gone or reduced
|
||||
// Go back and check - the unread indicator should be gone or reduced
|
||||
await page.goBack();
|
||||
await page.waitForTimeout(1_000);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ test.describe('Webhooks', () => {
|
||||
await saveBtn.click();
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
// Secret may be shown in a toast, dialog, or inline — non-strict smoke assertion
|
||||
// Secret may be shown in a toast, dialog, or inline - non-strict smoke assertion
|
||||
expect(true).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Custom Fields', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Fill field name (snake_case) — Radix combobox inputs are hidden so use placeholder
|
||||
// Fill field name (snake_case) - Radix combobox inputs are hidden so use placeholder
|
||||
const nameInput = dialog.getByPlaceholder(/vessel_type/i).first();
|
||||
await nameInput.fill('custom_text_test');
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe('Custom Fields', () => {
|
||||
await clientRow.click();
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
// Custom fields section should be present (even if collapsed) — non-strict smoke
|
||||
// Custom fields section should be present (even if collapsed) - non-strict smoke
|
||||
expect(true).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe('Client Portal', () => {
|
||||
|
||||
// Test 37: Portal cannot access CRM dashboard routes
|
||||
test('portal auth cannot access CRM routes', async ({ page }) => {
|
||||
// Navigate to CRM route without CRM auth — should redirect to login
|
||||
// Navigate to CRM route without CRM auth - should redirect to login
|
||||
await page.goto(`/${PORT_SLUG}/clients`);
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
@@ -68,7 +68,7 @@ test.describe('Client Portal', () => {
|
||||
.isVisible({ timeout: 2_000 })
|
||||
.catch(() => false);
|
||||
|
||||
// If on clients page, it means we still have CRM auth from previous tests — that's expected
|
||||
// If on clients page, it means we still have CRM auth from previous tests - that's expected
|
||||
// The key point is that portal auth (separate JWT) wouldn't grant CRM access
|
||||
expect(isOnLogin || isOnClients || hasAuthError).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ test.describe('AI Features', () => {
|
||||
await firstInterest.click();
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
// Check for score badge — should NOT be visible if flag is off
|
||||
// Check for score badge - should NOT be visible if flag is off
|
||||
const scoreBadge = page.locator('[data-testid="interest-score"], [class*="score-badge"]');
|
||||
const hotBadge = page.getByText(/^(Hot|Warm|Cool|Cold)$/).first();
|
||||
|
||||
@@ -125,7 +125,7 @@ test.describe('AI Features', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Should return 202 with jobId, or 404 if flag is disabled — both are valid
|
||||
// Should return 202 with jobId, or 404 if flag is disabled - both are valid
|
||||
expect([200, 202, 404].includes(draftRes.status())).toBeTruthy();
|
||||
|
||||
if (draftRes.status() === 202) {
|
||||
|
||||
@@ -71,7 +71,7 @@ test.describe('System Monitoring', () => {
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Try to access monitoring via API. 400 (missing port context) is also a valid
|
||||
// blocking response for non-super_admins — the API requires port context for
|
||||
// blocking response for non-super_admins - the API requires port context for
|
||||
// regular users, and super_admins bypass it. So 400/401/403 all mean "blocked".
|
||||
const healthRes = await page.request.get('/api/v1/admin/health');
|
||||
expect([400, 401, 403].includes(healthRes.status())).toBeTruthy();
|
||||
@@ -80,7 +80,7 @@ test.describe('System Monitoring', () => {
|
||||
expect([400, 401, 403].includes(queuesRes.status())).toBeTruthy();
|
||||
|
||||
// Try accessing the page directly. API-level (above) is the real boundary.
|
||||
// UI may navigate to the page, but with APIs returning 403 no queue data renders —
|
||||
// UI may navigate to the page, but with APIs returning 403 no queue data renders -
|
||||
// which is the observable effect of being "blocked" from data.
|
||||
await navigateTo(page, '/admin/monitoring');
|
||||
await page.waitForTimeout(3_000);
|
||||
@@ -94,7 +94,7 @@ test.describe('System Monitoring', () => {
|
||||
const wasRedirected = !url.includes('/admin/monitoring');
|
||||
// Queue cards render queue names when data loads. With 403, no cards render.
|
||||
// Queue names render as CardTitle elements in the main content area.
|
||||
// Sidebar also has "Email"/"Documents" nav links — scope to <main> to exclude sidebar.
|
||||
// Sidebar also has "Email"/"Documents" nav links - scope to <main> to exclude sidebar.
|
||||
const queueCardCount = await page
|
||||
.locator('main')
|
||||
.getByText(/^(webhooks|notifications|reports|maintenance|ai|bulk)$/i)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Accessibility smoke test — runs axe-core against the main authenticated
|
||||
* Accessibility smoke test - runs axe-core against the main authenticated
|
||||
* pages and fails CI if a new WCAG 2.1 A/AA serious-or-critical violation
|
||||
* shows up. Existing violations on third-party / shadcn primitives are
|
||||
* pruned via the disableRules list, kept small and audited.
|
||||
@@ -25,13 +25,13 @@ const PAGES = [
|
||||
// Tightened as the design system addresses each one.
|
||||
const DISABLED_RULES = [
|
||||
// Radix's hidden focus traps occasionally produce "tabindex >= 0 should
|
||||
// not be on a non-interactive element" — known shadcn dialog/sheet shape.
|
||||
// not be on a non-interactive element" - known shadcn dialog/sheet shape.
|
||||
'tabindex',
|
||||
// Color-contrast on muted body text — design pass pending.
|
||||
// Color-contrast on muted body text - design pass pending.
|
||||
'color-contrast',
|
||||
];
|
||||
|
||||
test.describe('accessibility — smoke', () => {
|
||||
test.describe('accessibility - smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ test.describe('Critical Path: Client → Interest → Invoice', () => {
|
||||
const rowCount = await rows.count();
|
||||
|
||||
if (rowCount === 0) {
|
||||
console.log(' ℹ No interests found — skipping stage advancement');
|
||||
console.log(' ℹ No interests found - skipping stage advancement');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Role-Based UI', () => {
|
||||
await page.waitForTimeout(3_000);
|
||||
|
||||
// Sidebar exposes a Settings link inside the Admin section (visible by
|
||||
// default for super_admin). Match the link directly — earlier OR-fallbacks
|
||||
// default for super_admin). Match the link directly - earlier OR-fallbacks
|
||||
// ambiguously matched the section header label too.
|
||||
const settingsLink = page.getByRole('link', { name: 'Settings', exact: true }).first();
|
||||
await expect(settingsLink).toBeVisible({ timeout: 10_000 });
|
||||
@@ -83,7 +83,7 @@ test.describe('Role-Based UI', () => {
|
||||
.isVisible({ timeout: 3_000 })
|
||||
.catch(() => false);
|
||||
const wasRedirected = !isStillOnMonitoring;
|
||||
// With APIs returning 403 for non-admins, queue cards don't render — no data leak.
|
||||
// With APIs returning 403 for non-admins, queue cards don't render - no data leak.
|
||||
// Scope to <main> because sidebar has "Email"/"Documents" nav links.
|
||||
const queueCardCount = await page
|
||||
.locator('main')
|
||||
@@ -118,11 +118,11 @@ test.describe('Role-Based UI', () => {
|
||||
const isDisabled = await newClientBtn.isDisabled().catch(() => false);
|
||||
if (!isDisabled) {
|
||||
console.warn(
|
||||
' ⚠️ APP BUG: New Client button not gated for viewer — server-side must enforce',
|
||||
' ⚠️ APP BUG: New Client button not gated for viewer - server-side must enforce',
|
||||
);
|
||||
}
|
||||
}
|
||||
// Test passes regardless — this validates the UI state (server is authoritative)
|
||||
// Test passes regardless - this validates the UI state (server is authoritative)
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@ test.describe('Role-Based UI', () => {
|
||||
const rowVisible = await firstRow.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
if (!rowVisible) {
|
||||
console.log(' ℹ No client rows found — skipping edit button check');
|
||||
console.log(' ℹ No client rows found - skipping edit button check');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ test.describe('Role-Based UI', () => {
|
||||
|
||||
const url = page.url();
|
||||
if (!url.includes('/clients/')) {
|
||||
console.log(' ℹ Could not navigate to client detail — skipping');
|
||||
console.log(' ℹ Could not navigate to client detail - skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ test.describe('Role-Based UI', () => {
|
||||
const isDisabled = await firstEditBtn.isDisabled().catch(() => false);
|
||||
|
||||
if (isVisible && !isDisabled) {
|
||||
console.warn(' ⚠️ Edit button visible/enabled for viewer — PermissionGate should hide it');
|
||||
console.warn(' ⚠️ Edit button visible/enabled for viewer - PermissionGate should hide it');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ test.describe('Error Recovery', () => {
|
||||
await emailInput.fill('recovery@test.com');
|
||||
}
|
||||
|
||||
// Re-submit — should succeed or at minimum no longer show fullName error
|
||||
// Re-submit - should succeed or at minimum no longer show fullName error
|
||||
if (await submitBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(2_000);
|
||||
@@ -94,7 +94,7 @@ test.describe('Error Recovery', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Find the search input — try multiple selectors
|
||||
// Find the search input - try multiple selectors
|
||||
const searchInput = page
|
||||
.locator('input[type="search"]')
|
||||
.first()
|
||||
@@ -105,10 +105,12 @@ test.describe('Error Recovery', () => {
|
||||
const searchVisible = await searchInput.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
if (!searchVisible) {
|
||||
console.log(' ℹ Search input not found — checking global search');
|
||||
console.log(' ℹ Search input not found - checking global search');
|
||||
|
||||
// Try the global search bar (usually a keyboard shortcut or top-bar icon)
|
||||
const globalSearch = page.locator('[class*="global-search"], [data-testid*="global"]').first()
|
||||
const globalSearch = page
|
||||
.locator('[class*="global-search"], [data-testid*="global"]')
|
||||
.first()
|
||||
.or(page.getByRole('button', { name: /search/i }).first());
|
||||
|
||||
if (await globalSearch.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
@@ -117,35 +119,47 @@ test.describe('Error Recovery', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const activeSearch = page.locator('input[type="search"], input[placeholder*="search" i], [role="searchbox"]').first();
|
||||
const activeSearch = page
|
||||
.locator('input[type="search"], input[placeholder*="search" i], [role="searchbox"]')
|
||||
.first();
|
||||
const isActive = await activeSearch.isVisible({ timeout: 3_000 }).catch(() => false);
|
||||
|
||||
if (!isActive) {
|
||||
console.log(' ℹ No accessible search input found — skipping search validation');
|
||||
console.log(' ℹ No accessible search input found - skipping search validation');
|
||||
expect(true).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single character — should not crash
|
||||
// Single character - should not crash
|
||||
await activeSearch.fill('a');
|
||||
await page.waitForTimeout(1_500);
|
||||
|
||||
const noError1 = await page.getByText(/uncaught error|cannot read|undefined/i).isVisible({ timeout: 1_000 }).catch(() => false);
|
||||
const noError1 = await page
|
||||
.getByText(/uncaught error|cannot read|undefined/i)
|
||||
.isVisible({ timeout: 1_000 })
|
||||
.catch(() => false);
|
||||
expect(noError1).toBeFalsy();
|
||||
|
||||
// SQL injection payload — should not crash or expose DB errors
|
||||
// SQL injection payload - should not crash or expose DB errors
|
||||
await activeSearch.fill("'; DROP TABLE clients; --");
|
||||
await page.waitForTimeout(1_500);
|
||||
|
||||
const noSqlError = await page.getByText(/sql|syntax error|pg error|database/i).isVisible({ timeout: 1_000 }).catch(() => false);
|
||||
const noSqlError = await page
|
||||
.getByText(/sql|syntax error|pg error|database/i)
|
||||
.isVisible({ timeout: 1_000 })
|
||||
.catch(() => false);
|
||||
expect(noSqlError).toBeFalsy();
|
||||
|
||||
// XSS payload — should be escaped, not executed
|
||||
// XSS payload - should be escaped, not executed
|
||||
await activeSearch.fill('<script>alert("xss")</script>');
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
// The payload text itself (escaped) may appear, but no alert dialog
|
||||
const hasAlertDialog = await page.locator('[role="alertdialog"]').filter({ hasText: 'xss' }).isVisible({ timeout: 1_000 }).catch(() => false);
|
||||
const hasAlertDialog = await page
|
||||
.locator('[role="alertdialog"]')
|
||||
.filter({ hasText: 'xss' })
|
||||
.isVisible({ timeout: 1_000 })
|
||||
.catch(() => false);
|
||||
expect(hasAlertDialog).toBeFalsy();
|
||||
|
||||
// Clear the search
|
||||
@@ -153,7 +167,10 @@ test.describe('Error Recovery', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Page should still be functional
|
||||
const body = await page.locator('body').textContent().catch(() => '');
|
||||
const body = await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.catch(() => '');
|
||||
expect(body && body.length > 10).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -164,12 +181,24 @@ test.describe('Error Recovery', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
const is404 = await page.getByText('404').isVisible({ timeout: 3_000 }).catch(() => false);
|
||||
const isNotFound = await page.getByText(/not found/i).isVisible({ timeout: 3_000 }).catch(() => false);
|
||||
const isError = await page.getByText(/error/i).isVisible({ timeout: 3_000 }).catch(() => false);
|
||||
const is404 = await page
|
||||
.getByText('404')
|
||||
.isVisible({ timeout: 3_000 })
|
||||
.catch(() => false);
|
||||
const isNotFound = await page
|
||||
.getByText(/not found/i)
|
||||
.isVisible({ timeout: 3_000 })
|
||||
.catch(() => false);
|
||||
const isError = await page
|
||||
.getByText(/error/i)
|
||||
.isVisible({ timeout: 3_000 })
|
||||
.catch(() => false);
|
||||
|
||||
// Page should not be a blank crash — must render something meaningful
|
||||
const body = await page.locator('body').textContent().catch(() => '');
|
||||
// Page should not be a blank crash - must render something meaningful
|
||||
const body = await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.catch(() => '');
|
||||
const hasContent = body !== null && body.length > 20;
|
||||
|
||||
expect(is404 || isNotFound || isError || hasContent).toBeTruthy();
|
||||
@@ -182,13 +211,19 @@ test.describe('Error Recovery', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
const body = await page.locator('body').textContent().catch(() => '');
|
||||
const body = await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.catch(() => '');
|
||||
|
||||
// Should render something — not crash with empty body
|
||||
// Should render something - not crash with empty body
|
||||
expect(body && body.length > 10).toBeTruthy();
|
||||
|
||||
// Should not show a raw Next.js error page with stack traces
|
||||
const hasStackTrace = await page.getByText(/at Object|at Module|stack trace/i).isVisible({ timeout: 1_000 }).catch(() => false);
|
||||
const hasStackTrace = await page
|
||||
.getByText(/at Object|at Module|stack trace/i)
|
||||
.isVisible({ timeout: 1_000 })
|
||||
.catch(() => false);
|
||||
expect(hasStackTrace).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -209,9 +244,15 @@ test.describe('Error Recovery', () => {
|
||||
|
||||
// Should be back on a functional page
|
||||
const returnUrl = page.url();
|
||||
const body = await page.locator('body').textContent().catch(() => '');
|
||||
const body = await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.catch(() => '');
|
||||
expect(body && body.length > 10).toBeTruthy();
|
||||
// URL should differ from the 404 page (we went back)
|
||||
expect(returnUrl !== `${page.url().split('//')[0]}//${page.url().split('//')[1]?.split('/')[0]}/${PORT_SLUG}/this-does-not-exist`).toBeTruthy();
|
||||
expect(
|
||||
returnUrl !==
|
||||
`${page.url().split('//')[0]}//${page.url().split('//')[1]?.split('/')[0]}/${PORT_SLUG}/this-does-not-exist`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('Portal Flow', () => {
|
||||
await expect(crmEmailInput).toBeVisible({ timeout: 5_000 });
|
||||
await expect(crmPasswordInput).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Navigate to portal login — should be a different page
|
||||
// Navigate to portal login - should be a different page
|
||||
await page.goto('/portal/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1_000);
|
||||
@@ -27,8 +27,10 @@ test.describe('Portal Flow', () => {
|
||||
|
||||
const hasPortalHeading = await portalHeading.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
// Look for an email-only input (magic link — no password field)
|
||||
const portalEmailInput = page.locator('input[type="email"], input[placeholder*="email" i], #email').first();
|
||||
// Look for an email-only input (magic link - no password field)
|
||||
const portalEmailInput = page
|
||||
.locator('input[type="email"], input[placeholder*="email" i], #email')
|
||||
.first();
|
||||
const portalPasswordInput = page.locator('input[type="password"]').first();
|
||||
|
||||
const hasEmail = await portalEmailInput.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
@@ -39,7 +41,7 @@ test.describe('Portal Flow', () => {
|
||||
|
||||
// Portal should NOT require a password (magic link flow)
|
||||
if (hasEmail && hasPassword) {
|
||||
console.warn(' ⚠️ Portal login shows password field — expected email-only magic link flow');
|
||||
console.warn(' ⚠️ Portal login shows password field - expected email-only magic link flow');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,11 +59,13 @@ test.describe('Portal Flow', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const emailInput = page.locator('input[type="email"], input[placeholder*="email" i], #email').first();
|
||||
const emailInput = page
|
||||
.locator('input[type="email"], input[placeholder*="email" i], #email')
|
||||
.first();
|
||||
const inputVisible = await emailInput.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
if (!inputVisible) {
|
||||
console.log(' ℹ Portal login email input not found — page may not be implemented yet');
|
||||
console.log(' ℹ Portal login email input not found - page may not be implemented yet');
|
||||
expect(true).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
@@ -112,7 +116,7 @@ test.describe('Portal Flow', () => {
|
||||
|
||||
test('portal document download endpoint requires auth', async ({ page }) => {
|
||||
const response = await page.request.get('/api/portal/documents/00000000-fake-id/download');
|
||||
// Must be 401 (not 500 — endpoint exists and guards correctly)
|
||||
// Must be 401 (not 500 - endpoint exists and guards correctly)
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('API Security — unauthenticated access', () => {
|
||||
test.describe('API Security - unauthenticated access', () => {
|
||||
test('GET /api/v1/clients returns 401 or 403 without a session', async ({ page }) => {
|
||||
const response = await page.request.get('/api/v1/clients');
|
||||
expect([401, 403]).toContain(response.status());
|
||||
@@ -29,7 +29,9 @@ test.describe('API Security — unauthenticated access', () => {
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('GET /api/v1/notifications/unread-count returns 401 or 403 without a session', async ({ page }) => {
|
||||
test('GET /api/v1/notifications/unread-count returns 401 or 403 without a session', async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.request.get('/api/v1/notifications/unread-count');
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
@@ -55,10 +57,10 @@ test.describe('API Security — unauthenticated access', () => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('API Security — error response sanitization', () => {
|
||||
test.describe('API Security - error response sanitization', () => {
|
||||
test('404 on a non-existent API route does not contain stack traces', async ({ page }) => {
|
||||
const response = await page.request.get('/api/v1/nonexistent-endpoint-xyzzy');
|
||||
// Accept any non-200 status — we just care about the body content
|
||||
// Accept any non-200 status - we just care about the body content
|
||||
const body = await response.json().catch(() => ({ error: response.statusText() }));
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
@@ -70,7 +72,9 @@ test.describe('API Security — error response sanitization', () => {
|
||||
expect(bodyStr).not.toContain('/app/src');
|
||||
});
|
||||
|
||||
test('unauthenticated response body follows { error } shape, no internal details', async ({ page }) => {
|
||||
test('unauthenticated response body follows { error } shape, no internal details', async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.request.get('/api/v1/clients');
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body) {
|
||||
@@ -87,14 +91,16 @@ test.describe('API Security — error response sanitization', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed JSON body to POST endpoint returns 400/422 without stack trace', async ({ page }) => {
|
||||
// Send invalid JSON as body — should trigger a validation or parse error
|
||||
test('malformed JSON body to POST endpoint returns 400/422 without stack trace', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Send invalid JSON as body - should trigger a validation or parse error
|
||||
const response = await page.request.post('/api/v1/clients', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: '{ invalid json }',
|
||||
});
|
||||
// Must be a client error (4xx), not a 500 stack dump
|
||||
// (401/403 is also acceptable — auth check happens before parse)
|
||||
// (401/403 is also acceptable - auth check happens before parse)
|
||||
expect(response.status()).toBeLessThan(600);
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body) {
|
||||
@@ -107,7 +113,7 @@ test.describe('API Security — error response sanitization', () => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('API Security — portal / CRM auth separation', () => {
|
||||
test.describe('API Security - portal / CRM auth separation', () => {
|
||||
test('portal dashboard endpoint returns 401 without portal JWT', async ({ page }) => {
|
||||
// The portal uses a separate JWT auth flow, not the CRM session cookie.
|
||||
// Even if called with no credentials, it must reject with 401.
|
||||
@@ -117,12 +123,14 @@ test.describe('API Security — portal / CRM auth separation', () => {
|
||||
|
||||
test('CRM login credentials cannot be used to access portal endpoints', async ({ page }) => {
|
||||
// Attempt to authenticate as a CRM user via Better Auth
|
||||
const loginRes = await page.request.post('/api/auth/sign-in/email', {
|
||||
data: {
|
||||
email: 'admin@portnimara.test',
|
||||
password: 'SuperAdmin12345!',
|
||||
},
|
||||
}).catch(() => null);
|
||||
const loginRes = await page.request
|
||||
.post('/api/auth/sign-in/email', {
|
||||
data: {
|
||||
email: 'admin@portnimara.test',
|
||||
password: 'SuperAdmin12345!',
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// Whether or not login succeeded, portal endpoints should be inaccessible
|
||||
// via the CRM session (portal uses a separate JWT issued by /api/portal/auth)
|
||||
@@ -138,19 +146,21 @@ test.describe('API Security — portal / CRM auth separation', () => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('API Security — response headers', () => {
|
||||
test('API responses do not expose internal server technology via X-Powered-By', async ({ page }) => {
|
||||
test.describe('API Security - response headers', () => {
|
||||
test('API responses do not expose internal server technology via X-Powered-By', async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.request.get('/api/v1/clients');
|
||||
// Next.js sets X-Powered-By by default — should be removed in production config.
|
||||
// Next.js sets X-Powered-By by default - should be removed in production config.
|
||||
// This test documents the expectation; it warns if the header is present.
|
||||
const poweredBy = response.headers()['x-powered-by'];
|
||||
if (poweredBy) {
|
||||
console.warn(
|
||||
`⚠️ SECURITY: X-Powered-By header exposed: "${poweredBy}". ` +
|
||||
'Set headers: { "X-Powered-By": "" } in next.config.ts to suppress.',
|
||||
'Set headers: { "X-Powered-By": "" } in next.config.ts to suppress.',
|
||||
);
|
||||
}
|
||||
// Not a hard fail — but the header should not be present in production
|
||||
// Not a hard fail - but the header should not be present in production
|
||||
// expect(poweredBy).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ test.describe('Residential', () => {
|
||||
await expect(page.getByRole('heading', { name: /residential clients/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Either table or empty state should render — both ok.
|
||||
// Either table or empty state should render - both ok.
|
||||
const hasTableOrEmpty = await Promise.race([
|
||||
page
|
||||
.locator('table')
|
||||
@@ -134,7 +134,7 @@ test.describe('Residential', () => {
|
||||
expect(interestRes.status()).toBe(201);
|
||||
const interestId = (await interestRes.json()).data?.id;
|
||||
|
||||
// Navigate to the detail route — the page should render the standard
|
||||
// Navigate to the detail route - the page should render the standard
|
||||
// residential-interest detail layout (eyebrow + client name h1 + sections).
|
||||
await navigateTo(page, `/residential/interests/${interestId}`);
|
||||
await expect(page.getByText('Residential interest', { exact: true }).first()).toBeVisible({
|
||||
@@ -146,7 +146,7 @@ test.describe('Residential', () => {
|
||||
});
|
||||
|
||||
test('public residential inquiry endpoint accepts a submission', async ({ page }) => {
|
||||
// Direct API call to the public endpoint — confirms wiring without UI.
|
||||
// Direct API call to the public endpoint - confirms wiring without UI.
|
||||
// Public endpoint takes portId via query string (no auth, but the helper
|
||||
// resolves the port ID for us via the admin API).
|
||||
const portId = await getPortId(page);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login, PORT_SLUG } from './helpers';
|
||||
|
||||
test.describe('Berth detail — Interests tab (Phase B)', () => {
|
||||
test.describe('Berth detail - Interests tab (Phase B)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
@@ -15,7 +15,7 @@ test.describe('Berth detail — Interests tab (Phase B)', () => {
|
||||
await expect(firstRow).toBeVisible({ timeout: 20_000 });
|
||||
// The list table uses an `onRowClick` handler on the <tr> that calls
|
||||
// `router.push`. Open the row's actions menu and click "View details"
|
||||
// — the menu item's handler routes the same way and is more reliable
|
||||
// - the menu item's handler routes the same way and is more reliable
|
||||
// than firing a synthetic <tr> click under React 19 + dev-mode HMR.
|
||||
await firstRow.getByRole('button', { name: /open menu/i }).click();
|
||||
await page.getByRole('menuitem', { name: /view details/i }).click();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* i18n PR11 — combobox surfaces.
|
||||
* i18n PR11 - combobox surfaces.
|
||||
*
|
||||
* Proves the new country / timezone / phone / subdivision combobox triggers
|
||||
* actually render in the create sheets we wired in PR6–8. Doesn't exercise
|
||||
@@ -36,7 +36,7 @@ test.describe('i18n combobox wiring', () => {
|
||||
|
||||
test('new client form swaps nationality input for CountryCombobox', async ({ page }) => {
|
||||
await navigateTo(page, '/clients');
|
||||
// Sheet trigger label varies by tenant — stick to the topbar action.
|
||||
// Sheet trigger label varies by tenant - stick to the topbar action.
|
||||
await page
|
||||
.locator('main')
|
||||
.getByRole('button', { name: /^new client$/i })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Global Setup — Seed the database with test users via Better Auth API
|
||||
* Global Setup - Seed the database with test users via Better Auth API
|
||||
* and insert supporting data (berths, system_settings) via direct SQL.
|
||||
*
|
||||
* This runs BEFORE any test spec via Playwright's `dependencies` config.
|
||||
@@ -48,7 +48,7 @@ async function signUpUser(email: string, password: string, name: string) {
|
||||
return data.user?.id ?? data.id;
|
||||
}
|
||||
|
||||
// User may already exist — try sign-in instead
|
||||
// User may already exist - try sign-in instead
|
||||
const loginRes = await fetch(`${BASE}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { login, navigateTo } from '../smoke/helpers';
|
||||
* tests/e2e/visual/snapshots.spec.ts-snapshots/.
|
||||
*
|
||||
* Pages chosen are list/landing screens that don't depend on per-row
|
||||
* fixture data — they tolerate seed drift between runs. Detail screens
|
||||
* fixture data - they tolerate seed drift between runs. Detail screens
|
||||
* (yacht detail, EOI dialog, invoice form review) are intentionally
|
||||
* deferred until we have stable fixtures wired up.
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ test.describe('Visual regression', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub entity-folder visual — click into the Clients system root, then into
|
||||
* Hub entity-folder visual - click into the Clients system root, then into
|
||||
* the first visible entity sub-folder. This is a best-effort baseline:
|
||||
* if no entity sub-folders exist yet the test skips with a clear message
|
||||
* rather than failing.
|
||||
@@ -81,7 +81,7 @@ test.describe('Visual regression', () => {
|
||||
const expandBtn = page.locator('aside').getByRole('button', { name: 'Expand' }).first();
|
||||
const hasExpand = await expandBtn.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
if (!hasExpand) {
|
||||
test.skip(true, 'No expandable folder found in sidebar — hub-entity-folder baseline skipped');
|
||||
test.skip(true, 'No expandable folder found in sidebar - hub-entity-folder baseline skipped');
|
||||
return;
|
||||
}
|
||||
await expandBtn.click();
|
||||
@@ -108,7 +108,7 @@ test.describe('Visual regression', () => {
|
||||
if (!entityFolderBtn) {
|
||||
test.skip(
|
||||
true,
|
||||
'No entity sub-folder visible after expanding — hub-entity-folder baseline skipped',
|
||||
'No entity sub-folder visible after expanding - hub-entity-folder baseline skipped',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user