Files
pn-new-crm/tests/helpers/factories.ts

652 lines
20 KiB
TypeScript
Raw Normal View History

/**
* Test factory helpers.
*
* 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 { and, eq, sql } from 'drizzle-orm';
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,
companyMemberships,
type NewCompany,
type Company,
} from '@/lib/db/schema/companies';
import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations';
// ─── 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 async function makeClient(args: {
portId: string;
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 async function makeBerth(args: {
portId: string;
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!;
}
// ─── Yacht ───────────────────────────────────────────────────────────────────
export async function makeYacht(args: {
portId: string;
ownerType: 'client' | 'company';
ownerId: string;
name?: string;
status?: 'active' | 'retired' | 'sold_away';
hullNumber?: string;
registration?: string;
overrides?: Partial<NewYacht>;
}): Promise<Yacht> {
const [yacht] = await db
.insert(yachts)
.values({
portId: args.portId,
name: args.name ?? args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`,
currentOwnerType: args.ownerType,
currentOwnerId: args.ownerId,
...(args.status !== undefined ? { status: args.status } : {}),
...(args.hullNumber !== undefined ? { hullNumber: args.hullNumber } : {}),
...(args.registration !== undefined ? { registration: args.registration } : {}),
...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!;
}
// ─── Company Membership ──────────────────────────────────────────────────────
export async function makeMembership(args: {
companyId: string;
clientId: string;
role?: string;
roleDetail?: string;
startDate?: Date;
endDate?: Date | null;
isPrimary?: boolean;
notes?: string;
}) {
const [row] = await db
.insert(companyMemberships)
.values({
companyId: args.companyId,
clientId: args.clientId,
role: args.role ?? 'director',
roleDetail: args.roleDetail ?? null,
startDate: args.startDate ?? new Date(),
endDate: args.endDate ?? null,
isPrimary: args.isPrimary ?? false,
notes: args.notes ?? null,
})
.returning();
if (!row) throw new Error('Failed to create membership');
return row;
}
// ─── Berth Reservation ───────────────────────────────────────────────────────
export async function makeReservation(args: {
berthId: string;
portId: string;
clientId: string;
yachtId: string;
status: 'pending' | 'active' | 'ended' | 'cancelled';
startDate?: Date;
endDate?: Date | null;
tenureType?: 'permanent' | 'fixed_term' | 'seasonal';
interestId?: string;
createdBy?: string;
notes?: string;
}): Promise<BerthReservation> {
const [row] = await db
.insert(berthReservations)
.values({
berthId: args.berthId,
portId: args.portId,
clientId: args.clientId,
yachtId: args.yachtId,
interestId: args.interestId ?? null,
status: args.status,
startDate: args.startDate ?? new Date(),
endDate: args.endDate ?? null,
tenureType: args.tenureType ?? 'permanent',
contractFileId: null,
notes: args.notes ?? null,
createdBy: args.createdBy ?? 'seed-user',
})
.returning();
if (!row) throw new Error('Failed to create reservation');
return row;
}
// ─── Yacht Ownership Transfer ────────────────────────────────────────────────
export async function makeOwnershipTransfer(args: {
yachtId: string;
newOwner: { type: 'client' | 'company'; id: string };
effectiveDate?: Date;
transferReason?: string;
transferNotes?: string;
createdBy?: string;
}) {
const effective = args.effectiveDate ?? new Date();
const createdBy = args.createdBy ?? 'seed-user';
return await db.transaction(async (tx) => {
// Close current open row
await tx
.update(yachtOwnershipHistory)
.set({ endDate: effective })
.where(
and(
eq(yachtOwnershipHistory.yachtId, args.yachtId),
sql`${yachtOwnershipHistory.endDate} IS NULL`,
),
);
// Insert new open row
const [newHistory] = await tx
.insert(yachtOwnershipHistory)
.values({
yachtId: args.yachtId,
ownerType: args.newOwner.type,
ownerId: args.newOwner.id,
startDate: effective,
endDate: null,
transferReason: args.transferReason ?? null,
transferNotes: args.transferNotes ?? null,
createdBy,
})
.returning();
// Update yacht's denormalized current owner
const [updated] = await tx
.update(yachts)
.set({
currentOwnerType: args.newOwner.type,
currentOwnerId: args.newOwner.id,
updatedAt: new Date(),
})
.where(eq(yachts.id, args.yachtId))
.returning();
return { history: newHistory!, yacht: updated! };
});
}
// ─── Webhook ──────────────────────────────────────────────────────────────────
export interface WebhookData {
id: string;
portId: string;
name: string;
url: string;
secret: string | null;
events: string[];
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export function makeWebhook(overrides?: Partial<WebhookData>): WebhookData {
return {
id: crypto.randomUUID(),
portId: crypto.randomUUID(),
name: 'Test Webhook',
url: 'https://example.com/webhook',
secret: null,
events: ['client.created'],
isActive: true,
createdBy: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// ─── Audit Log ────────────────────────────────────────────────────────────────
export interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
export function makeAuditMeta(overrides?: Partial<AuditMeta>): AuditMeta {
return {
userId: crypto.randomUUID(),
portId: crypto.randomUUID(),
ipAddress: '127.0.0.1',
userAgent: 'vitest/1.0',
...overrides,
};
}
// ─── Auth Context ─────────────────────────────────────────────────────────────
import type { RolePermissions } from '@/lib/db/schema/users';
/** Full permissions — every action allowed. */
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,
},
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,
},
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,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: true, export: true },
document_templates: { view: true, generate: true, manage: true },
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
companies: { view: true, create: true, edit: true, delete: true },
memberships: { view: true, manage: true },
reservations: { view: true, create: true, activate: true, cancel: true },
admin: {
manage_users: true,
view_audit_log: true,
manage_settings: true,
manage_webhooks: true,
manage_reports: true,
manage_custom_fields: true,
manage_forms: true,
manage_tags: true,
system_backup: true,
},
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
residential_clients: { view: true, create: true, edit: true, delete: true },
residential_interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
},
};
}
/** Read-only viewer permissions — no create/update/delete. */
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,
},
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,
},
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,
},
calendar: { connect: false, view_events: true },
reports: { view_dashboard: true, view_analytics: false, export: false },
document_templates: { view: true, generate: false, manage: false },
yachts: { view: true, create: false, edit: false, delete: false, transfer: false },
companies: { view: true, create: false, edit: false, delete: false },
memberships: { view: true, manage: false },
reservations: { view: true, create: false, activate: false, cancel: false },
admin: {
manage_users: false,
view_audit_log: false,
manage_settings: false,
manage_webhooks: false,
manage_reports: false,
manage_custom_fields: false,
manage_forms: false,
manage_tags: false,
system_backup: false,
},
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
}
/** Sales agent permissions — own clients/interests, no admin. */
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,
},
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,
},
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,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: false, export: false },
document_templates: { view: true, generate: true, manage: false },
yachts: { view: true, create: true, edit: true, delete: false, transfer: false },
companies: { view: true, create: true, edit: false, delete: false },
memberships: { view: true, manage: false },
reservations: { view: true, create: true, activate: true, cancel: false },
admin: {
manage_users: false,
view_audit_log: false,
manage_settings: false,
manage_webhooks: false,
manage_reports: false,
manage_custom_fields: false,
manage_forms: false,
manage_tags: false,
system_backup: false,
},
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
}
/** Sales manager — can do most things, limited admin. */
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,
},
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,
},
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,
},
calendar: { connect: true, view_events: true },
reports: { view_dashboard: true, view_analytics: true, export: true },
document_templates: { view: true, generate: true, manage: false },
yachts: { view: true, create: true, edit: true, delete: false, transfer: true },
companies: { view: true, create: true, edit: true, delete: false },
memberships: { view: true, manage: true },
reservations: { view: true, create: true, activate: true, cancel: true },
admin: {
manage_users: false,
view_audit_log: true,
manage_settings: false,
manage_webhooks: false,
manage_reports: true,
manage_custom_fields: false,
manage_forms: false,
manage_tags: true,
system_backup: false,
},
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
residential_clients: { view: true, create: true, edit: true, delete: true },
residential_interests: {
view: true,
create: true,
edit: true,
delete: true,
change_stage: true,
},
};
}
/** Director — everything except system backup. */
export function makeDirectorPermissions(): RolePermissions {
return {
...makeFullPermissions(),
admin: {
...makeFullPermissions().admin,
system_backup: false,
},
};
}
// ─── Minimal valid CreateClientInput ─────────────────────────────────────────
/** Returns a minimal valid CreateClientInput object for use in service calls. */
export function makeCreateClientInput(overrides?: { fullName?: string; portId?: string }) {
return {
fullName: overrides?.fullName ?? 'Test Client',
contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }],
tagIds: [] as string[],
};
}
/** Returns a minimal valid CreateInterestInput object. */
export function makeCreateInterestInput(overrides?: {
clientId?: string;
pipelineStage?:
| 'open'
| 'details_sent'
| 'in_communication'
| 'eoi_sent'
| 'eoi_signed'
| 'deposit_10pct'
| 'contract_sent'
| 'contract_signed'
| 'completed';
}) {
return {
clientId: overrides?.clientId ?? crypto.randomUUID(),
pipelineStage: overrides?.pipelineStage ?? ('open' as const),
reminderEnabled: false,
tagIds: [] as string[],
};
}