test(schema): verify partial unique indexes and case-insensitive company uniqueness

Adds integration test covering:
- idx_yoh_active: only one active ownership row per yacht
- idx_br_active: only one active reservation per berth (non-active rows
  are ignored by the partial index)
- Case-insensitive company name uniqueness within a port, with same-name
  companies allowed across different ports

Extends tests/helpers/factories.ts with async DB-inserting factories for
ports, clients, berths, yachts (+ ownership history row) and companies.
The new factories use the app's `db` handle so FK and partial unique
indexes are enforced by Postgres. The in-memory data helpers used by
unit tests (makeAuditMeta, makeCreateClientInput, permission helpers)
are preserved.
This commit is contained in:
Matt Ciaccio
2026-04-23 18:06:37 +02:00
parent 077ba5bf6b
commit 7a6e95c87a
2 changed files with 440 additions and 118 deletions

View File

@@ -1,114 +1,115 @@
/**
* Test factory helpers.
* These return plain data objects — NOT database-inserted records.
* Safe to use whether or not a database is available.
*
* Two flavours:
* 1. Async DB-inserting factories (makePort, makeClient, makeBerth, makeYacht,
* makeCompany, ...) — insert a row via the app's `db` handle and return the
* inserted record. Use these from integration tests that need real FK /
* unique-index enforcement. They require DATABASE_URL to be reachable.
* 2. Plain-data helpers (makeAuditMeta, makeCreateClientInput, makeCreate*)
* — return in-memory objects suitable for unit tests with mocked `db`.
*/
import { db } from '@/lib/db';
import { ports, type NewPort, type Port } from '@/lib/db/schema/ports';
import { clients, type NewClient, type Client } from '@/lib/db/schema/clients';
import { berths, type NewBerth, type Berth } from '@/lib/db/schema/berths';
import { yachts, yachtOwnershipHistory, type NewYacht, type Yacht } from '@/lib/db/schema/yachts';
import { companies, type NewCompany, type Company } from '@/lib/db/schema/companies';
// ─── Port ────────────────────────────────────────────────────────────────────
export async function makePort(args?: { overrides?: Partial<NewPort> }): Promise<Port> {
const suffix = Math.random().toString(36).slice(2, 10);
const [port] = await db
.insert(ports)
.values({
name: args?.overrides?.name ?? `Test Port ${suffix}`,
slug: args?.overrides?.slug ?? `test-port-${suffix}`,
...args?.overrides,
})
.returning();
return port!;
}
// ─── Client ──────────────────────────────────────────────────────────────────
export interface ClientData {
id: string;
export async function makeClient(args: {
portId: string;
fullName: string;
companyName: string | null;
nationality: string | null;
isProxy: boolean;
source: string | null;
yachtLengthFt: string | null;
yachtLengthM: string | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export function makeClient(overrides?: Partial<ClientData>): ClientData {
return {
id: crypto.randomUUID(),
portId: crypto.randomUUID(),
fullName: 'Test Client',
companyName: null,
nationality: null,
isProxy: false,
source: 'manual',
yachtLengthFt: null,
yachtLengthM: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// ─── Interest ─────────────────────────────────────────────────────────────────
export interface InterestData {
id: string;
portId: string;
clientId: string;
berthId: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
eoiStatus: string | null;
contractStatus: string | null;
depositStatus: string | null;
notes: string | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export function makeInterest(overrides?: Partial<InterestData>): InterestData {
return {
id: crypto.randomUUID(),
portId: crypto.randomUUID(),
clientId: crypto.randomUUID(),
berthId: null,
pipelineStage: 'open',
leadCategory: null,
source: 'manual',
eoiStatus: null,
contractStatus: null,
depositStatus: null,
notes: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
overrides?: Partial<NewClient>;
}): Promise<Client> {
const [client] = await db
.insert(clients)
.values({
portId: args.portId,
fullName: args.overrides?.fullName ?? `Test Client ${Math.random().toString(36).slice(2, 8)}`,
...args.overrides,
})
.returning();
return client!;
}
// ─── Berth ────────────────────────────────────────────────────────────────────
export interface BerthData {
id: string;
export async function makeBerth(args: {
portId: string;
mooringNumber: string;
status: string;
area: string | null;
lengthM: string | null;
price: string | null;
tenureType: string | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
overrides?: Partial<NewBerth>;
}): Promise<Berth> {
const [berth] = await db
.insert(berths)
.values({
portId: args.portId,
mooringNumber: args.overrides?.mooringNumber ?? `B-${Math.random().toString(36).slice(2, 8)}`,
...args.overrides,
})
.returning();
return berth!;
}
export function makeBerth(overrides?: Partial<BerthData>): BerthData {
return {
id: crypto.randomUUID(),
portId: crypto.randomUUID(),
mooringNumber: `B-${Math.floor(Math.random() * 999) + 1}`,
status: 'available',
area: null,
lengthM: '12',
price: '50000',
tenureType: 'freehold',
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
// ─── Yacht ───────────────────────────────────────────────────────────────────
export async function makeYacht(args: {
portId: string;
ownerType: 'client' | 'company';
ownerId: string;
overrides?: Partial<NewYacht>;
}): Promise<Yacht> {
const [yacht] = await db
.insert(yachts)
.values({
portId: args.portId,
name: args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`,
currentOwnerType: args.ownerType,
currentOwnerId: args.ownerId,
...args.overrides,
})
.returning();
await db.insert(yachtOwnershipHistory).values({
yachtId: yacht!.id,
ownerType: args.ownerType,
ownerId: args.ownerId,
startDate: new Date(),
endDate: null,
createdBy: 'test',
});
return yacht!;
}
// ─── Company ─────────────────────────────────────────────────────────────────
export async function makeCompany(args: {
portId: string;
overrides?: Partial<NewCompany>;
}): Promise<Company> {
const [company] = await db
.insert(companies)
.values({
portId: args.portId,
name: args.overrides?.name ?? `Company ${Math.random().toString(36).slice(2, 8)}`,
...args.overrides,
})
.returning();
return company!;
}
// ─── Webhook ──────────────────────────────────────────────────────────────────
@@ -169,14 +170,50 @@ import type { RolePermissions } from '@/lib/db/schema/users';
export function makeFullPermissions(): RolePermissions {
return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true },
interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
generate_eoi: true,
export: true,
},
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true },
expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true },
invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: true },
documents: {
view: true,
create: true,
send_for_signing: true,
upload_signed: true,
delete: true,
},
expenses: {
view: true,
create: true,
edit: true,
delete: true,
export: true,
scan_receipt: true,
},
invoices: {
view: true,
create: true,
edit: true,
delete: true,
send: true,
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true },
reminders: {
view_own: true,
view_all: true,
create: true,
edit_own: true,
edit_all: true,
assign_others: true,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: true, export: true },
document_templates: { view: true, generate: true, manage: true },
@@ -198,14 +235,50 @@ export function makeFullPermissions(): RolePermissions {
export function makeViewerPermissions(): RolePermissions {
return {
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
interests: { view: true, create: false, edit: false, delete: false, change_stage: false, generate_eoi: false, export: false },
interests: {
view: true,
create: false,
edit: false,
delete: false,
change_stage: false,
generate_eoi: false,
export: false,
},
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
documents: { view: true, create: false, send_for_signing: false, upload_signed: false, delete: false },
expenses: { view: true, create: false, edit: false, delete: false, export: false, scan_receipt: false },
invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false },
documents: {
view: true,
create: false,
send_for_signing: false,
upload_signed: false,
delete: false,
},
expenses: {
view: true,
create: false,
edit: false,
delete: false,
export: false,
scan_receipt: false,
},
invoices: {
view: true,
create: false,
edit: false,
delete: false,
send: false,
record_payment: false,
export: false,
},
files: { view: true, upload: false, delete: false, manage_folders: false },
email: { view: true, send: false, configure_account: false },
reminders: { view_own: true, view_all: false, create: false, edit_own: false, edit_all: false, assign_others: false },
reminders: {
view_own: true,
view_all: false,
create: false,
edit_own: false,
edit_all: false,
assign_others: false,
},
calendar: { connect: false, view_events: true },
reports: { view_dashboard: true, view_analytics: false, export: false },
document_templates: { view: true, generate: false, manage: false },
@@ -227,14 +300,50 @@ export function makeViewerPermissions(): RolePermissions {
export function makeSalesAgentPermissions(): RolePermissions {
return {
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false },
interests: { view: true, create: true, edit: true, delete: false, change_stage: true, generate_eoi: true, export: false },
interests: {
view: true,
create: true,
edit: true,
delete: false,
change_stage: true,
generate_eoi: true,
export: false,
},
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: false },
expenses: { view: true, create: true, edit: true, delete: false, export: false, scan_receipt: true },
invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false },
documents: {
view: true,
create: true,
send_for_signing: true,
upload_signed: true,
delete: false,
},
expenses: {
view: true,
create: true,
edit: true,
delete: false,
export: false,
scan_receipt: true,
},
invoices: {
view: true,
create: false,
edit: false,
delete: false,
send: false,
record_payment: false,
export: false,
},
files: { view: true, upload: true, delete: false, manage_folders: false },
email: { view: true, send: true, configure_account: false },
reminders: { view_own: true, view_all: false, create: true, edit_own: true, edit_all: false, assign_others: false },
reminders: {
view_own: true,
view_all: false,
create: true,
edit_own: true,
edit_all: false,
assign_others: false,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: false, export: false },
document_templates: { view: true, generate: true, manage: false },
@@ -256,14 +365,50 @@ export function makeSalesAgentPermissions(): RolePermissions {
export function makeSalesManagerPermissions(): RolePermissions {
return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true },
interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
generate_eoi: true,
export: true,
},
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true },
expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true },
invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: true },
documents: {
view: true,
create: true,
send_for_signing: true,
upload_signed: true,
delete: true,
},
expenses: {
view: true,
create: true,
edit: true,
delete: true,
export: true,
scan_receipt: true,
},
invoices: {
view: true,
create: true,
edit: true,
delete: false,
send: true,
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: false },
reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true },
reminders: {
view_own: true,
view_all: true,
create: true,
edit_own: true,
edit_all: true,
assign_others: true,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: true, export: true },
document_templates: { view: true, generate: true, manage: false },
@@ -306,7 +451,15 @@ export function makeCreateClientInput(overrides?: { fullName?: string; portId?:
/** Returns a minimal valid CreateInterestInput object. */
export function makeCreateInterestInput(overrides?: {
clientId?: string;
pipelineStage?: 'open' | 'details_sent' | 'in_communication' | 'visited' | 'signed_eoi_nda' | 'deposit_10pct' | 'contract' | 'completed';
pipelineStage?:
| 'open'
| 'details_sent'
| 'in_communication'
| 'visited'
| 'signed_eoi_nda'
| 'deposit_10pct'
| 'contract'
| 'completed';
}) {
return {
clientId: overrides?.clientId ?? crypto.randomUUID(),