From 4181af6d15b500e42d0391c31e6d2a8ea65996dd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 18:56:19 +0000 Subject: [PATCH] add syncMeasuresFieldToHubSpot helper Generic push for multi-value HubSpot deal properties (semicolon-separated) with the same ECONNRESET retry pattern as pushDealPropertiesToHubSpot. Returns a discriminated ok result so callers can stamp pushed_at only on success. Slice 4 (PIBI selections) will reuse the same helper. Co-Authored-By: Claude Opus 4.7 --- src/app/lib/hubspot/dealSync.test.ts | 124 +++++++++++++++++++++++++++ src/app/lib/hubspot/dealSync.ts | 55 ++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/app/lib/hubspot/dealSync.test.ts diff --git a/src/app/lib/hubspot/dealSync.test.ts b/src/app/lib/hubspot/dealSync.test.ts new file mode 100644 index 0000000..2ca92a0 --- /dev/null +++ b/src/app/lib/hubspot/dealSync.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the HubSpot client module before importing the helper. The helper +// calls `getHubSpotClient()` lazily inside each retry attempt, so we +// re-create the mock on every test to control behaviour per-test. +const updateMock = vi.fn(); +vi.mock("./client", () => ({ + getHubSpotClient: () => ({ + crm: { deals: { basicApi: { update: updateMock } } }, + }), +})); + +import { syncMeasuresFieldToHubSpot } from "./dealSync"; + +describe("syncMeasuresFieldToHubSpot", () => { + beforeEach(() => { + updateMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("joins measure names with a semicolon and pushes to the named property", async () => { + updateMock.mockResolvedValueOnce(undefined); + const result = await syncMeasuresFieldToHubSpot({ + hubspotDealId: "deal-1", + propName: "instructed_measures", + measureNames: ["ASHP", "Solar PV", "Loft insulation"], + }); + expect(result).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateMock).toHaveBeenCalledWith("deal-1", { + properties: { instructed_measures: "ASHP;Solar PV;Loft insulation" }, + }); + }); + + it("sends an empty string when the list is empty (clear field)", async () => { + updateMock.mockResolvedValueOnce(undefined); + const result = await syncMeasuresFieldToHubSpot({ + hubspotDealId: "deal-2", + propName: "instructed_measures", + measureNames: [], + }); + expect(result).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalledWith("deal-2", { + properties: { instructed_measures: "" }, + }); + }); + + it("retries up to 3 times on ECONNRESET and succeeds on the final attempt", async () => { + vi.useFakeTimers(); + const reset1 = Object.assign(new Error("reset"), { code: "ECONNRESET" }); + const reset2 = Object.assign(new Error("reset"), { code: "ECONNRESET" }); + updateMock + .mockRejectedValueOnce(reset1) + .mockRejectedValueOnce(reset2) + .mockResolvedValueOnce(undefined); + + const promise = syncMeasuresFieldToHubSpot({ + hubspotDealId: "deal-3", + propName: "instructed_measures", + measureNames: ["EWI"], + }); + // Advance timers past the two backoff windows (200ms then 400ms). + await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(400); + const result = await promise; + expect(result).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalledTimes(3); + }); + + it("returns failure when ECONNRESET persists past the third attempt", async () => { + vi.useFakeTimers(); + const reset = Object.assign(new Error("network reset"), { + code: "ECONNRESET", + }); + updateMock + .mockRejectedValueOnce(reset) + .mockRejectedValueOnce(reset) + .mockRejectedValueOnce(reset); + + const promise = syncMeasuresFieldToHubSpot({ + hubspotDealId: "deal-4", + propName: "instructed_measures", + measureNames: ["IWI"], + }); + await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(400); + const result = await promise; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("network reset"); + } + expect(updateMock).toHaveBeenCalledTimes(3); + }); + + it("does not retry for non-ECONNRESET errors", async () => { + const boom = new Error("HubSpot 400 — invalid property value"); + updateMock.mockRejectedValueOnce(boom); + const result = await syncMeasuresFieldToHubSpot({ + hubspotDealId: "deal-5", + propName: "instructed_measures", + measureNames: ["DMevs"], + }); + expect(result).toEqual({ + ok: false, + error: "HubSpot 400 — invalid property value", + }); + expect(updateMock).toHaveBeenCalledTimes(1); + }); + + it("works for any property name (so PIBI slice 4 can reuse it)", async () => { + updateMock.mockResolvedValueOnce(undefined); + await syncMeasuresFieldToHubSpot({ + hubspotDealId: "deal-6", + propName: "pibi_ordered_measures", + measureNames: ["ASHP", "EWI"], + }); + expect(updateMock).toHaveBeenCalledWith("deal-6", { + properties: { pibi_ordered_measures: "ASHP;EWI" }, + }); + }); +}); diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 563c703..05662ad 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -130,6 +130,61 @@ export async function syncContractorDocUploadToHubSpot(params: { } } +/** + * Generic helper for pushing a list of measure names to a multi-value + * HubSpot deal property (e.g. `instructed_measures`, `pibi_ordered_measures`). + * + * HubSpot's multi-select / multi-value text fields use `;` as the native + * delimiter — see how `proposed_measures` is parsed by `parseMeasures`. + * + * Mirrors the retry-on-`ECONNRESET` pattern in + * `pushDealPropertiesToHubSpot` and the other deal-sync helpers in this + * file. Returns a discriminated `{ ok }` so the caller can decide whether + * to stamp `pushed_at` on the local DB row. + * + * Issue #253 introduces this helper; slice 4 (PIBI selections) reuses it. + */ +export async function syncMeasuresFieldToHubSpot(params: { + hubspotDealId: string; + propName: string; + measureNames: ReadonlyArray; +}): Promise<{ ok: true } | { ok: false; error: string }> { + // HubSpot multi-value text properties expect a `;`-joined string. + // Empty list collapses to "" so the field can be cleared. + const value = params.measureNames.join(";"); + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const client = getHubSpotClient(); + await client.crm.deals.basicApi.update(params.hubspotDealId, { + properties: { + [params.propName]: value, + }, + }); + return { ok: true }; + } catch (err) { + const isReset = + err instanceof Error && + "code" in err && + (err as NodeJS.ErrnoException).code === "ECONNRESET"; + if (isReset && attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 200 * attempt)); + continue; + } + const message = + err instanceof Error ? err.message : "HubSpot sync failed"; + console.error("[HubSpot] syncMeasuresFieldToHubSpot failed", { + dealId: params.hubspotDealId, + propName: params.propName, + attempt, + error: err, + }); + return { ok: false, error: message }; + } + } + return { ok: false, error: "HubSpot sync failed" }; +} + export async function syncMeasureApprovalsToHubSpot(params: { hubspotDealId: string; approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>;