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:
@@ -1,114 +1,115 @@
|
|||||||
/**
|
/**
|
||||||
* Test factory helpers.
|
* 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 ──────────────────────────────────────────────────────────────────
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ClientData {
|
export async function makeClient(args: {
|
||||||
id: string;
|
|
||||||
portId: string;
|
portId: string;
|
||||||
fullName: string;
|
overrides?: Partial<NewClient>;
|
||||||
companyName: string | null;
|
}): Promise<Client> {
|
||||||
nationality: string | null;
|
const [client] = await db
|
||||||
isProxy: boolean;
|
.insert(clients)
|
||||||
source: string | null;
|
.values({
|
||||||
yachtLengthFt: string | null;
|
portId: args.portId,
|
||||||
yachtLengthM: string | null;
|
fullName: args.overrides?.fullName ?? `Test Client ${Math.random().toString(36).slice(2, 8)}`,
|
||||||
archivedAt: Date | null;
|
...args.overrides,
|
||||||
createdAt: Date;
|
})
|
||||||
updatedAt: Date;
|
.returning();
|
||||||
}
|
return client!;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Berth ────────────────────────────────────────────────────────────────────
|
// ─── Berth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface BerthData {
|
export async function makeBerth(args: {
|
||||||
id: string;
|
|
||||||
portId: string;
|
portId: string;
|
||||||
mooringNumber: string;
|
overrides?: Partial<NewBerth>;
|
||||||
status: string;
|
}): Promise<Berth> {
|
||||||
area: string | null;
|
const [berth] = await db
|
||||||
lengthM: string | null;
|
.insert(berths)
|
||||||
price: string | null;
|
.values({
|
||||||
tenureType: string | null;
|
portId: args.portId,
|
||||||
archivedAt: Date | null;
|
mooringNumber: args.overrides?.mooringNumber ?? `B-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
createdAt: Date;
|
...args.overrides,
|
||||||
updatedAt: Date;
|
})
|
||||||
|
.returning();
|
||||||
|
return berth!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeBerth(overrides?: Partial<BerthData>): BerthData {
|
// ─── Yacht ───────────────────────────────────────────────────────────────────
|
||||||
return {
|
|
||||||
id: crypto.randomUUID(),
|
export async function makeYacht(args: {
|
||||||
portId: crypto.randomUUID(),
|
portId: string;
|
||||||
mooringNumber: `B-${Math.floor(Math.random() * 999) + 1}`,
|
ownerType: 'client' | 'company';
|
||||||
status: 'available',
|
ownerId: string;
|
||||||
area: null,
|
overrides?: Partial<NewYacht>;
|
||||||
lengthM: '12',
|
}): Promise<Yacht> {
|
||||||
price: '50000',
|
const [yacht] = await db
|
||||||
tenureType: 'freehold',
|
.insert(yachts)
|
||||||
archivedAt: null,
|
.values({
|
||||||
createdAt: new Date(),
|
portId: args.portId,
|
||||||
updatedAt: new Date(),
|
name: args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`,
|
||||||
...overrides,
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Webhook ──────────────────────────────────────────────────────────────────
|
||||||
@@ -169,14 +170,50 @@ import type { RolePermissions } from '@/lib/db/schema/users';
|
|||||||
export function makeFullPermissions(): RolePermissions {
|
export function makeFullPermissions(): RolePermissions {
|
||||||
return {
|
return {
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
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 },
|
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 },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: 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 },
|
files: { view: true, upload: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: 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 },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||||
document_templates: { view: true, generate: true, manage: true },
|
document_templates: { view: true, generate: true, manage: true },
|
||||||
@@ -198,14 +235,50 @@ export function makeFullPermissions(): RolePermissions {
|
|||||||
export function makeViewerPermissions(): RolePermissions {
|
export function makeViewerPermissions(): RolePermissions {
|
||||||
return {
|
return {
|
||||||
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
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 },
|
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 },
|
documents: {
|
||||||
expenses: { view: true, create: false, edit: false, delete: false, export: false, scan_receipt: false },
|
view: true,
|
||||||
invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false },
|
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 },
|
files: { view: true, upload: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: false, configure_account: 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 },
|
calendar: { connect: false, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: false, export: false },
|
reports: { view_dashboard: true, view_analytics: false, export: false },
|
||||||
document_templates: { view: true, generate: false, manage: false },
|
document_templates: { view: true, generate: false, manage: false },
|
||||||
@@ -227,14 +300,50 @@ export function makeViewerPermissions(): RolePermissions {
|
|||||||
export function makeSalesAgentPermissions(): RolePermissions {
|
export function makeSalesAgentPermissions(): RolePermissions {
|
||||||
return {
|
return {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false },
|
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 },
|
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 },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: false, export: false, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false },
|
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 },
|
files: { view: true, upload: true, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: true, configure_account: 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 },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: false, export: false },
|
reports: { view_dashboard: true, view_analytics: false, export: false },
|
||||||
document_templates: { view: true, generate: true, manage: false },
|
document_templates: { view: true, generate: true, manage: false },
|
||||||
@@ -256,14 +365,50 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
|||||||
export function makeSalesManagerPermissions(): RolePermissions {
|
export function makeSalesManagerPermissions(): RolePermissions {
|
||||||
return {
|
return {
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
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 },
|
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 },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: 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 },
|
files: { view: true, upload: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: false },
|
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 },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||||
document_templates: { view: true, generate: true, manage: false },
|
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. */
|
/** Returns a minimal valid CreateInterestInput object. */
|
||||||
export function makeCreateInterestInput(overrides?: {
|
export function makeCreateInterestInput(overrides?: {
|
||||||
clientId?: string;
|
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 {
|
return {
|
||||||
clientId: overrides?.clientId ?? crypto.randomUUID(),
|
clientId: overrides?.clientId ?? crypto.randomUUID(),
|
||||||
|
|||||||
169
tests/integration/schema-constraints.test.ts
Normal file
169
tests/integration/schema-constraints.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Schema constraint integration tests.
|
||||||
|
*
|
||||||
|
* Verifies DB-level enforcement of:
|
||||||
|
* - Partial unique index idx_yoh_active (one active ownership row per yacht)
|
||||||
|
* - Partial unique index idx_br_active (one active reservation per berth)
|
||||||
|
* - Non-active reservations on the same berth are permitted
|
||||||
|
* - Case-insensitive company name uniqueness within a port
|
||||||
|
* - Same company name allowed across different ports
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
||||||
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||||
|
import { companies } from '@/lib/db/schema/companies';
|
||||||
|
import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories';
|
||||||
|
|
||||||
|
// ─── DB availability ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dbAvailable = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
await db.execute(`SELECT 1`);
|
||||||
|
dbAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[schema-constraints] DATABASE_URL not reachable — skipping integration tests',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function itDb(name: string, fn: () => Promise<void>) {
|
||||||
|
it(name, async () => {
|
||||||
|
if (!dbAvailable) return;
|
||||||
|
await fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('schema constraints', () => {
|
||||||
|
itDb(
|
||||||
|
'rejects a second active ownership row per yacht (partial unique idx_yoh_active)',
|
||||||
|
async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const clientA = await makeClient({ portId: port.id });
|
||||||
|
const clientB = await makeClient({ portId: port.id });
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: clientA.id,
|
||||||
|
});
|
||||||
|
// makeYacht already inserted one active (end_date IS NULL) ownership row.
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.insert(yachtOwnershipHistory).values({
|
||||||
|
yachtId: yacht.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: clientB.id,
|
||||||
|
startDate: new Date(),
|
||||||
|
endDate: null, // another open row — should violate partial unique
|
||||||
|
createdBy: 'test',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/duplicate key|unique/i);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
itDb('rejects a second active reservation per berth (partial unique idx_br_active)', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const clientA = await makeClient({ portId: port.id });
|
||||||
|
const clientB = await makeClient({ portId: port.id });
|
||||||
|
const yachtA = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: clientA.id,
|
||||||
|
});
|
||||||
|
const yachtB = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: clientB.id,
|
||||||
|
});
|
||||||
|
const berth = await makeBerth({ portId: port.id });
|
||||||
|
|
||||||
|
await db.insert(berthReservations).values({
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: clientA.id,
|
||||||
|
yachtId: yachtA.id,
|
||||||
|
status: 'active',
|
||||||
|
startDate: new Date(),
|
||||||
|
createdBy: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.insert(berthReservations).values({
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: clientB.id,
|
||||||
|
yachtId: yachtB.id,
|
||||||
|
status: 'active',
|
||||||
|
startDate: new Date(),
|
||||||
|
createdBy: 'test',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/duplicate key|unique/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
itDb(
|
||||||
|
'allows multiple non-active reservations on the same berth (partial index ignores non-active)',
|
||||||
|
async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const clientA = await makeClient({ portId: port.id });
|
||||||
|
const yachtA = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: clientA.id,
|
||||||
|
});
|
||||||
|
const berth = await makeBerth({ portId: port.id });
|
||||||
|
|
||||||
|
// Two ended reservations on same berth — both should succeed
|
||||||
|
// (partial index only constrains status='active').
|
||||||
|
await expect(
|
||||||
|
db.insert(berthReservations).values([
|
||||||
|
{
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: clientA.id,
|
||||||
|
yachtId: yachtA.id,
|
||||||
|
status: 'ended',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2024-06-30'),
|
||||||
|
createdBy: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: clientA.id,
|
||||||
|
yachtId: yachtA.id,
|
||||||
|
status: 'ended',
|
||||||
|
startDate: new Date('2024-07-01'),
|
||||||
|
endDate: new Date('2024-12-31'),
|
||||||
|
createdBy: 'test',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
itDb('enforces case-insensitive company name uniqueness per port', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await makeCompany({ portId: port.id, overrides: { name: 'Aegean Holdings' } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.insert(companies).values({ portId: port.id, name: 'AEGEAN HOLDINGS' }),
|
||||||
|
).rejects.toThrow(/duplicate key|unique/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
itDb('allows same-name companies in different ports', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
await makeCompany({ portId: portA.id, overrides: { name: 'Aegean Holdings' } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.insert(companies).values({ portId: portB.id, name: 'Aegean Holdings' }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user