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:
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 7 — validator-level guarantees for the send-out flow.
|
||||
* Phase 7 - validator-level guarantees for the send-out flow.
|
||||
*
|
||||
* §14.7 mitigation: recipient typo (the strict email regex is the first
|
||||
* line of defense; the confirmation modal is the second).
|
||||
|
||||
@@ -6,10 +6,10 @@ describe('formatDate', () => {
|
||||
const REF = '2026-05-12T14:30:45.000Z';
|
||||
|
||||
it('returns fallback for null/undefined/invalid', () => {
|
||||
expect(formatDate(null)).toBe('—');
|
||||
expect(formatDate(undefined)).toBe('—');
|
||||
expect(formatDate('not a date')).toBe('—');
|
||||
expect(formatDate(NaN)).toBe('—');
|
||||
expect(formatDate(null)).toBe('-');
|
||||
expect(formatDate(undefined)).toBe('-');
|
||||
expect(formatDate('not a date')).toBe('-');
|
||||
expect(formatDate(NaN)).toBe('-');
|
||||
expect(formatDate(null, 'date.medium', { fallback: 'N/A' })).toBe('N/A');
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('formatDate', () => {
|
||||
expect(out.toLowerCase()).toContain('may');
|
||||
});
|
||||
|
||||
it('respects timezone — datetime.short in different zones', () => {
|
||||
it('respects timezone - datetime.short in different zones', () => {
|
||||
const ny = formatDate(REF, 'datetime.short', { tz: 'America/New_York' });
|
||||
const utc = formatDate(REF, 'datetime.short', { tz: 'UTC' });
|
||||
expect(ny).not.toBe(utc);
|
||||
@@ -55,7 +55,7 @@ describe('formatDate', () => {
|
||||
|
||||
describe('formatDateRange', () => {
|
||||
it('handles missing start/end', () => {
|
||||
expect(formatDateRange(null, null)).toBe('—');
|
||||
expect(formatDateRange(null, null)).toBe('-');
|
||||
expect(formatDateRange('2026-05-12', null)).toMatch(/→$/);
|
||||
expect(formatDateRange(null, '2026-05-12')).toMatch(/^→/);
|
||||
});
|
||||
@@ -107,6 +107,6 @@ describe('formatRelative', () => {
|
||||
});
|
||||
|
||||
it('returns fallback for invalid input', () => {
|
||||
expect(formatRelative(null)).toBe('—');
|
||||
expect(formatRelative(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
/**
|
||||
* Pure-logic tests for the realtime-invalidation subscription helper. The
|
||||
* React hook (`useRealtimeInvalidation`) is just a thin wrapper around this
|
||||
* function — verifying the handler-registration / fire-time-lookup behavior
|
||||
* function - verifying the handler-registration / fire-time-lookup behavior
|
||||
* here is sufficient to lock in the bug fixes:
|
||||
* 1. Re-subscribe storm (caller passing inline literals)
|
||||
* 2. Fresh queryKeys read at fire-time
|
||||
@@ -129,7 +129,7 @@ describe('subscribeRealtimeInvalidations', () => {
|
||||
cleanup();
|
||||
|
||||
expect(offCalls.map((c) => c.event).sort()).toEqual(['a:event', 'b:event']);
|
||||
// All listeners removed — emitting after cleanup invalidates nothing.
|
||||
// All listeners removed - emitting after cleanup invalidates nothing.
|
||||
emit('a:event');
|
||||
emit('b:event');
|
||||
expect(calls).toEqual([]);
|
||||
@@ -150,7 +150,7 @@ describe('subscribeRealtimeInvalidations', () => {
|
||||
};
|
||||
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
|
||||
|
||||
// Wipe the entry — handler will fire but find nothing to invalidate.
|
||||
// Wipe the entry - handler will fire but find nothing to invalidate.
|
||||
currentMap = {};
|
||||
expect(() => emit('client:updated')).not.toThrow();
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* i18n PR1 — country dataset.
|
||||
* i18n PR1 - country dataset.
|
||||
*
|
||||
* Validates:
|
||||
* 1. The dataset includes every common ISO-3166-1 alpha-2 code we'd
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* i18n PR2 — phone helpers.
|
||||
* i18n PR2 - phone helpers.
|
||||
*
|
||||
* Validates:
|
||||
* 1. parsePhone yields E.164 + country + display formats
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* i18n PR4 — subdivisions.
|
||||
* i18n PR4 - subdivisions.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Major countries (PL, US, CA, GB, AU) return non-empty lists
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* i18n PR3 — timezone helpers.
|
||||
* i18n PR3 - timezone helpers.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Single-zone countries return one IANA string
|
||||
|
||||
@@ -18,7 +18,7 @@ async function makePng(
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe('processLogoUpload — sharp pipeline', () => {
|
||||
describe('processLogoUpload - sharp pipeline', () => {
|
||||
it('accepts a healthy PNG with alpha and resizes if needed', async () => {
|
||||
const buf = await makePng(2400, 800);
|
||||
const result = await processLogoUpload(buf);
|
||||
@@ -40,7 +40,7 @@ describe('processLogoUpload — sharp pipeline', () => {
|
||||
});
|
||||
|
||||
it('rejects buffers that exceed the raw size cap', async () => {
|
||||
// 6 MB of zero bytes — fails the raw size cap before sharp parses.
|
||||
// 6 MB of zero bytes - fails the raw size cap before sharp parses.
|
||||
const buf = Buffer.alloc(6 * 1024 * 1024);
|
||||
await expect(processLogoUpload(buf)).rejects.toThrow(/MB/i);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Every code path that turns rep-authored markdown into the email's
|
||||
* `html` body is required to go through `renderEmailBody()`. These tests
|
||||
* are the canary — if any future change to the renderer lets a known XSS
|
||||
* are the canary - if any future change to the renderer lets a known XSS
|
||||
* payload through, the test breaks before the change ships.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
renderEmailBody,
|
||||
} from '@/lib/utils/markdown-email';
|
||||
|
||||
describe('renderEmailBody — XSS payload coverage', () => {
|
||||
describe('renderEmailBody - XSS payload coverage', () => {
|
||||
it('escapes <script> tags so they render as text, not active script', () => {
|
||||
const html = renderEmailBody('Hi <script>alert(1)</script> there');
|
||||
expect(html).not.toContain('<script>');
|
||||
@@ -68,7 +68,7 @@ describe('renderEmailBody — XSS payload coverage', () => {
|
||||
|
||||
it('escapes attribute-style XSS attempts (no live <svg> tag survives)', () => {
|
||||
const html = renderEmailBody('"><svg onload=alert(1)>');
|
||||
// The literal "<svg" must never appear unescaped — the angle bracket is
|
||||
// The literal "<svg" must never appear unescaped - the angle bracket is
|
||||
// what the browser parses, not the word "onload".
|
||||
expect(html).not.toContain('<svg');
|
||||
expect(html).toContain('<svg');
|
||||
@@ -92,7 +92,7 @@ describe('renderEmailBody — XSS payload coverage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderEmailBody — markdown rules', () => {
|
||||
describe('renderEmailBody - markdown rules', () => {
|
||||
it('renders **bold** as <strong>', () => {
|
||||
expect(renderEmailBody('this is **bold**')).toContain('<strong>bold</strong>');
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('PDF report renderer', () => {
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(2_000);
|
||||
// PDF files start with `%PDF-` magic bytes — sanity-check that
|
||||
// PDF files start with `%PDF-` magic bytes - sanity-check that
|
||||
// the renderer produced an actual PDF, not an error blob or
|
||||
// empty buffer.
|
||||
const head = buf.subarray(0, 5).toString('utf-8');
|
||||
@@ -96,7 +96,7 @@ describe('PDF report renderer', () => {
|
||||
occupancyRate: 0,
|
||||
},
|
||||
// Provide pipelineCounts even though widgetIds didn't ask for
|
||||
// it — the renderer should still skip the section since it's
|
||||
// it - the renderer should still skip the section since it's
|
||||
// gated on widgetIds, not data presence.
|
||||
pipelineCounts: [{ stage: 'enquiry', count: 1 }],
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('fillEoiFormFields', () => {
|
||||
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
||||
|
||||
const out = await PDFDocument.load(filled);
|
||||
// Flatten removes the interactive fields from the form — the values
|
||||
// Flatten removes the interactive fields from the form - the values
|
||||
// are baked into the page content stream so the signer can't edit
|
||||
// them after the fact.
|
||||
expect(out.getForm().getFields()).toEqual([]);
|
||||
@@ -114,7 +114,7 @@ describe('fillEoiFormFields', () => {
|
||||
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
||||
|
||||
// Round-trip the saved bytes. With the AcroForm flattened, the doc
|
||||
// still loads as a valid PDF and retains the original page count —
|
||||
// still loads as a valid PDF and retains the original page count -
|
||||
// the text-field widgets have been baked into the content stream.
|
||||
const out = await PDFDocument.load(filled);
|
||||
expect(out.getPageCount()).toBe(1);
|
||||
@@ -138,7 +138,7 @@ describe('fillEoiFormFields', () => {
|
||||
);
|
||||
|
||||
// Still flattens cleanly and round-trips as a valid PDF even with
|
||||
// null email/address — the doc doesn't error out.
|
||||
// null email/address - the doc doesn't error out.
|
||||
const out = await PDFDocument.load(filled);
|
||||
expect(out.getForm().getFields()).toEqual([]);
|
||||
expect(out.getTitle()).toBe('EOI – Bob');
|
||||
|
||||
@@ -24,7 +24,7 @@ beforeAll(() => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AES-256-GCM — plaintext non-exposure', () => {
|
||||
describe('AES-256-GCM - plaintext non-exposure', () => {
|
||||
it('encrypted output does not contain the plaintext', async () => {
|
||||
const { encrypt } = await import('@/lib/utils/encryption');
|
||||
const plaintext = 'my-secret-password';
|
||||
@@ -65,7 +65,7 @@ describe('AES-256-GCM — plaintext non-exposure', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AES-256-GCM — IV randomness (semantic security)', () => {
|
||||
describe('AES-256-GCM - IV randomness (semantic security)', () => {
|
||||
it('different plaintexts produce different ciphertexts', async () => {
|
||||
const { encrypt } = await import('@/lib/utils/encryption');
|
||||
const enc1 = encrypt('password1');
|
||||
@@ -77,7 +77,7 @@ describe('AES-256-GCM — IV randomness (semantic security)', () => {
|
||||
const { encrypt } = await import('@/lib/utils/encryption');
|
||||
const enc1 = encrypt('same-password');
|
||||
const enc2 = encrypt('same-password');
|
||||
// IVs differ, so ciphertexts differ — prevents ciphertext comparison attacks
|
||||
// IVs differ, so ciphertexts differ - prevents ciphertext comparison attacks
|
||||
expect(enc1).not.toBe(enc2);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('AES-256-GCM — IV randomness (semantic security)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AES-256-GCM — authenticated encryption (tamper detection)', () => {
|
||||
describe('AES-256-GCM - authenticated encryption (tamper detection)', () => {
|
||||
it('tampered data field throws on decrypt', async () => {
|
||||
const { encrypt, decrypt } = await import('@/lib/utils/encryption');
|
||||
const encrypted = encrypt('test');
|
||||
@@ -134,7 +134,7 @@ describe('AES-256-GCM — authenticated encryption (tamper detection)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AES-256-GCM — decryption correctness', () => {
|
||||
describe('AES-256-GCM - decryption correctness', () => {
|
||||
it('decrypt recovers original plaintext', async () => {
|
||||
const { encrypt, decrypt } = await import('@/lib/utils/encryption');
|
||||
const plaintext = 'my-secret-credentials';
|
||||
@@ -161,7 +161,7 @@ describe('AES-256-GCM — decryption correctness', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AES-256-GCM — key validation', () => {
|
||||
describe('AES-256-GCM - key validation', () => {
|
||||
it('throws when EMAIL_CREDENTIAL_KEY is not set', async () => {
|
||||
const { encrypt } = await import('@/lib/utils/encryption');
|
||||
const saved = process.env.EMAIL_CREDENTIAL_KEY;
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Error response security — AppError subclasses', () => {
|
||||
describe('Error response security - AppError subclasses', () => {
|
||||
it('AppError returns correct status without leaking constructor args', async () => {
|
||||
const error = new AppError(400, 'Bad request', 'BAD_REQUEST');
|
||||
const response = errorResponse(error);
|
||||
@@ -110,7 +110,7 @@ describe('Error response security — AppError subclasses', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error response security — unknown / native errors', () => {
|
||||
describe('Error response security - unknown / native errors', () => {
|
||||
it('native Error with SQL content returns generic 500', async () => {
|
||||
const error = new Error('SELECT * FROM users WHERE id = 1; DROP TABLE users;--');
|
||||
const response = errorResponse(error);
|
||||
@@ -170,7 +170,7 @@ describe('Error response security — unknown / native errors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error response security — ZodError', () => {
|
||||
describe('Error response security - ZodError', () => {
|
||||
it('ZodError returns 400 with VALIDATION_ERROR code', async () => {
|
||||
const { ZodError, ZodIssueCode } = await import('zod');
|
||||
const error = new ZodError([
|
||||
@@ -214,7 +214,7 @@ describe('Error response security — ZodError', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error response security — response shape invariants', () => {
|
||||
describe('Error response security - response shape invariants', () => {
|
||||
it('every AppError response body follows { error, code } shape', async () => {
|
||||
const errors = [
|
||||
new AppError(400, 'Bad request', 'BAD_REQUEST'),
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('SQL injection prevention via Zod schemas', () => {
|
||||
fullName: "Robert'); DROP TABLE clients;--",
|
||||
contacts: [{ channel: 'email', value: 'test@example.com' }],
|
||||
});
|
||||
// Zod must accept this as a valid string — we rely on Drizzle for SQL safety
|
||||
// Zod must accept this as a valid string - we rely on Drizzle for SQL safety
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// The payload passes through unchanged; the query layer uses $1 placeholders
|
||||
@@ -61,7 +61,7 @@ describe('SQL injection prevention via Zod schemas', () => {
|
||||
const result = searchQuerySchema.safeParse({
|
||||
q: "'; DROP TABLE clients;--",
|
||||
});
|
||||
// Min length 2, so this passes — Drizzle uses $1 for the actual query
|
||||
// Min length 2, so this passes - Drizzle uses $1 for the actual query
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('SQL injection prevention via Zod schemas', () => {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('File upload validation — MIME type allowlist', () => {
|
||||
describe('File upload validation - MIME type allowlist', () => {
|
||||
it('rejects application/x-executable (binary/shellcode)', async () => {
|
||||
const { ALLOWED_MIME_TYPES } = await import('@/lib/constants/file-validation');
|
||||
expect(ALLOWED_MIME_TYPES.has('application/x-executable')).toBe(false);
|
||||
@@ -156,9 +156,7 @@ describe('File upload validation — MIME type allowlist', () => {
|
||||
).toBe(true);
|
||||
expect(ALLOWED_MIME_TYPES.has('application/vnd.ms-excel')).toBe(true);
|
||||
expect(
|
||||
ALLOWED_MIME_TYPES.has(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
),
|
||||
ALLOWED_MIME_TYPES.has('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -169,7 +167,7 @@ describe('File upload validation — MIME type allowlist', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('File upload validation — size limit', () => {
|
||||
describe('File upload validation - size limit', () => {
|
||||
it('MAX_FILE_SIZE is exactly 50 MB (52_428_800 bytes)', async () => {
|
||||
const { MAX_FILE_SIZE } = await import('@/lib/constants/file-validation');
|
||||
expect(MAX_FILE_SIZE).toBe(50 * 1024 * 1024);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { deepMerge } from '@/lib/api/helpers';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deepMerge — basic override behaviour', () => {
|
||||
describe('deepMerge - basic override behaviour', () => {
|
||||
it('override replaces a single base value', () => {
|
||||
const base = { clients: { view: true, create: true, delete: false } };
|
||||
const override = { clients: { delete: true } };
|
||||
@@ -52,7 +52,7 @@ describe('deepMerge — basic override behaviour', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge — nested structure preservation', () => {
|
||||
describe('deepMerge - nested structure preservation', () => {
|
||||
it('deep merges two levels of nesting without data loss', () => {
|
||||
const base = { admin: { manage_users: false, manage_settings: true } };
|
||||
const override = { admin: { manage_users: true } };
|
||||
@@ -65,7 +65,10 @@ describe('deepMerge — nested structure preservation', () => {
|
||||
const base = { reports: { export: { csv: true, pdf: false } } };
|
||||
const override = { reports: { export: { pdf: true } } };
|
||||
const result = deepMerge(base, override);
|
||||
const exportPerms = (result.reports as Record<string, unknown>).export as Record<string, boolean>;
|
||||
const exportPerms = (result.reports as Record<string, unknown>).export as Record<
|
||||
string,
|
||||
boolean
|
||||
>;
|
||||
expect(exportPerms.pdf).toBe(true);
|
||||
expect(exportPerms.csv).toBe(true);
|
||||
});
|
||||
@@ -89,7 +92,7 @@ describe('deepMerge — nested structure preservation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge — immutability', () => {
|
||||
describe('deepMerge - immutability', () => {
|
||||
it('does not mutate the target object', () => {
|
||||
const base = { clients: { view: true, delete: false } };
|
||||
const override = { clients: { delete: true } };
|
||||
@@ -106,7 +109,7 @@ describe('deepMerge — immutability', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge — edge cases', () => {
|
||||
describe('deepMerge - edge cases', () => {
|
||||
it('empty override returns a copy of the base', () => {
|
||||
const base = { clients: { view: true } };
|
||||
const result = deepMerge(base, {});
|
||||
@@ -126,7 +129,7 @@ describe('deepMerge — edge cases', () => {
|
||||
|
||||
it('scalar override value wins over nested base value (array not merged)', () => {
|
||||
// When source has a non-object value for a key that base has as an object,
|
||||
// the source scalar replaces the base object — this is the defined behaviour
|
||||
// the source scalar replaces the base object - this is the defined behaviour
|
||||
const base = { meta: { x: 1 } };
|
||||
const override = { meta: 'string-value' };
|
||||
const result = deepMerge(base, override as unknown as Record<string, unknown>);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { maskSensitiveFields } from '@/lib/audit';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sensitive data masking — field detection', () => {
|
||||
describe('Sensitive data masking - field detection', () => {
|
||||
it('masks "email" field', () => {
|
||||
const result = maskSensitiveFields({ email: 'user@example.com' });
|
||||
expect(result?.email).not.toBe('user@example.com');
|
||||
@@ -48,7 +48,7 @@ describe('Sensitive data masking — field detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive data masking — masking format', () => {
|
||||
describe('Sensitive data masking - masking format', () => {
|
||||
it('long email (len > 4) uses partial mask: first 2 + *** + last 2', () => {
|
||||
// 'user@example.com' → 'us***om'
|
||||
const result = maskSensitiveFields({ email: 'user@example.com' });
|
||||
@@ -84,7 +84,7 @@ describe('Sensitive data masking — masking format', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive data masking — non-sensitive fields', () => {
|
||||
describe('Sensitive data masking - non-sensitive fields', () => {
|
||||
it('preserves string non-sensitive fields unchanged', () => {
|
||||
const result = maskSensitiveFields({ name: 'John Smith', status: 'active' });
|
||||
expect(result?.name).toBe('John Smith');
|
||||
@@ -122,7 +122,7 @@ describe('Sensitive data masking — non-sensitive fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive data masking — edge cases', () => {
|
||||
describe('Sensitive data masking - edge cases', () => {
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(maskSensitiveFields(undefined)).toBeUndefined();
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe('Sensitive data masking — edge cases', () => {
|
||||
expect(original.email).toBe(originalEmail);
|
||||
});
|
||||
|
||||
it('only masks string values — non-string sensitive fields are left as-is', () => {
|
||||
it('only masks string values - non-string sensitive fields are left as-is', () => {
|
||||
// e.g. if someone stores a number in an "email" field (type error upstream),
|
||||
// the masking logic gracefully skips it (typeof check)
|
||||
const result = maskSensitiveFields({ email: 12345 as unknown as string });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: cold (no history) 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: cold (no history) 1`] = `
|
||||
{
|
||||
"eoiCount": 0,
|
||||
"furthestStage": 0,
|
||||
@@ -10,7 +10,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: cold (no history) 1`]
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: no fallthrough but many interests 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: no fallthrough but many interests 1`] = `
|
||||
{
|
||||
"eoiCount": 15,
|
||||
"furthestStage": 0,
|
||||
@@ -20,7 +20,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: no fallthrough but ma
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: old fallthrough at deposit stage (recency decayed) 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: old fallthrough at deposit stage (recency decayed) 1`] = `
|
||||
{
|
||||
"eoiCount": 15,
|
||||
"furthestStage": 40,
|
||||
@@ -30,7 +30,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: old fallthrough at de
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: recent fallthrough at deposit stage (deepest hurt) 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: recent fallthrough at deposit stage (deepest hurt) 1`] = `
|
||||
{
|
||||
"eoiCount": 15,
|
||||
"furthestStage": 40,
|
||||
@@ -40,7 +40,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: recent fallthrough at
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: recent fallthrough at enquiry stage 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: recent fallthrough at enquiry stage 1`] = `
|
||||
{
|
||||
"eoiCount": 0,
|
||||
"furthestStage": 0,
|
||||
@@ -50,7 +50,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: recent fallthrough at
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: recent fallthrough at eoi stage 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: recent fallthrough at eoi stage 1`] = `
|
||||
{
|
||||
"eoiCount": 5,
|
||||
"furthestStage": 20,
|
||||
@@ -60,7 +60,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: recent fallthrough at
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — heat at canonical inputs > heat: typical mid-funnel hot lead 1`] = `
|
||||
exports[`W7 snapshots - heat at canonical inputs > heat: typical mid-funnel hot lead 1`] = `
|
||||
{
|
||||
"eoiCount": 10,
|
||||
"furthestStage": 30,
|
||||
@@ -70,7 +70,7 @@ exports[`W7 snapshots — heat at canonical inputs > heat: typical mid-funnel ho
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=0, lost=0, stage=0) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=0, lost=0, stage=0) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 0,
|
||||
@@ -81,7 +81,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=0, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=0, lost=1, stage=0) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=0, lost=1, stage=0) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 0,
|
||||
@@ -92,7 +92,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=0, lost=1, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=0, lost=5, stage=0) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=0, lost=5, stage=0) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 0,
|
||||
@@ -103,7 +103,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=0, lost=5, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=1) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=1, lost=0, stage=1) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 1,
|
||||
@@ -114,7 +114,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=3) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=1, lost=0, stage=3) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 1,
|
||||
@@ -125,7 +125,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=4) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=1, lost=0, stage=4) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 1,
|
||||
@@ -136,7 +136,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=5) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=1, lost=0, stage=5) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 1,
|
||||
@@ -147,7 +147,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=6) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=1, lost=0, stage=6) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 1,
|
||||
@@ -158,7 +158,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=5, stage=6) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=1, lost=5, stage=6) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 1,
|
||||
@@ -169,7 +169,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=1, lost=5, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=2, lost=0, stage=5) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=2, lost=0, stage=5) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 2,
|
||||
@@ -180,7 +180,7 @@ exports[`W7 snapshots — tier-ladder boundaries > tier(active=2, lost=0, stage=
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`W7 snapshots — tier-ladder boundaries > tier(active=3, lost=2, stage=4) is stable 1`] = `
|
||||
exports[`W7 snapshots - tier-ladder boundaries > tier(active=3, lost=2, stage=4) is stable 1`] = `
|
||||
{
|
||||
"in": {
|
||||
"activeInterestCount": 3,
|
||||
|
||||
@@ -39,7 +39,7 @@ async function buildAcroFormPdf(): Promise<Buffer> {
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
describe('parseBerthPdf — AcroForm tier', () => {
|
||||
describe('parseBerthPdf - AcroForm tier', () => {
|
||||
it('extracts named fields and skips OCR', async () => {
|
||||
const buf = await buildAcroFormPdf();
|
||||
const result = await parseBerthPdf(buf, { skipOcr: true });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Unit tests for the berth PDF parser (Phase 6b — see plan §4.7b, §14.6).
|
||||
* Unit tests for the berth PDF parser (Phase 6b - see plan §4.7b, §14.6).
|
||||
*
|
||||
* Covers:
|
||||
* - Magic-byte check (`%PDF-`).
|
||||
@@ -58,7 +58,7 @@ describe('parseHumanDate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFromOcrText — sample berth A1', () => {
|
||||
describe('extractFromOcrText - sample berth A1', () => {
|
||||
// Mirrors the layout of Berth_Spec_Sheet_A1.pdf documented in plan §9.2.
|
||||
const sample = `
|
||||
PORT NIMARA
|
||||
@@ -144,7 +144,7 @@ Access: Car to Vessel (max. 3 ton)
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFromOcrText — imperial/metric drift warning', () => {
|
||||
describe('extractFromOcrText - imperial/metric drift warning', () => {
|
||||
it('flags a >1% mismatch', () => {
|
||||
const { warnings } = extractFromOcrText('Length: 100 ft / 50m');
|
||||
expect(warnings.some((w) => /mismatch/i.test(w))).toBe(true);
|
||||
|
||||
@@ -188,12 +188,12 @@ describe('computeHeat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── W7 snapshot lockfile — locks current tier-ladder boundaries and heat
|
||||
// ─── W7 snapshot lockfile - locks current tier-ladder boundaries and heat
|
||||
// ordering so weight-tuning changes can't silently shift outputs. The
|
||||
// existing toBe / toBeCloseTo tests above cover correctness; these
|
||||
// inline snapshots are the regression-catching tripwires.
|
||||
|
||||
describe('W7 snapshots — tier-ladder boundaries', () => {
|
||||
describe('W7 snapshots - tier-ladder boundaries', () => {
|
||||
it.each([
|
||||
[0, 0, 0],
|
||||
[0, 1, 0],
|
||||
@@ -217,7 +217,7 @@ describe('W7 snapshots — tier-ladder boundaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('W7 snapshots — heat at canonical inputs', () => {
|
||||
describe('W7 snapshots - heat at canonical inputs', () => {
|
||||
const NOW = new Date('2026-05-05T00:00:00Z');
|
||||
const w = DEFAULT_RECOMMENDER_SETTINGS;
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('W7 snapshots — heat at canonical inputs', () => {
|
||||
w,
|
||||
NOW,
|
||||
);
|
||||
// Snapshot the rounded breakdown — exact float math (toBeCloseTo)
|
||||
// Snapshot the rounded breakdown - exact float math (toBeCloseTo)
|
||||
// is covered above; this locks the relative ordering + magnitude.
|
||||
expect({
|
||||
total: Math.round(h.total * 1000) / 1000,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
|
||||
// ─── createPending ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('berth-reservations.service — createPending', () => {
|
||||
describe('berth-reservations.service - createPending', () => {
|
||||
it('creates pending reservation for client-owned yacht', async () => {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
@@ -203,7 +203,7 @@ describe('berth-reservations.service — createPending', () => {
|
||||
|
||||
// ─── Lifecycle transitions ───────────────────────────────────────────────────
|
||||
|
||||
describe('berth-reservations.service — lifecycle transitions', () => {
|
||||
describe('berth-reservations.service - lifecycle transitions', () => {
|
||||
async function setup() {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
@@ -330,7 +330,7 @@ describe('berth-reservations.service — lifecycle transitions', () => {
|
||||
|
||||
// ─── listReservations ────────────────────────────────────────────────────────
|
||||
|
||||
describe('berth-reservations.service — listReservations', () => {
|
||||
describe('berth-reservations.service - listReservations', () => {
|
||||
async function makeReservation(portId: string, opts?: { berthId?: string }) {
|
||||
const berth = opts?.berthId ? { id: opts.berthId } : await makeBerth({ portId });
|
||||
const client = await makeClient({ portId });
|
||||
@@ -418,7 +418,7 @@ describe('berth-reservations.service — listReservations', () => {
|
||||
|
||||
// ─── Self-check: DB state is as expected after cancel ────────────────────────
|
||||
|
||||
describe('berth-reservations.service — DB state', () => {
|
||||
describe('berth-reservations.service - DB state', () => {
|
||||
it('cancel persists status=cancelled in the database', async () => {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
|
||||
@@ -14,7 +14,7 @@ import { companies } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
describe('companies.service — createCompany', () => {
|
||||
describe('companies.service - createCompany', () => {
|
||||
it('creates a company with minimal required fields', async () => {
|
||||
const port = await makePort();
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('companies.service — createCompany', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('companies.service — upsertByName', () => {
|
||||
describe('companies.service - upsertByName', () => {
|
||||
it('returns existing company on case-insensitive match', async () => {
|
||||
const port = await makePort();
|
||||
const original = await createCompany(
|
||||
@@ -109,7 +109,7 @@ describe('companies.service — upsertByName', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('companies.service — updateCompany', () => {
|
||||
describe('companies.service - updateCompany', () => {
|
||||
it('updates fields', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({
|
||||
@@ -147,7 +147,7 @@ describe('companies.service — updateCompany', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('companies.service — archiveCompany', () => {
|
||||
describe('companies.service - archiveCompany', () => {
|
||||
it('sets archivedAt to a non-null timestamp', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
@@ -173,7 +173,7 @@ describe('companies.service — archiveCompany', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('companies.service — listCompanies', () => {
|
||||
describe('companies.service - listCompanies', () => {
|
||||
it('is tenant-scoped', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
@@ -229,7 +229,7 @@ describe('companies.service — listCompanies', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('companies.service — autocomplete', () => {
|
||||
describe('companies.service - autocomplete', () => {
|
||||
it('matches by name', async () => {
|
||||
const port = await makePort();
|
||||
await makeCompany({ portId: port.id, overrides: { name: 'Phoenix Ltd' } });
|
||||
@@ -248,7 +248,7 @@ describe('companies.service — autocomplete', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('companies.service — getCompanyById', () => {
|
||||
describe('companies.service - getCompanyById', () => {
|
||||
it('returns the company when same tenant', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
|
||||
@@ -13,7 +13,7 @@ import { companyMemberships } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
describe('company-memberships.service — addMembership', () => {
|
||||
describe('company-memberships.service - addMembership', () => {
|
||||
it('creates a membership for a valid client + company in the same port', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
@@ -122,7 +122,7 @@ describe('company-memberships.service — addMembership', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('company-memberships.service — updateMembership', () => {
|
||||
describe('company-memberships.service - updateMembership', () => {
|
||||
it('updates fields', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
@@ -180,7 +180,7 @@ describe('company-memberships.service — updateMembership', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('company-memberships.service — setPrimary', () => {
|
||||
describe('company-memberships.service - setPrimary', () => {
|
||||
it('sets only one membership as primary per company (atomic un-primary others)', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
@@ -222,7 +222,7 @@ describe('company-memberships.service — setPrimary', () => {
|
||||
makeAuditMeta({ portId: port.id }),
|
||||
);
|
||||
|
||||
// Mark all primary via the service — only the last call should leave a
|
||||
// Mark all primary via the service - only the last call should leave a
|
||||
// single primary survivor (m3).
|
||||
await setPrimary(m1.id, port.id, makeAuditMeta({ portId: port.id }));
|
||||
await setPrimary(m2.id, port.id, makeAuditMeta({ portId: port.id }));
|
||||
@@ -262,7 +262,7 @@ describe('company-memberships.service — setPrimary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('company-memberships.service — endMembership', () => {
|
||||
describe('company-memberships.service - endMembership', () => {
|
||||
it('sets endDate', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
@@ -293,7 +293,7 @@ describe('company-memberships.service — endMembership', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('company-memberships.service — listByCompany / listByClient', () => {
|
||||
describe('company-memberships.service - listByCompany / listByClient', () => {
|
||||
it('returns active memberships only by default', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('buildDocumensoPayload', () => {
|
||||
Length: '45 ft',
|
||||
Width: '14 ft',
|
||||
Draft: '6 ft',
|
||||
// Berth Number carries the formatBerthRange output — single-
|
||||
// Berth Number carries the formatBerthRange output - single-
|
||||
// berth EOI duplicates the primary mooring; multi-berth shows
|
||||
// the compact range. The separate 'Berth Range' formValue key
|
||||
// was retired 2026-05-14 (the Documenso template never had
|
||||
@@ -134,7 +134,7 @@ describe('buildDocumensoPayload', () => {
|
||||
|
||||
it('renders empty Section 3 when yacht and berth are not linked', () => {
|
||||
// Also explicitly clear the berth-range fallback that defaults to
|
||||
// the primary mooring — when there's no berth AND no bundle, the
|
||||
// the primary mooring - when there's no berth AND no bundle, the
|
||||
// form field renders as empty.
|
||||
const ctx = makeContext({ yacht: null, berth: null, eoiBerthRange: '' });
|
||||
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||
|
||||
@@ -115,7 +115,11 @@ describe('placeFields v2 dispatch', () => {
|
||||
'env-123',
|
||||
[
|
||||
{
|
||||
recipientId: 'rec-a',
|
||||
// v2 recipient ids are numeric - Documenso's distribute response
|
||||
// returns them as numbers. The CRM custom-document-upload
|
||||
// service preserves them as strings or numbers; the v2 placeFields
|
||||
// coercion normalises to number for the upstream payload.
|
||||
recipientId: '42',
|
||||
type: 'SIGNATURE',
|
||||
pageNumber: 1,
|
||||
pageX: 25,
|
||||
@@ -134,9 +138,13 @@ describe('placeFields v2 dispatch', () => {
|
||||
expect((init as RequestInit).method).toBe('POST');
|
||||
const body = JSON.parse(String((init as RequestInit).body)) as any;
|
||||
expect(body.envelopeId).toBe('env-123');
|
||||
expect(body.fields[0]).toMatchObject({
|
||||
recipientId: 'rec-a',
|
||||
// 2026-05-22: Documenso v2 expects the array under `data` (trpc-style
|
||||
// createMany input), not `fields`. recipientId is a number, and the
|
||||
// page-index key is `page` (not `pageNumber`).
|
||||
expect(body.data[0]).toMatchObject({
|
||||
recipientId: 42,
|
||||
type: 'SIGNATURE',
|
||||
page: 1,
|
||||
positionX: 25,
|
||||
positionY: 88,
|
||||
width: 20,
|
||||
@@ -262,17 +270,22 @@ describe('placeDefaultSignatureFields integration', () => {
|
||||
await placeDefaultSignatureFields(
|
||||
'env-x',
|
||||
[
|
||||
{ id: 'r1', pageNumber: 4 },
|
||||
{ id: 'r2', pageNumber: 4 },
|
||||
{ id: 'r3', pageNumber: 4 },
|
||||
{ id: '101', pageNumber: 4 },
|
||||
{ id: '102', pageNumber: 4 },
|
||||
{ id: '103', pageNumber: 4 },
|
||||
],
|
||||
'port-1',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)) as any;
|
||||
expect(body.fields).toHaveLength(3);
|
||||
expect(body.fields.every((f: { type: string }) => f.type === 'SIGNATURE')).toBe(true);
|
||||
expect(body.fields.every((f: { pageNumber: number }) => f.pageNumber === 4)).toBe(true);
|
||||
// 2026-05-22: Documenso v2 expects `data` (not `fields`), `page`
|
||||
// (not `pageNumber`), and numeric recipientIds.
|
||||
expect(body.data).toHaveLength(3);
|
||||
expect(body.data.every((f: { type: string }) => f.type === 'SIGNATURE')).toBe(true);
|
||||
expect(body.data.every((f: { page: number }) => f.page === 4)).toBe(true);
|
||||
expect(
|
||||
body.data.every((f: { recipientId: unknown }) => typeof f.recipientId === 'number'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the API call entirely with zero recipients', async () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('extractSigningToken', () => {
|
||||
});
|
||||
|
||||
it('rejects path tails with disallowed characters', () => {
|
||||
// Real tokens are URL-safe base64 — no spaces, no punctuation
|
||||
// Real tokens are URL-safe base64 - no spaces, no punctuation
|
||||
expect(extractSigningToken('https://example.com/sign/has%20space')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { describe, it, expect } from 'vitest';
|
||||
import { transformSigningUrl } from '@/lib/services/document-signing-emails.service';
|
||||
|
||||
/**
|
||||
* Phase 5 — pin the URL-wrapping contract.
|
||||
* Phase 5 - pin the URL-wrapping contract.
|
||||
*
|
||||
* The marketing website at portnimara.com/sign/[type]/[token] expects
|
||||
* specific path segments (`client | cc | developer | witness`) and the
|
||||
* Documenso webhook returns raw URLs of the form
|
||||
* `https://signatures.portnimara.com/sign/<token>`. transformSigningUrl
|
||||
* is the seam between the two — these tests guard the role-to-URL-
|
||||
* is the seam between the two - these tests guard the role-to-URL-
|
||||
* segment mapping so a future refactor can't silently break the
|
||||
* embedded signing pages.
|
||||
*/
|
||||
@@ -38,7 +38,7 @@ describe('transformSigningUrl', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('maps approver → /sign/cc/<token> — website only handles {client, cc, developer, witness}', () => {
|
||||
it('maps approver → /sign/cc/<token> - website only handles {client, cc, developer, witness}', () => {
|
||||
expect(transformSigningUrl(RAW, HOST, 'approver')).toBe(
|
||||
'https://portnimara.com/sign/cc/vbT8hi3jKQmrFP_LN1WcS',
|
||||
);
|
||||
@@ -50,7 +50,7 @@ describe('transformSigningUrl', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('maps other → /sign/cc/<token> — funnels through CC page with passive copy', () => {
|
||||
it('maps other → /sign/cc/<token> - funnels through CC page with passive copy', () => {
|
||||
expect(transformSigningUrl(RAW, HOST, 'other')).toBe(
|
||||
'https://portnimara.com/sign/cc/vbT8hi3jKQmrFP_LN1WcS',
|
||||
);
|
||||
@@ -65,7 +65,7 @@ describe('transformSigningUrl', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the token verbatim — no URL encoding / re-shaping', () => {
|
||||
it('preserves the token verbatim - no URL encoding / re-shaping', () => {
|
||||
const odd = 'https://sig.example.com/sign/Aa_-Zz09_-XYZ';
|
||||
expect(transformSigningUrl(odd, HOST, 'developer')).toBe(
|
||||
'https://portnimara.com/sign/developer/Aa_-Zz09_-XYZ',
|
||||
|
||||
@@ -305,7 +305,7 @@ describe('buildEoiContext', () => {
|
||||
it('throws ValidationError when client has no email', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
// Address only, no email — gate should fail.
|
||||
// Address only, no email - gate should fail.
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId: client.id,
|
||||
portId: port.id,
|
||||
@@ -324,7 +324,7 @@ describe('buildEoiContext', () => {
|
||||
it('throws ValidationError when client has no primary address', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
// Email only, no address — gate should fail.
|
||||
// Email only, no address - gate should fail.
|
||||
await db.insert(clientContacts).values({
|
||||
clientId: client.id,
|
||||
channel: 'email',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
describe('portal.service — getPortalUserYachts', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
describe('portal.service - getPortalUserYachts', () => {
|
||||
let getPortalUserYachts: (clientId: string, portId: string) => Promise<Array<any>>;
|
||||
|
||||
let makeClient: typeof import('../../helpers/factories').makeClient;
|
||||
@@ -87,7 +86,7 @@ describe('portal.service — getPortalUserYachts', () => {
|
||||
// defensive by forcing the yacht's current owner to company after the
|
||||
// direct query path has already cached it. We simulate the case by
|
||||
// creating a client-owned yacht, then manually flipping owner to a
|
||||
// company the client is a member of — if both queries ran they'd both
|
||||
// company the client is a member of - if both queries ran they'd both
|
||||
// match, but dedup by id ensures only one entry.
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
@@ -104,7 +103,7 @@ describe('portal.service — getPortalUserYachts', () => {
|
||||
ownerId: client.id,
|
||||
name: 'Ambiguous',
|
||||
});
|
||||
// Flip the denormalized owner to the company (without updating history) —
|
||||
// Flip the denormalized owner to the company (without updating history) -
|
||||
// this is artificial but exercises the dedup branch.
|
||||
await db
|
||||
.update(yachts)
|
||||
@@ -142,7 +141,7 @@ describe('portal.service — getPortalUserYachts', () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const clientInA = await makeClient({ portId: portA.id });
|
||||
// Directly-owned yacht in portB with the SAME client id — must not leak
|
||||
// Directly-owned yacht in portB with the SAME client id - must not leak
|
||||
// because getPortalUserYachts filters on portId.
|
||||
// We insert a yacht row in portB with ownerId=clientInA.id. The FK on
|
||||
// yachts.currentOwnerId isn't to clients, so this is valid.
|
||||
@@ -172,8 +171,7 @@ describe('portal.service — getPortalUserYachts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('portal.service — getPortalUserMemberships', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
describe('portal.service - getPortalUserMemberships', () => {
|
||||
let getPortalUserMemberships: (clientId: string, portId: string) => Promise<Array<any>>;
|
||||
|
||||
let makeClient: typeof import('../../helpers/factories').makeClient;
|
||||
@@ -223,7 +221,7 @@ describe('portal.service — getPortalUserMemberships', () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const client = await makeClient({ portId: portA.id });
|
||||
// Company in portB — but membership references clientId on portA.
|
||||
// Company in portB - but membership references clientId on portA.
|
||||
const companyInB = await makeCompany({ portId: portB.id });
|
||||
await makeMembership({
|
||||
companyId: companyInB.id,
|
||||
@@ -240,8 +238,7 @@ describe('portal.service — getPortalUserMemberships', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('portal.service — getPortalUserReservations', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
describe('portal.service - getPortalUserReservations', () => {
|
||||
let getPortalUserReservations: (clientId: string, portId: string) => Promise<Array<any>>;
|
||||
|
||||
let makeClient: typeof import('../../helpers/factories').makeClient;
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('computeTotalForecast', () => {
|
||||
],
|
||||
{ enquiry: 0.3 },
|
||||
);
|
||||
// 'unknown_stage' canonicalizes to 'enquiry' (fallback) — so it ALSO
|
||||
// 'unknown_stage' canonicalizes to 'enquiry' (fallback) - so it ALSO
|
||||
// gets the enquiry weight. Verifies canonicalization stays consistent
|
||||
// between rollup and forecast so the totals reconcile.
|
||||
// 1000*0.3 + 2000*0.3 = 300 + 600 = 900
|
||||
|
||||
@@ -7,13 +7,13 @@ import { yachts, companies } from '@/lib/db/schema';
|
||||
|
||||
import { makePort, makeClient, makeYacht, makeCompany } from '../../helpers/factories';
|
||||
|
||||
// Default opts — super admin so every bucket runs without per-resource
|
||||
// Default opts - super admin so every bucket runs without per-resource
|
||||
// permission gating getting in the way of the assertions.
|
||||
const ADMIN_OPTS = { permissions: null, isSuperAdmin: true } as const;
|
||||
|
||||
// ─── Yachts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('search.service — yachts', () => {
|
||||
describe('search.service - yachts', () => {
|
||||
it('matches yachts by name (case-insensitive)', async () => {
|
||||
const port = await makePort();
|
||||
const owner = await makeClient({ portId: port.id });
|
||||
@@ -106,7 +106,7 @@ describe('search.service — yachts', () => {
|
||||
|
||||
// ─── Companies ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('search.service — companies', () => {
|
||||
describe('search.service - companies', () => {
|
||||
it('matches companies by name', async () => {
|
||||
const port = await makePort();
|
||||
await makeCompany({ portId: port.id, overrides: { name: 'Poseidon Maritime Ltd' } });
|
||||
@@ -165,7 +165,7 @@ describe('search.service — companies', () => {
|
||||
|
||||
// ─── Combined ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('search.service — combined', () => {
|
||||
describe('search.service - combined', () => {
|
||||
it('returns clients, yachts, and companies for a query that matches multiple', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({
|
||||
@@ -242,7 +242,7 @@ describe('normalizePhoneQuery', () => {
|
||||
|
||||
// ─── Partial name matching ───────────────────────────────────────────────────
|
||||
|
||||
describe('search.service — partial name matching', () => {
|
||||
describe('search.service - partial name matching', () => {
|
||||
it('matches "joh smi" against "John Smith" via tokenized prefix tsquery', async () => {
|
||||
const port = await makePort();
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'John Smith' } });
|
||||
@@ -263,7 +263,7 @@ describe('search.service — partial name matching', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('search.service — bucket totals', () => {
|
||||
describe('search.service - bucket totals', () => {
|
||||
it('emits per-bucket totals so the UI can render "show more" links', async () => {
|
||||
const port = await makePort();
|
||||
await makeClient({ portId: port.id, overrides: { fullName: 'TotalsCheck One' } });
|
||||
|
||||
@@ -18,7 +18,7 @@ import { db } from '@/lib/db';
|
||||
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
describe('yachts.service — createYacht', () => {
|
||||
describe('yachts.service - createYacht', () => {
|
||||
it('creates a yacht with a client owner and opens an ownership history row', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
@@ -79,7 +79,7 @@ describe('yachts.service — createYacht', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('yachts.service — updateYacht', () => {
|
||||
describe('yachts.service - updateYacht', () => {
|
||||
it('updates name and notes', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
@@ -144,7 +144,7 @@ describe('yachts.service — updateYacht', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('yachts.service — archiveYacht', () => {
|
||||
describe('yachts.service - archiveYacht', () => {
|
||||
it('sets archivedAt to a non-null timestamp', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
@@ -174,7 +174,7 @@ describe('yachts.service — archiveYacht', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('yachts.service — listYachts', () => {
|
||||
describe('yachts.service - listYachts', () => {
|
||||
it('is scoped to port (tenant isolation)', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
@@ -269,7 +269,7 @@ describe('yachts.service — listYachts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('yachts.service — listYachtsForOwner', () => {
|
||||
describe('yachts.service - listYachtsForOwner', () => {
|
||||
it('returns all yachts owned by a given client', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
@@ -305,7 +305,7 @@ describe('yachts.service — listYachtsForOwner', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('yachts.service — autocomplete', () => {
|
||||
describe('yachts.service - autocomplete', () => {
|
||||
it('matches by name (ILIKE)', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Stub the env module BEFORE importing the backend so the
|
||||
// MULTI_NODE_DEPLOYMENT toggle works — env is now read from the zod
|
||||
// MULTI_NODE_DEPLOYMENT toggle works - env is now read from the zod
|
||||
// schema once at module load, not from process.env at runtime.
|
||||
vi.mock('@/lib/env', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/env')>('@/lib/env');
|
||||
@@ -102,7 +102,7 @@ describe('FilesystemBackend realpath check', () => {
|
||||
// realpath'd storage root. Note: Node's path.resolve doesn't follow
|
||||
// symlinks; the runtime guard relies on the resolved target string staying
|
||||
// under rootResolved. Since the symlink itself lives under root, path.resolve
|
||||
// would produce <root>/evil/file.txt — which IS under root by string check.
|
||||
// would produce <root>/evil/file.txt - which IS under root by string check.
|
||||
// The defense-in-depth here is that the storage root itself is realpath'd
|
||||
// at create time, AND the OS perms (0o700) limit lateral movement. We assert
|
||||
// the obvious traversal attack still fails.
|
||||
@@ -140,7 +140,7 @@ describe('FilesystemBackend realpath check', () => {
|
||||
const prev = env.MULTI_NODE_DEPLOYMENT;
|
||||
// The backend reads env.MULTI_NODE_DEPLOYMENT (zod-validated, set
|
||||
// once at module load). Mutate the in-memory env for the duration of
|
||||
// this case — the surrounding vi.mock() above keeps every other env
|
||||
// this case - the surrounding vi.mock() above keeps every other env
|
||||
// field intact.
|
||||
(env as unknown as { MULTI_NODE_DEPLOYMENT: boolean }).MULTI_NODE_DEPLOYMENT = true;
|
||||
try {
|
||||
|
||||
@@ -401,7 +401,7 @@ describe('updateFieldSchema', () => {
|
||||
});
|
||||
|
||||
it('does NOT accept fieldType (immutability by omission)', () => {
|
||||
// fieldType is omitted from the schema — it should be stripped or cause a strict failure
|
||||
// fieldType is omitted from the schema - it should be stripped or cause a strict failure
|
||||
// With Zod default (strip mode), unknown keys are stripped and parse succeeds.
|
||||
// The important check is that the parsed output does NOT include fieldType.
|
||||
const result = updateFieldSchema.safeParse({ fieldType: 'number' });
|
||||
|
||||
@@ -8,7 +8,7 @@ const baseInput = {
|
||||
bodyHtml: '<p>x</p>',
|
||||
};
|
||||
|
||||
describe('createTemplateSchema — mergeFields allow-list', () => {
|
||||
describe('createTemplateSchema - mergeFields allow-list', () => {
|
||||
it('accepts valid tokens from the catalog', () => {
|
||||
const parsed = createTemplateSchema.parse({
|
||||
...baseInput,
|
||||
|
||||
@@ -38,13 +38,13 @@ describe('isLocalOrPrivateHost', () => {
|
||||
'https://api.example.com/webhook',
|
||||
'https://1.1.1.1/x', // public DNS
|
||||
'https://8.8.8.8/x', // public DNS
|
||||
'https://203.0.113.5/x', // TEST-NET-3 documentation range — public
|
||||
'https://203.0.113.5/x', // TEST-NET-3 documentation range - public
|
||||
])('allows %s', (url) => {
|
||||
expect(isLocalOrPrivateHost(url)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for malformed URLs (fail closed)', () => {
|
||||
expect(isLocalOrPrivateHost('not a url')).toBe(true);
|
||||
expect(isLocalOrPrivateHost('javascript:alert(1)')).toBe(false); // parses, hostname empty — but hostname check below catches
|
||||
expect(isLocalOrPrivateHost('javascript:alert(1)')).toBe(false); // parses, hostname empty - but hostname check below catches
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ function makeReq(body: unknown, headers: Record<string, string> = {}) {
|
||||
} as unknown as import('next/server').NextRequest;
|
||||
}
|
||||
|
||||
describe('POST /api/public/website-inquiries — 503 when secret unset', () => {
|
||||
describe('POST /api/public/website-inquiries - 503 when secret unset', () => {
|
||||
it('returns 503 even when a "valid" header + payload are supplied', async () => {
|
||||
const { POST } = await import('@/app/api/public/website-inquiries/route');
|
||||
const res = await POST(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* /api/public/website-inquiries route — unit tests.
|
||||
* /api/public/website-inquiries route - unit tests.
|
||||
*
|
||||
* Asserts:
|
||||
* 1. Auth: rejects missing/wrong X-Webhook-Secret with 401.
|
||||
@@ -22,7 +22,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const VALID_UUID = '11111111-1111-4111-8111-111111111111';
|
||||
const SECRET = 'test-secret-at-least-16-chars-long';
|
||||
|
||||
// ─── Mock state — module-scoped so test-level mutations are visible ────
|
||||
// ─── Mock state - module-scoped so test-level mutations are visible ────
|
||||
|
||||
interface MockState {
|
||||
portRow: Array<{ id: string }>;
|
||||
@@ -43,7 +43,7 @@ const state: MockState = {
|
||||
queryCount: 0,
|
||||
};
|
||||
|
||||
// ─── Hoisted mocks — apply for the entire file ────────────────────────
|
||||
// ─── Hoisted mocks - apply for the entire file ────────────────────────
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: { WEBSITE_INTAKE_SECRET: SECRET },
|
||||
@@ -134,7 +134,7 @@ afterEach(() => {
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/public/website-inquiries — auth + capture', () => {
|
||||
describe('POST /api/public/website-inquiries - auth + capture', () => {
|
||||
it('returns 401 when the X-Webhook-Secret header is missing', async () => {
|
||||
const { POST } = await import('@/app/api/public/website-inquiries/route');
|
||||
const res = await POST(makeReq({}));
|
||||
|
||||
Reference in New Issue
Block a user