feat(tenancies-p5): sidebar entry + 404 top-level page + API module gate

- Dashboard layout resolves tenanciesModuleByPort server-side (one
  isTenanciesModuleEnabled call per port the user has access to) and
  passes the map through AppShell → Sidebar. Atomic SSR — no
  flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
  entry (KeyRound icon, immediately below Berths) only renders when
  the currently-active port has the flag flipped on. Per-port live
  switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
  isTenanciesModuleEnabled and notFound() when disabled — guards
  against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
  prepended with assertTenanciesModuleEnabled — matches design §
  "All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
  (calls enableTenanciesModule after makePort) so the gate is
  satisfied. Affects 2 test files (16 tests retargeted).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:23:06 +02:00
parent bfb29ab619
commit 3a48150d13
10 changed files with 138 additions and 26 deletions

View File

@@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest';
import { listHandler } from '@/app/api/v1/tenancies/handlers';
import { createHandler as createTenancyHandler } from '@/app/api/v1/berths/[id]/tenancies/handlers';
import { enableTenanciesModule } from '@/lib/services/tenancies-module.service';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import {
makeBerth,
@@ -19,6 +20,12 @@ import {
makeYacht,
} from '../../helpers/factories';
async function makePortWithTenancies(): Promise<Awaited<ReturnType<typeof makePort>>> {
const port = await makePort();
await enableTenanciesModule(port.id);
return port;
}
async function seedTenancy(portId: string) {
const berth = await makeBerth({ portId });
const client = await makeClient({ portId });
@@ -40,7 +47,7 @@ async function seedTenancy(portId: string) {
describe('GET /api/v1/tenancies', () => {
it('returns all tenancies for the requesting port', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const r1 = await seedTenancy(port.id);
const r2 = await seedTenancy(port.id);
@@ -55,8 +62,8 @@ describe('GET /api/v1/tenancies', () => {
});
it('does not leak tenancies from a different port', async () => {
const portA = await makePort();
const portB = await makePort();
const portA = await makePortWithTenancies();
const portB = await makePortWithTenancies();
const tenancyInB = await seedTenancy(portB.id);
// Caller is operating in portA; portB's tenancy must not appear.
@@ -70,7 +77,7 @@ describe('GET /api/v1/tenancies', () => {
});
it('honors pagination via query params', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
await seedTenancy(port.id);
await seedTenancy(port.id);
await seedTenancy(port.id);

View File

@@ -12,6 +12,7 @@ import {
} from '@/app/api/v1/tenancies/[id]/handlers';
import { db } from '@/lib/db';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { enableTenanciesModule } from '@/lib/services/tenancies-module.service';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import {
makeBerth,
@@ -22,11 +23,20 @@ import {
makeYacht,
} from '../../helpers/factories';
/** Wrap makePort so every test in this file operates against a port that
* has the tenancies module enabled — the API handlers assertModuleEnabled
* up front (P5 design) and would otherwise 404 every call. */
async function makePortWithTenancies(): Promise<Awaited<ReturnType<typeof makePort>>> {
const port = await makePort();
await enableTenanciesModule(port.id);
return port;
}
// ─── POST /api/v1/berths/[id]/tenancies ───────────────────────────────────
describe('POST /api/v1/berths/[id]/tenancies', () => {
it('creates pending reservation (201)', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const berth = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
@@ -53,7 +63,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
});
it('returns 400 when yacht does not belong to reservation client', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const berth = await makeBerth({ portId: port.id });
const ownerClient = await makeClient({ portId: port.id });
const otherClient = await makeClient({ portId: port.id });
@@ -76,8 +86,8 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
});
it('returns 404 when berth is cross-tenant', async () => {
const portA = await makePort();
const portB = await makePort();
const portA = await makePortWithTenancies();
const portB = await makePortWithTenancies();
const berthA = await makeBerth({ portId: portA.id });
const client = await makeClient({ portId: portB.id });
const yacht = await makeYacht({
@@ -100,7 +110,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
});
it('ignores berthId from body, uses URL param instead', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const urlBerth = await makeBerth({ portId: port.id });
const bodyBerth = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
@@ -131,7 +141,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
describe('GET /api/v1/berths/[id]/tenancies', () => {
it('returns reservations filtered by that berth', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const berthA = await makeBerth({ portId: port.id });
const berthB = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
@@ -184,7 +194,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
describe('GET /api/v1/tenancies/[id]', () => {
it('returns the reservation', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const berth = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
@@ -218,8 +228,8 @@ describe('GET /api/v1/tenancies/[id]', () => {
});
it('returns 404 for cross-tenant', async () => {
const portA = await makePort();
const portB = await makePort();
const portA = await makePortWithTenancies();
const portB = await makePortWithTenancies();
const berth = await makeBerth({ portId: portA.id });
const client = await makeClient({ portId: portA.id });
const yacht = await makeYacht({
@@ -256,7 +266,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
describe('PATCH /api/v1/tenancies/[id]', () => {
async function seedReservation() {
const port = await makePort();
const port = await makePortWithTenancies();
const berth = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
@@ -438,7 +448,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
describe('DELETE /api/v1/tenancies/[id]', () => {
it('cancels the reservation (204)', async () => {
const port = await makePort();
const port = await makePortWithTenancies();
const berth = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({