From 9118f9202be95e62c2f7459a461aa8590386d6f1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 7 May 2026 20:08:04 +0000 Subject: [PATCH] fixing multi company bug --- .../your-projects/live/[dealId]/page.test.ts | 265 ++++++++++++++++++ .../your-projects/live/[dealId]/page.tsx | 15 +- vitest.config.ts | 3 + 3 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts new file mode 100644 index 00000000..f4fa001c --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +const { mockGetServerSession, mockDbSelect, mockNotFound, mockRedirect } = + vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockDbSelect: vi.fn(), + mockNotFound: vi.fn(() => { + throw new Error("NEXT_NOT_FOUND"); + }), + mockRedirect: vi.fn(() => { + throw new Error("NEXT_REDIRECT"); + }), + })); + +// ── Auth ────────────────────────────────────────────────────────────────────── +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} })); + +// ── Navigation ──────────────────────────────────────────────────────────────── +vi.mock("next/navigation", () => ({ + redirect: mockRedirect, + notFound: mockNotFound, +})); +vi.mock("next/link", () => ({ default: vi.fn(() => null) })); + +// ── Drizzle ORM ─────────────────────────────────────────────────────────────── +vi.mock("drizzle-orm", () => ({ + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })), + and: vi.fn((...args: unknown[]) => ({ $and: args })), + desc: vi.fn((col: unknown) => ({ $desc: col })), + sql: vi.fn(), +})); +vi.mock("drizzle-orm/pg-core", () => ({ + alias: vi.fn((table: unknown, _name: string) => table), +})); + +// ── DB ──────────────────────────────────────────────────────────────────────── +vi.mock("@/app/db/db", () => ({ + db: { get select() { return mockDbSelect; } }, +})); + +// ── Schemas ─────────────────────────────────────────────────────────────────── +vi.mock("@/app/db/schema/crm/hubspot_deal_table", () => ({ + hubspotDealData: { dealId: {}, companyId: {}, coordinator: {}, designer: {} }, +})); +vi.mock("@/app/db/schema/crm/hubspot_user_table", () => ({ + hubspotUsers: { hubspotOwnerId: {}, firstName: {}, lastName: {} }, +})); +vi.mock("@/app/db/schema/uploaded_files", () => ({ + uploadedFiles: { hubsotDealId: {}, fileType: {}, measureName: {}, uprn: {} }, +})); +vi.mock("@/app/db/schema/portfolio_organisation", () => ({ + portfolioOrganisation: { portfolioId: {}, organisationId: {} }, +})); +vi.mock("@/app/db/schema/organisation", () => ({ + organisation: { id: {}, hubspotCompanyId: {} }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} }, + portfolioUsers: { portfolioId: {}, userId: {}, role: {} }, +})); +vi.mock("@/app/db/schema/approvals", () => ({ + dealMeasureApprovals: { hubspotDealId: {}, isApproved: {}, measureName: {} }, +})); +vi.mock("@/app/db/schema/removal_requests", () => ({ + propertyRemovalRequests: { + portfolioId: {}, + hubspotDealId: {}, + type: {}, + status: {}, + requestedAt: {}, + }, +})); +vi.mock("@/app/db/schema/users", () => ({ + user: { id: {}, email: {} }, +})); + +// ── Transforms & lib ────────────────────────────────────────────────────────── +vi.mock("../transforms", () => ({ + classifyDeals: vi.fn((deals: unknown[]) => + deals.map((d) => ({ ...(d as object), displayStage: "Test Stage" })), + ), +})); +vi.mock("@/app/lib/measureDocumentRequirements", () => ({ + getRequiredDocs: vi.fn(() => []), +})); + +// ── DealPage component ──────────────────────────────────────────────────────── +vi.mock("./DealPage", () => ({ default: vi.fn(() => null) })); + +// ── Subject under test ──────────────────────────────────────────────────────── +import DealDetailPage from "./page"; + +// ── Chain helpers ───────────────────────────────────────────────────────────── + +// Generic chain: .limit(n) resolves to limitResult; awaiting without .limit +// resolves to directResult (thenable). +function makeSelectChain( + limitResult: unknown[] = [], + directResult: unknown[] = [], +) { + const self: Record = {}; + self.then = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(directResult).then(resolve, reject); + self.from = vi.fn(() => self); + self.innerJoin = vi.fn(() => self); + self.leftJoin = vi.fn(() => self); + self.where = vi.fn(() => self); + self.orderBy = vi.fn(() => self); + self.limit = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +// Deal chain: inspects the WHERE argument's JSON to decide what to return. +// Returns dealRow only when the condition includes $inArray, i.e. after fix. +// Before fix, eq() produces $eq and the deal is not found (empty result). +function makeDealChain(dealRow: Record) { + let conditionUsesInArray = false; + const self: Record = {}; + self.then = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve([]).then(resolve, reject); + self.from = vi.fn(() => self); + self.leftJoin = vi.fn(() => self); + self.where = vi.fn((condition: unknown) => { + conditionUsesInArray = JSON.stringify(condition).includes('"$inArray"'); + return self; + }); + self.orderBy = vi.fn(() => self); + self.limit = vi.fn(() => + Promise.resolve(conditionUsesInArray ? [dealRow] : []), + ); + return self; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const DEAL_COMPANY = "86970043613"; +const OTHER_COMPANIES = ["262091354351", "86797211838"]; +const DEAL_ID = "489569618109"; +const PORTFOLIO_ID = "737"; + +const mockDealRow = { + deal: { + id: "1", + dealId: DEAL_ID, + dealname: "Test Property", + dealstage: "test", + companyId: DEAL_COMPANY, + projectCode: null, + landlordPropertyId: null, + uprn: null, + outcome: null, + outcomeNotes: null, + majorConditionIssueDescription: null, + majorConditionIssuePhotos: null, + majorConditionIssuePhotosS3: null, + coordinationStatus: null, + designStatus: null, + pashubLink: null, + sharepointLink: null, + dampmouldGrowth: null, + damnpMouldAndRepairComments: null, + preSap: null, + mtpCompletionDate: null, + mtpReModelCompletionDate: null, + ioeV3CompletionDate: null, + proposedMeasures: null, + approvedPackage: null, + designCompletionDate: null, + actualMeasuresInstalled: null, + installer: null, + installerHandover: null, + lodgementStatus: null, + measuresLodgementDate: null, + lodgementDate: null, + confirmedSurveyDate: null, + confirmedSurveyTime: null, + surveyedDate: null, + dealType: null, + eiScore: null, + eiScorePotential: null, + epcSapScore: null, + epcSapScorePotential: null, + surveyType: null, + measuresForPibiOrdered: null, + pibiOrderDate: null, + pibiCompletedDate: null, + propertyHaltedDate: null, + propertyHaltedReason: null, + technicalApprovedMeasuresForInstall: null, + domnaSurveyType: null, + domnaSurveyDate: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + coordinator: null, + designer: null, +}; + +function setupDb() { + mockGetServerSession.mockResolvedValue({ + user: { email: "test@domna.homes" }, + }); + + // 1. Org lookup + // limitResult = [wrong company] — what .limit(1) returns (broken path) + // directResult = all three companies — what await-without-limit returns (fixed path) + mockDbSelect.mockImplementationOnce(() => + makeSelectChain( + [{ hubspotCompanyId: OTHER_COMPANIES[0] }], + [ + { hubspotCompanyId: DEAL_COMPANY }, + { hubspotCompanyId: OTHER_COMPANIES[0] }, + { hubspotCompanyId: OTHER_COMPANIES[1] }, + ], + ), + ); + + // 2. Deal lookup — returns deal only when inArray appears in the where clause + mockDbSelect.mockImplementationOnce(() => makeDealChain(mockDealRow)); + + // 3. User lookup + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }])); + + // 4. Cap rows (no .limit — awaited via thenable) + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); + + // 5. Role row + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ role: "read" }]), + ); + + // 6. Approval rows (no .limit) + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); + + // 7. Removal rows (has .orderBy + .limit) + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + + // 8. Phase-1 docs (no .limit) + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("DealDetailPage — multi-organisation portfolio", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders deal when its company is not the first linked organisation", async () => { + setupDb(); + await expect( + DealDetailPage({ + params: Promise.resolve({ slug: PORTFOLIO_ID, dealId: DEAL_ID }), + }), + ).resolves.toBeDefined(); + expect(mockNotFound).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx index 56c898b2..79a50c66 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx @@ -109,22 +109,23 @@ export default async function DealDetailPage(props: { redirect("/"); } - const link = await db + const links = await db .select({ hubspotCompanyId: organisation.hubspotCompanyId }) .from(portfolioOrganisation) .innerJoin( organisation, eq(portfolioOrganisation.organisationId, organisation.id), ) - .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))) - .limit(1); + .where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId))); - if (!link.length || !link[0].hubspotCompanyId) { + const companyIds = links + .map((l) => l.hubspotCompanyId) + .filter((id): id is string => !!id); + + if (companyIds.length === 0) { redirect(`/portfolio/${portfolioId}/your-projects/live`); } - const companyId = link[0].hubspotCompanyId; - const rawDeals = await db .select({ deal: hubspotDealData, @@ -142,7 +143,7 @@ export default async function DealDetailPage(props: { ) .where( and( - eq(hubspotDealData.companyId, companyId), + inArray(hubspotDealData.companyId, companyIds), eq(hubspotDealData.dealId, dealId), ), ) diff --git a/vitest.config.ts b/vitest.config.ts index 950c2d26..ede31fee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; export default defineConfig({ + esbuild: { + jsx: "automatic", + }, test: { environment: "node", include: ["src/**/*.test.ts", "src/**/*.test.tsx"],