fix(tenancies-audit): resolve findings from 7-agent system-wide rename audit

MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
  PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
  Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
  silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
  emitted `entityType: 'reservation'`. Every other tenancy-related
  audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
  + activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
  navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
  03-tenancies.spec.ts; tab + button locators updated to match renamed UI.

SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
  caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
  + snapshot.reservations + local loserReservations / movedReservations
  renamed to tenancies / loserTenancies / movedTenancies. No external
  consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
  renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
  local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
  bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
  admin/import/page, won-status-panel — all "reservations" prose updated
  to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
  `createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
  → makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
  /berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
  ("tenancies.service.ts" → "berth-tenancies.service.ts").

Verified: tsc clean, 1493/1493 vitest.

Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:03:14 +02:00
parent e9ef5831aa
commit dd25ccfb53
19 changed files with 71 additions and 75 deletions

View File

@@ -28,7 +28,6 @@ Scanned 182 route files under `src/app/api/v1/`.
| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. | | `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. | | `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. | | `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. | | `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. | | `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. |
| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. | | `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |

View File

@@ -237,7 +237,7 @@ The `tenancies_module` integration check resolves to `tenancies_module_enabled =
## Service layer additions ## Service layer additions
`src/lib/services/tenancies.service.ts` (renamed from `berth-reservations.service.ts`): `src/lib/services/berth-tenancies.service.ts` (renamed from `berth-reservations.service.ts`):
- `listTenancies({ portId, filters, page })` — gated read. - `listTenancies({ portId, filters, page })` — gated read.
- `createTenancy(portId, data, meta)` — mints a row; also triggers the module-enable flip on first insert. - `createTenancy(portId, data, meta)` — mints a row; also triggers the module-enable flip on first insert.

View File

@@ -73,10 +73,6 @@ const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
pattern: /\/custom-fields\/\[entityId\]\//, pattern: /\/custom-fields\/\[entityId\]\//,
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.', reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
}, },
{
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
},
]; ];
interface Finding { interface Finding {

View File

@@ -62,7 +62,7 @@ export default function DataImportPage() {
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li> <li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li> <li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
<li>Per-port import history with rollback.</li> <li>Per-port import history with rollback.</li>
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li> <li>Templates for clients, yachts, companies, berths, tenancies, expenses.</li>
</ul> </ul>
<p className="text-xs text-muted-foreground pt-2"> <p className="text-xs text-muted-foreground pt-2">
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial

View File

@@ -67,7 +67,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']), yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']),
companies: new Set(['view', 'create', 'edit', 'delete']), companies: new Set(['view', 'create', 'edit', 'delete']),
memberships: new Set(['view', 'manage']), memberships: new Set(['view', 'manage']),
reservations: new Set(['view', 'create', 'activate', 'cancel']), tenancies: new Set(['view', 'manage', 'cancel']),
admin: new Set([ admin: new Set([
'manage_users', 'manage_users',
'view_audit_log', 'view_audit_log',

View File

@@ -189,7 +189,7 @@ const GROUPS: AdminGroup[] = [
{ {
href: 'custom-fields', href: 'custom-fields',
label: 'Custom Fields', label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.', description: 'Tenant-defined fields for clients, yachts, and tenancies.',
icon: SlidersHorizontal, icon: SlidersHorizontal,
}, },
{ {
@@ -261,7 +261,7 @@ const GROUPS: AdminGroup[] = [
{ {
href: 'import', href: 'import',
label: 'Bulk Import', label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.', description: 'CSV-driven imports for clients, yachts, and tenancies.',
icon: FileUp, icon: FileUp,
}, },
{ {

View File

@@ -170,7 +170,7 @@ function BulkArchiveWizardBody({ open, onOpenChange, clientIds, onSuccess }: Pro
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"> <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel
reservations, leave invoices/signing requests alone. Yachts stay on the archived tenancies, leave invoices/signing requests alone. Yachts stay on the archived
client. To customise per-client, archive that client individually instead. client. To customise per-client, archive that client individually instead.
</div> </div>
</div> </div>

View File

@@ -128,8 +128,8 @@ function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props)
</p> </p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs"> <div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs">
For each client we delete: client record + addresses, contacts, notes, tags, portal For each client we delete: client record + addresses, contacts, notes, tags, portal
user, GDPR records, all interests, all reservations. Signed documents, email threads, user, GDPR records, all interests, all tenancies. Signed documents, email threads, files
files and reminders are detached but kept. and reminders are detached but kept.
</div> </div>
</div> </div>
)} )}

View File

@@ -114,6 +114,7 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
'yacht:ownership_transferred': [['clients', clientId]], 'yacht:ownership_transferred': [['clients', clientId]],
'company_membership:added': [['clients', clientId]], 'company_membership:added': [['clients', clientId]],
'company_membership:ended': [['clients', clientId]], 'company_membership:ended': [['clients', clientId]],
'berth_tenancy:created': [['clients', clientId]],
'berth_tenancy:activated': [['clients', clientId]], 'berth_tenancy:activated': [['clients', clientId]],
'berth_tenancy:ended': [['clients', clientId]], 'berth_tenancy:ended': [['clients', clientId]],
'berth_tenancy:cancelled': [['clients', clientId]], 'berth_tenancy:cancelled': [['clients', clientId]],

View File

@@ -120,7 +120,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
<DialogTitle>Personal data export</DialogTitle> <DialogTitle>Personal data export</DialogTitle>
<DialogDescription> <DialogDescription>
Bundles every record we hold about this client (profile, contacts, addresses, yachts, Bundles every record we hold about this client (profile, contacts, addresses, yachts,
companies, interests, reservations, invoices, documents, audit log) into a ZIP with JSON companies, interests, tenancies, invoices, documents, audit log) into a ZIP with JSON
and HTML copies. Used to satisfy GDPR Article 15 access requests. and HTML copies. Used to satisfy GDPR Article 15 access requests.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -114,7 +114,7 @@ function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }:
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5"> <ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>Client record + addresses, contacts, notes, tags</li> <li>Client record + addresses, contacts, notes, tags</li>
<li>Portal user account + GDPR consent records</li> <li>Portal user account + GDPR consent records</li>
<li>All pipeline interests + reservations for this client</li> <li>All pipeline interests + tenancies for this client</li>
</ul> </ul>
<p className="font-medium mt-2">What is preserved</p> <p className="font-medium mt-2">What is preserved</p>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5"> <ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">

View File

@@ -100,9 +100,9 @@ export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
Won - wrap-up checklist Won - wrap-up checklist
</CardTitle> </CardTitle>
<p className="text-xs text-emerald-800/80"> <p className="text-xs text-emerald-800/80">
Upload anything that didn&apos;t flow through the system automatically. Reservations, Upload anything that didn&apos;t flow through the system automatically. Tenancies, deposit
deposit invoicing, and client billing are handled outside the CRM - this checklist is for invoicing, and client billing are handled outside the CRM - this checklist is for the
the paperwork that lives on the deal itself. paperwork that lives on the deal itself.
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">

View File

@@ -65,7 +65,7 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
title: `Reservation needs an agreement`, title: `Reservation needs an agreement`,
body: `Active reservation for ${r.yachtName} (${r.clientName}) has no signed agreement yet.`, body: `Active reservation for ${r.yachtName} (${r.clientName}) has no signed agreement yet.`,
link: `/[port]/tenancies/${r.id}`, link: `/[port]/tenancies/${r.id}`,
entityType: 'reservation', entityType: 'berth_tenancy',
entityId: r.id, entityId: r.id,
})); }));
} }

View File

@@ -80,7 +80,7 @@ export interface MergeResult {
notes: number; notes: number;
tags: number; tags: number;
relationships: number; relationships: number;
reservations: number; tenancies: number;
}; };
} }
@@ -152,7 +152,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
.select({ id: interests.id }) .select({ id: interests.id })
.from(interests) .from(interests)
.where(eq(interests.clientId, opts.loserId)); .where(eq(interests.clientId, opts.loserId));
const loserReservations = await tx const loserTenancies = await tx
.select({ id: berthTenancies.id }) .select({ id: berthTenancies.id })
.from(berthTenancies) .from(berthTenancies)
.where(eq(berthTenancies.clientId, opts.loserId)); .where(eq(berthTenancies.clientId, opts.loserId));
@@ -172,7 +172,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
notes: loserNotes, notes: loserNotes,
tags: loserTags, tags: loserTags,
interests: loserInterests.map((r) => r.id), interests: loserInterests.map((r) => r.id),
reservations: loserReservations.map((r) => r.id), tenancies: loserTenancies.map((r) => r.id),
relationshipsAsA: loserRelationshipsAsA, relationshipsAsA: loserRelationshipsAsA,
relationshipsAsB: loserRelationshipsAsB, relationshipsAsB: loserRelationshipsAsB,
fieldChoices: opts.fieldChoices ?? {}, fieldChoices: opts.fieldChoices ?? {},
@@ -213,7 +213,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
.returning({ id: interests.id }) .returning({ id: interests.id })
).length; ).length;
const movedReservations = ( const movedTenancies = (
await tx await tx
.update(berthTenancies) .update(berthTenancies)
.set({ clientId: opts.winnerId, updatedAt: new Date() }) .set({ clientId: opts.winnerId, updatedAt: new Date() })
@@ -357,7 +357,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
loserId: opts.loserId, loserId: opts.loserId,
loserName: loserRow.fullName, loserName: loserRow.fullName,
movedInterests, movedInterests,
movedReservations, movedTenancies,
movedContacts, movedContacts,
movedAddresses, movedAddresses,
}, },
@@ -372,7 +372,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
notes: movedNotes, notes: movedNotes,
tags: movedTags, tags: movedTags,
relationships: movedRelationships, relationships: movedRelationships,
reservations: movedReservations, tenancies: movedTenancies,
}, },
}; };
}); });

View File

@@ -57,7 +57,7 @@ export interface GdprBundle {
}>; }>;
interests: Record<string, unknown>[]; interests: Record<string, unknown>[];
contactLog: Record<string, unknown>[]; contactLog: Record<string, unknown>[];
reservations: Record<string, unknown>[]; tenancies: Record<string, unknown>[];
invoices: Record<string, unknown>[]; invoices: Record<string, unknown>[];
documents: Record<string, unknown>[]; documents: Record<string, unknown>[];
files: Record<string, unknown>[]; files: Record<string, unknown>[];
@@ -95,7 +95,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
ownedYachts, ownedYachts,
membershipRows, membershipRows,
interestRows, interestRows,
reservationRows, tenancyRows,
invoiceRows, invoiceRows,
documentRows, documentRows,
fileRows, fileRows,
@@ -268,7 +268,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
})), })),
interests: interestRows.map(toJsonRow), interests: interestRows.map(toJsonRow),
contactLog: contactLogRows.map(toJsonRow), contactLog: contactLogRows.map(toJsonRow),
reservations: reservationRows.map(toJsonRow), tenancies: tenancyRows.map(toJsonRow),
invoices: invoiceRows.map(toJsonRow), invoices: invoiceRows.map(toJsonRow),
documents: documentRows.map(toJsonRow), documents: documentRows.map(toJsonRow),
files: fileRows.map(toJsonRow), files: fileRows.map(toJsonRow),
@@ -361,7 +361,7 @@ export function renderBundleHtml(bundle: GdprBundle): string {
), ),
tableSection('Interests', bundle.interests), tableSection('Interests', bundle.interests),
tableSection('Contact log', bundle.contactLog), tableSection('Contact log', bundle.contactLog),
tableSection('Reservations', bundle.reservations), tableSection('Tenancies', bundle.tenancies),
tableSection('Invoices', bundle.invoices), tableSection('Invoices', bundle.invoices),
tableSection('Documents', bundle.documents), tableSection('Documents', bundle.documents),
tableSection('Files', bundle.files), tableSection('Files', bundle.files),

View File

@@ -3,12 +3,12 @@ import { test, expect } from '@playwright/test';
import { clickEverythingOnPage } from '../../helpers/click-everything'; import { clickEverythingOnPage } from '../../helpers/click-everything';
import { login, navigateTo, PORT_SLUG } from '../smoke/helpers'; import { login, navigateTo, PORT_SLUG } from '../smoke/helpers';
test.describe('exhaustive: berth reservations', () => { test.describe('exhaustive: berth tenancies', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page, 'super_admin'); await login(page, 'super_admin');
}); });
test('berths list - reservation-related affordances are clickable', async ({ page }) => { test('berths list - tenancy-related affordances are clickable', async ({ page }) => {
await navigateTo(page, '/berths'); await navigateTo(page, '/berths');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
@@ -16,7 +16,7 @@ test.describe('exhaustive: berth reservations', () => {
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
}); });
test('berth detail - reservations tab opens and is interactive', async ({ page }) => { test('berth detail - tenancies tab opens and is interactive', async ({ page }) => {
await navigateTo(page, '/berths'); await navigateTo(page, '/berths');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
@@ -29,9 +29,9 @@ test.describe('exhaustive: berth reservations', () => {
await page.waitForURL(new RegExp(`/${PORT_SLUG}/berths/[^/]+`), { timeout: 10_000 }); await page.waitForURL(new RegExp(`/${PORT_SLUG}/berths/[^/]+`), { timeout: 10_000 });
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const reservationsTab = page.getByRole('tab', { name: /reservations/i }).first(); const tenanciesTab = page.getByRole('tab', { name: /tenancies/i }).first();
if (await reservationsTab.isVisible({ timeout: 3000 }).catch(() => false)) { if (await tenanciesTab.isVisible({ timeout: 3000 }).catch(() => false)) {
await reservationsTab.click(); await tenanciesTab.click();
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
} }
@@ -39,7 +39,7 @@ test.describe('exhaustive: berth reservations', () => {
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]); expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
}); });
test('reserve dialog opens and closes', async ({ page }) => { test('create-tenancy dialog opens and closes', async ({ page }) => {
await navigateTo(page, '/berths'); await navigateTo(page, '/berths');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const firstRow = page.locator('tbody tr a, tbody tr button').first(); const firstRow = page.locator('tbody tr a, tbody tr button').first();
@@ -50,9 +50,9 @@ test.describe('exhaustive: berth reservations', () => {
await firstRow.click(); await firstRow.click();
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const reserveBtn = page.getByRole('button', { name: /reserve|new reservation/i }).first(); const createBtn = page.getByRole('button', { name: /create tenancy|reserve/i }).first();
if (await reserveBtn.isVisible({ timeout: 3000 }).catch(() => false)) { if (await createBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await reserveBtn.click(); await createBtn.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog).toBeVisible({ timeout: 5000 });
const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first(); const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first();

View File

@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
import { clickEverythingOnPage } from '../../helpers/click-everything'; import { clickEverythingOnPage } from '../../helpers/click-everything';
import { login } from '../smoke/helpers'; import { login } from '../smoke/helpers';
const PORTAL_PAGES = ['/portal/yachts', '/portal/memberships', '/portal/my-reservations']; const PORTAL_PAGES = ['/portal/yachts', '/portal/memberships', '/portal/my-tenancies'];
test.describe('exhaustive: client portal', () => { test.describe('exhaustive: client portal', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {

View File

@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { import {
createHandler as createReservationHandler, createHandler as createTenancyHandler,
listHandler as listReservationsHandler, listHandler as listTenanciesHandler,
} from '@/app/api/v1/berths/[id]/tenancies/handlers'; } from '@/app/api/v1/berths/[id]/tenancies/handlers';
import { import {
getHandler as getReservationHandler, getHandler as getTenancyHandler,
patchHandler as patchReservationHandler, patchHandler as patchTenancyHandler,
deleteHandler as deleteReservationHandler, deleteHandler as deleteTenancyHandler,
} from '@/app/api/v1/tenancies/[id]/handlers'; } from '@/app/api/v1/tenancies/[id]/handlers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { berthTenancies } from '@/lib/db/schema/tenancies'; import { berthTenancies } from '@/lib/db/schema/tenancies';
@@ -53,7 +53,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
startDate: new Date().toISOString(), startDate: new Date().toISOString(),
}, },
}); });
const res = await createReservationHandler(req, ctx, { id: berth.id }); const res = await createTenancyHandler(req, ctx, { id: berth.id });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(body.data.berthId).toBe(berth.id); expect(body.data.berthId).toBe(berth.id);
@@ -81,7 +81,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
startDate: new Date().toISOString(), startDate: new Date().toISOString(),
}, },
}); });
const res = await createReservationHandler(req, ctx, { id: berth.id }); const res = await createTenancyHandler(req, ctx, { id: berth.id });
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
@@ -105,7 +105,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
startDate: new Date().toISOString(), startDate: new Date().toISOString(),
}, },
}); });
const res = await createReservationHandler(req, ctxB, { id: berthA.id }); const res = await createTenancyHandler(req, ctxB, { id: berthA.id });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
@@ -129,7 +129,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
startDate: new Date().toISOString(), startDate: new Date().toISOString(),
}, },
}); });
const res = await createReservationHandler(req, ctx, { id: urlBerth.id }); const res = await createTenancyHandler(req, ctx, { id: urlBerth.id });
expect(res.status).toBe(201); expect(res.status).toBe(201);
const body = (await res.json()) as any; const body = (await res.json()) as any;
expect(body.data.berthId).toBe(urlBerth.id); expect(body.data.berthId).toBe(urlBerth.id);
@@ -153,7 +153,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
// Create a reservation for berthA. // Create a reservation for berthA.
await createReservationHandler( await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/tenancies`, { makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/tenancies`, {
body: { body: {
clientId: client.id, clientId: client.id,
@@ -166,7 +166,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
); );
// Create a reservation for berthB. // Create a reservation for berthB.
await createReservationHandler( await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/tenancies`, { makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/tenancies`, {
body: { body: {
clientId: client.id, clientId: client.id,
@@ -178,7 +178,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
{ id: berthB.id }, { id: berthB.id },
); );
const res = await listReservationsHandler( const res = await listTenanciesHandler(
makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/tenancies`), makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/tenancies`),
ctx, ctx,
{ id: berthA.id }, { id: berthA.id },
@@ -204,7 +204,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
}); });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler( const createRes = await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, { makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: { body: {
clientId: client.id, clientId: client.id,
@@ -217,7 +217,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
); );
const reservation = ((await createRes.json()) as any).data; const reservation = ((await createRes.json()) as any).data;
const res = await getReservationHandler( const res = await getTenancyHandler(
makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`), makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`),
ctx, ctx,
{ id: reservation.id }, { id: reservation.id },
@@ -239,7 +239,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
}); });
const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() }); const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler( const createRes = await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, { makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: { body: {
clientId: client.id, clientId: client.id,
@@ -253,7 +253,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
const reservation = ((await createRes.json()) as any).data; const reservation = ((await createRes.json()) as any).data;
const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
const res = await getReservationHandler( const res = await getTenancyHandler(
makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`), makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`),
ctxB, ctxB,
{ id: reservation.id }, { id: reservation.id },
@@ -276,7 +276,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
}); });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler( const createRes = await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, { makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: { body: {
clientId: client.id, clientId: client.id,
@@ -293,7 +293,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
it('activate: pending → active (200)', async () => { it('activate: pending → active (200)', async () => {
const { ctx, reservation } = await seedReservation(); const { ctx, reservation } = await seedReservation();
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' }, body: { action: 'activate' },
}), }),
@@ -309,7 +309,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
const { ctx, reservation } = await seedReservation(); const { ctx, reservation } = await seedReservation();
// First activate. // First activate.
await patchReservationHandler( await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' }, body: { action: 'activate' },
}), }),
@@ -319,7 +319,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
// Then end. // Then end.
const endDate = new Date('2027-01-01T00:00:00.000Z'); const endDate = new Date('2027-01-01T00:00:00.000Z');
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { body: {
action: 'end', action: 'end',
@@ -338,7 +338,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
it('cancel: pending → cancelled (200)', async () => { it('cancel: pending → cancelled (200)', async () => {
const { ctx, reservation } = await seedReservation(); const { ctx, reservation } = await seedReservation();
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'cancel', reason: 'client changed mind' }, body: { action: 'cancel', reason: 'client changed mind' },
}), }),
@@ -354,7 +354,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
const { ctx, reservation } = await seedReservation(); const { ctx, reservation } = await seedReservation();
// pending → active. // pending → active.
await patchReservationHandler( await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' }, body: { action: 'activate' },
}), }),
@@ -362,7 +362,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
{ id: reservation.id }, { id: reservation.id },
); );
// active → ended. // active → ended.
await patchReservationHandler( await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { body: {
action: 'end', action: 'end',
@@ -374,7 +374,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
); );
// ended → activate should fail. // ended → activate should fail.
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' }, body: { action: 'activate' },
}), }),
@@ -386,7 +386,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
it('returns 400 on invalid body shape (action missing)', async () => { it('returns 400 on invalid body shape (action missing)', async () => {
const { ctx, reservation } = await seedReservation(); const { ctx, reservation } = await seedReservation();
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { notes: 'noop' }, body: { notes: 'noop' },
}), }),
@@ -406,7 +406,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
tenancies: { view: true, manage: false, cancel: true }, tenancies: { view: true, manage: false, cancel: true },
}, },
}); });
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' }, body: { action: 'activate' },
}), }),
@@ -423,7 +423,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
portId: port.id, portId: port.id,
permissions: makeSalesAgentPermissions(), permissions: makeSalesAgentPermissions(),
}); });
const res = await patchReservationHandler( const res = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'cancel', reason: 'test' }, body: { action: 'cancel', reason: 'test' },
}), }),
@@ -433,7 +433,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
expect(res.status).toBe(403); expect(res.status).toBe(403);
// But activate succeeds with the same permissions set. // But activate succeeds with the same permissions set.
const activateRes = await patchReservationHandler( const activateRes = await patchTenancyHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, { makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' }, body: { action: 'activate' },
}), }),
@@ -458,7 +458,7 @@ describe('DELETE /api/v1/tenancies/[id]', () => {
}); });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler( const createRes = await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, { makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: { body: {
clientId: client.id, clientId: client.id,
@@ -471,7 +471,7 @@ describe('DELETE /api/v1/tenancies/[id]', () => {
); );
const reservation = ((await createRes.json()) as any).data; const reservation = ((await createRes.json()) as any).data;
const delRes = await deleteReservationHandler( const delRes = await deleteTenancyHandler(
makeMockRequest('DELETE', `http://localhost/api/v1/tenancies/${reservation.id}`), makeMockRequest('DELETE', `http://localhost/api/v1/tenancies/${reservation.id}`),
ctx, ctx,
{ id: reservation.id }, { id: reservation.id },

View File

@@ -331,7 +331,7 @@ describe('berth-tenancies.service - lifecycle transitions', () => {
// ─── listTenancies ──────────────────────────────────────────────────────── // ─── listTenancies ────────────────────────────────────────────────────────
describe('berth-tenancies.service - listTenancies', () => { describe('berth-tenancies.service - listTenancies', () => {
async function makeReservation(portId: string, opts?: { berthId?: string }) { async function makeTenancyLocal(portId: string, opts?: { berthId?: string }) {
const berth = opts?.berthId ? { id: opts.berthId } : await makeBerth({ portId }); const berth = opts?.berthId ? { id: opts.berthId } : await makeBerth({ portId });
const client = await makeClient({ portId }); const client = await makeClient({ portId });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id }); const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
@@ -351,8 +351,8 @@ describe('berth-tenancies.service - listTenancies', () => {
it('is tenant-scoped', async () => { it('is tenant-scoped', async () => {
const portA = await makePort(); const portA = await makePort();
const portB = await makePort(); const portB = await makePort();
const resA = await makeReservation(portA.id); const resA = await makeTenancyLocal(portA.id);
await makeReservation(portB.id); await makeTenancyLocal(portB.id);
const result = await listTenancies(portA.id, { const result = await listTenancies(portA.id, {
page: 1, page: 1,
@@ -367,8 +367,8 @@ describe('berth-tenancies.service - listTenancies', () => {
it('filters by status', async () => { it('filters by status', async () => {
const port = await makePort(); const port = await makePort();
const resPending = await makeReservation(port.id); const resPending = await makeTenancyLocal(port.id);
const resActive = await makeReservation(port.id); const resActive = await makeTenancyLocal(port.id);
await activate(resActive.id, port.id, {}, makeAuditMeta({ portId: port.id })); await activate(resActive.id, port.id, {}, makeAuditMeta({ portId: port.id }));
const activeList = await listTenancies(port.id, { const activeList = await listTenancies(port.id, {