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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 18:56:19 +00:00
parent 54e093891d
commit 4181af6d15
2 changed files with 179 additions and 0 deletions

View file

@ -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" },
});
});
});

View file

@ -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<string>;
}): 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 }>;