mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Adding tests for modified approvals route
This commit is contained in:
parent
ffb2f8c7c3
commit
2b05abb185
2 changed files with 307 additions and 1 deletions
289
src/app/api/portfolio/[portfolioId]/approvals/route.test.ts
Normal file
289
src/app/api/portfolio/[portfolioId]/approvals/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue