Adding tests for modified approvals route

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-06 19:10:47 +00:00
parent ffb2f8c7c3
commit 2b05abb185
2 changed files with 307 additions and 1 deletions

View file

@ -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<string, unknown> = {};
// 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<string, unknown> = {};
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();
});
});

View file

@ -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<bigint | null> {
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 });