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:
@@ -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/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/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/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. |
|
||||
|
||||
@@ -237,7 +237,7 @@ The `tenancies_module` integration check resolves to `tenancies_module_enabled =
|
||||
|
||||
## 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.
|
||||
- `createTenancy(portId, data, meta)` — mints a row; also triggers the module-enable flip on first insert.
|
||||
|
||||
@@ -73,10 +73,6 @@ const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
|
||||
pattern: /\/custom-fields\/\[entityId\]\//,
|
||||
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 {
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function DataImportPage() {
|
||||
<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>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>
|
||||
<p className="text-xs text-muted-foreground pt-2">
|
||||
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
|
||||
|
||||
@@ -67,7 +67,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']),
|
||||
companies: new Set(['view', 'create', 'edit', 'delete']),
|
||||
memberships: new Set(['view', 'manage']),
|
||||
reservations: new Set(['view', 'create', 'activate', 'cancel']),
|
||||
tenancies: new Set(['view', 'manage', 'cancel']),
|
||||
admin: new Set([
|
||||
'manage_users',
|
||||
'view_audit_log',
|
||||
|
||||
@@ -189,7 +189,7 @@ const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
href: '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,
|
||||
},
|
||||
{
|
||||
@@ -261,7 +261,7 @@ const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
href: 'import',
|
||||
label: 'Bulk Import',
|
||||
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
||||
description: 'CSV-driven imports for clients, yachts, and tenancies.',
|
||||
icon: FileUp,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,8 +128,8 @@ function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props)
|
||||
</p>
|
||||
<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
|
||||
user, GDPR records, all interests, all reservations. Signed documents, email threads,
|
||||
files and reminders are detached but kept.
|
||||
user, GDPR records, all interests, all tenancies. Signed documents, email threads, files
|
||||
and reminders are detached but kept.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
'yacht:ownership_transferred': [['clients', clientId]],
|
||||
'company_membership:added': [['clients', clientId]],
|
||||
'company_membership:ended': [['clients', clientId]],
|
||||
'berth_tenancy:created': [['clients', clientId]],
|
||||
'berth_tenancy:activated': [['clients', clientId]],
|
||||
'berth_tenancy:ended': [['clients', clientId]],
|
||||
'berth_tenancy:cancelled': [['clients', clientId]],
|
||||
|
||||
@@ -120,7 +120,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
<DialogTitle>Personal data export</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -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">
|
||||
<li>Client record + addresses, contacts, notes, tags</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>
|
||||
<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">
|
||||
|
||||
@@ -100,9 +100,9 @@ export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
|
||||
Won - wrap-up checklist
|
||||
</CardTitle>
|
||||
<p className="text-xs text-emerald-800/80">
|
||||
Upload anything that didn't flow through the system automatically. Reservations,
|
||||
deposit invoicing, and client billing are handled outside the CRM - this checklist is for
|
||||
the paperwork that lives on the deal itself.
|
||||
Upload anything that didn't flow through the system automatically. Tenancies, deposit
|
||||
invoicing, and client billing are handled outside the CRM - this checklist is for the
|
||||
paperwork that lives on the deal itself.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
|
||||
@@ -65,7 +65,7 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
|
||||
title: `Reservation needs an agreement`,
|
||||
body: `Active reservation for ${r.yachtName} (${r.clientName}) has no signed agreement yet.`,
|
||||
link: `/[port]/tenancies/${r.id}`,
|
||||
entityType: 'reservation',
|
||||
entityType: 'berth_tenancy',
|
||||
entityId: r.id,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface MergeResult {
|
||||
notes: number;
|
||||
tags: number;
|
||||
relationships: number;
|
||||
reservations: number;
|
||||
tenancies: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(eq(interests.clientId, opts.loserId));
|
||||
const loserReservations = await tx
|
||||
const loserTenancies = await tx
|
||||
.select({ id: berthTenancies.id })
|
||||
.from(berthTenancies)
|
||||
.where(eq(berthTenancies.clientId, opts.loserId));
|
||||
@@ -172,7 +172,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
notes: loserNotes,
|
||||
tags: loserTags,
|
||||
interests: loserInterests.map((r) => r.id),
|
||||
reservations: loserReservations.map((r) => r.id),
|
||||
tenancies: loserTenancies.map((r) => r.id),
|
||||
relationshipsAsA: loserRelationshipsAsA,
|
||||
relationshipsAsB: loserRelationshipsAsB,
|
||||
fieldChoices: opts.fieldChoices ?? {},
|
||||
@@ -213,7 +213,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
.returning({ id: interests.id })
|
||||
).length;
|
||||
|
||||
const movedReservations = (
|
||||
const movedTenancies = (
|
||||
await tx
|
||||
.update(berthTenancies)
|
||||
.set({ clientId: opts.winnerId, updatedAt: new Date() })
|
||||
@@ -357,7 +357,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
loserId: opts.loserId,
|
||||
loserName: loserRow.fullName,
|
||||
movedInterests,
|
||||
movedReservations,
|
||||
movedTenancies,
|
||||
movedContacts,
|
||||
movedAddresses,
|
||||
},
|
||||
@@ -372,7 +372,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
notes: movedNotes,
|
||||
tags: movedTags,
|
||||
relationships: movedRelationships,
|
||||
reservations: movedReservations,
|
||||
tenancies: movedTenancies,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface GdprBundle {
|
||||
}>;
|
||||
interests: Record<string, unknown>[];
|
||||
contactLog: Record<string, unknown>[];
|
||||
reservations: Record<string, unknown>[];
|
||||
tenancies: Record<string, unknown>[];
|
||||
invoices: Record<string, unknown>[];
|
||||
documents: Record<string, unknown>[];
|
||||
files: Record<string, unknown>[];
|
||||
@@ -95,7 +95,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
ownedYachts,
|
||||
membershipRows,
|
||||
interestRows,
|
||||
reservationRows,
|
||||
tenancyRows,
|
||||
invoiceRows,
|
||||
documentRows,
|
||||
fileRows,
|
||||
@@ -268,7 +268,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
})),
|
||||
interests: interestRows.map(toJsonRow),
|
||||
contactLog: contactLogRows.map(toJsonRow),
|
||||
reservations: reservationRows.map(toJsonRow),
|
||||
tenancies: tenancyRows.map(toJsonRow),
|
||||
invoices: invoiceRows.map(toJsonRow),
|
||||
documents: documentRows.map(toJsonRow),
|
||||
files: fileRows.map(toJsonRow),
|
||||
@@ -361,7 +361,7 @@ export function renderBundleHtml(bundle: GdprBundle): string {
|
||||
),
|
||||
tableSection('Interests', bundle.interests),
|
||||
tableSection('Contact log', bundle.contactLog),
|
||||
tableSection('Reservations', bundle.reservations),
|
||||
tableSection('Tenancies', bundle.tenancies),
|
||||
tableSection('Invoices', bundle.invoices),
|
||||
tableSection('Documents', bundle.documents),
|
||||
tableSection('Files', bundle.files),
|
||||
|
||||
@@ -3,12 +3,12 @@ import { test, expect } from '@playwright/test';
|
||||
import { clickEverythingOnPage } from '../../helpers/click-everything';
|
||||
import { login, navigateTo, PORT_SLUG } from '../smoke/helpers';
|
||||
|
||||
test.describe('exhaustive: berth reservations', () => {
|
||||
test.describe('exhaustive: berth tenancies', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
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 page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
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 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.waitForLoadState('networkidle');
|
||||
|
||||
const reservationsTab = page.getByRole('tab', { name: /reservations/i }).first();
|
||||
if (await reservationsTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await reservationsTab.click();
|
||||
const tenanciesTab = page.getByRole('tab', { name: /tenancies/i }).first();
|
||||
if (await tenanciesTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await tenanciesTab.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
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 page.waitForLoadState('networkidle');
|
||||
const firstRow = page.locator('tbody tr a, tbody tr button').first();
|
||||
@@ -50,9 +50,9 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
await firstRow.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const reserveBtn = page.getByRole('button', { name: /reserve|new reservation/i }).first();
|
||||
if (await reserveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await reserveBtn.click();
|
||||
const createBtn = page.getByRole('button', { name: /create tenancy|reserve/i }).first();
|
||||
if (await createBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await createBtn.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first();
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
|
||||
import { clickEverythingOnPage } from '../../helpers/click-everything';
|
||||
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.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
createHandler as createReservationHandler,
|
||||
listHandler as listReservationsHandler,
|
||||
createHandler as createTenancyHandler,
|
||||
listHandler as listTenanciesHandler,
|
||||
} from '@/app/api/v1/berths/[id]/tenancies/handlers';
|
||||
import {
|
||||
getHandler as getReservationHandler,
|
||||
patchHandler as patchReservationHandler,
|
||||
deleteHandler as deleteReservationHandler,
|
||||
getHandler as getTenancyHandler,
|
||||
patchHandler as patchTenancyHandler,
|
||||
deleteHandler as deleteTenancyHandler,
|
||||
} from '@/app/api/v1/tenancies/[id]/handlers';
|
||||
import { db } from '@/lib/db';
|
||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||
@@ -53,7 +53,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
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);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.data.berthId).toBe(berth.id);
|
||||
@@ -81,7 +81,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
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);
|
||||
const body = (await res.json()) as any;
|
||||
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() });
|
||||
|
||||
// Create a reservation for berthA.
|
||||
await createReservationHandler(
|
||||
await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -166,7 +166,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
|
||||
);
|
||||
|
||||
// Create a reservation for berthB.
|
||||
await createReservationHandler(
|
||||
await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -178,7 +178,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
|
||||
{ id: berthB.id },
|
||||
);
|
||||
|
||||
const res = await listReservationsHandler(
|
||||
const res = await listTenanciesHandler(
|
||||
makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/tenancies`),
|
||||
ctx,
|
||||
{ id: berthA.id },
|
||||
@@ -204,7 +204,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
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`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -217,7 +217,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
);
|
||||
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}`),
|
||||
ctx,
|
||||
{ id: reservation.id },
|
||||
@@ -239,7 +239,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
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`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -253,7 +253,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
const reservation = ((await createRes.json()) as any).data;
|
||||
|
||||
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}`),
|
||||
ctxB,
|
||||
{ id: reservation.id },
|
||||
@@ -276,7 +276,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
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`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -293,7 +293,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
it('activate: pending → active (200)', async () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -309,7 +309,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
|
||||
// First activate.
|
||||
await patchReservationHandler(
|
||||
await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
// Then end.
|
||||
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}`, {
|
||||
body: {
|
||||
action: 'end',
|
||||
@@ -338,7 +338,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
it('cancel: pending → cancelled (200)', async () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'cancel', reason: 'client changed mind' },
|
||||
}),
|
||||
@@ -354,7 +354,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
|
||||
// pending → active.
|
||||
await patchReservationHandler(
|
||||
await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -362,7 +362,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
{ id: reservation.id },
|
||||
);
|
||||
// active → ended.
|
||||
await patchReservationHandler(
|
||||
await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: {
|
||||
action: 'end',
|
||||
@@ -374,7 +374,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
);
|
||||
|
||||
// ended → activate should fail.
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -386,7 +386,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
it('returns 400 on invalid body shape (action missing)', async () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { notes: 'noop' },
|
||||
}),
|
||||
@@ -406,7 +406,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
tenancies: { view: true, manage: false, cancel: true },
|
||||
},
|
||||
});
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -423,7 +423,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
portId: port.id,
|
||||
permissions: makeSalesAgentPermissions(),
|
||||
});
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'cancel', reason: 'test' },
|
||||
}),
|
||||
@@ -433,7 +433,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// 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}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -458,7 +458,7 @@ describe('DELETE /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
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`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -471,7 +471,7 @@ describe('DELETE /api/v1/tenancies/[id]', () => {
|
||||
);
|
||||
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}`),
|
||||
ctx,
|
||||
{ id: reservation.id },
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('berth-tenancies.service - lifecycle transitions', () => {
|
||||
// ─── 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 client = await makeClient({ portId });
|
||||
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
|
||||
@@ -351,8 +351,8 @@ describe('berth-tenancies.service - listTenancies', () => {
|
||||
it('is tenant-scoped', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const resA = await makeReservation(portA.id);
|
||||
await makeReservation(portB.id);
|
||||
const resA = await makeTenancyLocal(portA.id);
|
||||
await makeTenancyLocal(portB.id);
|
||||
|
||||
const result = await listTenancies(portA.id, {
|
||||
page: 1,
|
||||
@@ -367,8 +367,8 @@ describe('berth-tenancies.service - listTenancies', () => {
|
||||
|
||||
it('filters by status', async () => {
|
||||
const port = await makePort();
|
||||
const resPending = await makeReservation(port.id);
|
||||
const resActive = await makeReservation(port.id);
|
||||
const resPending = await makeTenancyLocal(port.id);
|
||||
const resActive = await makeTenancyLocal(port.id);
|
||||
await activate(resActive.id, port.id, {}, makeAuditMeta({ portId: port.id }));
|
||||
|
||||
const activeList = await listTenancies(port.id, {
|
||||
|
||||
Reference in New Issue
Block a user