mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
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:
parent
54e093891d
commit
4181af6d15
2 changed files with 179 additions and 0 deletions
124
src/app/lib/hubspot/dealSync.test.ts
Normal file
124
src/app/lib/hubspot/dealSync.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue