fixing multi company bug

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-07 20:08:04 +00:00
parent 2468d3600b
commit 9118f9202b
3 changed files with 276 additions and 7 deletions

View file

@ -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<string, unknown> = {};
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<string, unknown>) {
let conditionUsesInArray = false;
const self: Record<string, unknown> = {};
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();
});
});

View file

@ -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),
),
)

View file

@ -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"],