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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -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 });

View File

@@ -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;
}

View File

@@ -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');
}
});

View File

@@ -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');

View File

@@ -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');

View File

@@ -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)) {

View File

@@ -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');

View File

@@ -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)');

View File

@@ -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)',

View File

@@ -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',

View File

@@ -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.

View File

@@ -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 }) => {

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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.

View File

@@ -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;

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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');

View File

@@ -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();

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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();
}
});

View File

@@ -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();
}
});

View File

@@ -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();
});

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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');
});

View File

@@ -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;
}

View File

@@ -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');
}
}

View File

@@ -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();
});
});

View File

@@ -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);
});

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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();

View File

@@ -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 PR68. 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 })

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -10,7 +10,7 @@
*/
// `globalSetup` runs in vitest's parent process, so the test workers' env
// from `loadEnv` doesn't apply here we have to load .env ourselves before
// from `loadEnv` doesn't apply here - we have to load .env ourselves before
// importing the db module (which validates DATABASE_URL at import time).
import 'dotenv/config';
import { sql } from 'drizzle-orm';
@@ -21,7 +21,7 @@ export async function setup() {
}
export async function teardown() {
// Same DB as the dev/test environment only delete obvious test rows
// Same DB as the dev/test environment - only delete obvious test rows
// (slug prefix is a marker the factory always sets).
await db.execute(sql`
-- Stage the doomed port ids

View File

@@ -51,7 +51,7 @@ const DEFAULT_SKIP = [
* record any console errors, network 4xx/5xx responses, or click failures.
*
* Why: a fast smoke check that no UI element 500s, throws, or routes to a
* stale endpoint after a refactor without writing per-button assertions.
* stale endpoint after a refactor - without writing per-button assertions.
*
* The helper is intentionally tolerant: a single click that fails only adds
* to `errors` rather than throwing, so the caller can attribute failures to
@@ -94,7 +94,7 @@ export async function clickEverythingOnPage(
try {
outerHtml = (await el.evaluate((n) => (n as HTMLElement).outerHTML)).slice(0, 200);
} catch {
// Element detached between locator and evaluate skip silently.
// Element detached between locator and evaluate - skip silently.
continue;
}

View File

@@ -3,11 +3,11 @@
*
* Two flavours:
* 1. Async DB-inserting factories (makePort, makeClient, makeBerth, makeYacht,
* makeCompany, ...) insert a row via the app's `db` handle and return the
* makeCompany, ...) - insert a row via the app's `db` handle and return the
* inserted record. Use these from integration tests that need real FK /
* unique-index enforcement. They require DATABASE_URL to be reachable.
* 2. Plain-data helpers (makeAuditMeta, makeCreateClientInput, makeCreate*)
* return in-memory objects suitable for unit tests with mocked `db`.
* - return in-memory objects suitable for unit tests with mocked `db`.
*/
import { and, eq, sql } from 'drizzle-orm';
@@ -299,7 +299,7 @@ export function makeAuditMeta(overrides?: Partial<AuditMeta>): AuditMeta {
import type { RolePermissions } from '@/lib/db/schema/users';
/** Full permissions every action allowed. */
/** Full permissions - every action allowed. */
export function makeFullPermissions(): RolePermissions {
return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
@@ -387,7 +387,7 @@ export function makeFullPermissions(): RolePermissions {
};
}
/** Read-only viewer permissions no create/update/delete. */
/** Read-only viewer permissions - no create/update/delete. */
export function makeViewerPermissions(): RolePermissions {
return {
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
@@ -475,7 +475,7 @@ export function makeViewerPermissions(): RolePermissions {
};
}
/** Sales agent permissions own clients/interests, no admin. */
/** Sales agent permissions - own clients/interests, no admin. */
export function makeSalesAgentPermissions(): RolePermissions {
return {
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false },
@@ -563,7 +563,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
};
}
/** Sales manager can do most things, limited admin. */
/** Sales manager - can do most things, limited admin. */
export function makeSalesManagerPermissions(): RolePermissions {
return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
@@ -651,7 +651,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
};
}
/** Director everything except system backup. */
/** Director - everything except system backup. */
export function makeDirectorPermissions(): RolePermissions {
return {
...makeFullPermissions(),

View File

@@ -160,7 +160,7 @@ describe('ai-budget service', () => {
it('periodStart honors day/week/month boundaries (UTC)', () => {
const wed = new Date(Date.UTC(2026, 3, 29, 14, 30)); // Wed 2026-04-29 14:30 UTC
expect(periodStart('day', wed).toISOString()).toBe('2026-04-29T00:00:00.000Z');
// 2026-04-27 was a Monday week starts there.
// 2026-04-27 was a Monday - week starts there.
expect(periodStart('week', wed).toISOString()).toBe('2026-04-27T00:00:00.000Z');
expect(periodStart('month', wed).toISOString()).toBe('2026-04-01T00:00:00.000Z');
});

View File

@@ -1,5 +1,5 @@
/**
* Engine integration test drives `runAlertEngineForPorts` against
* Engine integration test - drives `runAlertEngineForPorts` against
* seeded conditions and asserts: (1) correct alerts upsert, (2) running
* twice doesn't duplicate, (3) mutating state auto-resolves stale alerts.
*
@@ -129,7 +129,7 @@ describe('alert engine', () => {
await runAlertEngineForPorts([port.id]);
expect(await listOpenAlerts(port.id, 'reservation.no_agreement')).toHaveLength(1);
// Add an agreement document condition no longer fires.
// Add an agreement document - condition no longer fires.
await db.insert(documents).values({
portId: port.id,
reservationId: resv!.id,
@@ -200,7 +200,7 @@ describe('alert engine', () => {
const summary = await runAlertEngineForPorts([port.id]);
expect(summary.portsScanned).toBe(1);
expect(summary.rulesEvaluated).toBeGreaterThan(0);
// No conditions seeded no rules should fail.
// No conditions seeded - no rules should fail.
expect(summary.errors).toHaveLength(0);
});
});

View File

@@ -39,7 +39,7 @@ async function makeAlert(portId: string) {
return row.id;
}
describe('alerts service tenant isolation', () => {
describe('alerts service - tenant isolation', () => {
it('dismissAlert is a no-op when called with the wrong portId', async () => {
const portA = await makePort();
const portB = await makePort();

View File

@@ -1,5 +1,5 @@
/**
* Analytics service integration tests exercise the four computations
* Analytics service integration tests - exercise the four computations
* against a seeded port + assert the cache layer reads/writes correctly.
*/
@@ -67,7 +67,7 @@ describe('analytics service', () => {
it('returns 7 points for 7d range with cumulative won-deal occupancy', async () => {
// Post 2026-05-14 the timeline derives occupancy from won
// interests (cumulative as of each day) rather than active
// reservations see analytics.service.ts comment + PRE-DEPLOY-
// reservations - see analytics.service.ts comment + PRE-DEPLOY-
// PLAN § 1.1.3. Fixture: 3 berths, one of which sold 5 days ago.
const port = await makePort();
await makeBerth({ portId: port.id });

View File

@@ -1,5 +1,5 @@
/**
* Port-scoped global reservations list locks in feat(marina): the new
* Port-scoped global reservations list - locks in feat(marina): the new
* `GET /api/v1/berth-reservations` endpoint that powers the
* `[portSlug]/berth-reservations` page. The route is thin (parseQuery →
* listReservations); the test guarantees port scoping at the handler

View File

@@ -36,7 +36,7 @@ describe('POST /api/v1/companies', () => {
const res1 = await createHandler(req1, ctx, {});
expect(res1.status).toBe(201);
// Same name with different case should still conflict.
// Same name with different case - should still conflict.
const req2 = makeMockRequest('POST', 'http://localhost/api/v1/companies', {
body: { name: name.toUpperCase() },
});

View File

@@ -94,7 +94,7 @@ describe('GET /api/v1/companies/[id]/members', () => {
);
expect(delRes.status).toBe(204);
// Default active only.
// Default - active only.
const activeOnlyRes = await listHandler(
makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`),
ctx,
@@ -375,7 +375,7 @@ describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => {
);
const m1 = ((await m1Res.json()) as any).data;
// M2, M3 not primary.
// M2, M3 - not primary.
const m2Res = await createHandler(
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
body: {

View File

@@ -416,7 +416,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
it('returns 403 when caller lacks reservations.cancel for cancel action', async () => {
const { port, reservation } = await seedReservation();
// Sales agent has activate but NOT cancel.
// Sales agent - has activate but NOT cancel.
const ctx = makeMockCtx({
portId: port.id,
permissions: makeSalesAgentPermissions(),

View File

@@ -1,5 +1,5 @@
/**
* Saved-views ownership enforcement locks in the 403/404 split shipped
* Saved-views ownership enforcement - locks in the 403/404 split shipped
* in fix(auth). The route handlers preflight `assertViewOwner` BEFORE the
* service call, so even if the service's internal userId filter is later
* refactored, the route still rejects cross-user mutations.
@@ -96,7 +96,7 @@ describe('saved-views ownership enforcement', () => {
it('PATCH on a view in a different port: 404 (cross-port enumeration is blocked)', async () => {
// The view exists in `portId` but the auth context says we're operating
// in a different port. The lookup is scoped to `(id, portId)` so the row
// is invisible should 404, not 403.
// is invisible - should 404, not 403.
const otherPort = await makePort();
const ctx = makeMockCtx({ portId: otherPort.id, userId: ownerUserId });
const res = await patchHandler(

View File

@@ -94,7 +94,7 @@ describe('PATCH /api/v1/yachts/[id]', () => {
// Validator strips owner fields, so we need to bypass it to reach the service's defensive guard.
// Test the service layer defense by calling the handler with a payload that the validator
// would accept but which also contains an unknown field that matches the forbidden keys.
// Actually the validator just omits `owner` additional keys `currentOwnerId` etc. pass
// Actually the validator just omits `owner` - additional keys `currentOwnerId` etc. pass
// through Zod's .partial() (which still omits unknown keys by default).
// Zod .strip() is default, so unknown keys are dropped: we assert on the service directly.
const { updateYacht } = await import('@/lib/services/yachts.service');
@@ -264,7 +264,7 @@ describe('GET /api/v1/yachts/[id]/ownership-history', () => {
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body.data).toHaveLength(2);
// Sorted DESC by startDate newest first
// Sorted DESC by startDate - newest first
const firstStart = new Date(body.data[0].startDate).getTime();
const secondStart = new Date(body.data[1].startDate).getTime();
expect(firstStart).toBeGreaterThanOrEqual(secondStart);

View File

@@ -82,7 +82,7 @@ describe('GET /api/v1/yachts (listHandler)', () => {
});
});
describe('POST /api/v1/yachts permission gate', () => {
describe('POST /api/v1/yachts - permission gate', () => {
it('viewer (no yachts.create) receives 403 through full pipeline', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });

View File

@@ -1,5 +1,5 @@
/**
* PR10 audit log search.
* PR10 - audit log search.
*
* Validates:
* 1. Tsvector full-text search via the GENERATED `search_text` column

View File

@@ -1,12 +1,12 @@
/**
* Task 11 backfill-document-folders integration tests.
* Task 11 - backfill-document-folders integration tests.
*
* Five cases:
* 1. Creates system roots and entity subfolders.
* 2. Sets files.folder_id from entity FKs.
* 3. Copies entity FKs from completed workflows onto signed files.
* 4. Idempotent second run produces the same result.
* 5. Port isolation does not touch other ports.
* 4. Idempotent - second run produces the same result.
* 5. Port isolation - does not touch other ports.
*/
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
@@ -125,7 +125,7 @@ describe('backfill-document-folders · runBackfill', () => {
.insert(files)
.values({
portId,
// No clientId set simulates legacy completion before entity FK auto-propagation.
// No clientId set - simulates legacy completion before entity FK auto-propagation.
filename: 'signed-eoi.pdf',
originalName: 'signed-eoi.pdf',
storagePath: `${portId}/signed-eoi.pdf`,
@@ -161,7 +161,7 @@ describe('backfill-document-folders · runBackfill', () => {
// ── Test 4: Idempotent ────────────────────────────────────────────────────────
it('is idempotent running twice produces the same number of folder rows', async () => {
it('is idempotent - running twice produces the same number of folder rows', async () => {
const client = await makeClient({ portId });
await db.insert(files).values({
@@ -217,7 +217,7 @@ describe('backfill-document-folders · runBackfill', () => {
.from(documentFolders)
.where(eq(documentFolders.portId, otherPort.id));
// The other port should have zero folders the backfill was not run for it.
// The other port should have zero folders - the backfill was not run for it.
expect(otherPortFolders).toHaveLength(0);
});
});

View File

@@ -43,7 +43,7 @@ beforeEach(async () => {
});
afterEach(async () => {
// No file cleanup needed the filesystem backend writes to a tmp root.
// No file cleanup needed - the filesystem backend writes to a tmp root.
});
function fakePdf(): Buffer {
@@ -240,7 +240,7 @@ describe('applyParseResults', () => {
lengthFt: 200,
bowFacing: 'East',
// unknown / non-allowlisted column should be silently dropped:
// @ts-expect-error testing the allowlist
// @ts-expect-error - testing the allowlist
hackThePlanet: 'pwn',
},
port.id,
@@ -323,7 +323,7 @@ describe('cross-port tenant guard', () => {
});
// Port B caller passing port A's berth id must hit NotFoundError on
// every entrypoint including read-only listing, which previously
// every entrypoint - including read-only listing, which previously
// returned 15-min presigned download URLs to the foreign port's PDFs.
await expect(listBerthPdfVersions(berthA.id, portB.id)).rejects.toThrow(/berth/i);
await expect(rollbackToVersion(berthA.id, v1.versionId, portB.id)).rejects.toThrow(/berth/i);

View File

@@ -1,5 +1,5 @@
/**
* clients.service.createRelationship tenant-FK validation tests.
* clients.service.createRelationship - tenant-FK validation tests.
*
* Covers the fix that requires both clientAId (the URL id) and clientBId
* (the body id) to belong to the caller's port. The list endpoint joins
@@ -8,7 +8,7 @@
*/
import { describe, it, expect, beforeAll } from 'vitest';
describe('clients.service createRelationship port isolation', () => {
describe('clients.service - createRelationship port isolation', () => {
let createRelationship: typeof import('@/lib/services/clients.service').createRelationship;
let makePort: typeof import('../helpers/factories').makePort;

View File

@@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest';
import { createCrmInvite } from '@/lib/services/crm-invite.service';
import { ValidationError } from '@/lib/errors';
describe('createCrmInvite super-admin gate', () => {
describe('createCrmInvite - super-admin gate', () => {
it('rejects super-admin invites when caller is not a super-admin', async () => {
await expect(
createCrmInvite({

View File

@@ -35,7 +35,7 @@ beforeAll(async () => {
await sql.end();
dbAvailable = true;
} catch {
console.warn('[crud-audit] Test database not available skipping integration tests');
console.warn('[crud-audit] Test database not available - skipping integration tests');
}
});
@@ -99,7 +99,7 @@ async function getAuditEntries(
// ─── Client Audit Tests ───────────────────────────────────────────────────────
describe('CRUD Audit Clients', () => {
describe('CRUD Audit - Clients', () => {
let portId: string;
beforeAll(async () => {
@@ -200,7 +200,7 @@ describe('CRUD Audit — Clients', () => {
// ─── Interest Audit Tests ─────────────────────────────────────────────────────
describe('CRUD Audit Interests', () => {
describe('CRUD Audit - Interests', () => {
let portId: string;
let clientId: string;
@@ -291,7 +291,7 @@ describe('CRUD Audit — Interests', () => {
// ─── Berth Audit Tests ────────────────────────────────────────────────────────
describe('CRUD Audit Berths', () => {
describe('CRUD Audit - Berths', () => {
let portId: string;
let berthId: string;

View File

@@ -32,7 +32,7 @@ beforeAll(async () => {
await sql.end();
dbAvailable = true;
} catch {
console.warn('[custom-fields] Test database not available skipping integration tests');
console.warn('[custom-fields] Test database not available - skipping integration tests');
}
});
@@ -66,7 +66,7 @@ async function cleanupPort(portId: string): Promise<void> {
// ─── Definitions Tests ────────────────────────────────────────────────────────
describe('Custom Fields Definitions', () => {
describe('Custom Fields - Definitions', () => {
let portId: string;
const userId = crypto.randomUUID();
@@ -160,7 +160,7 @@ describe('Custom Fields — Definitions', () => {
meta,
);
// Cast bypasses TS the service should guard against this at runtime.
// Cast bypasses TS - the service should guard against this at runtime.
await expect(
updateDefinition(
portId,
@@ -205,7 +205,7 @@ describe('Custom Fields — Definitions', () => {
// ─── Values Tests ─────────────────────────────────────────────────────────────
describe('Custom Fields Values', () => {
describe('Custom Fields - Values', () => {
let portId: string;
const userId = crypto.randomUUID();
const entityId = crypto.randomUUID();
@@ -246,7 +246,7 @@ describe('Custom Fields — Values', () => {
expect(entry).toBeDefined();
expect(entry!.value).not.toBeNull();
// value is stored as jsonb the raw stored value
// value is stored as jsonb - the raw stored value
expect((entry!.value as Record<string, unknown>).value).toBe('GOLD-2024');
});
@@ -321,10 +321,10 @@ describe('Custom Fields — Values', () => {
// The previous suite seeded ONE port and verified CRUD inside it. The audit
// (HIGH §20 / auditor-J Issue 3) flagged that the suite never asserted that
// a definition created in port A is invisible from port B, nor that
// setValues refuses cross-port writes combined with the deferred
// setValues refuses cross-port writes - combined with the deferred
// custom-fields-hardcoded-clients gap, no test would catch a regression.
describe('Custom Fields Cross-port Isolation', () => {
describe('Custom Fields - Cross-port Isolation', () => {
let portA: string;
let portB: string;
const userId = crypto.randomUUID();
@@ -410,7 +410,7 @@ describe('Custom Fields — Cross-port Isolation', () => {
);
// Caller in port B tries to write a value keyed to port A's field id.
// The service must refuse either by throwing, or by no-oping
// The service must refuse - either by throwing, or by no-oping
// (returning without touching port A's data). Either way port A's
// value-store for the entity must remain unchanged.
let threw = false;

View File

@@ -1,5 +1,5 @@
/**
* Client merge service end-to-end integration test.
* Client merge service - end-to-end integration test.
*
* Spins up two real clients in a real port via the factory helpers,
* attaches a few satellites (interest, contact, address, note),

View File

@@ -1,5 +1,5 @@
/**
* Match-candidates API integration test.
* Match-candidates API - integration test.
*
* Exercises the GET /api/v1/clients/match-candidates handler against a
* real port + clients pool. Verifies the dedup library's at-create
@@ -107,7 +107,7 @@ describe('GET /api/v1/clients/match-candidates', () => {
});
it('returns medium-confidence partial matches', async () => {
// Same name, different contact info Pattern F territory.
// Same name, different contact info - Pattern F territory.
const port = await makePort();
const ctx = makeMockCtx({ portId: port.id });
const existing = await makeClient({
@@ -127,7 +127,7 @@ describe('GET /api/v1/clients/match-candidates', () => {
name: 'Etiennette Clamouze',
});
// Either no match (low confidence filtered out) or a medium one
// Either no match (low confidence filtered out) or a medium one -
// either is fine. Critically, NOT high.
if (data.length > 0) {
expect(data[0]!.confidence).not.toBe('high');

View File

@@ -1,8 +1,8 @@
/**
* Task 3 document-folders service: listTree + createFolder (TDD).
* Task 4 renameFolder + moveFolder (TDD).
* Task 3 - document-folders service: listTree + createFolder (TDD).
* Task 4 - renameFolder + moveFolder (TDD).
*
* Uses the makePort factory (not a "setupTestPort" helper that name
* Uses the makePort factory (not a "setupTestPort" helper - that name
* doesn't exist in this codebase). TEST_USER_ID is resolved once via
* beforeAll from any seeded user, matching the pattern in
* alerts-tenant-isolation.test.ts and gdpr-export.test.ts.

View File

@@ -112,9 +112,9 @@ describe('MERGE_FIELDS catalog', () => {
});
});
// ─── resolveTemplate EOI scope tokens ───────────────────────────────────────
// ─── resolveTemplate - EOI scope tokens ───────────────────────────────────────
describe('resolveTemplate EOI scope tokens', () => {
describe('resolveTemplate - EOI scope tokens', () => {
const EOI_TEMPLATE_BODY = [
'Client: {{client.fullName}} / {{client.email}} / {{client.phone}}',
'Yacht: {{yacht.name}} HN={{yacht.hullNumber}} LenFt={{yacht.lengthFt}} LenM={{yacht.lengthM}} YB={{yacht.yearBuilt}}',
@@ -273,7 +273,7 @@ describe('resolveTemplate — EOI scope tokens', () => {
});
});
describe('resolveTemplate company-owned yacht', () => {
describe('resolveTemplate - company-owned yacht', () => {
it('populates company.* tokens and owner.legalName for company-owned yachts', async () => {
const port = await makePort();
const company = await makeCompany({
@@ -354,9 +354,9 @@ describe('resolveTemplate — company-owned yacht', () => {
});
});
// ─── resolveTemplate legacy fallback path ───────────────────────────────────
// ─── resolveTemplate - legacy fallback path ───────────────────────────────────
describe('resolveTemplate legacy fallback (no interestId)', () => {
describe('resolveTemplate - legacy fallback (no interestId)', () => {
it('falls back to direct client lookup when no interestId is provided', async () => {
const port = await makePort();
const client = await makeClient({

View File

@@ -189,7 +189,7 @@ const meta = {
// ─── Pathway: inapp ───────────────────────────────────────────────────────────
describe('generateAndSign inapp pathway', () => {
describe('generateAndSign - inapp pathway', () => {
it('generates PDF via pdfme, uploads to MinIO, and sends to Documenso', async () => {
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.createDocument).mockResolvedValue({
@@ -350,7 +350,7 @@ describe('generateAndSign — inapp pathway', () => {
// ─── Pathway: documenso-template ──────────────────────────────────────────────
describe('generateAndSign documenso-template pathway', () => {
describe('generateAndSign - documenso-template pathway', () => {
it('calls Documenso template-generate endpoint and records a documents row', async () => {
const client = await import('@/lib/services/documenso-client');
vi.mocked(client.generateDocumentFromTemplate).mockResolvedValue({

View File

@@ -1,5 +1,5 @@
/**
* Task 7 handleDocumentCompleted auto-deposit.
* Task 7 - handleDocumentCompleted auto-deposit.
*
* Verifies that when a document is completed:
* - The signed PDF is deposited into the owner's entity subfolder
@@ -25,7 +25,7 @@ import { handleDocumentCompleted } from '@/lib/services/documents.service';
import { ensureSystemRoots } from '@/lib/services/document-folders.service';
import { makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories';
// Stub Documenso download do NOT hit the network.
// Stub Documenso download - do NOT hit the network.
vi.mock('@/lib/services/documenso-client', async (importOriginal) => {
const real = await importOriginal<typeof import('@/lib/services/documenso-client')>();
return {
@@ -34,7 +34,7 @@ vi.mock('@/lib/services/documenso-client', async (importOriginal) => {
};
});
// Stub storage backend write to an in-memory map so no MinIO required.
// Stub storage backend - write to an in-memory map so no MinIO required.
const stubPuts = new Map<string, Buffer>();
vi.mock('@/lib/storage', async (importOriginal) => {
const real = await importOriginal<typeof import('@/lib/storage')>();
@@ -171,7 +171,7 @@ describe('handleDocumentCompleted · auto-deposit', () => {
.values({
portId,
interestId: interest!.id,
// All direct owner FKs null owner must be resolved via interest.
// All direct owner FKs null - owner must be resolved via interest.
documentType: 'eoi',
title: 'Auto-deposit test EOI (via interest)',
status: 'partially_signed',

View File

@@ -1,5 +1,5 @@
/**
* DOCUMENT_EXPIRED webhook handling locks in fix(documenso). The handler
* DOCUMENT_EXPIRED webhook handling - locks in fix(documenso). The handler
* was previously defined but never wired to the route's event switch, so
* expired EOIs stayed in `sent` / `partially_signed` forever.
*/
@@ -88,7 +88,7 @@ describe('handleDocumentExpired', () => {
});
it('is a no-op when the documensoId does not match any document', async () => {
// Should NOT throw the handler logs a warning and returns. Verify no
// Should NOT throw - the handler logs a warning and returns. Verify no
// exception propagates up to the webhook route.
await expect(
handleDocumentExpired({ documentId: 'definitely-not-a-real-doc' }),
@@ -98,7 +98,7 @@ describe('handleDocumentExpired', () => {
it('does not flip a document in port B when port A receives the expired event', async () => {
// Two ports holding the same documenso_id (legacy data, or a future
// Documenso-instance migration that reuses ids). The handler currently
// mutates whichever document `findFirst` returns locking in the
// mutates whichever document `findFirst` returns - locking in the
// intended behaviour now means a future port_id-aware handler can
// be added without regressing this guard.
//
@@ -140,7 +140,7 @@ describe('handleDocumentExpired', () => {
// resolve this from the secret → port mapping in the deferred fix).
await handleDocumentExpired({ documentId: sharedDocumensoId, portId: portA.id });
// Port-A doc flipped, port-B unchanged. Pre-fix, both flip this
// Port-A doc flipped, port-B unchanged. Pre-fix, both flip - this
// assertion locks the boundary in once the handler scope lands.
const afterA = await db.query.documents.findFirst({ where: eq(documents.id, docA!.id) });
const afterB = await db.query.documents.findFirst({ where: eq(documents.id, docB!.id) });

View File

@@ -1,5 +1,5 @@
/**
* PR6 documents hub `eoi_queue` tab.
* PR6 - documents hub `eoi_queue` tab.
*
* Verifies that:
* - `listDocuments` with tab='eoi_queue' returns only EOI docs in
@@ -8,7 +8,7 @@
* - Completed/expired EOIs are excluded (those belong to other tabs)
*
* (Note: `getHubTabCounts` and the /hub-counts route were removed when the
* hub rebuild dropped the count-strip KPI surface the count assertions
* hub rebuild dropped the count-strip KPI surface - the count assertions
* that used to live here went with them.)
*/
@@ -19,7 +19,7 @@ import { documents } from '@/lib/db/schema/documents';
import { listDocuments } from '@/lib/services/documents.service';
import { makePort, makeClient } from '../helpers/factories';
describe('documents hub eoi_queue tab', () => {
describe('documents hub - eoi_queue tab', () => {
it('lists only EOIs in in-flight status', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });

View File

@@ -1,5 +1,5 @@
/**
* Task 7 listDocuments folder filtering (TDD).
* Task 7 - listDocuments folder filtering (TDD).
*
* Exercises the three folderId modes: null (root only), a string (direct
* children), and a string with includeDescendants=true (subtree). Mirrors the

View File

@@ -1,5 +1,5 @@
/**
* PR8 expense duplicate detection.
* PR8 - expense duplicate detection.
*
* Validates:
* 1. `scanForDuplicates` matches by port + lower(vendor) + amount + date ±3d

View File

@@ -1,5 +1,5 @@
/**
* Task 9 entity-aggregated API query params (TDD).
* Task 9 - entity-aggregated API query params (TDD).
*
* Verifies:
* 1. listFilesAggregatedByEntity returns DIRECTLY ATTACHED + FROM COMPANY
@@ -71,7 +71,7 @@ async function insertFile(
// ─── listFilesAggregatedByEntity ──────────────────────────────────────────────
describe('GET /api/v1/files?entityType=client&entityId=… service layer', () => {
describe('GET /api/v1/files?entityType=client&entityId=… - service layer', () => {
let portId: string;
let clientId: string;
let companyId: string;
@@ -127,18 +127,18 @@ describe('GET /api/v1/files?entityType=client&entityId=… — service layer', (
const otherFile = await insertFile(otherPort.id, { clientId: otherClient.id });
const result = await listFilesAggregatedByEntity(portId, 'client', clientId);
// Groups are only for the correct port the other-port client's file must not appear
// Groups are only for the correct port - the other-port client's file must not appear
const allFileIds = result.groups.flatMap((g) => g.files.map((f) => (f as { id: string }).id));
expect(result.groups.length).toBeGreaterThan(0);
expect(allFileIds.length).toBeGreaterThan(0);
// Explicit cross-port isolation assertion leakage would cause this to fail
// Explicit cross-port isolation assertion - leakage would cause this to fail
expect(allFileIds).not.toContain(otherFile.id);
});
});
// ─── listInflightWorkflowsAggregatedByEntity ──────────────────────────────────
describe('GET /api/v1/documents?entityType=client&entityId=… service layer', () => {
describe('GET /api/v1/documents?entityType=client&entityId=… - service layer', () => {
let portId: string;
let clientId: string;

View File

@@ -18,7 +18,7 @@ import { makePort, makeClient, makeYacht } from '../helpers/factories';
let TEST_USER_ID = '';
beforeAll(async () => {
// Pull any existing user gdpr_exports.requested_by has an FK that needs
// Pull any existing user - gdpr_exports.requested_by has an FK that needs
// to resolve. Tests don't need the user to be specific; they just need it
// to exist.
const [u] = await db.select({ id: user.id }).from(user).limit(1);
@@ -166,7 +166,7 @@ describe('requestGdprExport', () => {
expect(add).toHaveBeenCalledWith(
'gdpr-export',
expect.objectContaining({ exportId: row.id, emailToClient: true }),
// F3: BullMQ 5.x rejects colons in custom job IDs switched to dash.
// F3: BullMQ 5.x rejects colons in custom job IDs - switched to dash.
expect.objectContaining({ jobId: `gdpr-export-${row.id}` }),
);

View File

@@ -8,7 +8,7 @@
*/
import { describe, it, expect, beforeAll } from 'vitest';
describe('interests.service port-scope FK validation', () => {
describe('interests.service - port-scope FK validation', () => {
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
let updateInterest: typeof import('@/lib/services/interests.service').updateInterest;
let linkBerth: typeof import('@/lib/services/interests.service').linkBerth;

View File

@@ -15,7 +15,7 @@
*/
import { describe, it, expect, beforeAll } from 'vitest';
describe('interests.service yacht ownership validation', () => {
describe('interests.service - yacht ownership validation', () => {
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
let updateInterest: typeof import('@/lib/services/interests.service').updateInterest;

View File

@@ -14,7 +14,7 @@
*/
import { describe, it, expect, beforeAll } from 'vitest';
describe('invoices.service billing entity', () => {
describe('invoices.service - billing entity', () => {
let createInvoice: typeof import('@/lib/services/invoices').createInvoice;
let makePort: typeof import('../helpers/factories').makePort;

View File

@@ -75,7 +75,7 @@ describe('gdpr-export-cleanup query semantics', () => {
expect(ids).toContain(expiredNoKey!.id); // expired-with-no-key is *also* in lt(), but the worker filters with isNotNull(storageKey) too
expect(ids).not.toContain(stillFresh!.id);
// The full worker filter (expires past, storageKey not null) only one row.
// The full worker filter (expires past, storageKey not null) - only one row.
const fullMatch = candidates.filter(
(r) => r.id !== expiredNoKey!.id && r.id !== stillFresh!.id,
);

View File

@@ -14,7 +14,7 @@
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
// Socket and queue mocked these are tested in isolation here.
// Socket and queue mocked - these are tested in isolation here.
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
vi.mock('@/lib/queue', () => ({
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
@@ -34,7 +34,7 @@ beforeAll(async () => {
dbAvailable = true;
} catch {
console.warn(
'[notification-lifecycle] Test database not available skipping integration tests',
'[notification-lifecycle] Test database not available - skipping integration tests',
);
}
});

View File

@@ -1,5 +1,5 @@
/**
* PR9 OCR config service.
* PR9 - OCR config service.
*
* Validates:
* 1. Per-port save/read round-trip (key encrypted at rest, decrypted on resolve)
@@ -108,7 +108,7 @@ describe('OCR config', () => {
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'keep-me' },
'user-1',
);
// Update model only no apiKey field provided.
// Update model only - no apiKey field provided.
await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1');
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.apiKey).toBe('keep-me');

View File

@@ -66,7 +66,7 @@ async function checkPermission(
// ─── super_admin ──────────────────────────────────────────────────────────────
describe('Permission Matrix super_admin', () => {
describe('Permission Matrix - super_admin', () => {
const ctx = makeCtx({ isSuperAdmin: true, permissions: null });
it('can access clients.create', async () => {
@@ -88,7 +88,7 @@ describe('Permission Matrix — super_admin', () => {
// ─── viewer ───────────────────────────────────────────────────────────────────
describe('Permission Matrix viewer', () => {
describe('Permission Matrix - viewer', () => {
const ctx = makeCtx({ permissions: makeViewerPermissions() });
it('can view clients', async () => {
@@ -122,7 +122,7 @@ describe('Permission Matrix — viewer', () => {
// ─── sales_agent ─────────────────────────────────────────────────────────────
describe('Permission Matrix sales_agent', () => {
describe('Permission Matrix - sales_agent', () => {
const ctx = makeCtx({ permissions: makeSalesAgentPermissions() });
it('can view clients', async () => {
@@ -168,7 +168,7 @@ describe('Permission Matrix — sales_agent', () => {
// ─── sales_manager ────────────────────────────────────────────────────────────
describe('Permission Matrix sales_manager', () => {
describe('Permission Matrix - sales_manager', () => {
const ctx = makeCtx({ permissions: makeSalesManagerPermissions() });
it('can do everything with clients', async () => {
@@ -192,7 +192,7 @@ describe('Permission Matrix — sales_manager', () => {
// ─── director ─────────────────────────────────────────────────────────────────
describe('Permission Matrix director', () => {
describe('Permission Matrix - director', () => {
const ctx = makeCtx({ permissions: makeDirectorPermissions() });
it('can manage webhooks', async () => {
@@ -210,7 +210,7 @@ describe('Permission Matrix — director', () => {
// ─── deepMerge ────────────────────────────────────────────────────────────────
describe('deepMerge permission override merging', () => {
describe('deepMerge - permission override merging', () => {
it('overrides a single leaf value', () => {
const base = { clients: { view: true, create: false } };
const override = { clients: { create: true } };

View File

@@ -40,7 +40,7 @@ beforeAll(async () => {
await sql.end();
dbAvailable = true;
} catch {
console.warn('[pipeline-transitions] Test database not available skipping integration tests');
console.warn('[pipeline-transitions] Test database not available - skipping integration tests');
}
});

View File

@@ -1,7 +1,7 @@
/**
* Port-scoping integration tests (SECURITY-CRITICAL).
*
* Codex Addenda: Two-port testing every entity must be invisible
* Codex Addenda: Two-port testing - every entity must be invisible
* when queried under a different portId.
*
* Skips gracefully when TEST_DATABASE_URL is not reachable.
@@ -29,7 +29,7 @@ beforeAll(async () => {
await sql.end();
dbAvailable = true;
} catch {
console.warn('[port-scoping] Test database not available skipping integration tests');
console.warn('[port-scoping] Test database not available - skipping integration tests');
}
});
@@ -69,7 +69,7 @@ async function cleanupPorts(portA: string, portB: string): Promise<void> {
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Port Scoping Clients', () => {
describe('Port Scoping - Clients', () => {
let portA: string;
let portB: string;
@@ -153,7 +153,7 @@ describe('Port Scoping — Clients', () => {
});
});
describe('Port Scoping Interests', () => {
describe('Port Scoping - Interests', () => {
let portA: string;
let portB: string;
let clientIdA: string;

View File

@@ -1,5 +1,5 @@
/**
* Portal JWT verification locks in the audience/issuer hardening shipped
* Portal JWT verification - locks in the audience/issuer hardening shipped
* in fix(auth): a token without `aud: 'portal'` + `iss: 'pn-crm'` claims
* must NOT verify, even if it's signed with the correct shared secret.
*
@@ -50,7 +50,7 @@ describe('portal JWT', () => {
});
it('rejects a token missing the `aud: portal` claim', async () => {
// Issuer present, audience absent exactly the shape an old (pre-fix)
// Issuer present, audience absent - exactly the shape an old (pre-fix)
// portal session would have.
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
@@ -74,7 +74,7 @@ describe('portal JWT', () => {
});
it('rejects a token with the wrong audience (CRM session replay shape)', async () => {
// What a better-auth session token might roughly look like same secret,
// What a better-auth session token might roughly look like - same secret,
// different audience. Must not verify against the portal path.
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })

View File

@@ -29,7 +29,7 @@ function uniqueIp(): string {
return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
}
describe('POST /api/public/interests trio creation', () => {
describe('POST /api/public/interests - trio creation', () => {
let POST: typeof import('@/app/api/public/interests/route').POST;
beforeAll(async () => {
@@ -212,7 +212,7 @@ describe('POST /api/public/interests — trio creation', () => {
.where(eq(interests.id, secondBody.data.id));
expect(secondInterest!.clientId).toBe(originalClientId);
// A second yacht row was created (not deduped) each submission is its
// A second yacht row was created (not deduped) - each submission is its
// own inquiry about a possibly-different yacht.
const clientsMatching = await db.select().from(clients).where(eq(clients.id, originalClientId));
expect(clientsMatching.length).toBe(1);
@@ -259,7 +259,7 @@ describe('POST /api/public/interests — trio creation', () => {
.where(eq(yachts.id, firstInterest!.yachtId!));
const originalCompanyId = firstYacht!.currentOwnerId;
// Second submission same company name, different casing, different client
// Second submission - same company name, different casing, different client
const secondReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,

View File

@@ -1,5 +1,5 @@
/**
* i18n PR910 public residential inquiry endpoint.
* i18n PR910 - public residential inquiry endpoint.
*
* Validates the server-side phone normalization that the public inquiry
* route runs when the website posts a raw international format (older
@@ -47,7 +47,7 @@ describe('POST /api/public/residential-inquiries', () => {
firstName: 'Anna',
lastName: 'Nowak',
email,
// Raw international format server should normalize.
// Raw international format - server should normalize.
phone: '+44 20 7946 0958',
placeOfResidence: 'Warsaw',
},
@@ -122,7 +122,7 @@ describe('POST /api/public/residential-inquiries', () => {
lastName: 'Lewandowska',
email,
phone: '22 555 0200', // National-format
phoneCountry: 'PL', // Hint only no E.164 yet.
phoneCountry: 'PL', // Hint only - no E.164 yet.
},
},
);

View File

@@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
describe('recommendations.service yacht dimensions source', () => {
describe('recommendations.service - yacht dimensions source', () => {
let generateRecommendations: typeof import('@/lib/services/recommendations').generateRecommendations;
let makePort: typeof import('../helpers/factories').makePort;

View File

@@ -26,7 +26,7 @@ beforeAll(async () => {
dbAvailable = true;
} catch (err) {
console.warn(
'[schema-constraints] DATABASE_URL not reachable skipping integration tests',
'[schema-constraints] DATABASE_URL not reachable - skipping integration tests',
err,
);
}
@@ -61,7 +61,7 @@ describe('schema constraints', () => {
ownerType: 'client',
ownerId: clientB.id,
startDate: new Date(),
endDate: null, // another open row should violate partial unique
endDate: null, // another open row - should violate partial unique
createdBy: 'test',
}),
).rejects.toMatchObject({
@@ -123,7 +123,7 @@ describe('schema constraints', () => {
});
const berth = await makeBerth({ portId: port.id });
// Two ended reservations on same berth both should succeed
// Two ended reservations on same berth - both should succeed
// (partial index only constrains status='active').
await expect(
db.insert(berthReservations).values([

View File

@@ -14,7 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { makeAuditMeta } from '../helpers/factories';
// vi.mock is hoisted to the top of the module keep mocks there so vitest
// vi.mock is hoisted to the top of the module - keep mocks there so vitest
// doesn't warn about non-top-level calls. Use `vi.hoisted` for any mock that
// references a value (mockQueueAdd) so it's evaluated before the mock factory
// runs.
@@ -48,7 +48,7 @@ beforeAll(async () => {
await sql.end();
dbAvailable = true;
} catch {
console.warn('[webhook-delivery] Test database not available skipping integration tests');
console.warn('[webhook-delivery] Test database not available - skipping integration tests');
}
});

View File

@@ -1,5 +1,5 @@
/**
* Task 8 aggregated projection (TDD).
* Task 8 - aggregated projection (TDD).
*
* Tests for:
* 1. listFilesAggregatedByEntity (4 cases)
@@ -8,7 +8,7 @@
*
* Fixture convention: makePort / makeClient / makeCompany / makeYacht from
* helpers/factories; TEST_USER_ID resolved once via beforeAll from a seeded
* user same pattern as document-folders-system-folders.test.ts.
* user - same pattern as document-folders-system-folders.test.ts.
*/
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
@@ -172,7 +172,7 @@ describe('files service · listFilesAggregatedByEntity', () => {
// File attached to the yacht at the time john owns it
await insertFile(portId, { yachtId, clientId: johnId });
// Transfer yacht to Mary (update currentOwner in place simulates transfer)
// Transfer yacht to Mary (update currentOwner in place - simulates transfer)
await db
.update(yachts)
.set({ currentOwnerType: 'client', currentOwnerId: maryId })
@@ -189,7 +189,7 @@ describe('files service · listFilesAggregatedByEntity', () => {
it("Mary's view does NOT see john's file (it has clientId=john, not mary)", async () => {
const result = await listFilesAggregatedByEntity(portId, 'client', maryId);
// Mary owns the yacht now, so FROM YACHT group will appear but the
// Mary owns the yacht now, so FROM YACHT group will appear - but the
// file has clientId=johnId (snapshotted FK), so it WON'T appear under
// Mary's DIRECTLY ATTACHED. The FROM YACHT group WILL appear since the
// file still has yachtId set.

View File

@@ -145,7 +145,7 @@ describe('maskSensitiveFields', () => {
});
it('does not over-mask innocuous "name" fields without PII context', () => {
// 'name' alone (port name, tag name, column name) must NOT be redacted
// 'name' alone (port name, tag name, column name) - must NOT be redacted
// unless it's part of first_name / last_name / full_name etc.
const result = maskSensitiveFields({
port_name: 'Port Nimara',

View File

@@ -1,5 +1,5 @@
/**
* EMAIL_REDIRECT_TO safety net comprehensive verification.
* EMAIL_REDIRECT_TO safety net - comprehensive verification.
*
* Goal: a single env flip (`EMAIL_REDIRECT_TO=<address>`) MUST pause every
* outbound communication channel. This test file exercises each channel
@@ -17,7 +17,7 @@ const REDIRECT_TARGET = 'redirect@example.test';
// 1. Documenso recipient redirect (createDocument + generateDocumentFromTemplate)
// -------------------------------------------------------------------------
describe('Documenso recipient redirect EMAIL_REDIRECT_TO', () => {
describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
const originalDocumensoUrl = process.env.DOCUMENSO_API_URL;
const originalDocumensoKey = process.env.DOCUMENSO_API_KEY;
@@ -52,7 +52,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
vi.resetModules();
});
it('createDocument every recipient.email rewritten to redirect target', async () => {
it('createDocument - every recipient.email rewritten to redirect target', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
@@ -70,7 +70,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
}
});
it('generateDocumentFromTemplate formValues *Email keys rewritten', async () => {
it('generateDocumentFromTemplate - formValues *Email keys rewritten', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.generateDocumentFromTemplate(42, {
@@ -89,7 +89,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
expect(callBody.formValues['client.fullName']).toBe('Alice Smith');
});
it('generateDocumentFromTemplate recipients array rewritten (v2.x shape)', async () => {
it('generateDocumentFromTemplate - recipients array rewritten (v2.x shape)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.generateDocumentFromTemplate(42, {
@@ -106,7 +106,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
}
});
it('sendDocument short-circuited when redirect is set (no /send call)', async () => {
it('sendDocument - short-circuited when redirect is set (no /send call)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.sendDocument('doc-1');
@@ -118,7 +118,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
expect(sendCall).toBeUndefined();
});
it('sendReminder short-circuited when redirect is set (no /remind call)', async () => {
it('sendReminder - short-circuited when redirect is set (no /remind call)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.sendReminder('doc-1', 'signer-1');
@@ -126,7 +126,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it('createDocument recipients NOT redirected when EMAIL_REDIRECT_TO unset', async () => {
it('createDocument - recipients NOT redirected when EMAIL_REDIRECT_TO unset', async () => {
delete process.env.EMAIL_REDIRECT_TO;
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
@@ -143,7 +143,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
// 2. sendEmail redirect (covers the centralized path used by 5+ services)
// -------------------------------------------------------------------------
describe('sendEmail redirect EMAIL_REDIRECT_TO', () => {
describe('sendEmail redirect - EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
afterEach(() => {
@@ -174,7 +174,7 @@ describe('sendEmail redirect — EMAIL_REDIRECT_TO', () => {
}
// The mock is typed as `vi.fn(async () => …)` which gives `calls: unknown[]`
// so the indexer reads come back as possibly-undefined. The test arms
// - so the indexer reads come back as possibly-undefined. The test arms
// the spy and asserts toHaveBeenCalledOnce above, then this helper picks
// the first call with a runtime non-null check that satisfies tsc.
function firstSendMailArgs(spy: ReturnType<typeof vi.fn>): {
@@ -198,7 +198,7 @@ describe('sendEmail redirect — EMAIL_REDIRECT_TO', () => {
expect(args.subject).toMatch(/^\[redirected from alice@realclient\.com\] Welcome$/);
});
it('handles array of recipients joins original list into the subject prefix', async () => {
it('handles array of recipients - joins original list into the subject prefix', async () => {
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
await mod.sendEmail(['alice@realclient.com', 'bob@realclient.com'], 'Update', '<p>x</p>');
@@ -223,7 +223,7 @@ describe('sendEmail redirect — EMAIL_REDIRECT_TO', () => {
// 3. Webhook short-circuit (covers the per-port outbound webhook delivery)
// -------------------------------------------------------------------------
describe('Webhook short-circuit EMAIL_REDIRECT_TO', () => {
describe('Webhook short-circuit - EMAIL_REDIRECT_TO', () => {
// The actual webhook worker pulls from BullMQ + the DB. To keep this a
// pure unit test, we extract the "should I dispatch?" predicate and
// assert against env.EMAIL_REDIRECT_TO directly. The full integration

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
describe('Concurrent operation safety', () => {
it('concurrent interest score calculations should not interfere', async () => {
// Scoring is a pure read + compute operation no shared mutable state.
// Scoring is a pure read + compute operation - no shared mutable state.
// Simulates 10 parallel calculations to verify isolation.
const promises = Array.from({ length: 10 }, (_, i) =>
Promise.resolve({ interestId: `interest-${i}`, score: Math.random() * 100 }),
@@ -25,9 +25,7 @@ describe('Concurrent operation safety', () => {
payload: { clientId: `client-${i}` },
}));
const results = await Promise.allSettled(
events.map((e) => Promise.resolve(e)),
);
const results = await Promise.allSettled(events.map((e) => Promise.resolve(e)));
expect(results).toHaveLength(10);
expect(results.every((r) => r.status === 'fulfilled')).toBe(true);
@@ -39,9 +37,7 @@ describe('Concurrent operation safety', () => {
const readKpis = (portId: string) =>
Promise.resolve({ portId, totalClients: 120, activeInterests: 34 });
const results = await Promise.all(
Array.from({ length: 5 }, () => readKpis('port-abc')),
);
const results = await Promise.all(Array.from({ length: 5 }, () => readKpis('port-abc')));
results.forEach((r) => {
expect(r).toHaveProperty('portId', 'port-abc');
@@ -74,9 +70,7 @@ describe('Concurrent operation safety', () => {
const writeAuditEntry = (index: number) =>
Promise.resolve({ id: `audit-${Date.now()}-${index}`, index });
const entries = await Promise.all(
Array.from({ length: 20 }, (_, i) => writeAuditEntry(i)),
);
const entries = await Promise.all(Array.from({ length: 20 }, (_, i) => writeAuditEntry(i)));
const ids = entries.map((e) => e.id);
const uniqueIds = new Set(ids);
@@ -101,16 +95,14 @@ describe('Concurrent operation safety', () => {
const fulfilled = results.filter((r) => r.status === 'fulfilled');
const rejected = results.filter((r) => r.status === 'rejected');
// Indices 0, 3, 6, 9 fail 4 rejections, 6 successes.
// Indices 0, 3, 6, 9 fail - 4 rejections, 6 successes.
expect(fulfilled).toHaveLength(6);
expect(rejected).toHaveLength(4);
});
it('high-concurrency burst (50 simultaneous requests) all settle', async () => {
// Smoke-tests that the Promise machinery handles a realistic burst.
const burst = Array.from({ length: 50 }, (_, i) =>
Promise.resolve({ requestId: i }),
);
const burst = Array.from({ length: 50 }, (_, i) => Promise.resolve({ requestId: i }));
const results = await Promise.allSettled(burst);

View File

@@ -26,7 +26,7 @@ describe('PIPELINE_STAGES', () => {
]);
});
it('is a readonly tuple type-level immutability via `as const`', () => {
it('is a readonly tuple - type-level immutability via `as const`', () => {
const arr = PIPELINE_STAGES as unknown as string[];
expect(arr).toHaveLength(7);
});

View File

@@ -1,5 +1,5 @@
/**
* Tests for validateCustomFieldValue the private validation helper in
* Tests for validateCustomFieldValue - the private validation helper in
* custom-fields.service.ts. Since it is not exported we test it via the
* public setValues function, using vi.mock to avoid database calls.
* All assertions focus on what error message (if any) is thrown.
@@ -111,7 +111,7 @@ async function validate(
// ─── text ─────────────────────────────────────────────────────────────────────
describe('custom field validation text', () => {
describe('custom field validation - text', () => {
it('accepts a string value', async () => {
await expect(validate('text', 'hello')).resolves.toBeDefined();
});
@@ -131,7 +131,7 @@ describe('custom field validation — text', () => {
// ─── number ──────────────────────────────────────────────────────────────────
describe('custom field validation number', () => {
describe('custom field validation - number', () => {
it('accepts a valid number', async () => {
await expect(validate('number', 42)).resolves.toBeDefined();
});
@@ -151,7 +151,7 @@ describe('custom field validation — number', () => {
// ─── date ─────────────────────────────────────────────────────────────────────
describe('custom field validation date', () => {
describe('custom field validation - date', () => {
it('accepts a valid ISO date string', async () => {
await expect(validate('date', '2026-06-15')).resolves.toBeDefined();
});
@@ -171,7 +171,7 @@ describe('custom field validation — date', () => {
// ─── boolean ─────────────────────────────────────────────────────────────────
describe('custom field validation boolean', () => {
describe('custom field validation - boolean', () => {
it('accepts true', async () => {
await expect(validate('boolean', true)).resolves.toBeDefined();
});
@@ -191,7 +191,7 @@ describe('custom field validation — boolean', () => {
// ─── select ──────────────────────────────────────────────────────────────────
describe('custom field validation select', () => {
describe('custom field validation - select', () => {
const options = ['Small', 'Medium', 'Large'];
it('accepts a valid option', async () => {
@@ -220,7 +220,7 @@ describe('custom field validation — select', () => {
// ─── required / non-required null handling ───────────────────────────────────
describe('custom field validation required vs optional null', () => {
describe('custom field validation - required vs optional null', () => {
it('required field: null value → throws ValidationError', async () => {
await expect(validate('text', null, { isRequired: true })).rejects.toBeInstanceOf(
ValidationError,
@@ -234,7 +234,7 @@ describe('custom field validation — required vs optional null', () => {
});
it('non-required field: null value → succeeds (no error)', async () => {
// null for non-required means "clear the value" setValues will upsert null
// null for non-required means "clear the value" - setValues will upsert null
await expect(validate('text', null, { isRequired: false })).resolves.toBeDefined();
});
});

View File

@@ -1,10 +1,10 @@
/**
* Match-finding library unit tests.
* Match-finding library - unit tests.
*
* Each duplicate cluster from the legacy NocoDB Interests audit (see
* docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §1.2)
* is encoded as a fixture here. The expected scoring tier (high / medium
* / low) is the design contract if the algorithm starts returning
* / low) is the design contract - if the algorithm starts returning
* "high" for a Pattern F case (Etiennette / Bruno+Bruce) it has lost
* the false-positive guard and we'll know immediately.
*/
@@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest';
import { findClientMatches, type MatchCandidate } from '@/lib/dedup/find-matches';
// Sensible defaults for tests match the design's recommended thresholds.
// Sensible defaults for tests - match the design's recommended thresholds.
const THRESHOLDS = {
highScore: 90,
mediumScore: 50,
@@ -30,7 +30,7 @@ function candidate(partial: Partial<MatchCandidate> & { id: string }): MatchCand
}
describe('findClientMatches', () => {
describe('Pattern A pure double-submit (high confidence)', () => {
describe('Pattern A - pure double-submit (high confidence)', () => {
it('flags identical email + phone as high', () => {
// From real data: Deepak Ramchandani #624/#625, identical fields.
const incoming = candidate({
@@ -60,7 +60,7 @@ describe('findClientMatches', () => {
});
});
describe('Pattern B same email, different phone format (high)', () => {
describe('Pattern B - same email, different phone format (high)', () => {
it('high confidence when phones already normalize-equal', () => {
// From real data: Howard Wiarda #236/#536, "574-274-0548" vs "+15742740548".
// After normalization both phones are the same E.164, so the rule fires.
@@ -88,7 +88,7 @@ describe('findClientMatches', () => {
});
});
describe('Pattern C name capitalization variant (high)', () => {
describe('Pattern C - name capitalization variant (high)', () => {
it('treats lowercase + uppercase as the same person when surname-token + email + phone all match', () => {
// From real data: Nicolas Ruiz #681/#682/#683, email differs only by case.
const incoming = candidate({
@@ -114,7 +114,7 @@ describe('findClientMatches', () => {
});
});
describe('Pattern D name shortening (high)', () => {
describe('Pattern D - name shortening (high)', () => {
it('Chris vs Christopher with same email + phone scores high', () => {
// From real data: Chris Allen #700 vs Christopher Allen #534.
const incoming = candidate({
@@ -140,9 +140,9 @@ describe('findClientMatches', () => {
});
});
describe('Pattern E typo on resubmit', () => {
describe('Pattern E - typo on resubmit', () => {
it('same email + nearly-identical phone (typo in last digits) scores high', () => {
// Christopher Camazou #649/#650 phone differs in last 4 digits but
// Christopher Camazou #649/#650 - phone differs in last 4 digits but
// everything else matches. Exact phone equality fails; email exact
// match alone (60) + name-token match (20) puts us in medium tier.
// The user can confirm the merge.
@@ -166,15 +166,15 @@ describe('findClientMatches', () => {
const matches = findClientMatches(incoming, pool, THRESHOLDS);
expect(matches).toHaveLength(1);
// Email + name match without phone match strong but not certain.
// Email + name match without phone match - strong but not certain.
expect(matches[0]!.confidence).toMatch(/^(high|medium)$/);
expect(matches[0]!.score).toBeGreaterThanOrEqual(70);
});
it('Constanzo / Costanzo surname typo with same email + phone scores high', () => {
// Gianfranco Di Constanzo #585 vs Di Costanzo #336 same email + phone
// Gianfranco Di Constanzo #585 vs Di Costanzo #336 - same email + phone
// and only a 1-letter surname typo. This is a strong "same client,
// multiple yachts" signal the design's signature win.
// multiple yachts" signal - the design's signature win.
const incoming = candidate({
id: 'b',
fullName: 'Gianfranco Di Constanzo',
@@ -199,9 +199,9 @@ describe('findClientMatches', () => {
});
});
describe('Pattern F hard cases (must NOT auto-merge)', () => {
describe('Pattern F - hard cases (must NOT auto-merge)', () => {
it('same name with different country phone + different email scores at most medium', () => {
// Etiennette Clamouze #188/#717 same name but completely different
// Etiennette Clamouze #188/#717 - same name but completely different
// email + phone (and the phones are in different country codes,
// suggesting either a relative, a coworker, or a name-collision).
// We must NOT classify this as "high" or it would force-merge two
@@ -236,7 +236,7 @@ describe('findClientMatches', () => {
});
it('shared email between two clearly different names is medium not high', () => {
// Bruno Joyerot #18 vs Bruce Hearn #19 Bruno's row shows email
// Bruno Joyerot #18 vs Bruce Hearn #19 - Bruno's row shows email
// belonging to "catherine elaine hearn" (Bruce's spouse). Same
// household phone area code. Name overlap is partial. Don't merge.
const incoming = candidate({
@@ -258,7 +258,7 @@ describe('findClientMatches', () => {
const matches = findClientMatches(incoming, pool, THRESHOLDS);
// Names don't match, emails don't match, phones differ there's
// Names don't match, emails don't match, phones differ - there's
// no reason for this to surface at all. Either no match or low.
if (matches.length > 0) {
expect(matches[0]!.confidence).toBe('low');
@@ -266,7 +266,7 @@ describe('findClientMatches', () => {
});
});
describe('Negative evidence same email but different country phone', () => {
describe('Negative evidence - same email but different country phone', () => {
it('reduces score when email matches but phone country differs', () => {
// Constructed: same email, but one phone is +33 (FR) and the other
// is +1 (US). Likely a shared-inbox spouse situation. We want
@@ -298,7 +298,7 @@ describe('findClientMatches', () => {
});
});
describe('Blocking only relevant candidates are scored', () => {
describe('Blocking - only relevant candidates are scored', () => {
it('does not score candidates with no shared emails / phones / surname token', () => {
const incoming = candidate({
id: 'newbie',
@@ -352,7 +352,7 @@ describe('findClientMatches', () => {
});
const pool = [
candidate({
// High match same email + phone
// High match - same email + phone
id: 'high-match',
fullName: 'John Smith',
surnameToken: 'smith',
@@ -360,7 +360,7 @@ describe('findClientMatches', () => {
phonesE164: ['+15551234567'],
}),
candidate({
// Medium match same email only
// Medium match - same email only
id: 'medium-match',
fullName: 'Different Person',
surnameToken: 'person',

View File

@@ -1,5 +1,5 @@
/**
* Migration transform fixture-based regression test.
* Migration transform - fixture-based regression test.
*
* Feeds the transform a small frozen NocoDB snapshot containing one
* representative row from each duplicate pattern documented in
@@ -59,7 +59,7 @@ const FIXTURE: NocoDbSnapshot = {
'Sales Process Level': 'General Qualified Interest',
}),
// Pattern C: name capitalization (Nicolas Ruiz #681/#682/#683 three rows)
// Pattern C: name capitalization (Nicolas Ruiz #681/#682/#683 - three rows)
row({
Id: 681,
'Full Name': 'Nicolas Ruiz',
@@ -127,7 +127,7 @@ const FIXTURE: NocoDbSnapshot = {
],
};
describe('transformSnapshot fixture regression', () => {
describe('transformSnapshot - fixture regression', () => {
it('produces the expected number of clients + interests', () => {
const plan = transformSnapshot(FIXTURE);
@@ -203,7 +203,7 @@ describe('transformSnapshot — fixture regression', () => {
});
it('produces deterministic output (same input → same plan)', () => {
// The transform is pure running it twice should yield bit-identical
// The transform is pure - running it twice should yield bit-identical
// results. Catches order-dependent bugs in the dedup clustering.
const a = transformSnapshot(FIXTURE);
const b = transformSnapshot(FIXTURE);
@@ -214,7 +214,7 @@ describe('transformSnapshot — fixture regression', () => {
// ─── EOI document derivation ───────────────────────────────────────────────
describe('transformSnapshot EOI document derivation', () => {
describe('transformSnapshot - EOI document derivation', () => {
/**
* A fixture row that mimics a fully-signed legacy interest with a
* Documenso ID, all three signing slots populated, and an S3 path.
@@ -301,7 +301,7 @@ describe('transformSnapshot — EOI document derivation', () => {
eoiFixture({
Id: 800,
documensoID: '200',
// No EOI Status, no developer sign only client has signed.
// No EOI Status, no developer sign - only client has signed.
clientSignTime: '2026-04-01T12:00:00.000Z',
}),
);
@@ -395,7 +395,7 @@ describe('parseFlexibleDate format handling', () => {
});
});
describe('transformSnapshot residential leads', () => {
describe('transformSnapshot - residential leads', () => {
it('produces one PlannedResidentialClient per source row', () => {
const plan = transformSnapshot({
fetchedAt: '2026-05-04T00:00:00.000Z',

View File

@@ -1,5 +1,5 @@
/**
* Normalization library unit tests.
* Normalization library - unit tests.
*
* Every fixture here comes from real dirty values observed in the legacy
* NocoDB Interests table during the 2026-05-03 audit (see
@@ -90,7 +90,7 @@ describe('normalizeName', () => {
expect(normalizeName("Liam O'Brien").surnameToken).toBe("o'brien");
});
it('handles single-token names surnameToken is the only token', () => {
it('handles single-token names - surnameToken is the only token', () => {
expect(normalizeName('Madonna').surnameToken).toBe('madonna');
});
@@ -121,7 +121,7 @@ describe('normalizeEmail', () => {
expect(normalizeEmail('Hef355@yahoo.com')).toBe('hef355@yahoo.com');
});
it('preserves plus-aliases both legitimate and tricks', () => {
it('preserves plus-aliases - both legitimate and tricks', () => {
// Per design §3.2: "+aliases" are not stripped. Compare by full localpart.
expect(normalizeEmail('marcus+sales@example.com')).toBe('marcus+sales@example.com');
});
@@ -180,7 +180,7 @@ describe('normalizePhone', () => {
});
it('flags placeholder all-zeros numbers and returns null', () => {
// From real data: "+447000000000" (#641, "Milos Vitkovic" clearly fake).
// From real data: "+447000000000" (#641, "Milos Vitkovic" - clearly fake).
const out = normalizePhone('+447000000000', 'GB');
expect(out?.flagged).toBe('placeholder');
expect(out?.e164).toBeNull();

View File

@@ -6,7 +6,7 @@
* - `applyEntityRestoredSuffix` no-op when the folder was never archived
* (must not flip archived_at, must not rename anything, must not emit
* an audit log).
* - `syncEntityFolderName` collision loop past `(2)` proves the suffix
* - `syncEntityFolderName` collision loop past `(2)` - proves the suffix
* loop iterates correctly when the first numbered candidate is also
* taken. Existing coverage only asserted the `(2)` case.
*
@@ -14,7 +14,7 @@
* `partially_signed → 'partial'` mapping, but that helper currently lives
* inside React component files (`entity-folder-view.tsx`,
* `signing-details-dialog.tsx`, `documents-hub.tsx`) and is not exported.
* A real unit test would require extracting it to a shared util out of
* A real unit test would require extracting it to a shared util - out of
* scope for this subagent's file ownership. See the audit report for the
* deferred fix.
*/
@@ -73,13 +73,13 @@ describe('document-folders · applyEntityRestoredSuffix no-op (regression)', ()
});
expect(after?.name).toBe(originalName);
expect(after?.archivedAt).toBeNull();
// updatedAt should not advance on a no-op restore the row write is
// updatedAt should not advance on a no-op restore - the row write is
// skipped entirely.
expect(after?.updatedAt?.getTime()).toBe(before?.updatedAt?.getTime());
});
it('is a no-op when called for an entity whose folder does not exist (lazy creation)', async () => {
// Different port no folder for this client.
// Different port - no folder for this client.
const otherPort = await makePort();
await ensureSystemRoots(otherPort.id, TEST_USER_ID);
const [other] = await db
@@ -117,7 +117,7 @@ describe('document-folders · syncEntityFolderName collision loop > (2) (regress
});
it('walks past (2) → (3) when the (2) suffix is also taken', async () => {
// Three clients with the same name first two are pre-created with their
// Three clients with the same name - first two are pre-created with their
// entity folders so `sharedName` and `sharedName (2)` are both occupied
// before we trigger the rename on the third.
const sharedName = `Triple Collision ${crypto.randomUUID().slice(0, 6)}`;
@@ -127,10 +127,10 @@ describe('document-folders · syncEntityFolderName collision loop > (2) (regress
const [second] = await db.insert(clients).values({ portId, fullName: sharedName }).returning();
const secondFolder = await ensureEntityFolder(portId, 'client', second!.id, TEST_USER_ID);
// Sanity second client's folder is the "(2)" variant.
// Sanity - second client's folder is the "(2)" variant.
expect(secondFolder.name).toBe(`${sharedName} (2)`);
// Third client start with a different name so its folder is unique,
// Third client - start with a different name so its folder is unique,
// then rename it to the shared name to force `syncEntityFolderName` to
// walk past (2).
const placeholderName = `Triple Collision Placeholder ${crypto.randomUUID().slice(0, 6)}`;

View File

@@ -1,9 +1,9 @@
/**
* Task 2 ensureSystemRoots (TDD).
* Task 3 ensureEntityFolder (TDD).
* Task 2 - ensureSystemRoots (TDD).
* Task 3 - ensureEntityFolder (TDD).
*
* Fixture convention: makePort from helpers/factories (async DB insert);
* TEST_USER_ID resolved once via beforeAll from a seeded user same pattern
* TEST_USER_ID resolved once via beforeAll from a seeded user - same pattern
* as document-folders-crud.test.ts and alerts-tenant-isolation.test.ts.
*/
@@ -48,7 +48,7 @@ describe('document-folders service · ensureSystemRoots', () => {
}
});
it('is idempotent second call does not create duplicates', async () => {
it('is idempotent - second call does not create duplicates', async () => {
await ensureSystemRoots(portId, TEST_USER_ID);
await ensureSystemRoots(portId, TEST_USER_ID);
const rows = await db
@@ -97,7 +97,7 @@ describe('document-folders service · ensureEntityFolder', () => {
expect(folder.name).toBe(row!.fullName);
});
it('is idempotent returns the same row on second call', async () => {
it('is idempotent - returns the same row on second call', async () => {
const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
expect(a.id).toBe(b.id);
@@ -282,7 +282,7 @@ describe('document-folders service · archive lifecycle', () => {
expect(folder?.systemManaged).toBe(true);
});
it('is idempotent on archive second call does not double-append', async () => {
it('is idempotent on archive - second call does not double-append', async () => {
await applyEntityArchivedSuffix(portId, 'client', clientId);
await applyEntityArchivedSuffix(portId, 'client', clientId);
const folder = await db.query.documentFolders.findFirst({

View File

@@ -42,7 +42,7 @@ describe('document-folder validators', () => {
// ─── folderId='' → null transform (regression) ─────────────────────────────
//
// The frontend's URL-query builder emits `?folderId=` (empty string) when
// the user picks "All documents" without the transform, Zod would parse
// the user picks "All documents" - without the transform, Zod would parse
// this as the literal string "" and the SQL layer would try to JOIN on an
// empty folder id, returning zero rows instead of the expected unscoped
// result. The transform lives on `listDocumentsSchema` (and

View File

@@ -43,8 +43,8 @@ describe('parseImportPath', () => {
});
it('preserves special characters in folder names', () => {
expect(parseImportPath('', "Q1 Year's End/contract & rider.pdf")).toEqual({
folderSegments: ["Q1 Year's End"],
expect(parseImportPath('', "Q1 - Year's End/contract & rider.pdf")).toEqual({
folderSegments: ["Q1 - Year's End"],
filename: 'contract & rider.pdf',
});
});

Some files were not shown because too many files have changed in this diff Show More