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
241 lines
8.7 KiB
TypeScript
241 lines
8.7 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// ─── Mock database + dependencies ────────────────────────────────────────────
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
db: {
|
|
query: {
|
|
customFieldDefinitions: { findMany: vi.fn(), findFirst: vi.fn() },
|
|
// Entity-port-scope checks added by the security fix; default to a
|
|
// truthy row so existing assertions still focus on validation logic.
|
|
clients: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
|
interests: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
|
berths: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
|
yachts: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
|
companies: { findFirst: vi.fn().mockResolvedValue({ id: 'entity-1', portId: 'port-1' }) },
|
|
},
|
|
insert: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
select: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/db/schema/clients', () => ({ clients: {} }));
|
|
vi.mock('@/lib/db/schema/interests', () => ({ interests: {} }));
|
|
vi.mock('@/lib/db/schema/berths', () => ({ berths: {} }));
|
|
vi.mock('@/lib/db/schema/yachts', () => ({ yachts: {} }));
|
|
vi.mock('@/lib/db/schema/companies', () => ({ companies: {} }));
|
|
|
|
vi.mock('@/lib/audit', () => ({
|
|
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('@/lib/logger', () => ({
|
|
logger: { warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('@/lib/db/schema/system', () => ({
|
|
customFieldDefinitions: {},
|
|
customFieldValues: {},
|
|
}));
|
|
|
|
// next/server is not available in vitest node environment
|
|
vi.mock('next/server', () => ({
|
|
NextResponse: {
|
|
json: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { setValues } from '@/lib/services/custom-fields.service';
|
|
import { db } from '@/lib/db';
|
|
import { ValidationError } from '@/lib/errors';
|
|
|
|
// ─── Helper to build a minimal CustomFieldDefinition ─────────────────────────
|
|
|
|
function makeDefinition(
|
|
fieldType: string,
|
|
extras: { isRequired?: boolean; selectOptions?: string[] } = {},
|
|
) {
|
|
return {
|
|
id: 'field-1',
|
|
portId: 'port-1',
|
|
entityType: 'client',
|
|
fieldName: 'test_field',
|
|
fieldLabel: 'Test Field',
|
|
fieldType,
|
|
selectOptions: extras.selectOptions ?? null,
|
|
isRequired: extras.isRequired ?? false,
|
|
sortOrder: 0,
|
|
createdAt: new Date(),
|
|
};
|
|
}
|
|
|
|
const AUDIT_META = {
|
|
userId: 'user-1',
|
|
portId: 'port-1',
|
|
ipAddress: '127.0.0.1',
|
|
userAgent: 'test',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Default: no existing values, upsert succeeds
|
|
const insertChain = {
|
|
values: vi.fn().mockReturnThis(),
|
|
onConflictDoUpdate: vi.fn().mockReturnThis(),
|
|
returning: vi.fn().mockResolvedValue([{ id: 'cfv-1' }]),
|
|
};
|
|
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue(insertChain);
|
|
});
|
|
|
|
/** Convenience: call setValues with a single field/value pair. */
|
|
async function validate(
|
|
fieldType: string,
|
|
value: unknown,
|
|
extras?: { isRequired?: boolean; selectOptions?: string[] },
|
|
) {
|
|
(db.query.customFieldDefinitions.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
makeDefinition(fieldType, extras),
|
|
]);
|
|
|
|
return setValues('entity-1', 'port-1', 'user-1', [{ fieldId: 'field-1', value }], AUDIT_META);
|
|
}
|
|
|
|
// ─── text ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('custom field validation - text', () => {
|
|
it('accepts a string value', async () => {
|
|
await expect(validate('text', 'hello')).resolves.toBeDefined();
|
|
});
|
|
|
|
it('rejects a number value', async () => {
|
|
await expect(validate('text', 42)).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('rejects a boolean value', async () => {
|
|
await expect(validate('text', true)).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('rejects a string longer than 1000 chars', async () => {
|
|
await expect(validate('text', 'x'.repeat(1001))).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
});
|
|
|
|
// ─── number ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('custom field validation - number', () => {
|
|
it('accepts a valid number', async () => {
|
|
await expect(validate('number', 42)).resolves.toBeDefined();
|
|
});
|
|
|
|
it('accepts zero', async () => {
|
|
await expect(validate('number', 0)).resolves.toBeDefined();
|
|
});
|
|
|
|
it('rejects a string', async () => {
|
|
await expect(validate('number', '42')).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('rejects NaN', async () => {
|
|
await expect(validate('number', NaN)).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
});
|
|
|
|
// ─── date ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('custom field validation - date', () => {
|
|
it('accepts a valid ISO date string', async () => {
|
|
await expect(validate('date', '2026-06-15')).resolves.toBeDefined();
|
|
});
|
|
|
|
it('accepts a full ISO datetime string', async () => {
|
|
await expect(validate('date', '2026-06-15T10:00:00.000Z')).resolves.toBeDefined();
|
|
});
|
|
|
|
it('rejects "not-a-date"', async () => {
|
|
await expect(validate('date', 'not-a-date')).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('rejects a number', async () => {
|
|
await expect(validate('date', 20260615)).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
});
|
|
|
|
// ─── boolean ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('custom field validation - boolean', () => {
|
|
it('accepts true', async () => {
|
|
await expect(validate('boolean', true)).resolves.toBeDefined();
|
|
});
|
|
|
|
it('accepts false', async () => {
|
|
await expect(validate('boolean', false)).resolves.toBeDefined();
|
|
});
|
|
|
|
it('rejects the string "true"', async () => {
|
|
await expect(validate('boolean', 'true')).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it('rejects 1 (number)', async () => {
|
|
await expect(validate('boolean', 1)).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
});
|
|
|
|
// ─── select ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('custom field validation - select', () => {
|
|
const options = ['Small', 'Medium', 'Large'];
|
|
|
|
it('accepts a valid option', async () => {
|
|
await expect(validate('select', 'Small', { selectOptions: options })).resolves.toBeDefined();
|
|
});
|
|
|
|
it('rejects an option not in the list', async () => {
|
|
await expect(validate('select', 'XL', { selectOptions: options })).rejects.toBeInstanceOf(
|
|
ValidationError,
|
|
);
|
|
});
|
|
|
|
it('error message lists the valid options', async () => {
|
|
try {
|
|
await validate('select', 'XL', { selectOptions: options });
|
|
expect.fail('Should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ValidationError);
|
|
// The service wraps the error in ValidationError with an errors array
|
|
const ve = err as ValidationError;
|
|
const messages = JSON.stringify(ve);
|
|
expect(messages).toMatch(/Small|Medium|Large/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── required / non-required null handling ───────────────────────────────────
|
|
|
|
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,
|
|
);
|
|
});
|
|
|
|
it('required field: undefined value → throws ValidationError', async () => {
|
|
await expect(validate('text', undefined, { isRequired: true })).rejects.toBeInstanceOf(
|
|
ValidationError,
|
|
);
|
|
});
|
|
|
|
it('non-required field: null value → succeeds (no error)', async () => {
|
|
// null for non-required means "clear the value" - setValues will upsert null
|
|
await expect(validate('text', null, { isRequired: false })).resolves.toBeDefined();
|
|
});
|
|
});
|