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