Files
pn-new-crm/tests/integration/public-residential-inquiry.test.ts
Matt 172af02f81 feat(residential-toggle): port-level module gate for Residential
Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.

Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
  (covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
  residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
  the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)

Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:16 +02:00

174 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* i18n PR910 - public residential inquiry endpoint.
*
* Validates the server-side phone normalization that the public inquiry
* route runs when the website posts a raw international format (older
* Nuxt builds), and that pre-normalized payloads pass through unchanged.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialClients } from '@/lib/db/schema/residential';
import { disableResidentialModule } from '@/lib/services/residential-module.service';
import { makePort } from '../helpers/factories';
import { makeMockRequest } from '../helpers/route-tester';
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
// Randomize per-run prefix so leftover redis sliding-window entries from a
// previous run don't 429 the new run (publicForm limiter pexpires after 1h).
const IP_PREFIX = `10.${Math.floor(Math.random() * 200) + 10}`;
let ipCounter = 1;
function uniqueIp(): string {
ipCounter += 1;
return `${IP_PREFIX}.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
}
describe('POST /api/public/residential-inquiries', () => {
let POST: typeof import('@/app/api/public/residential-inquiries/route').POST;
beforeAll(async () => {
const mod = await import('@/app/api/public/residential-inquiries/route');
POST = mod.POST;
});
it('parses a raw international phone string into E.164 + country', async () => {
const port = await makePort();
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Anna',
lastName: 'Nowak',
email,
// Raw international format - server should normalize.
phone: '+44 20 7946 0958',
placeOfResidence: 'Warsaw',
},
},
);
const res = await POST(req);
expect(res.status).toBe(201);
const [row] = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(row).toBeDefined();
expect(row?.phoneE164).toBe('+442079460958');
expect(row?.phoneCountry).toBe('GB');
// Free-text legacy column preserved verbatim for backfill.
expect(row?.phone).toBe('+44 20 7946 0958');
});
it('passes pre-normalized E.164 + country through unchanged', async () => {
const port = await makePort();
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Jan',
lastName: 'Kowalski',
email,
phone: '+48 22 555 0100',
phoneE164: '+48225550100',
phoneCountry: 'PL',
nationalityIso: 'PL',
timezone: 'Europe/Warsaw',
placeOfResidence: 'Warsaw',
placeOfResidenceCountryIso: 'PL',
},
},
);
const res = await POST(req);
expect(res.status).toBe(201);
const [row] = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(row?.phoneE164).toBe('+48225550100');
expect(row?.phoneCountry).toBe('PL');
expect(row?.nationalityIso).toBe('PL');
expect(row?.timezone).toBe('Europe/Warsaw');
expect(row?.placeOfResidenceCountryIso).toBe('PL');
});
it('persists a national-format phone when the website only sends a country hint', async () => {
const port = await makePort();
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Marta',
lastName: 'Lewandowska',
email,
phone: '22 555 0200', // National-format
phoneCountry: 'PL', // Hint only - no E.164 yet.
},
},
);
const res = await POST(req);
expect(res.status).toBe(201);
const [row] = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(row?.phoneE164).toBe('+48225550200');
expect(row?.phoneCountry).toBe('PL');
});
it('rejects the inquiry when the port has the Residential module disabled', async () => {
const port = await makePort();
await disableResidentialModule(port.id);
const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest(
'POST',
`http://localhost/api/public/residential-inquiries?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Ola',
lastName: 'Disabled',
email,
phone: '+48 22 555 0300',
placeOfResidence: 'Warsaw',
},
},
);
const res = await POST(req);
// Module gate maps NotFoundError → 404; no client row should be written.
expect(res.status).toBe(404);
const rows = await db
.select()
.from(residentialClients)
.where(eq(residentialClients.email, email));
expect(rows).toHaveLength(0);
});
});