From 2b05abb1854fa7fe44d01a5cc49b73afc50e69c1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 19:10:47 +0000 Subject: [PATCH] Adding tests for modified approvals route --- .../[portfolioId]/approvals/route.test.ts | 289 ++++++++++++++++++ .../[portfolioId]/approvals/route.ts | 19 +- 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/app/api/portfolio/[portfolioId]/approvals/route.test.ts diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts new file mode 100644 index 0000000..ba668d4 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts @@ -0,0 +1,289 @@ +/** + * Unit tests for the approvals POST handler. + * + * Focuses on HubSpot sync behaviour: after approve/unapprove changes are + * persisted to the DB, the handler must push both the audit log + * (client_measures_approval_log) and the structured field (approved_measures) + * to HubSpot. Prior to this fix only the audit log was synced. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks (declared before vi.mock factories run) ───────────────────── +const { + mockGetServerSession, + syncMeasureApprovalsToHubSpotMock, + syncMeasuresFieldToHubSpotMock, + mockDbSelect, + mockDbInsert, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + syncMeasureApprovalsToHubSpotMock: vi.fn(), + syncMeasuresFieldToHubSpotMock: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), +})); + +// ── Auth ────────────────────────────────────────────────────────────────────── +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ + AuthOptions: {}, +})); + +// ── HubSpot syncs ───────────────────────────────────────────────────────────── +vi.mock("@/app/lib/hubspot/dealSync", () => ({ + syncMeasureApprovalsToHubSpot: syncMeasureApprovalsToHubSpotMock, + syncMeasuresFieldToHubSpot: syncMeasuresFieldToHubSpotMock, +})); +vi.mock("@/app/lib/instructMeasure", () => ({ + APPROVED_MEASURES_PROP: "approved_measures", +})); + +// ── Drizzle ORM ─────────────────────────────────────────────────────────────── +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })), + sql: vi.fn(), +})); + +// ── DB schema stubs ─────────────────────────────────────────────────────────── +vi.mock("@/app/db/schema/approvals", () => ({ + dealMeasureApprovals: { hubspotDealId: {}, measureName: {}, isApproved: {}, approvedBy: {}, approvedAt: {} }, + dealMeasureApprovalEvents: { hubspotDealId: {}, measureName: {}, action: {}, actedBy: {}, actedAt: {} }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {}, id: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ + user: { id: {}, email: {}, firstName: {} }, +})); + +// ── DB mock ─────────────────────────────────────────────────────────────────── +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + }, +})); + +// ── DB mock helpers ──────────────────────────────────────────────────────────── +// Builds a thenable select chain where .limit() resolves to `limitResult` +// and awaiting the chain without .limit() resolves to `directResult`. +function makeSelectChain( + limitResult: unknown[], + directResult: unknown[] = [], +) { + const self: Record = {}; + // thenable so `await chain.where(...)` resolves to directResult + self["then"] = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(directResult).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["leftJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +// Builds an insert chain. .values() is thenable (plain insert) and also +// exposes .onConflictDoUpdate() (upsert). +function makeInsertChain() { + const values: Record = {}; + values["then"] = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(undefined).then(resolve, reject); + values["onConflictDoUpdate"] = vi.fn(() => Promise.resolve(undefined)); + const insert = { values: vi.fn(() => values) }; + return insert; +} + + +// ── Subject under test ──────────────────────────────────────────────────────── +import { POST } from "./route"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeRequest(body: unknown, portfolioId = "10") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/approvals`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +function setupHappyPath(approvalRowsAfterChange: Array<{ measureName: string; approvedByEmail: string }>) { + // 1. getServerSession + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + + // 2. getUserId select + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ id: 1n }]), + ); + + // 3. hasApproverCapability select + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ id: 1n }]), + ); + + // 4. upsert dealMeasureApprovals (one per change) + mockDbInsert.mockImplementationOnce(() => makeInsertChain()); + + // 5. insert dealMeasureApprovalEvents (one per change) + mockDbInsert.mockImplementationOnce(() => makeInsertChain()); + + // 6. post-change approvalRows select (no .limit — awaited at .where()) + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([], approvalRowsAfterChange), + ); + + // HubSpot syncs + syncMeasureApprovalsToHubSpotMock.mockResolvedValue(undefined); + syncMeasuresFieldToHubSpotMock.mockResolvedValue({ ok: true }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe("POST /approvals — approved_measures HubSpot sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("syncs approved_measures field to HubSpot after an unapprove action", async () => { + setupHappyPath([ + // Only one measure remains approved after the unapprove + { measureName: "ASHP", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-1", measureName: "Solar PV", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + // Allow fire-and-forget promises to settle + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: "approved_measures", + measureNames: ["ASHP"], + }); + }); + + it("syncs approved_measures with empty list when all measures removed", async () => { + setupHappyPath([]); // nothing approved after removal + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-2", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-2", + propName: "approved_measures", + measureNames: [], + }); + }); + + it("syncs approved_measures when a new measure is approved", async () => { + setupHappyPath([ + { measureName: "ASHP", approvedByEmail: "approver@test.com" }, + { measureName: "Solar PV", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-3", measureName: "Solar PV", approved: true }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-3", + propName: "approved_measures", + measureNames: ["ASHP", "Solar PV"], + }); + }); + + it("also calls the audit-log sync (existing behaviour preserved)", async () => { + setupHappyPath([ + { measureName: "EWI", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-4", measureName: "EWI", approved: true }, + ], + }); + + await POST(req, { params }); + + await vi.waitFor(() => + expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalledWith( + expect.objectContaining({ + hubspotDealId: "deal-4", + approvedMeasures: [{ measureName: "EWI", approvedByEmail: "approver@test.com" }], + }), + ); + }); + + it("does not call HubSpot syncs when session is missing", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-5", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(401); + expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled(); + }); + + it("does not call HubSpot syncs when user lacks approver capability", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "writer@test.com" } }); + + // getUserId + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n }])); + // hasApproverCapability → empty → no capability + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-6", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(403); + expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.ts index 85f2962..90bc7b4 100644 --- a/src/app/api/portfolio/[portfolioId]/approvals/route.ts +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.ts @@ -10,7 +10,11 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync"; +import { + syncMeasureApprovalsToHubSpot, + syncMeasuresFieldToHubSpot, +} from "@/app/lib/hubspot/dealSync"; +import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure"; async function getRequestingUserId(email: string): Promise { const rows = await db @@ -230,6 +234,19 @@ export async function POST( actedByEmail: session.user.email, actedAt: now, }); + + void syncMeasuresFieldToHubSpot({ + hubspotDealId: dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: approvalRows.map((r) => r.measureName), + }).then((result) => { + if (!result.ok) { + console.error("[HubSpot] approved_measures sync failed", { + dealId, + error: result.error, + }); + } + }); } return NextResponse.json({ success: true });