import { describe, it, expect, beforeAll } from 'vitest'; describe('portal.service — getPortalUserYachts', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let getPortalUserYachts: (clientId: string, portId: string) => Promise>; let makeClient: typeof import('../../helpers/factories').makeClient; let makePort: typeof import('../../helpers/factories').makePort; let makeYacht: typeof import('../../helpers/factories').makeYacht; let makeCompany: typeof import('../../helpers/factories').makeCompany; let makeMembership: typeof import('../../helpers/factories').makeMembership; let db: typeof import('@/lib/db').db; let yachts: typeof import('@/lib/db/schema').yachts; let eq: typeof import('drizzle-orm').eq; beforeAll(async () => { const portalMod = await import('@/lib/services/portal.service'); getPortalUserYachts = portalMod.getPortalUserYachts; const factoriesMod = await import('../../helpers/factories'); makeClient = factoriesMod.makeClient; makePort = factoriesMod.makePort; makeYacht = factoriesMod.makeYacht; makeCompany = factoriesMod.makeCompany; makeMembership = factoriesMod.makeMembership; const dbMod = await import('@/lib/db'); db = dbMod.db; const schemaMod = await import('@/lib/db/schema'); yachts = schemaMod.yachts; const ormMod = await import('drizzle-orm'); eq = ormMod.eq; }); it('returns client-owned yachts only when client has no memberships', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Solo Sail', }); const result = await getPortalUserYachts(client.id, port.id); expect(result).toHaveLength(1); expect(result[0]!.id).toBe(yacht.id); expect(result[0]!.ownerContext).toBe('direct'); expect(result[0]!.ownerCompanyId).toBeNull(); expect(result[0]!.ownerCompanyName).toBeNull(); }); it('returns company-owned yachts when client is an active member', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const company = await makeCompany({ portId: port.id, overrides: { name: `Acme Holdings ${Math.random().toString(36).slice(2, 6)}` }, }); await makeMembership({ companyId: company.id, clientId: client.id, role: 'director', endDate: null, }); const yacht = await makeYacht({ portId: port.id, ownerType: 'company', ownerId: company.id, name: 'Corporate Cruise', }); const result = await getPortalUserYachts(client.id, port.id); expect(result).toHaveLength(1); expect(result[0]!.id).toBe(yacht.id); expect(result[0]!.ownerContext).toBe('company'); expect(result[0]!.ownerCompanyId).toBe(company.id); expect(result[0]!.ownerCompanyName).toBe(company.name); }); it('de-dupes if same yacht id appears in both paths (defensive)', async () => { // A yacht cannot legitimately be owned by both, but we verify dedup is // defensive by forcing the yacht's current owner to company after the // direct query path has already cached it. We simulate the case by // creating a client-owned yacht, then manually flipping owner to a // company the client is a member of — if both queries ran they'd both // match, but dedup by id ensures only one entry. const port = await makePort(); const client = await makeClient({ portId: port.id }); const company = await makeCompany({ portId: port.id }); await makeMembership({ companyId: company.id, clientId: client.id, role: 'director', endDate: null, }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Ambiguous', }); // Flip the denormalized owner to the company (without updating history) — // this is artificial but exercises the dedup branch. await db .update(yachts) .set({ currentOwnerType: 'company', currentOwnerId: company.id }) .where(eq(yachts.id, yacht.id)); const result = await getPortalUserYachts(client.id, port.id); const matches = result.filter((y) => y.id === yacht.id); expect(matches).toHaveLength(1); }); it('excludes yachts from companies where membership ended', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const company = await makeCompany({ portId: port.id }); await makeMembership({ companyId: company.id, clientId: client.id, role: 'director', startDate: new Date('2024-01-01'), endDate: new Date('2025-06-01'), }); await makeYacht({ portId: port.id, ownerType: 'company', ownerId: company.id, name: 'Past Company Yacht', }); const result = await getPortalUserYachts(client.id, port.id); expect(result).toHaveLength(0); }); it('is tenant-scoped', async () => { const portA = await makePort(); const portB = await makePort(); const clientInA = await makeClient({ portId: portA.id }); // Directly-owned yacht in portB with the SAME client id — must not leak // because getPortalUserYachts filters on portId. // We insert a yacht row in portB with ownerId=clientInA.id. The FK on // yachts.currentOwnerId isn't to clients, so this is valid. await makeYacht({ portId: portB.id, ownerType: 'client', ownerId: clientInA.id, name: 'Other Port Yacht', }); const result = await getPortalUserYachts(clientInA.id, portA.id); expect(result).toHaveLength(0); }); it('excludes archived yachts', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, overrides: { archivedAt: new Date() }, }); const result = await getPortalUserYachts(client.id, port.id); expect(result).toHaveLength(0); }); }); describe('portal.service — getPortalUserMemberships', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let getPortalUserMemberships: (clientId: string, portId: string) => Promise>; let makeClient: typeof import('../../helpers/factories').makeClient; let makePort: typeof import('../../helpers/factories').makePort; let makeCompany: typeof import('../../helpers/factories').makeCompany; let makeMembership: typeof import('../../helpers/factories').makeMembership; beforeAll(async () => { const portalMod = await import('@/lib/services/portal.service'); getPortalUserMemberships = portalMod.getPortalUserMemberships; const factoriesMod = await import('../../helpers/factories'); makeClient = factoriesMod.makeClient; makePort = factoriesMod.makePort; makeCompany = factoriesMod.makeCompany; makeMembership = factoriesMod.makeMembership; }); it('returns only active memberships', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const activeCompany = await makeCompany({ portId: port.id }); const endedCompany = await makeCompany({ portId: port.id }); await makeMembership({ companyId: activeCompany.id, clientId: client.id, role: 'director', endDate: null, }); await makeMembership({ companyId: endedCompany.id, clientId: client.id, role: 'officer', startDate: new Date('2024-01-01'), endDate: new Date('2025-01-01'), }); const result = await getPortalUserMemberships(client.id, port.id); expect(result).toHaveLength(1); expect(result[0]!.company.id).toBe(activeCompany.id); expect(result[0]!.role).toBe('director'); }); it('is tenant-scoped (memberships on companies in different port are excluded)', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); // Company in portB — but membership references clientId on portA. const companyInB = await makeCompany({ portId: portB.id }); await makeMembership({ companyId: companyInB.id, clientId: client.id, role: 'director', endDate: null, }); const resultA = await getPortalUserMemberships(client.id, portA.id); expect(resultA).toHaveLength(0); const resultB = await getPortalUserMemberships(client.id, portB.id); expect(resultB).toHaveLength(1); }); }); describe('portal.service — getPortalUserReservations', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let getPortalUserReservations: (clientId: string, portId: string) => Promise>; let makeClient: typeof import('../../helpers/factories').makeClient; let makePort: typeof import('../../helpers/factories').makePort; let makeYacht: typeof import('../../helpers/factories').makeYacht; let makeBerth: typeof import('../../helpers/factories').makeBerth; let makeReservation: typeof import('../../helpers/factories').makeReservation; beforeAll(async () => { const portalMod = await import('@/lib/services/portal.service'); getPortalUserReservations = portalMod.getPortalUserReservations; const factoriesMod = await import('../../helpers/factories'); makeClient = factoriesMod.makeClient; makePort = factoriesMod.makePort; makeYacht = factoriesMod.makeYacht; makeBerth = factoriesMod.makeBerth; makeReservation = factoriesMod.makeReservation; }); it('returns active + pending reservations', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const berth1 = await makeBerth({ portId: port.id }); const berth2 = await makeBerth({ portId: port.id }); await makeReservation({ berthId: berth1.id, portId: port.id, clientId: client.id, yachtId: yacht.id, status: 'active', }); await makeReservation({ berthId: berth2.id, portId: port.id, clientId: client.id, yachtId: yacht.id, status: 'pending', }); const result = await getPortalUserReservations(client.id, port.id); expect(result).toHaveLength(2); const statuses = result.map((r) => r.status).sort(); expect(statuses).toEqual(['active', 'pending']); }); it('excludes ended and cancelled', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const berth1 = await makeBerth({ portId: port.id }); const berth2 = await makeBerth({ portId: port.id }); await makeReservation({ berthId: berth1.id, portId: port.id, clientId: client.id, yachtId: yacht.id, status: 'ended', }); await makeReservation({ berthId: berth2.id, portId: port.id, clientId: client.id, yachtId: yacht.id, status: 'cancelled', }); const result = await getPortalUserReservations(client.id, port.id); expect(result).toHaveLength(0); }); it('is tenant-scoped', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); const yachtB = await makeYacht({ portId: portB.id, ownerType: 'client', ownerId: client.id, }); const berthB = await makeBerth({ portId: portB.id }); await makeReservation({ berthId: berthB.id, portId: portB.id, clientId: client.id, yachtId: yachtB.id, status: 'active', }); const resultA = await getPortalUserReservations(client.id, portA.id); expect(resultA).toHaveLength(0); }); it('includes joined yacht name + berth mooring number', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Test Vessel', }); const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'M-42' }, }); await makeReservation({ berthId: berth.id, portId: port.id, clientId: client.id, yachtId: yacht.id, status: 'active', }); const result = await getPortalUserReservations(client.id, port.id); expect(result).toHaveLength(1); expect(result[0]!.yachtName).toBe('Test Vessel'); expect(result[0]!.berthMooringNumber).toBe('M-42'); expect(result[0]!.tenureType).toBe('permanent'); }); });