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:
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