@volter/twin-hubspot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +197 -0
  3. package/client/hubspot-mirror.css +43 -0
  4. package/client/hubspot-mirror.tsx +132 -0
  5. package/dist/client/hubspot-mirror.bundle.js +449 -0
  6. package/dist/client/hubspot-mirror.css +43 -0
  7. package/dist/client/hubspot-mirror.d.ts +15 -0
  8. package/dist/client/hubspot-mirror.js +59 -0
  9. package/dist/client/hubspot-mirror.tsx +132 -0
  10. package/dist/src/accounts.d.ts +30 -0
  11. package/dist/src/accounts.js +122 -0
  12. package/dist/src/cli.d.ts +2 -0
  13. package/dist/src/cli.js +31 -0
  14. package/dist/src/generated/surface.gen.json +1 -0
  15. package/dist/src/generated/ui.gen.json +1 -0
  16. package/dist/src/hubspot-areas.d.ts +10 -0
  17. package/dist/src/hubspot-areas.js +114 -0
  18. package/dist/src/hubspot-budget.d.ts +58 -0
  19. package/dist/src/hubspot-budget.js +176 -0
  20. package/dist/src/hubspot-capabilities.d.ts +3 -0
  21. package/dist/src/hubspot-capabilities.js +1588 -0
  22. package/dist/src/hubspot-conformance.d.ts +16 -0
  23. package/dist/src/hubspot-conformance.js +523 -0
  24. package/dist/src/hubspot-connector.d.ts +125 -0
  25. package/dist/src/hubspot-connector.js +390 -0
  26. package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
  27. package/dist/src/hubspot-deferred-capabilities.js +64 -0
  28. package/dist/src/hubspot-mirror-ui.d.ts +62 -0
  29. package/dist/src/hubspot-mirror-ui.js +152 -0
  30. package/dist/src/hubspot-oauth.d.ts +8 -0
  31. package/dist/src/hubspot-oauth.js +291 -0
  32. package/dist/src/hubspot-server.d.ts +24 -0
  33. package/dist/src/hubspot-server.js +116 -0
  34. package/dist/src/hubspot-twin.d.ts +65 -0
  35. package/dist/src/hubspot-twin.js +1558 -0
  36. package/dist/src/index.d.ts +11 -0
  37. package/dist/src/index.js +94 -0
  38. package/dist/src/manifest.d.ts +2 -0
  39. package/dist/src/manifest.js +68 -0
  40. package/dist/src/portal.d.ts +20 -0
  41. package/dist/src/portal.js +30 -0
  42. package/dist/src/screens/account.d.ts +1 -0
  43. package/dist/src/screens/account.js +139 -0
  44. package/dist/src/screens/crm.d.ts +2 -0
  45. package/dist/src/screens/crm.js +153 -0
  46. package/dist/src/screens/developer.d.ts +4 -0
  47. package/dist/src/screens/developer.js +191 -0
  48. package/dist/src/screens/forms.d.ts +5 -0
  49. package/dist/src/screens/forms.js +126 -0
  50. package/dist/src/screens/page.d.ts +21 -0
  51. package/dist/src/screens/page.js +49 -0
  52. package/dist/src/screens/session.d.ts +1 -0
  53. package/dist/src/screens/session.js +32 -0
  54. package/dist/src/semantics/crm.d.ts +8 -0
  55. package/dist/src/semantics/crm.js +101 -0
  56. package/dist/src/webhooks.d.ts +12 -0
  57. package/dist/src/webhooks.js +77 -0
  58. package/package.json +75 -0
  59. package/src/accounts.ts +127 -0
  60. package/src/cli.ts +29 -0
  61. package/src/generated/surface.gen.json +1 -0
  62. package/src/generated/ui.gen.json +1 -0
  63. package/src/hubspot-areas.ts +155 -0
  64. package/src/hubspot-budget.ts +202 -0
  65. package/src/hubspot-capabilities.ts +1523 -0
  66. package/src/hubspot-conformance.ts +537 -0
  67. package/src/hubspot-connector.ts +419 -0
  68. package/src/hubspot-deferred-capabilities.ts +99 -0
  69. package/src/hubspot-journey.uitest.ts +104 -0
  70. package/src/hubspot-mirror-ui.ts +166 -0
  71. package/src/hubspot-oauth.tsx +296 -0
  72. package/src/hubspot-server.ts +115 -0
  73. package/src/hubspot-twin.ts +1534 -0
  74. package/src/index.ts +152 -0
  75. package/src/manifest.ts +96 -0
  76. package/src/portal.ts +40 -0
  77. package/src/screens/account.tsx +129 -0
  78. package/src/screens/crm.tsx +154 -0
  79. package/src/screens/developer.tsx +181 -0
  80. package/src/screens/forms.tsx +117 -0
  81. package/src/screens/page.tsx +55 -0
  82. package/src/screens/session.tsx +36 -0
  83. package/src/semantics/crm.ts +116 -0
  84. package/src/webhooks.ts +80 -0
@@ -0,0 +1,1588 @@
1
+ // HubSpot capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored
2
+ // top-down from what HubSpot's CRM API actually does — NOT from what this twin has built.
3
+ //
4
+ // The DENOMINATOR's source is `@hubspot/api-client` 14.0.1's generated client: 936 operations
5
+ // across eleven top-level families, enumerated from every `lib/codegen/**/apis/*Api.js` request
6
+ // factory. The eleven families are censused in `hubspot-areas.ts`; the CRM ones are modeled here
7
+ // and the rest carry their area's reason in `hubspot-deferred-capabilities.ts` (the azure
8
+ // precedent). Coverage reads LOW and that is correct — a broad honest denominator beats a thin
9
+ // self-portrait.
10
+ //
11
+ // `verify()` (required to count as done) is ground truth: offline, deterministic, and FAILABLE —
12
+ // a real create → read → assert VALUES, plus the vendor's negative 4xx. Every `expected:'done'`
13
+ // is genuinely claimed, so a broken one shows as a regression.
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { createElement } from 'react';
18
+ import { renderToStaticMarkup } from 'react-dom/server';
19
+ import { assertAreaCensus, checkCapabilities, harnessError, isInfrastructureError, uiDataCoupled, verifyBoundary, } from '@volter/world-tooling';
20
+ import { DealBoard, RecordDetail, RecordsTable } from "../client/hubspot-mirror.js";
21
+ import { HUBSPOT_AREAS, HUBSPOT_AREA_IDS } from "./hubspot-areas.js";
22
+ import { hubspotOAuth, TWIN_HUB_ID } from "./hubspot-oauth.js";
23
+ import { HUBSPOT_DEFERRED_AREA_CAPABILITIES, HUBSPOT_UNMODELED_CRM_CAPABILITIES } from "./hubspot-deferred-capabilities.js";
24
+ import { MIRROR_SECTIONS, createHubspotMirrorServer, groupByStage, recordTitle, stageTone } from "./hubspot-mirror-ui.js";
25
+ import { HUBSPOT_LOCAL_ID_BASE, SEARCH_FILTER_OPERATORS, handleHubspotTwinRequest } from "./hubspot-twin.js";
26
+ import { hubspotRequestForAction, mapCrmObject, pullHubspotAll, pushPendingHubspotActions, syncHubspotFromReal, } from "./hubspot-connector.js";
27
+ const AT = '2026-02-01T00:00:00.000Z';
28
+ async function withRoot(steps) {
29
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-'));
30
+ const h = (s) => handleHubspotTwinRequest({ method: s.m, path: s.p, body: s.b === undefined ? undefined : JSON.stringify(s.b), root, occurredAt: AT });
31
+ try {
32
+ return await verifyBoundary('hubspot.withRoot', () => steps(h));
33
+ }
34
+ finally {
35
+ rmSync(root, { recursive: true, force: true });
36
+ }
37
+ }
38
+ /** Same, but with a CALLER-CHOSEN occurredAt per step — needed wherever a verify repeats an
39
+ * identical transition (the kernel dedupes an action by content + millisecond). */
40
+ async function withClock(steps) {
41
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-clock-'));
42
+ const h = (s) => handleHubspotTwinRequest({ method: s.m, path: s.p, body: s.b === undefined ? undefined : JSON.stringify(s.b), root, occurredAt: s.at ?? AT });
43
+ try {
44
+ return await verifyBoundary('hubspot.withClock', () => steps(h));
45
+ }
46
+ finally {
47
+ rmSync(root, { recursive: true, force: true });
48
+ }
49
+ }
50
+ const ok = (r) => r.status >= 200 && r.status < 300;
51
+ const body = (r) => r.body;
52
+ const errOf = (r) => r.body;
53
+ /**
54
+ * The four keys HubSpot's own error-handling reference shows on every error body. NB the
55
+ * generated `ModelError.d.ts` declares message/correlationId/category (plus optional
56
+ * subCategory/context/links/errors) and NOT `status` — the vendor emits it and `StandardError`
57
+ * declares it, so the twin serving it is correct, but under §6's precedence rule this one key is
58
+ * grounded in the DOCS rather than in a generated type. Said out loud rather than implied.
59
+ */
60
+ const isHubspotError = (r, category) => {
61
+ const e = errOf(r);
62
+ return e?.status === 'error' && e.category === category && typeof e.message === 'string' && e.message.length > 0
63
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(e.correlationId));
64
+ };
65
+ const mk = async (h, type, props) => {
66
+ const r = await h({ m: 'POST', p: `/crm/v3/objects/${type}`, b: { properties: props } });
67
+ return r.status === 201 ? String(body(r).id) : '';
68
+ };
69
+ // ── shorthands ──
70
+ const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
71
+ const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
72
+ // ── connector fakes (offline, no network) ──
73
+ /** A fake portal with two pages of contacts and one of everything else. */
74
+ function fakePortal(opts = {}) {
75
+ const calls = [];
76
+ const execute = async (method, path) => {
77
+ calls.push(`${method} ${path}`);
78
+ if (opts.failOn && path.includes(opts.failOn)) {
79
+ return { httpStatus: opts.httpStatus ?? 401, body: { status: 'error', message: 'refused', category: 'INVALID_AUTHENTICATION', correlationId: 'x' } };
80
+ }
81
+ if (path.startsWith('/crm/v3/owners')) {
82
+ return { httpStatus: 200, body: { results: [{ id: '77', email: 'real@portal.test', firstName: 'Real', lastName: 'Owner', type: 'PERSON', userId: 9, archived: false, createdAt: AT, updatedAt: AT }] } };
83
+ }
84
+ if (path.startsWith('/crm/v3/objects/contacts')) {
85
+ // TWO pages, so the connector's paging.next.after loop is genuinely exercised — and the ids
86
+ // are SPARSE and low, the shape real HubSpot contact ids actually take. §9's revert matrix
87
+ // found the first version of this fixture (501/502) could not expose a count-based mint at
88
+ // all, because both ids sat below the mint's base: the collision the doctrine names needs a
89
+ // pulled id sitting in the gap a naive mint walks through.
90
+ if (!path.includes('after=')) {
91
+ return { httpStatus: 200, body: { results: [{ id: '1001', properties: { email: 'p1@portal.test' }, createdAt: AT, updatedAt: AT, archived: false }], paging: { next: { after: '1003' } } } };
92
+ }
93
+ return { httpStatus: 200, body: { results: [{ id: '1003', properties: { email: 'p2@portal.test' }, createdAt: AT, updatedAt: AT, archived: false }] } };
94
+ }
95
+ if (path.startsWith('/crm/v3/objects/companies')) {
96
+ return { httpStatus: 200, body: { results: [{ id: '1051', properties: { name: 'Portal Co' }, createdAt: AT, updatedAt: AT, archived: false }] } };
97
+ }
98
+ return { httpStatus: 200, body: { results: [] } };
99
+ };
100
+ return { execute, calls };
101
+ }
102
+ export const HUBSPOT_CAPABILITIES = [
103
+ // ══ CRM objects ═════════════════════════════════════════════════════════════════════════
104
+ done('hubspot.objects.create', 'objects', 'POST /crm/v3/objects/{objectType} — create a record (201 SimplePublicObject)', 'api', 'core', () => withRoot(async (h) => {
105
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'a@b.test', firstname: 'Ada' } } });
106
+ const d = body(r);
107
+ return r.status === 201 && d.properties.email === 'a@b.test' && d.properties.firstname === 'Ada'
108
+ && d.properties.hs_object_id === d.id && d.archived === false
109
+ && typeof d.createdAt === 'string' && typeof d.updatedAt === 'string';
110
+ })),
111
+ done('hubspot.objects.create.rejects_bad_properties', 'objects', 'POST rejects a non-string / non-object properties map (400 VALIDATION_ERROR)', 'api', 'core', () => withRoot(async (h) => {
112
+ const missing = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: {} });
113
+ const nested = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: { nested: true } } } });
114
+ return missing.status === 400 && isHubspotError(missing, 'VALIDATION_ERROR')
115
+ && nested.status === 400 && Array.isArray(errOf(nested).errors);
116
+ })),
117
+ done('hubspot.objects.create.unknown_object_type', 'objects', 'An objectType HubSpot cannot infer either → 400 "Unable to infer object type from: …"', 'api', 'core', () => withRoot(async (h) => {
118
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/wombats', b: { properties: { name: 'x' } } });
119
+ const props = await h({ m: 'GET', p: '/crm/v3/properties/wombats' });
120
+ return r.status === 400 && isHubspotError(r, 'VALIDATION_ERROR') && String(errOf(r).message).startsWith('Unable to infer object type from: wombats')
121
+ && props.status === 400;
122
+ })),
123
+ done('hubspot.objects.get', 'objects', 'GET /crm/v3/objects/{objectType}/{objectId} — read a record back', 'api', 'core', () => withRoot(async (h) => {
124
+ const id = await mk(h, 'contacts', { email: 'a@b.test' });
125
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}` });
126
+ return ok(g) && body(g).id === id && body(g).properties.email === 'a@b.test';
127
+ })),
128
+ done('hubspot.objects.get.unknown_404', 'objects', 'An unknown record id → 404 OBJECT_NOT_FOUND in the documented envelope', 'api', 'core', () => withRoot(async (h) => {
129
+ const r = await h({ m: 'GET', p: '/crm/v3/objects/contacts/99999999' });
130
+ return r.status === 404 && isHubspotError(r, 'OBJECT_NOT_FOUND');
131
+ })),
132
+ done('hubspot.objects.get.properties_param', 'objects', '?properties= narrows the returned property map (system properties always ride along)', 'api', 'common', () => withRoot(async (h) => {
133
+ const id = await mk(h, 'contacts', { email: 'a@b.test', firstname: 'Ada', lastname: 'L' });
134
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}?properties=firstname` });
135
+ const p = body(g).properties;
136
+ return ok(g) && p.firstname === 'Ada' && p.lastname === undefined
137
+ && p.hs_object_id === id && typeof p.createdate === 'string' && typeof p.lastmodifieddate === 'string';
138
+ })),
139
+ done('hubspot.objects.list', 'objects', 'GET /crm/v3/objects/{objectType} — the { results } collection envelope', 'api', 'core', () => withRoot(async (h) => {
140
+ await mk(h, 'contacts', { email: 'a@b.test' });
141
+ await mk(h, 'contacts', { email: 'c@d.test' });
142
+ const r = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
143
+ return ok(r) && body(r).results.length === 2 && body(r).results.every((x) => typeof x.id === 'string')
144
+ && body(r).paging === undefined;
145
+ })),
146
+ done('hubspot.objects.list.paging_after', 'objects', 'paging.next.after is the NEXT record id, and following it returns the rest without overlap', 'api', 'core', () => withRoot(async (h) => {
147
+ const ids = [];
148
+ for (let i = 0; i < 5; i += 1)
149
+ ids.push(await mk(h, 'contacts', { email: `p${i}@b.test` }));
150
+ const p1 = await h({ m: 'GET', p: '/crm/v3/objects/contacts?limit=2' });
151
+ const after = body(p1).paging?.next?.after;
152
+ if (body(p1).results.length !== 2 || after !== ids[2])
153
+ return false;
154
+ const p2 = await h({ m: 'GET', p: `/crm/v3/objects/contacts?limit=2&after=${after}` });
155
+ if (body(p2).results.length !== 2 || body(p2).results[0].id !== ids[2])
156
+ return false;
157
+ const p3 = await h({ m: 'GET', p: `/crm/v3/objects/contacts?limit=2&after=${body(p2).paging.next.after}` });
158
+ const seen = [...body(p1).results, ...body(p2).results, ...body(p3).results].map((x) => x.id);
159
+ return body(p3).paging === undefined && new Set(seen).size === 5;
160
+ })),
161
+ done('hubspot.objects.list.limit_cap', 'objects', 'The documented list cap: ?limit above 100 (or below 1) → 400 VALIDATION_ERROR', 'api', 'common', () => withRoot(async (h) => {
162
+ const over = await h({ m: 'GET', p: '/crm/v3/objects/contacts?limit=101' });
163
+ const at = await h({ m: 'GET', p: '/crm/v3/objects/contacts?limit=100' });
164
+ const zero = await h({ m: 'GET', p: '/crm/v3/objects/contacts?limit=0' });
165
+ return over.status === 400 && isHubspotError(over, 'VALIDATION_ERROR') && at.status === 200 && zero.status === 400;
166
+ })),
167
+ done('hubspot.objects.list.archived', 'objects', '?archived=true lists archived records and excludes live ones (and vice versa)', 'api', 'common', () => withRoot(async (h) => {
168
+ const live = await mk(h, 'contacts', { email: 'live@b.test' });
169
+ const gone = await mk(h, 'contacts', { email: 'gone@b.test' });
170
+ await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${gone}` });
171
+ const active = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
172
+ const arch = await h({ m: 'GET', p: '/crm/v3/objects/contacts?archived=true' });
173
+ return body(active).results.length === 1 && body(active).results[0].id === live
174
+ && body(arch).results.length === 1 && body(arch).results[0].id === gone && body(arch).results[0].archived === true;
175
+ })),
176
+ done('hubspot.objects.update', 'objects', 'PATCH merges the given properties and leaves the rest alone', 'api', 'core', () => withClock(async (h) => {
177
+ const c = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'a@b.test', firstname: 'Ada' } }, at: '2026-02-01T00:00:00.000Z' });
178
+ const id = body(c).id;
179
+ const p = await h({ m: 'PATCH', p: `/crm/v3/objects/contacts/${id}`, b: { properties: { lastname: 'Lovelace' } }, at: '2026-02-02T00:00:00.000Z' });
180
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}` });
181
+ return ok(p) && body(g).properties.firstname === 'Ada' && body(g).properties.lastname === 'Lovelace'
182
+ && body(g).properties.email === 'a@b.test'
183
+ && body(g).properties.lastmodifieddate === '2026-02-02T00:00:00.000Z'
184
+ && body(g).properties.createdate === '2026-02-01T00:00:00.000Z';
185
+ })),
186
+ done('hubspot.objects.update.unknown_404', 'objects', 'PATCH on an unknown or archived record → 404 OBJECT_NOT_FOUND', 'api', 'common', () => withRoot(async (h) => {
187
+ const id = await mk(h, 'contacts', { email: 'a@b.test' });
188
+ await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${id}` });
189
+ const gone = await h({ m: 'PATCH', p: `/crm/v3/objects/contacts/${id}`, b: { properties: { firstname: 'x' } } });
190
+ const never = await h({ m: 'PATCH', p: '/crm/v3/objects/contacts/424242', b: { properties: { firstname: 'x' } } });
191
+ return gone.status === 404 && never.status === 404 && isHubspotError(never, 'OBJECT_NOT_FOUND');
192
+ })),
193
+ done('hubspot.objects.archive', 'objects', 'DELETE archives (204, no body) — a later GET is 404 and the list drops it', 'api', 'core', () => withRoot(async (h) => {
194
+ const id = await mk(h, 'contacts', { email: 'a@b.test' });
195
+ const d = await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${id}` });
196
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}` });
197
+ const l = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
198
+ const again = await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${id}` });
199
+ return d.status === 204 && d.body === null && g.status === 404 && body(l).results.length === 0 && again.status === 404;
200
+ })),
201
+ // ── DIRTY-STATE: behavior that only breaks OVER prior state ──
202
+ done('hubspot.objects.id_mint_never_reuses_an_archived_id', 'objects', 'DIRTY STATE: archive then create — the new record never reuses the archived id', 'api', 'core', () => withRoot(async (h) => {
203
+ const a = await mk(h, 'contacts', { email: 'a@b.test' });
204
+ const b2 = await mk(h, 'contacts', { email: 'b@b.test' });
205
+ await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${b2}` });
206
+ await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${a}` });
207
+ const c = await mk(h, 'contacts', { email: 'c@b.test' });
208
+ const d = await mk(h, 'contacts', { email: 'd@b.test' });
209
+ // A count-based mint would answer `a` and `b2` again here and clobber the tombstones.
210
+ if (c === a || c === b2 || d === a || d === b2 || c === d)
211
+ return false;
212
+ const archived = await h({ m: 'GET', p: '/crm/v3/objects/contacts?archived=true' });
213
+ return body(archived).results.length === 2 && Number(c) > Number(b2);
214
+ })),
215
+ done('hubspot.objects.id_mint_never_collides_with_a_pulled_id', 'objects', 'DIRTY STATE: create AFTER a connector pull — a local mint never lands on an observed portal id', 'api', 'core', async () => {
216
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-mint-'));
217
+ try {
218
+ const { execute } = fakePortal();
219
+ await pullHubspotAll(execute, root); // observes contacts 1001 and 1003 — a SPARSE id range
220
+ const r = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties: { email: 'local@b.test' } }), root, occurredAt: AT });
221
+ const minted = String(r.body.id);
222
+ // The mint must clear EVERY observed id AND sit above the namespaced base — and both pulled
223
+ // records must still hold their own values. A count-based mint answers exactly '1003' here
224
+ // (a base of 1000 plus two observed rows plus one) and silently overwrites the second
225
+ // pulled contact, which is why the fixture's ids are sparse rather than contiguous.
226
+ const first = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/objects/contacts/1001', root, occurredAt: AT });
227
+ const second = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/objects/contacts/1003', root, occurredAt: AT });
228
+ // The magnitude is asserted against a LITERAL written here, not against the constant
229
+ // itself: `minted > HUBSPOT_LOCAL_ID_BASE` alone is two constants agreeing, and would stay
230
+ // green if the base were shrunk back into HubSpot's own id range. 1e11 is ~4x the largest
231
+ // HubSpot record id this repo has observed (~2.7e10).
232
+ return r.status === 201
233
+ && HUBSPOT_LOCAL_ID_BASE >= 100_000_000_000
234
+ && minted !== '1001' && minted !== '1003' && Number(minted) > HUBSPOT_LOCAL_ID_BASE
235
+ && first.status === 200 && first.body.properties.email === 'p1@portal.test'
236
+ && second.status === 200 && second.body.properties.email === 'p2@portal.test';
237
+ }
238
+ catch (err) {
239
+ if (isInfrastructureError(err))
240
+ throw harnessError('hubspot.objects.id_mint_never_collides_with_a_pulled_id', err);
241
+ return false;
242
+ }
243
+ finally {
244
+ rmSync(root, { recursive: true, force: true });
245
+ }
246
+ }),
247
+ // The INVERSE direction, which §9 round one found unhunted: a pull arriving AFTER a local
248
+ // create. `mapCrmObject` keys its subject as `<objectType>_<portal id>`, so a portal record
249
+ // numbered like a locally minted one would fold onto the same kernel subject. Two things make
250
+ // that safe, and this pins BOTH:
251
+ // • the mint is NAMESPACED (HUBSPOT_LOCAL_ID_BASE) an order of magnitude above the largest
252
+ // HubSpot record id this repo has observed, so the two ranges cannot meet; and
253
+ // • `mapCrmObject` stamps `hsLocalMint: false`, so an OBSERVED row is addressable upstream
254
+ // while a locally minted one is not — the contrast this verify asserts directly.
255
+ done('hubspot.objects.id_mint_pull_after_local_create', 'objects', 'DIRTY STATE: a connector pull AFTER a local create — ordinary portal ids cannot reach the local mint range, and an observed record is addressable where a minted one is not', 'api', 'core', async () => {
256
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-mint-inverse-'));
257
+ try {
258
+ const local = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties: { email: 'local@b.test' } }), root, occurredAt: '2026-02-01T00:00:00.000Z' });
259
+ const localId = String(local.body.id);
260
+ if (Number(localId) <= HUBSPOT_LOCAL_ID_BASE)
261
+ return false;
262
+ await pullHubspotAll(fakePortal().execute, root, '2026-02-02T00:00:00.000Z');
263
+ const list = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/objects/contacts?limit=100', root, occurredAt: AT });
264
+ const rows = list.body.results;
265
+ const byId = new Map(rows.map((x) => [x.id, x.properties.email]));
266
+ // three DISTINCT records survive, each holding its own value — nothing was folded away.
267
+ if (rows.length !== 3 || byId.get(localId) !== 'local@b.test' || byId.get('1001') !== 'p1@portal.test' || byId.get('1003') !== 'p2@portal.test')
268
+ return false;
269
+ // …and the ranges genuinely cannot meet: every id the portal handed over is below the base.
270
+ if (!['1001', '1003'].every((id) => Number(id) < HUBSPOT_LOCAL_ID_BASE))
271
+ return false;
272
+ // The addressability contrast, asserted on the mapper and the request builder directly: an
273
+ // OBSERVED row carries `hsLocalMint: false` and is addressed by its real id; a MINTED one
274
+ // carries true and can only ever be a create.
275
+ // Read the stamp back OUT OF FOLDED STATE, not only off the mapper's return value: the
276
+ // claim is about what a pull leaves in the projection (§9 round two).
277
+ const { twinResources } = await import('@volter/world-core');
278
+ const folded = twinResources('hubspot', root).find((x) => x.id === 'contacts_1001');
279
+ if (!folded || folded.hsLocalMint !== false)
280
+ return false;
281
+ const localFolded = twinResources('hubspot', root).find((x) => x.id === `contacts_${localId}`);
282
+ if (!localFolded || localFolded.hsLocalMint !== true)
283
+ return false;
284
+ const observedFields = mapCrmObject('contacts', { id: '1001', properties: { email: 'p1@portal.test' }, createdAt: AT, updatedAt: AT, archived: false }).fields;
285
+ if (observedFields.hsLocalMint !== false)
286
+ return false;
287
+ const observedReq = hubspotRequestForAction({ id: 'o', subject: { type: 'crm_object', id: 'contacts_1001' }, fields: { ...observedFields, props: { email: 'edited@portal.test' } } });
288
+ const mintedReq = hubspotRequestForAction({ id: 'm', subject: { type: 'crm_object', id: `contacts_${localId}` }, fields: { objectType: 'contacts', hsId: localId, hsLocalMint: true, props: { email: 'local@b.test' } } });
289
+ return observedReq?.method === 'PATCH' && observedReq.path === '/crm/v3/objects/contacts/1001'
290
+ && mintedReq?.method === 'POST' && mintedReq.path === '/crm/v3/objects/contacts';
291
+ }
292
+ catch (err) {
293
+ if (isInfrastructureError(err))
294
+ throw harnessError('hubspot.objects.id_mint_pull_after_local_create', err);
295
+ return false;
296
+ }
297
+ finally {
298
+ rmSync(root, { recursive: true, force: true });
299
+ }
300
+ }),
301
+ done('hubspot.objects.merge', 'objects', 'POST /merge — the primary keeps its values, its BLANKS fill from the secondary, the secondary archives', 'api', 'common', () => withRoot(async (h) => {
302
+ const primary = await mk(h, 'contacts', { email: 'keep@b.test', firstname: '' });
303
+ const secondary = await mk(h, 'contacts', { email: 'drop@b.test', firstname: 'Filled', phone: '+15550100' });
304
+ const m = await h({ m: 'POST', p: '/crm/v3/objects/contacts/merge', b: { primaryObjectId: primary, objectIdToMerge: secondary } });
305
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${primary}` });
306
+ const s = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${secondary}` });
307
+ return ok(m) && body(m).id === primary
308
+ && body(g).properties.email === 'keep@b.test' // primary's own value wins
309
+ && body(g).properties.firstname === 'Filled' // primary's BLANK filled from secondary
310
+ && body(g).properties.phone === '+15550100' // absent on primary → taken
311
+ && s.status === 404; // secondary archived
312
+ })),
313
+ done('hubspot.objects.merge.rejects_bad_input', 'objects', 'POST /merge refuses missing ids, identical ids, and an unknown id', 'api', 'niche', () => withRoot(async (h) => {
314
+ const a = await mk(h, 'contacts', { email: 'a@b.test' });
315
+ const missing = await h({ m: 'POST', p: '/crm/v3/objects/contacts/merge', b: { primaryObjectId: a } });
316
+ const same = await h({ m: 'POST', p: '/crm/v3/objects/contacts/merge', b: { primaryObjectId: a, objectIdToMerge: a } });
317
+ const gone = await h({ m: 'POST', p: '/crm/v3/objects/contacts/merge', b: { primaryObjectId: a, objectIdToMerge: '424242' } });
318
+ return missing.status === 400 && same.status === 400 && gone.status === 404 && isHubspotError(gone, 'OBJECT_NOT_FOUND');
319
+ })),
320
+ done('hubspot.objects.object_type_id_alias', 'objects', 'The numeric objectTypeId path segment (0-1/0-2/0-3/0-5) resolves to the same records', 'api', 'niche', () => withRoot(async (h) => {
321
+ const id = await mk(h, 'contacts', { email: 'a@b.test' });
322
+ const viaAlias = await h({ m: 'GET', p: `/crm/v3/objects/0-1/${id}` });
323
+ const dealAlias = await h({ m: 'POST', p: '/crm/v3/objects/0-3', b: { properties: { dealname: 'D' } } });
324
+ const dealByName = await h({ m: 'GET', p: '/crm/v3/objects/deals' });
325
+ return ok(viaAlias) && body(viaAlias).id === id && dealAlias.status === 201
326
+ && body(dealByName).results.length === 1 && body(dealByName).results[0].id === body(dealAlias).id;
327
+ })),
328
+ done('hubspot.objects.companies', 'objects', 'The companies object: create/read with its own HubSpot-defined properties', 'api', 'core', () => withRoot(async (h) => {
329
+ const id = await mk(h, 'companies', { name: 'Acme', domain: 'acme.test' });
330
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/companies/${id}` });
331
+ // companies use hs_lastmodifieddate, NOT contacts' lastmodifieddate
332
+ return ok(g) && body(g).properties.name === 'Acme' && typeof body(g).properties.hs_lastmodifieddate === 'string'
333
+ && body(g).properties.lastmodifieddate === undefined;
334
+ })),
335
+ done('hubspot.objects.deals', 'objects', 'The deals object: create/read with dealname, amount, dealstage, pipeline', 'api', 'core', () => withRoot(async (h) => {
336
+ const id = await mk(h, 'deals', { dealname: 'Big One', amount: '2500', dealstage: 'qualifiedtobuy', pipeline: 'default' });
337
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/deals/${id}` });
338
+ return ok(g) && body(g).properties.dealname === 'Big One' && body(g).properties.amount === '2500'
339
+ && body(g).properties.dealstage === 'qualifiedtobuy';
340
+ })),
341
+ done('hubspot.objects.tickets', 'objects', 'The tickets object: create/read with subject, priority and pipeline stage', 'api', 'common', () => withRoot(async (h) => {
342
+ const id = await mk(h, 'tickets', { subject: 'Broken', hs_ticket_priority: 'HIGH', hs_pipeline: '0', hs_pipeline_stage: '1' });
343
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/tickets/${id}` });
344
+ return ok(g) && body(g).properties.subject === 'Broken' && body(g).properties.hs_pipeline_stage === '1';
345
+ })),
346
+ todo('hubspot.objects.get_by_id_property', 'objects', 'GET ?idProperty= — read a record by a unique custom property instead of its record id', 'api', 'common'),
347
+ todo('hubspot.objects.properties_with_history', 'objects', '?propertiesWithHistory= — the per-property ValueWithTimestamp history HubSpot keeps', 'api', 'common'),
348
+ todo('hubspot.objects.gdpr_delete', 'objects', 'POST /crm/v3/objects/contacts/gdpr-delete — permanent GDPR erasure', 'api', 'niche'),
349
+ todo('hubspot.objects.object_write_trace_id', 'objects', 'objectWriteTraceId on create/update, echoed back on the response', 'api', 'niche'),
350
+ todo('hubspot.objects.calculated_properties', 'objects', 'HubSpot-calculated rollup properties (num_associated_contacts, total revenue…)', 'api', 'common'),
351
+ todo('hubspot.objects.required_property_validation', 'objects', 'Per-object required-property validation (a company needs name or domain)', 'api', 'common'),
352
+ todo('hubspot.objects.unique_property_conflict', 'objects', 'A duplicate value on a hasUniqueValue property → 409 CONFLICT', 'api', 'common'),
353
+ todo('hubspot.objects.enumeration_option_validation', 'objects', 'A value outside an enumeration property\'s option set is refused', 'api', 'common'),
354
+ todo('hubspot.objects.number_and_date_property_types', 'objects', 'Type coercion + validation for number/date/datetime/bool property types', 'api', 'common'),
355
+ todo('hubspot.objects.restore_archived', 'objects', 'Restoring an archived record (HubSpot\'s 90-day recycle window)', 'api', 'niche'),
356
+ todo('hubspot.objects.secondary_email', 'objects', 'Contacts\' hs_additional_emails secondary-email semantics', 'api', 'niche'),
357
+ todo('hubspot.objects.company_domain_dedupe', 'objects', 'HubSpot\'s automatic company de-duplication by domain', 'api', 'niche'),
358
+ todo('hubspot.objects.contact_email_dedupe', 'objects', 'HubSpot\'s automatic contact de-duplication by email on create', 'api', 'common'),
359
+ todo('hubspot.objects.lifecycle_stage_transitions', 'objects', 'Lifecycle-stage forward-only transition rules and their timestamps', 'api', 'niche'),
360
+ todo('hubspot.objects.owner_assignment_validation', 'objects', 'hubspot_owner_id must name a real owner', 'api', 'common'),
361
+ // ══ batch ═══════════════════════════════════════════════════════════════════════════════
362
+ done('hubspot.batch.create', 'objects', 'POST /batch/create — 201 BatchResponseSimplePublicObject with distinct ids', 'api', 'core', () => withRoot(async (h) => {
363
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/create', b: { inputs: [{ properties: { email: 'a@b.test' } }, { properties: { email: 'c@d.test' } }] } });
364
+ const d = body(r);
365
+ return r.status === 201 && d.status === 'COMPLETE' && d.results.length === 2
366
+ && d.results[0].id !== d.results[1].id && d.results[1].properties.email === 'c@d.test'
367
+ && typeof d.startedAt === 'string' && typeof d.completedAt === 'string';
368
+ })),
369
+ done('hubspot.batch.read', 'objects', 'POST /batch/read — 200 for a fully-found batch, with the requested properties', 'api', 'core', () => withRoot(async (h) => {
370
+ const a = await mk(h, 'contacts', { email: 'a@b.test', firstname: 'Ada' });
371
+ const b2 = await mk(h, 'contacts', { email: 'c@d.test', firstname: 'Bo' });
372
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/read', b: { properties: ['email'], inputs: [{ id: a }, { id: b2 }] } });
373
+ return r.status === 200 && body(r).results.length === 2
374
+ && body(r).results[0].properties.email === 'a@b.test' && body(r).results[0].properties.firstname === undefined;
375
+ })),
376
+ done('hubspot.batch.read.partial_207', 'objects', 'A batch read with a missing id → 207 MULTI-STATUS carrying the found results AND the errors', 'api', 'common', () => withRoot(async (h) => {
377
+ const a = await mk(h, 'contacts', { email: 'a@b.test' });
378
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/read', b: { properties: [], inputs: [{ id: a }, { id: '424242' }] } });
379
+ const e = body(r).errors[0];
380
+ // `StandardError` declares status/category/message/context/links/errors as REQUIRED — a
381
+ // partial entry would not survive the SDK's own deserializer (§9 round two: unpinned).
382
+ return r.status === 207 && body(r).results.length === 1 && body(r).numErrors === 1
383
+ && e.category === 'OBJECT_NOT_FOUND' && e.status === 'error' && typeof e.message === 'string'
384
+ && Array.isArray(e.context.ids) && e.context.ids[0] === '424242'
385
+ && typeof e.links === 'object' && Array.isArray(e.errors);
386
+ })),
387
+ done('hubspot.batch.read.id_property', 'objects', 'POST /batch/read with idProperty resolves records by a property value', 'api', 'common', () => withRoot(async (h) => {
388
+ const a = await mk(h, 'contacts', { email: 'ada@b.test' });
389
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/read', b: { idProperty: 'email', properties: ['email'], inputs: [{ id: 'ada@b.test' }] } });
390
+ const miss = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/read', b: { idProperty: 'email', properties: [], inputs: [{ id: 'nobody@b.test' }] } });
391
+ return r.status === 200 && body(r).results[0].id === a && miss.status === 207 && body(miss).results.length === 0;
392
+ })),
393
+ done('hubspot.batch.update', 'objects', 'POST /batch/update — 200, and the projection reflects every input', 'api', 'core', () => withClock(async (h) => {
394
+ const c1 = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'a@b.test' } }, at: '2026-02-01T00:00:00.000Z' });
395
+ const c2 = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'c@d.test' } }, at: '2026-02-01T00:00:00.000Z' });
396
+ const a = body(c1).id;
397
+ const b2 = body(c2).id;
398
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/update', b: { inputs: [{ id: a, properties: { firstname: 'Ada' } }, { id: b2, properties: { firstname: 'Bo' } }] }, at: '2026-02-02T00:00:00.000Z' });
399
+ const ga = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${a}` });
400
+ const gb = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${b2}` });
401
+ return r.status === 200 && body(ga).properties.firstname === 'Ada' && body(gb).properties.firstname === 'Bo';
402
+ })),
403
+ done('hubspot.batch.upsert', 'objects', 'POST /batch/upsert by idProperty — new:true on a create, new:false on an update', 'api', 'common', () => withClock(async (h) => {
404
+ const first = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/upsert', b: { inputs: [{ idProperty: 'email', id: 'up@b.test', properties: { firstname: 'One' } }] }, at: '2026-02-01T00:00:00.000Z' });
405
+ if (first.status !== 200 || body(first).results[0].new !== true)
406
+ return false;
407
+ const id = body(first).results[0].id;
408
+ const second = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/upsert', b: { inputs: [{ idProperty: 'email', id: 'up@b.test', properties: { firstname: 'Two' } }] }, at: '2026-02-02T00:00:00.000Z' });
409
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}` });
410
+ const l = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
411
+ return body(second).results[0].new === false && body(second).results[0].id === id
412
+ && body(g).properties.firstname === 'Two' && body(l).results.length === 1;
413
+ })),
414
+ done('hubspot.batch.archive', 'objects', 'POST /batch/archive — 204, and every listed record is gone from the live list', 'api', 'common', () => withRoot(async (h) => {
415
+ const a = await mk(h, 'contacts', { email: 'a@b.test' });
416
+ const b2 = await mk(h, 'contacts', { email: 'c@d.test' });
417
+ const keep = await mk(h, 'contacts', { email: 'e@f.test' });
418
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/archive', b: { inputs: [{ id: a }, { id: b2 }] } });
419
+ const l = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
420
+ return r.status === 204 && r.body === null && body(l).results.length === 1 && body(l).results[0].id === keep;
421
+ })),
422
+ done('hubspot.batch.requires_inputs', 'objects', 'Every batch endpoint refuses a body with no inputs array (400 VALIDATION_ERROR)', 'api', 'common', () => withRoot(async (h) => {
423
+ const ops = ['create', 'read', 'update', 'upsert', 'archive'];
424
+ for (const op of ops) {
425
+ const r = await h({ m: 'POST', p: `/crm/v3/objects/contacts/batch/${op}`, b: {} });
426
+ if (r.status !== 400 || !isHubspotError(r, 'VALIDATION_ERROR'))
427
+ return false;
428
+ }
429
+ const unknown = await h({ m: 'POST', p: '/crm/v3/objects/contacts/batch/frobnicate', b: { inputs: [] } });
430
+ return unknown.status === 404;
431
+ })),
432
+ todo('hubspot.batch.size_cap', 'objects', 'The documented 100-input cap per batch call, refused above it', 'api', 'common'),
433
+ todo('hubspot.batch.upsert_conflict', 'objects', 'Upsert by a non-unique idProperty matching two records → the documented conflict', 'api', 'niche'),
434
+ todo('hubspot.batch.create_partial_207', 'objects', 'A partially-invalid batch CREATE → 207 with per-input errors instead of an all-or-nothing 400', 'api', 'common'),
435
+ todo('hubspot.batch.update_partial_207', 'objects', 'A batch UPDATE naming one missing id → 207 with per-input errors (today the whole batch aborts on that record\'s 404)', 'api', 'common'),
436
+ todo('hubspot.batch.upsert_partial_207', 'objects', 'A partially-invalid batch UPSERT → 207 with per-input errors', 'api', 'common'),
437
+ todo('hubspot.batch.archive_partial_207', 'objects', 'A batch ARCHIVE naming one missing id → 207 with per-input errors', 'api', 'niche'),
438
+ // ══ CRM Search ══════════════════════════════════════════════════════════════════════════
439
+ done('hubspot.search.filter_eq', 'search', 'POST /search — an EQ filter returns the matching records with a total', 'api', 'core', () => withRoot(async (h) => {
440
+ await mk(h, 'contacts', { email: 'a@b.test', lifecyclestage: 'lead' });
441
+ await mk(h, 'contacts', { email: 'c@d.test', lifecyclestage: 'customer' });
442
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' }] }] } });
443
+ return ok(r) && body(r).total === 1 && body(r).results[0].properties.email === 'a@b.test';
444
+ })),
445
+ done('hubspot.search.operator_set_is_closed', 'search', 'Exactly the 13 documented FilterOperatorEnum values are accepted; anything else → 400', 'api', 'core', () => withRoot(async (h) => {
446
+ await mk(h, 'contacts', { email: 'a@b.test' });
447
+ // The allowlist is the GENERATED FilterOperatorEnum from @hubspot/api-client, spelled out
448
+ // here as a literal so this is an oracle rather than the twin agreeing with itself.
449
+ const documented = ['EQ', 'NEQ', 'LT', 'LTE', 'GT', 'GTE', 'BETWEEN', 'IN', 'NOT_IN', 'HAS_PROPERTY', 'NOT_HAS_PROPERTY', 'CONTAINS_TOKEN', 'NOT_CONTAINS_TOKEN'];
450
+ if (documented.length !== SEARCH_FILTER_OPERATORS.length)
451
+ return false;
452
+ for (const operator of documented) {
453
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'email', operator, value: 'a@b.test', values: ['a@b.test'], highValue: '1' }] }] } });
454
+ if (r.status !== 200)
455
+ return false;
456
+ }
457
+ for (const operator of ['LIKE', 'CONTAINS', 'eq', '']) {
458
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'email', operator, value: 'x' }] }] } });
459
+ if (r.status !== 400 || !isHubspotError(r, 'VALIDATION_ERROR'))
460
+ return false;
461
+ }
462
+ const noProp = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ operator: 'EQ', value: 'x' }] }] } });
463
+ return noProp.status === 400;
464
+ })),
465
+ done('hubspot.search.filter_groups_or_and', 'search', 'Filters AND within a group; groups OR with each other', 'api', 'core', () => withRoot(async (h) => {
466
+ await mk(h, 'contacts', { email: 'a@b.test', lifecyclestage: 'lead', firstname: 'Ada' });
467
+ await mk(h, 'contacts', { email: 'c@d.test', lifecyclestage: 'customer', firstname: 'Bo' });
468
+ await mk(h, 'contacts', { email: 'e@f.test', lifecyclestage: 'lead', firstname: 'Cy' });
469
+ const and = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' }, { propertyName: 'firstname', operator: 'EQ', value: 'Ada' }] }] } });
470
+ const or = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'firstname', operator: 'EQ', value: 'Ada' }] }, { filters: [{ propertyName: 'firstname', operator: 'EQ', value: 'Bo' }] }] } });
471
+ return body(and).total === 1 && body(and).results[0].properties.firstname === 'Ada' && body(or).total === 2;
472
+ })),
473
+ done('hubspot.search.comparison_operators', 'search', 'LT/LTE/GT/GTE/BETWEEN compare numerically, not lexically', 'api', 'common', () => withRoot(async (h) => {
474
+ for (const amount of ['5', '50', '500'])
475
+ await mk(h, 'deals', { dealname: `D${amount}`, amount });
476
+ const gt = await h({ m: 'POST', p: '/crm/v3/objects/deals/search', b: { filterGroups: [{ filters: [{ propertyName: 'amount', operator: 'GT', value: '40' }] }] } });
477
+ const between = await h({ m: 'POST', p: '/crm/v3/objects/deals/search', b: { filterGroups: [{ filters: [{ propertyName: 'amount', operator: 'BETWEEN', value: '10', highValue: '100' }] }] } });
478
+ const lte = await h({ m: 'POST', p: '/crm/v3/objects/deals/search', b: { filterGroups: [{ filters: [{ propertyName: 'amount', operator: 'LTE', value: '50' }] }] } });
479
+ // Lexically '5' > '40'; numerically it is not. That difference is the whole assertion.
480
+ return body(gt).total === 2 && body(between).total === 1 && body(lte).total === 2;
481
+ })),
482
+ done('hubspot.search.membership_operators', 'search', 'IN / NOT_IN match against the values[] array', 'api', 'common', () => withRoot(async (h) => {
483
+ for (const stage of ['lead', 'customer', 'other'])
484
+ await mk(h, 'contacts', { email: `${stage}@b.test`, lifecyclestage: stage });
485
+ const inR = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'IN', values: ['lead', 'customer'] }] }] } });
486
+ const notIn = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'NOT_IN', values: ['lead'] }] }] } });
487
+ return body(inR).total === 2 && body(notIn).total === 2;
488
+ })),
489
+ done('hubspot.search.presence_operators', 'search', 'HAS_PROPERTY / NOT_HAS_PROPERTY treat an empty string as absent', 'api', 'common', () => withRoot(async (h) => {
490
+ await mk(h, 'contacts', { email: 'a@b.test', phone: '+15550100' });
491
+ await mk(h, 'contacts', { email: 'c@d.test', phone: '' });
492
+ await mk(h, 'contacts', { email: 'e@f.test' });
493
+ const has = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'phone', operator: 'HAS_PROPERTY' }] }] } });
494
+ const not = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'phone', operator: 'NOT_HAS_PROPERTY' }] }] } });
495
+ return body(has).total === 1 && body(not).total === 2;
496
+ })),
497
+ done('hubspot.search.contains_token', 'search', 'CONTAINS_TOKEN matches whole tokens and honours a trailing * wildcard', 'api', 'common', () => withRoot(async (h) => {
498
+ await mk(h, 'companies', { name: 'Acme Rocket Works' });
499
+ await mk(h, 'companies', { name: 'Globex Industries' });
500
+ const exact = await h({ m: 'POST', p: '/crm/v3/objects/companies/search', b: { filterGroups: [{ filters: [{ propertyName: 'name', operator: 'CONTAINS_TOKEN', value: 'rocket' }] }] } });
501
+ const wild = await h({ m: 'POST', p: '/crm/v3/objects/companies/search', b: { filterGroups: [{ filters: [{ propertyName: 'name', operator: 'CONTAINS_TOKEN', value: 'glob*' }] }] } });
502
+ const partial = await h({ m: 'POST', p: '/crm/v3/objects/companies/search', b: { filterGroups: [{ filters: [{ propertyName: 'name', operator: 'CONTAINS_TOKEN', value: 'ocke' }] }] } });
503
+ const negated = await h({ m: 'POST', p: '/crm/v3/objects/companies/search', b: { filterGroups: [{ filters: [{ propertyName: 'name', operator: 'NOT_CONTAINS_TOKEN', value: 'rocket' }] }] } });
504
+ // A partial token must NOT match — that is what makes this a token match, not a substring.
505
+ return body(exact).total === 1 && body(wild).total === 1 && body(partial).total === 0 && body(negated).total === 1;
506
+ })),
507
+ done('hubspot.search.free_text_query', 'search', 'A bare `query` searches across every property value', 'api', 'common', () => withRoot(async (h) => {
508
+ await mk(h, 'contacts', { email: 'ada@lovelace.test', firstname: 'Ada' });
509
+ await mk(h, 'contacts', { email: 'bo@peep.test', firstname: 'Bo' });
510
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { query: 'lovelace' } });
511
+ const none = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { query: 'nobody' } });
512
+ return body(r).total === 1 && body(r).results[0].properties.firstname === 'Ada' && body(none).total === 0;
513
+ })),
514
+ done('hubspot.search.limit_cap', 'search', 'The documented search page cap: limit above 200 → 400 (200 itself is accepted)', 'api', 'common', () => withRoot(async (h) => {
515
+ const at = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { limit: 200 } });
516
+ const over = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { limit: 201 } });
517
+ const zero = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { limit: 0 } });
518
+ return at.status === 200 && over.status === 400 && isHubspotError(over, 'VALIDATION_ERROR') && zero.status === 400;
519
+ })),
520
+ done('hubspot.search.result_cap', 'search', 'Paging past the documented 10 000-result cap → 400', 'api', 'niche', () => withRoot(async (h) => {
521
+ const inside = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { after: 9800, limit: 200 } });
522
+ const past = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { after: 9801, limit: 200 } });
523
+ const negative = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { after: -1 } });
524
+ return inside.status === 200 && past.status === 400 && negative.status === 400;
525
+ })),
526
+ done('hubspot.search.filter_count_caps', 'search', 'The documented 5 filterGroups / 6 filters per group / 18 filters overall caps', 'api', 'niche', () => withRoot(async (h) => {
527
+ const f = (n) => Array.from({ length: n }, () => ({ propertyName: 'email', operator: 'HAS_PROPERTY' }));
528
+ const groups = (g, per) => ({ filterGroups: Array.from({ length: g }, () => ({ filters: f(per) })) });
529
+ const okGroups = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: groups(3, 6) });
530
+ const tooManyGroups = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: groups(6, 1) });
531
+ const tooManyPerGroup = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: groups(1, 7) });
532
+ const tooManyTotal = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: groups(4, 5) });
533
+ return okGroups.status === 200 && tooManyGroups.status === 400 && tooManyPerGroup.status === 400 && tooManyTotal.status === 400;
534
+ })),
535
+ done('hubspot.search.paging', 'search', 'Search paging: total is the FULL match count and paging.next.after is the next offset', 'api', 'common', () => withRoot(async (h) => {
536
+ for (let i = 0; i < 5; i += 1)
537
+ await mk(h, 'contacts', { email: `s${i}@b.test`, lifecyclestage: 'lead' });
538
+ const p1 = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { limit: 2, filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' }] }] } });
539
+ if (body(p1).total !== 5 || body(p1).results.length !== 2 || body(p1).paging.next.after !== '2')
540
+ return false;
541
+ const p2 = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { limit: 2, after: '2', filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' }] }] } });
542
+ const p3 = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { limit: 2, after: '4', filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' }] }] } });
543
+ const seen = [...body(p1).results, ...body(p2).results, ...body(p3).results].map((x) => x.id);
544
+ return new Set(seen).size === 5 && body(p3).paging === undefined;
545
+ })),
546
+ done('hubspot.search.excludes_archived', 'search', 'Search never returns archived records', 'api', 'common', () => withRoot(async (h) => {
547
+ const a = await mk(h, 'contacts', { email: 'a@b.test', lifecyclestage: 'lead' });
548
+ await mk(h, 'contacts', { email: 'c@d.test', lifecyclestage: 'lead' });
549
+ await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${a}` });
550
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'lifecyclestage', operator: 'EQ', value: 'lead' }] }] } });
551
+ return body(r).total === 1 && body(r).results[0].id !== a;
552
+ })),
553
+ done('hubspot.search.properties_projection', 'search', 'The `properties` array narrows the returned property map on search results', 'api', 'niche', () => withRoot(async (h) => {
554
+ await mk(h, 'contacts', { email: 'a@b.test', firstname: 'Ada', lastname: 'L' });
555
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { properties: ['firstname'] } });
556
+ const p = body(r).results[0].properties;
557
+ return p.firstname === 'Ada' && p.lastname === undefined && p.hs_object_id !== undefined;
558
+ })),
559
+ done('hubspot.search.sorts', 'search', 'A `sorts` entry orders the result page by that property', 'api', 'niche', () => withRoot(async (h) => {
560
+ await mk(h, 'companies', { name: 'Zeta' });
561
+ await mk(h, 'companies', { name: 'Alpha' });
562
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/companies/search', b: { sorts: ['name'] } });
563
+ return body(r).results[0].properties.name === 'Alpha' && body(r).results[1].properties.name === 'Zeta';
564
+ })),
565
+ todo('hubspot.search.sort_direction', 'search', 'The `{ propertyName, direction }` sort object form, and DESCENDING order', 'api', 'common'),
566
+ todo('hubspot.search.associations_filter', 'search', 'Filtering by associations.{objectType} — records associated with a given record', 'api', 'common'),
567
+ todo('hubspot.search.eventual_consistency_window', 'search', 'HubSpot\'s documented indexing delay between a write and its searchability', 'api', 'niche'),
568
+ todo('hubspot.search.crm_search_rate_limit', 'search', 'The Search API\'s own 5-requests-per-second burst limit and its 429', 'api', 'niche'),
569
+ // ══ properties ══════════════════════════════════════════════════════════════════════════
570
+ done('hubspot.properties.list', 'properties', 'GET /crm/v3/properties/{objectType} — the HubSpot-defined property set per object', 'api', 'core', () => withRoot(async (h) => {
571
+ const c = await h({ m: 'GET', p: '/crm/v3/properties/contacts' });
572
+ const d = await h({ m: 'GET', p: '/crm/v3/properties/deals' });
573
+ const names = (r) => body(r).results.map((p) => p.name);
574
+ return ok(c) && ['email', 'firstname', 'lastname', 'hs_object_id'].every((n) => names(c).includes(n))
575
+ && ['dealname', 'amount', 'dealstage', 'pipeline'].every((n) => names(d).includes(n))
576
+ && !names(d).includes('email')
577
+ && body(c).results.find((p) => p.name === 'email').hubspotDefined === true;
578
+ })),
579
+ done('hubspot.properties.create', 'properties', 'POST /crm/v3/properties/{objectType} — 201 Property, hubspotDefined false', 'api', 'core', () => withRoot(async (h) => {
580
+ const r = await h({ m: 'POST', p: '/crm/v3/properties/contacts', b: { name: 'account_tier', label: 'Account Tier', type: 'string', fieldType: 'text', groupName: 'contactinformation', description: 'The tier' } });
581
+ const g = await h({ m: 'GET', p: '/crm/v3/properties/contacts/account_tier' });
582
+ return r.status === 201 && body(r).name === 'account_tier' && body(r).label === 'Account Tier'
583
+ && body(r).hubspotDefined === false && body(g).description === 'The tier';
584
+ })),
585
+ done('hubspot.properties.create.requires_fields', 'properties', 'POST refuses a property missing name/label/type/fieldType/groupName (400 with errors[])', 'api', 'common', () => withRoot(async (h) => {
586
+ for (const omit of ['name', 'label', 'type', 'fieldType', 'groupName']) {
587
+ const full = { name: 'x', label: 'X', type: 'string', fieldType: 'text', groupName: 'contactinformation' };
588
+ delete full[omit];
589
+ const r = await h({ m: 'POST', p: '/crm/v3/properties/contacts', b: full });
590
+ if (r.status !== 400 || !isHubspotError(r, 'VALIDATION_ERROR') || !Array.isArray(errOf(r).errors))
591
+ return false;
592
+ }
593
+ return true;
594
+ })),
595
+ done('hubspot.properties.create.duplicate', 'properties', 'Creating a property whose name already exists → 409', 'api', 'common', () => withRoot(async (h) => {
596
+ const r = await h({ m: 'POST', p: '/crm/v3/properties/contacts', b: { name: 'email', label: 'E', type: 'string', fieldType: 'text', groupName: 'contactinformation' } });
597
+ return r.status === 409 && isHubspotError(r, 'CONFLICT');
598
+ })),
599
+ done('hubspot.properties.get', 'properties', 'GET one property by name; an unknown name → 404 OBJECT_NOT_FOUND', 'api', 'core', () => withRoot(async (h) => {
600
+ const g = await h({ m: 'GET', p: '/crm/v3/properties/contacts/email' });
601
+ const miss = await h({ m: 'GET', p: '/crm/v3/properties/contacts/nope' });
602
+ return ok(g) && body(g).name === 'email' && body(g).groupName === 'contactinformation'
603
+ && miss.status === 404 && isHubspotError(miss, 'OBJECT_NOT_FOUND');
604
+ })),
605
+ done('hubspot.properties.update', 'properties', 'PATCH a property — the change folds and both the single GET and the list reflect it', 'api', 'common', () => withRoot(async (h) => {
606
+ const p = await h({ m: 'PATCH', p: '/crm/v3/properties/contacts/email', b: { label: 'E-mail address' } });
607
+ const g = await h({ m: 'GET', p: '/crm/v3/properties/contacts/email' });
608
+ const l = await h({ m: 'GET', p: '/crm/v3/properties/contacts' });
609
+ const inList = body(l).results.find((x) => x.name === 'email');
610
+ const miss = await h({ m: 'PATCH', p: '/crm/v3/properties/contacts/nope', b: { label: 'x' } });
611
+ return ok(p) && body(g).label === 'E-mail address' && inList.label === 'E-mail address' && miss.status === 404;
612
+ })),
613
+ done('hubspot.properties.archive', 'properties', 'DELETE a property — 204, then GET 404 and the list drops it', 'api', 'common', () => withRoot(async (h) => {
614
+ await h({ m: 'POST', p: '/crm/v3/properties/contacts', b: { name: 'temp_prop', label: 'T', type: 'string', fieldType: 'text', groupName: 'contactinformation' } });
615
+ const d = await h({ m: 'DELETE', p: '/crm/v3/properties/contacts/temp_prop' });
616
+ const g = await h({ m: 'GET', p: '/crm/v3/properties/contacts/temp_prop' });
617
+ const l = await h({ m: 'GET', p: '/crm/v3/properties/contacts' });
618
+ const again = await h({ m: 'DELETE', p: '/crm/v3/properties/contacts/temp_prop' });
619
+ return d.status === 204 && g.status === 404 && !body(l).results.some((x) => x.name === 'temp_prop') && again.status === 404;
620
+ })),
621
+ done('hubspot.properties.custom_property_round_trips', 'properties', 'A custom property\'s value round-trips on a record and is searchable', 'api', 'common', () => withRoot(async (h) => {
622
+ await h({ m: 'POST', p: '/crm/v3/properties/contacts', b: { name: 'account_tier', label: 'Tier', type: 'string', fieldType: 'text', groupName: 'contactinformation' } });
623
+ const id = await mk(h, 'contacts', { email: 'a@b.test', account_tier: 'gold' });
624
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}?properties=account_tier` });
625
+ const s = await h({ m: 'POST', p: '/crm/v3/objects/contacts/search', b: { filterGroups: [{ filters: [{ propertyName: 'account_tier', operator: 'EQ', value: 'gold' }] }] } });
626
+ return body(g).properties.account_tier === 'gold' && body(s).total === 1;
627
+ })),
628
+ done('hubspot.property_groups.crud', 'properties', 'Property groups: list the HubSpot-defined group, create, read, rename, archive', 'api', 'common', () => withRoot(async (h) => {
629
+ const seeded = await h({ m: 'GET', p: '/crm/v3/properties/contacts/groups' });
630
+ if (!body(seeded).results.some((g) => g.name === 'contactinformation'))
631
+ return false;
632
+ const c = await h({ m: 'POST', p: '/crm/v3/properties/contacts/groups', b: { name: 'custom_group', label: 'Custom Group', displayOrder: 4 } });
633
+ if (c.status !== 201 || body(c).label !== 'Custom Group' || body(c).displayOrder !== 4)
634
+ return false;
635
+ const dup = await h({ m: 'POST', p: '/crm/v3/properties/contacts/groups', b: { name: 'custom_group', label: 'Again' } });
636
+ const g = await h({ m: 'GET', p: '/crm/v3/properties/contacts/groups/custom_group' });
637
+ const p = await h({ m: 'PATCH', p: '/crm/v3/properties/contacts/groups/custom_group', b: { label: 'Renamed' } });
638
+ const d = await h({ m: 'DELETE', p: '/crm/v3/properties/contacts/groups/custom_group' });
639
+ const gone = await h({ m: 'GET', p: '/crm/v3/properties/contacts/groups/custom_group' });
640
+ return dup.status === 409 && isHubspotError(dup, 'CONFLICT') && body(g).name === 'custom_group' && body(p).label === 'Renamed' && d.status === 204 && gone.status === 404;
641
+ })),
642
+ done('hubspot.properties.unknown_object_type', 'properties', 'The properties routes refuse an unknown {objectType} the same way the object routes do', 'api', 'niche', () => withRoot(async (h) => {
643
+ const l = await h({ m: 'GET', p: '/crm/v3/properties/wombats' });
644
+ const g = await h({ m: 'GET', p: '/crm/v3/properties/wombats/groups' });
645
+ // …and a real-but-unmodeled type is refused HONESTLY on this family too, not with the
646
+ // vendor's cannot-infer wording.
647
+ const known = await h({ m: 'GET', p: '/crm/v3/properties/notes' });
648
+ return l.status === 400 && g.status === 400 && String(errOf(l).message).startsWith('Unable to infer object type from:')
649
+ && known.status === 404 && String(errOf(known).message).includes('does not model');
650
+ })),
651
+ // "Create a batch of properties" (the CRM properties reference): each input created as POST /crm/v3/properties/{objectType}
652
+ // creates one, answered in the batch envelope; a refused input refuses the batch and nothing of it is written.
653
+ done('hubspot.properties.batch_create', 'properties', 'POST /crm/v3/properties/{objectType}/batch/create — 201 batch of Property (formField kept); one refused input (a taken name, the same name twice, a missing field) refuses the batch, writing none', 'api', 'common', () => withRoot(async (h) => {
654
+ const prop = (name, extra = {}) => ({ name, label: name, type: 'string', fieldType: 'text', groupName: 'contactinformation', ...extra });
655
+ const made = await h({ m: 'POST', p: '/crm/v3/properties/0-1/batch/create', b: { inputs: [prop('dub_id', { formField: true }), prop('dub_partner_email')] } });
656
+ const conflict = await h({ m: 'POST', p: '/crm/v3/properties/contacts/batch/create', b: { inputs: [prop('fresh_one'), prop('dub_id')] } });
657
+ const fresh = await h({ m: 'GET', p: '/crm/v3/properties/contacts/fresh_one' });
658
+ const missing = await h({ m: 'POST', p: '/crm/v3/properties/contacts/batch/create', b: { inputs: [{ name: 'no_label' }] } });
659
+ // one name twice in the same batch is the same conflict, and neither is created
660
+ const twice = await h({ m: 'POST', p: '/crm/v3/properties/contacts/batch/create', b: { inputs: [prop('twin_twice'), prop('twin_twice')] } });
661
+ const twiceRead = await h({ m: 'GET', p: '/crm/v3/properties/contacts/twin_twice' });
662
+ const kept = await h({ m: 'GET', p: '/crm/v3/properties/contacts/dub_id' });
663
+ return made.status === 201 && body(made).status === 'COMPLETE' && body(made).results.map((p) => p.name).join() === 'dub_id,dub_partner_email'
664
+ && body(made).results[0].formField === true && body(made).results[1].formField === false
665
+ && conflict.status === 409 && isHubspotError(conflict, 'CONFLICT') && fresh.status === 404
666
+ && missing.status === 400 && twice.status === 409 && isHubspotError(twice, 'CONFLICT') && twiceRead.status === 404 && body(kept).formField === true;
667
+ })),
668
+ todo('hubspot.properties.batch_read', 'properties', 'POST /crm/v3/properties/{objectType}/batch/read', 'api', 'common'),
669
+ todo('hubspot.properties.batch_archive', 'properties', 'POST /crm/v3/properties/{objectType}/batch/archive', 'api', 'niche'),
670
+ todo('hubspot.properties.enumeration_options', 'properties', 'Enumeration property `options` validation and option ordering', 'api', 'common'),
671
+ todo('hubspot.properties.calculation_formula', 'properties', 'Calculated properties: calculationFormula, calculated:true, read-only enforcement', 'api', 'niche'),
672
+ todo('hubspot.properties.modification_metadata', 'properties', 'modificationMetadata (readOnlyValue/readOnlyDefinition/archivable) on every property', 'api', 'niche'),
673
+ todo('hubspot.properties.referenced_object_type', 'properties', 'referencedObjectType on owner/record-reference properties', 'api', 'niche'),
674
+ todo('hubspot.properties.archive_blocks_hubspot_defined', 'properties', 'HubSpot refuses to archive a hubspotDefined property', 'api', 'common'),
675
+ todo('hubspot.properties.field_type_validation', 'properties', 'The closed PropertyCreateFieldTypeEnum / PropertyCreateTypeEnum vocabularies are enforced', 'api', 'common'),
676
+ todo('hubspot.properties.display_order', 'properties', 'displayOrder ordering of properties within a group', 'api', 'niche'),
677
+ // ══ pipelines ═══════════════════════════════════════════════════════════════════════════
678
+ done('hubspot.pipelines.default_deal_pipeline', 'pipelines', 'The out-of-the-box deal pipeline and its seven documented stage ids', 'api', 'core', () => withRoot(async (h) => {
679
+ const r = await h({ m: 'GET', p: '/crm/v3/pipelines/deals' });
680
+ const p = body(r).results.find((x) => x.id === 'default');
681
+ const stageIds = p ? p.stages.map((s) => s.id) : [];
682
+ return ok(r) && p?.label === 'Sales Pipeline'
683
+ && ['appointmentscheduled', 'qualifiedtobuy', 'presentationscheduled', 'decisionmakerboughtin', 'contractsent', 'closedwon', 'closedlost'].every((s) => stageIds.includes(s))
684
+ && p.stages.find((s) => s.id === 'closedwon').metadata.isClosed === 'true';
685
+ })),
686
+ done('hubspot.pipelines.default_ticket_pipeline', 'pipelines', 'The out-of-the-box ticket pipeline (id "0") and its four stages', 'api', 'common', () => withRoot(async (h) => {
687
+ const r = await h({ m: 'GET', p: '/crm/v3/pipelines/tickets' });
688
+ const p = body(r).results.find((x) => x.id === '0');
689
+ return ok(r) && p?.stages.length === 4 && p.stages.map((s) => s.id).join(',') === '1,2,3,4'
690
+ && p.stages[3].metadata.ticketState === 'CLOSED';
691
+ })),
692
+ done('hubspot.pipelines.create', 'pipelines', 'POST /crm/v3/pipelines/{objectType} — 201 Pipeline with its stages', 'api', 'common', () => withRoot(async (h) => {
693
+ const r = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'Renewals', displayOrder: 2, stages: [{ label: 'Contacted', displayOrder: 0 }, { label: 'Renewed', displayOrder: 1 }] } });
694
+ const l = await h({ m: 'GET', p: '/crm/v3/pipelines/deals' });
695
+ return r.status === 201 && body(r).label === 'Renewals' && body(r).stages.length === 2
696
+ && body(r).stages[0].label === 'Contacted' && body(r).archived === false
697
+ && body(l).results.length === 2;
698
+ })),
699
+ done('hubspot.pipelines.create.requires_fields', 'pipelines', 'POST refuses a pipeline missing label/displayOrder, or a stage missing either', 'api', 'niche', () => withRoot(async (h) => {
700
+ const noLabel = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { displayOrder: 1, stages: [] } });
701
+ const noOrder = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'X', stages: [] } });
702
+ const badStage = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'X', displayOrder: 1, stages: [{ label: 'S' }] } });
703
+ return noLabel.status === 400 && noOrder.status === 400 && badStage.status === 400 && isHubspotError(badStage, 'VALIDATION_ERROR');
704
+ })),
705
+ done('hubspot.pipelines.get', 'pipelines', 'GET one pipeline; an unknown id → 404 OBJECT_NOT_FOUND', 'api', 'common', () => withRoot(async (h) => {
706
+ const g = await h({ m: 'GET', p: '/crm/v3/pipelines/deals/default' });
707
+ const miss = await h({ m: 'GET', p: '/crm/v3/pipelines/deals/nope' });
708
+ return ok(g) && body(g).id === 'default' && miss.status === 404 && isHubspotError(miss, 'OBJECT_NOT_FOUND');
709
+ })),
710
+ done('hubspot.pipelines.update_and_replace', 'pipelines', 'PATCH renames; PUT replaces and REQUIRES the full body', 'api', 'common', () => withRoot(async (h) => {
711
+ const c = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'Renewals', displayOrder: 2, stages: [{ label: 'A', displayOrder: 0 }, { label: 'B', displayOrder: 1 }] } });
712
+ const id = body(c).id;
713
+ const patched = await h({ m: 'PATCH', p: `/crm/v3/pipelines/deals/${id}`, b: { label: 'Renamed' } });
714
+ if (!ok(patched) || body(patched).label !== 'Renamed' || body(patched).stages.length !== 2)
715
+ return false;
716
+ const partialPut = await h({ m: 'PUT', p: `/crm/v3/pipelines/deals/${id}`, b: { label: 'Half' } });
717
+ const put = await h({ m: 'PUT', p: `/crm/v3/pipelines/deals/${id}`, b: { label: 'Replaced', displayOrder: 3, stages: [{ label: 'Only', displayOrder: 0 }] } });
718
+ const g = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}` });
719
+ return partialPut.status === 400 && ok(put) && body(g).label === 'Replaced' && body(g).stages.length === 1;
720
+ })),
721
+ done('hubspot.pipelines.archive', 'pipelines', 'DELETE a pipeline — 204, then GET 404 and the list drops it', 'api', 'niche', () => withRoot(async (h) => {
722
+ const c = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'Temp', displayOrder: 9, stages: [] } });
723
+ const id = body(c).id;
724
+ const d = await h({ m: 'DELETE', p: `/crm/v3/pipelines/deals/${id}` });
725
+ const g = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}` });
726
+ const l = await h({ m: 'GET', p: '/crm/v3/pipelines/deals' });
727
+ return d.status === 204 && g.status === 404 && body(l).results.length === 1;
728
+ })),
729
+ done('hubspot.pipelines.stages_crud', 'pipelines', 'Stages: list, add (201), read, rename, replace and remove — all folded into the pipeline', 'api', 'common', () => withRoot(async (h) => {
730
+ const c = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'P', displayOrder: 2, stages: [{ label: 'A', displayOrder: 0 }] } });
731
+ const id = body(c).id;
732
+ const add = await h({ m: 'POST', p: `/crm/v3/pipelines/deals/${id}/stages`, b: { label: 'B', displayOrder: 1, metadata: { isClosed: 'false' } } });
733
+ if (add.status !== 201 || body(add).label !== 'B' || body(add).metadata.isClosed !== 'false')
734
+ return false;
735
+ const sid = body(add).id;
736
+ const list = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}/stages` });
737
+ if (body(list).results.length !== 2)
738
+ return false;
739
+ const get = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}/stages/${sid}` });
740
+ const patch = await h({ m: 'PATCH', p: `/crm/v3/pipelines/deals/${id}/stages/${sid}`, b: { label: 'B2' } });
741
+ const put = await h({ m: 'PUT', p: `/crm/v3/pipelines/deals/${id}/stages/${sid}`, b: { label: 'B3', displayOrder: 5 } });
742
+ const badPut = await h({ m: 'PUT', p: `/crm/v3/pipelines/deals/${id}/stages/${sid}`, b: { label: 'B4' } });
743
+ const del = await h({ m: 'DELETE', p: `/crm/v3/pipelines/deals/${id}/stages/${sid}` });
744
+ const gone = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}/stages/${sid}` });
745
+ const after = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}` });
746
+ return body(get).id === sid && body(patch).label === 'B2' && body(put).displayOrder === 5
747
+ && badPut.status === 400 && del.status === 204 && gone.status === 404 && body(after).stages.length === 1;
748
+ })),
749
+ done('hubspot.pipelines.stage_id_mint_never_reuses', 'pipelines', 'DIRTY STATE: remove a stage then add one — the new stage never reuses the removed id', 'api', 'niche', () => withRoot(async (h) => {
750
+ const c = await h({ m: 'POST', p: '/crm/v3/pipelines/deals', b: { label: 'P', displayOrder: 2, stages: [{ label: 'A', displayOrder: 0 }, { label: 'B', displayOrder: 1 }] } });
751
+ const id = body(c).id;
752
+ const stages = body(c).stages.map((s) => s.id);
753
+ await h({ m: 'DELETE', p: `/crm/v3/pipelines/deals/${id}/stages/${stages[0]}` });
754
+ const added = await h({ m: 'POST', p: `/crm/v3/pipelines/deals/${id}/stages`, b: { label: 'C', displayOrder: 2 } });
755
+ const after = await h({ m: 'GET', p: `/crm/v3/pipelines/deals/${id}` });
756
+ // A count-based mint would answer stages[0] again and silently take C's place.
757
+ return body(added).id !== stages[0] && body(after).stages.length === 2
758
+ && new Set(body(after).stages.map((s) => s.id)).size === 2;
759
+ })),
760
+ done('hubspot.pipelines.unsupported_object_type', 'pipelines', 'Only deals and tickets have pipelines — contacts/companies are refused (400)', 'api', 'niche', () => withRoot(async (h) => {
761
+ const contacts = await h({ m: 'GET', p: '/crm/v3/pipelines/contacts' });
762
+ const companies = await h({ m: 'GET', p: '/crm/v3/pipelines/companies' });
763
+ const deals = await h({ m: 'GET', p: '/crm/v3/pipelines/deals' });
764
+ return contacts.status === 400 && companies.status === 400 && deals.status === 200
765
+ && String(errOf(contacts).message).includes('does not support pipelines');
766
+ })),
767
+ todo('hubspot.pipelines.audit', 'pipelines', 'GET /crm/v3/pipelines/{objectType}/{pipelineId}/audit — the pipeline change audit trail', 'api', 'niche'),
768
+ todo('hubspot.pipelines.stage_audit', 'pipelines', 'GET …/stages/{stageId}/audit — the stage change audit trail', 'api', 'niche'),
769
+ todo('hubspot.pipelines.write_permissions', 'pipelines', 'PipelineStage writePermissions enforcement (CRM_PERMISSIONS_ENFORCEMENT vs READ_ONLY)', 'api', 'niche'),
770
+ todo('hubspot.pipelines.stage_validation_on_records', 'pipelines', 'A deal\'s dealstage must name a stage that exists in its pipeline', 'api', 'common'),
771
+ todo('hubspot.pipelines.probability_metadata', 'pipelines', 'Stage probability metadata and its effect on forecast properties', 'api', 'niche'),
772
+ todo('hubspot.pipelines.archive_blocks_default', 'pipelines', 'HubSpot refuses to archive a portal\'s only/default pipeline', 'api', 'niche'),
773
+ // ══ owners ══════════════════════════════════════════════════════════════════════════════
774
+ done('hubspot.owners.list', 'owners', 'GET /crm/v3/owners — the portal\'s owners in the PublicOwner shape', 'api', 'common', () => withRoot(async (h) => {
775
+ const r = await h({ m: 'GET', p: '/crm/v3/owners' });
776
+ const o = body(r).results[0];
777
+ return ok(r) && body(r).results.length >= 1 && o.id === '1' && o.type === 'PERSON'
778
+ && typeof o.email === 'string' && Array.isArray(o.teams) && o.teams[0].primary === true;
779
+ })),
780
+ done('hubspot.owners.get', 'owners', 'GET /crm/v3/owners/{ownerId}; an unknown id → 404 OBJECT_NOT_FOUND', 'api', 'common', () => withRoot(async (h) => {
781
+ const g = await h({ m: 'GET', p: '/crm/v3/owners/1' });
782
+ const miss = await h({ m: 'GET', p: '/crm/v3/owners/9999' });
783
+ return ok(g) && body(g).id === '1' && miss.status === 404 && isHubspotError(miss, 'OBJECT_NOT_FOUND');
784
+ })),
785
+ done('hubspot.owners.filter_email', 'owners', 'GET /crm/v3/owners?email= narrows to that owner', 'api', 'niche', () => withRoot(async (h) => {
786
+ const all = await h({ m: 'GET', p: '/crm/v3/owners' });
787
+ const hit = await h({ m: 'GET', p: `/crm/v3/owners?email=${encodeURIComponent(body(all).results[0].email)}` });
788
+ const miss = await h({ m: 'GET', p: '/crm/v3/owners?email=nobody@nowhere.test' });
789
+ return body(hit).results.length === 1 && body(miss).results.length === 0;
790
+ })),
791
+ done('hubspot.owners.pulled_owners_surface', 'owners', 'Owners observed by a connector pull are served by the owners API', 'connector', 'common', async () => {
792
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-owners-'));
793
+ try {
794
+ const { execute } = fakePortal();
795
+ await pullHubspotAll(execute, root);
796
+ const r = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/owners', root, occurredAt: AT });
797
+ const ids = r.body.results.map((o) => o.id);
798
+ const one = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/owners/77', root, occurredAt: AT });
799
+ return ids.includes('77') && one.body.email === 'real@portal.test';
800
+ }
801
+ catch (err) {
802
+ if (isInfrastructureError(err))
803
+ throw harnessError('hubspot.owners.pulled_owners_surface', err);
804
+ return false;
805
+ }
806
+ finally {
807
+ rmSync(root, { recursive: true, force: true });
808
+ }
809
+ }),
810
+ todo('hubspot.owners.archived_filter', 'owners', 'GET /crm/v3/owners?archived=true — deactivated owners', 'api', 'niche'),
811
+ todo('hubspot.owners.teams', 'owners', 'GET /settings/v3/users/teams — the team roster owners reference', 'api', 'niche'),
812
+ todo('hubspot.owners.queues', 'owners', 'Owner type QUEUE (a shared inbox/queue owner rather than a person)', 'api', 'niche'),
813
+ // ══ associations ════════════════════════════════════════════════════════════════════════
814
+ done('hubspot.associations.create_default', 'associations', 'PUT …/associations/default/… uses HubSpot\'s documented default type id (contact→company 279)', 'api', 'core', () => withRoot(async (h) => {
815
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
816
+ const co = await mk(h, 'companies', { name: 'Acme' });
817
+ const r = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/default/companies/${co}` });
818
+ return ok(r) && body(r).status === 'COMPLETE'
819
+ && body(r).results[0].associationSpec.associationTypeId === 279
820
+ && body(r).results[0].associationSpec.associationCategory === 'HUBSPOT_DEFINED'
821
+ && body(r).results[0].to.id === co;
822
+ })),
823
+ done('hubspot.associations.default_is_bidirectional', 'associations', 'A default association is readable from BOTH records, each with its own direction\'s type id', 'api', 'core', () => withRoot(async (h) => {
824
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
825
+ const co = await mk(h, 'companies', { name: 'Acme' });
826
+ await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/default/companies/${co}` });
827
+ const forward = await h({ m: 'GET', p: `/crm/v4/objects/contacts/${c}/associations/companies` });
828
+ const back = await h({ m: 'GET', p: `/crm/v4/objects/companies/${co}/associations/contacts` });
829
+ return body(forward).results[0].toObjectId === co && body(forward).results[0].associationTypes[0].typeId === 279
830
+ && body(back).results[0].toObjectId === c && body(back).results[0].associationTypes[0].typeId === 280;
831
+ })),
832
+ done('hubspot.associations.create_with_spec', 'associations', 'PUT with an AssociationSpec[] body → 201 LabelsBetweenObjectPair, and the spec round-trips onto the association', 'api', 'common', () => withRoot(async (h) => {
833
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
834
+ const co = await mk(h, 'companies', { name: 'Acme' });
835
+ const r = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}`, b: [{ associationCategory: 'USER_DEFINED', associationTypeId: 42 }] });
836
+ const g = await h({ m: 'GET', p: `/crm/v4/objects/contacts/${c}/associations/companies` });
837
+ return r.status === 201
838
+ // the numeric objectTypeIds HubSpot reports, NOT the plural path segments
839
+ && body(r).fromObjectTypeId === '0-1' && body(r).toObjectTypeId === '0-2'
840
+ && body(r).fromObjectId === c && body(r).toObjectId === co
841
+ && body(g).results[0].toObjectId === co
842
+ && body(g).results[0].associationTypes[0].typeId === 42
843
+ && body(g).results[0].associationTypes[0].category === 'USER_DEFINED';
844
+ })),
845
+ // §9 round one: the previous version of the capability above PASSED ONLY BECAUSE the handler
846
+ // accepted a `label` on the request body and echoed it back. `AssociationSpec` declares exactly
847
+ // two keys and `label` is not one of them — on real HubSpot a label is RESOLVED from the
848
+ // association-type DEFINITION (`hubspot.associations.label_definitions`, unmodeled here). This
849
+ // pins the anti-invention property directly: a caller-supplied `label` must not come back.
850
+ done('hubspot.associations.spec_is_a_closed_two_key_shape', 'associations', 'A caller-supplied `label` on an AssociationSpec is NOT a request field and is never echoed back', 'api', 'common', () => withRoot(async (h) => {
851
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
852
+ const co = await mk(h, 'companies', { name: 'Acme' });
853
+ const r = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}`, b: [{ associationCategory: 'USER_DEFINED', associationTypeId: 42, label: 'Decision maker' }] });
854
+ const g = await h({ m: 'GET', p: `/crm/v4/objects/contacts/${c}/associations/companies` });
855
+ const batch = await h({ m: 'POST', p: '/crm/v4/associations/contacts/companies/batch/create', b: { inputs: [{ from: { id: c }, to: { id: co }, types: [{ associationCategory: 'USER_DEFINED', associationTypeId: 43, label: 'Smuggled' }] }] } });
856
+ const flat = JSON.stringify([r.body, g.body, batch.body]);
857
+ // `labels` stays EMPTY (no definitions are modeled) and the smuggled string appears nowhere.
858
+ return r.status === 201 && Array.isArray(body(r).labels) && body(r).labels.length === 0
859
+ // `AssociationSpecWithLabel` declares `label?: string` — OPTIONAL, so an unlabeled
860
+ // association carries NO key at all. An explicit null would be an invented value.
861
+ && body(g).results[0].associationTypes.every((t) => !('label' in t))
862
+ && !flat.includes('Decision maker') && !flat.includes('Smuggled');
863
+ })),
864
+ done('hubspot.associations.category_set_is_closed', 'associations', 'Only the three documented AssociationSpec categories are accepted; anything else → 400', 'api', 'common', () => withRoot(async (h) => {
865
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
866
+ const co = await mk(h, 'companies', { name: 'Acme' });
867
+ // The generated AssociationSpecAssociationCategoryEnum, spelled out as a literal here.
868
+ for (const associationCategory of ['HUBSPOT_DEFINED', 'USER_DEFINED', 'INTEGRATOR_DEFINED']) {
869
+ const r = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}`, b: [{ associationCategory, associationTypeId: 1 }] });
870
+ if (r.status !== 201)
871
+ return false;
872
+ }
873
+ for (const associationCategory of ['CUSTOM', 'hubspot_defined', '']) {
874
+ const r = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}`, b: [{ associationCategory, associationTypeId: 1 }] });
875
+ if (r.status !== 400 || !isHubspotError(r, 'VALIDATION_ERROR'))
876
+ return false;
877
+ }
878
+ const noId = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}`, b: [{ associationCategory: 'USER_DEFINED', associationTypeId: 'x' }] });
879
+ const empty = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}`, b: [] });
880
+ return noId.status === 400 && empty.status === 400;
881
+ })),
882
+ done('hubspot.associations.unknown_record_404', 'associations', 'Associating to or from a record that does not exist → 404 OBJECT_NOT_FOUND', 'api', 'common', () => withRoot(async (h) => {
883
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
884
+ const noFrom = await h({ m: 'PUT', p: '/crm/v4/objects/contacts/424242/associations/default/companies/1' });
885
+ const noTo = await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/default/companies/424242` });
886
+ return noFrom.status === 404 && noTo.status === 404 && isHubspotError(noTo, 'OBJECT_NOT_FOUND');
887
+ })),
888
+ done('hubspot.associations.archive', 'associations', 'DELETE an association — 204, and it is gone from BOTH directions', 'api', 'common', () => withRoot(async (h) => {
889
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
890
+ const co = await mk(h, 'companies', { name: 'Acme' });
891
+ await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/default/companies/${co}` });
892
+ const d = await h({ m: 'DELETE', p: `/crm/v4/objects/contacts/${c}/associations/companies/${co}` });
893
+ const forward = await h({ m: 'GET', p: `/crm/v4/objects/contacts/${c}/associations/companies` });
894
+ const back = await h({ m: 'GET', p: `/crm/v4/objects/companies/${co}/associations/contacts` });
895
+ return d.status === 204 && body(forward).results.length === 0 && body(back).results.length === 0;
896
+ })),
897
+ done('hubspot.associations.on_create', 'associations', 'The `associations` block on a create body associates the new record in the same call', 'api', 'common', () => withRoot(async (h) => {
898
+ const co = await mk(h, 'companies', { name: 'Acme' });
899
+ const r = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'a@b.test' }, associations: [{ to: { id: co }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 279 }] }] } });
900
+ if (r.status !== 201)
901
+ return false;
902
+ const g = await h({ m: 'GET', p: `/crm/v4/objects/contacts/${body(r).id}/associations/companies` });
903
+ const bad = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'c@d.test' }, associations: [{ to: { id: '424242' }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 279 }] }] } });
904
+ return body(g).results[0].toObjectId === co && bad.status === 404;
905
+ })),
906
+ done('hubspot.associations.rendered_on_get', 'associations', '?associations= renders the associated ids on a record read', 'api', 'common', () => withRoot(async (h) => {
907
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
908
+ const co = await mk(h, 'companies', { name: 'Acme' });
909
+ await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/default/companies/${co}` });
910
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${c}?associations=companies` });
911
+ const without = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${c}` });
912
+ return body(g).associations.companies.results[0].id === co
913
+ && body(g).associations.companies.results[0].type === 'contact_to_company'
914
+ && body(without).associations === undefined;
915
+ })),
916
+ done('hubspot.associations.batch', 'associations', 'v4 batch create / read / archive over the association pair', 'api', 'common', () => withRoot(async (h) => {
917
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
918
+ const co1 = await mk(h, 'companies', { name: 'Acme' });
919
+ const co2 = await mk(h, 'companies', { name: 'Globex' });
920
+ const create = await h({ m: 'POST', p: '/crm/v4/associations/contacts/companies/batch/create', b: { inputs: [
921
+ { from: { id: c }, to: { id: co1 }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 279 }] },
922
+ { from: { id: c }, to: { id: co2 }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 279 }] },
923
+ ] } });
924
+ if (create.status !== 201 || body(create).results.length !== 2)
925
+ return false;
926
+ if (body(create).results[0].fromObjectTypeId !== '0-1' || body(create).results[0].toObjectTypeId !== '0-2')
927
+ return false;
928
+ const read = await h({ m: 'POST', p: '/crm/v4/associations/contacts/companies/batch/read', b: { inputs: [{ id: c }] } });
929
+ if (body(read).results[0].to.length !== 2)
930
+ return false;
931
+ const archive = await h({ m: 'POST', p: '/crm/v4/associations/contacts/companies/batch/archive', b: { inputs: [{ from: { id: c }, to: [{ id: co1 }] }] } });
932
+ const after = await h({ m: 'POST', p: '/crm/v4/associations/contacts/companies/batch/read', b: { inputs: [{ id: c }] } });
933
+ const bad = await h({ m: 'POST', p: '/crm/v4/associations/contacts/companies/batch/create', b: { inputs: [{ from: { id: c }, to: { id: co1 } }] } });
934
+ return archive.status === 204 && body(after).results[0].to.length === 1 && body(after).results[0].to[0].toObjectId === co2 && bad.status === 400;
935
+ })),
936
+ done('hubspot.associations.unmodeled_default_pair_refused', 'associations', 'A pair with no documented default type id is REFUSED rather than given a fabricated one', 'api', 'niche', () => withRoot(async (h) => {
937
+ const t = await mk(h, 'tickets', { subject: 'T' });
938
+ const co = await mk(h, 'companies', { name: 'Acme' });
939
+ const r = await h({ m: 'PUT', p: `/crm/v4/objects/tickets/${t}/associations/default/companies/${co}` });
940
+ // The twin has no source for a ticket→company default id, so it refuses instead of
941
+ // inventing one — the honest failure `hubspot.associations.default_pairs_beyond_the_eight`
942
+ // files as the gap.
943
+ return r.status === 400 && isHubspotError(r, 'VALIDATION_ERROR') && String(errOf(r).message).includes('standard CRM pairs');
944
+ })),
945
+ done('hubspot.associations.unmodeled_type_in_query_is_refused', 'associations', '?associations=<an unmodeled object type> is REFUSED, not silently dropped from the response', 'api', 'common', () => withRoot(async (h) => {
946
+ const c = await mk(h, 'contacts', { email: 'a@b.test' });
947
+ const co = await mk(h, 'companies', { name: 'Acme' });
948
+ await h({ m: 'PUT', p: `/crm/v4/objects/contacts/${c}/associations/default/companies/${co}` });
949
+ // §9 round two: this used to answer 200 with the block simply absent — a silent partial
950
+ // success, where the PATH form of the same question is an honest 404.
951
+ const one = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${c}?associations=notes` });
952
+ const listed = await h({ m: 'GET', p: '/crm/v3/objects/contacts?associations=notes' });
953
+ const nonsense = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${c}?associations=wombats` });
954
+ const good = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${c}?associations=companies` });
955
+ return one.status === 404 && String(errOf(one).message).includes('does not model')
956
+ && listed.status === 404
957
+ && nonsense.status === 400 && isHubspotError(nonsense, 'VALIDATION_ERROR')
958
+ && good.status === 200 && body(good).associations.companies.results[0].id === co;
959
+ })),
960
+ todo('hubspot.associations.default_pairs_beyond_the_eight', 'associations', 'HubSpot-defined default type ids for every standard pair (tickets↔companies, deals↔tickets, engagements↔…)', 'api', 'common'),
961
+ todo('hubspot.associations.label_definitions', 'associations', 'CRUD on /crm/v4/associations/{from}/{to}/labels — user-defined association labels', 'api', 'common'),
962
+ todo('hubspot.associations.schema_types', 'associations', 'GET /crm/v3/associations/{from}/{to}/types — the association type catalogue', 'api', 'common'),
963
+ todo('hubspot.associations.limits', 'associations', 'Association definition CONFIGURATIONS (per-pair cardinality limits) and their enforcement', 'api', 'niche'),
964
+ todo('hubspot.associations.paging', 'associations', 'paging.next.after on an association list with more than one page', 'api', 'common'),
965
+ todo('hubspot.associations.primary_type_ids', 'associations', 'The "primary" default variants (contact→primary company = 1, deal→primary company = 5)', 'api', 'common'),
966
+ todo('hubspot.associations.v3_batch_compat', 'associations', 'The legacy v3 /crm/v3/associations/{from}/{to}/batch/* endpoints', 'api', 'niche'),
967
+ todo('hubspot.associations.high_usage_report', 'associations', 'POST /crm/v4/associations/usage/high-usage-report/{userId}', 'api', 'niche'),
968
+ // ══ protocol fidelity ═══════════════════════════════════════════════════════════════════
969
+ done('hubspot.protocol.error_envelope', 'protocol', 'Errors carry { status:"error", message, correlationId, category } — and errors[] where the vendor does', 'api', 'core', () => withRoot(async (h) => {
970
+ const notFound = await h({ m: 'GET', p: '/crm/v3/objects/contacts/424242' });
971
+ const validation = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: { bad: 1 } } } });
972
+ const e = errOf(validation);
973
+ return isHubspotError(notFound, 'OBJECT_NOT_FOUND') && isHubspotError(validation, 'VALIDATION_ERROR')
974
+ && Array.isArray(e.errors) && typeof e.errors[0].message === 'string'
975
+ && typeof e.errors[0].code === 'string';
976
+ })),
977
+ done('hubspot.protocol.unmodeled_404', 'protocol', 'An unmodeled operation fails like the vendor (404), never a fake success', 'api', 'core', () => withRoot(async (h) => {
978
+ // Paths HubSpot has no route for at all. A WRONG METHOD on a routed path is deliberately
979
+ // NOT asserted here: HubSpot may answer 405 for it and the SDK gives no oracle either way,
980
+ // so claiming 404 would be inventing a refusal (`hubspot.protocol.method_not_allowed`).
981
+ const nonsense = await h({ m: 'GET', p: '/crm/v3/nonsense' });
982
+ const deep = await h({ m: 'GET', p: '/crm/v9/objects/contacts' });
983
+ const subresource = await h({ m: 'GET', p: '/crm/v3/objects/contacts/1/frobnicate' });
984
+ return nonsense.status === 404 && deep.status === 404 && subresource.status === 404
985
+ && isHubspotError(nonsense, 'OBJECT_NOT_FOUND');
986
+ })),
987
+ done('hubspot.protocol.read_only', 'protocol', 'A read-only twin serves reads and refuses every write with 405', 'api', 'common', async () => {
988
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-ro-'));
989
+ try {
990
+ const write = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: '{}', root, readOnly: true });
991
+ const del = await handleHubspotTwinRequest({ method: 'DELETE', path: '/crm/v3/objects/contacts/1', root, readOnly: true });
992
+ const read = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/objects/contacts', root, readOnly: true });
993
+ return write.status === 405 && del.status === 405 && read.status === 200
994
+ && write.body.category === 'TWIN_READ_ONLY';
995
+ }
996
+ catch (err) {
997
+ if (isInfrastructureError(err))
998
+ throw harnessError('hubspot.protocol.read_only', err);
999
+ return false;
1000
+ }
1001
+ finally {
1002
+ rmSync(root, { recursive: true, force: true });
1003
+ }
1004
+ }),
1005
+ done('hubspot.protocol.rate_limit_headers', 'protocol', 'Every response carries HubSpot\'s documented rate-limit POLICY headers', 'api', 'common', () => withRoot(async (h) => {
1006
+ const okRes = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
1007
+ const errRes = await h({ m: 'GET', p: '/crm/v3/objects/contacts/424242' });
1008
+ const created = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'a@b.test' } } });
1009
+ const del = await h({ m: 'DELETE', p: `/crm/v3/objects/contacts/${body(created).id}` });
1010
+ const has = (r) => r.headers?.['x-hubspot-ratelimit-max'] === '190'
1011
+ && r.headers?.['x-hubspot-ratelimit-interval-milliseconds'] === '10000'
1012
+ && r.headers?.['x-hubspot-ratelimit-daily'] === '625000';
1013
+ // …and NOT the two `-Remaining` counters: a local twin cannot report a truthful account-wide
1014
+ // remaining count without wall-clock metering, and inventing one would break replayability.
1015
+ const noCounters = (r) => r.headers?.['x-hubspot-ratelimit-remaining'] === undefined
1016
+ && r.headers?.['x-hubspot-ratelimit-daily-remaining'] === undefined;
1017
+ return [okRes, errRes, created, del].every((r) => has(r) && noCounters(r));
1018
+ })),
1019
+ done('hubspot.protocol.deterministic_correlation_id', 'protocol', 'Serve-path determinism: an identical request answers byte-identically, correlationId included', 'api', 'core', () => withRoot(async (h) => {
1020
+ const a = await h({ m: 'GET', p: '/crm/v3/objects/contacts/424242' });
1021
+ const b2 = await h({ m: 'GET', p: '/crm/v3/objects/contacts/424242' });
1022
+ const other = await h({ m: 'GET', p: '/crm/v3/objects/companies/424242' });
1023
+ return JSON.stringify(a.body) === JSON.stringify(b2.body)
1024
+ && errOf(a).correlationId !== errOf(other).correlationId; // derived from the request, not constant
1025
+ })),
1026
+ done('hubspot.protocol.reserved_field_round_trip', 'protocol', 'id / createdAt / updatedAt survive the projection (the kernel\'s META set drops all three)', 'api', 'core', () => withClock(async (h) => {
1027
+ const c = await h({ m: 'POST', p: '/crm/v3/objects/contacts', b: { properties: { email: 'a@b.test' } }, at: '2026-02-01T00:00:00.000Z' });
1028
+ const id = body(c).id;
1029
+ await h({ m: 'PATCH', p: `/crm/v3/objects/contacts/${id}`, b: { properties: { firstname: 'Ada' } }, at: '2026-02-05T00:00:00.000Z' });
1030
+ const g = await h({ m: 'GET', p: `/crm/v3/objects/contacts/${id}` });
1031
+ const l = await h({ m: 'GET', p: '/crm/v3/objects/contacts' });
1032
+ return body(g).id === id && body(g).createdAt === '2026-02-01T00:00:00.000Z' && body(g).updatedAt === '2026-02-05T00:00:00.000Z'
1033
+ && body(l).results[0].createdAt === '2026-02-01T00:00:00.000Z';
1034
+ })),
1035
+ done('hubspot.protocol.invalid_json', 'protocol', 'A malformed or non-object JSON body → 400 VALIDATION_ERROR, never a 500', 'api', 'common', async () => {
1036
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-json-'));
1037
+ try {
1038
+ const broken = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: '{not json', root, occurredAt: AT });
1039
+ const array = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts/batch/create', body: '[1,2]', root, occurredAt: AT });
1040
+ return broken.status === 400 && broken.body.category === 'VALIDATION_ERROR' && array.status === 400;
1041
+ }
1042
+ catch (err) {
1043
+ if (isInfrastructureError(err))
1044
+ throw harnessError('hubspot.protocol.invalid_json', err);
1045
+ return false;
1046
+ }
1047
+ finally {
1048
+ rmSync(root, { recursive: true, force: true });
1049
+ }
1050
+ }),
1051
+ done('hubspot.protocol.collection_envelope', 'protocol', 'Collections are { results } with paging ONLY when another page exists', 'api', 'common', () => withRoot(async (h) => {
1052
+ await mk(h, 'contacts', { email: 'a@b.test' });
1053
+ const short = await h({ m: 'GET', p: '/crm/v3/objects/contacts?limit=10' });
1054
+ await mk(h, 'contacts', { email: 'c@d.test' });
1055
+ const paged = await h({ m: 'GET', p: '/crm/v3/objects/contacts?limit=1' });
1056
+ return Array.isArray(body(short).results) && body(short).paging === undefined
1057
+ && typeof body(paged).paging.next.after === 'string'
1058
+ // …and NO fabricated `link`: HubSpot's is an absolute URL rooted at its public host, which
1059
+ // a twin on an ephemeral loopback port does not know. Omitted rather than faked.
1060
+ && body(paged).paging.next.link === undefined;
1061
+ })),
1062
+ // ── oauth: the install (hubspot-oauth.tsx) ──
1063
+ done('hubspot.oauth.token_exchange', 'oauth', 'app.hubspot.com/oauth/authorize consents and redirects with a code; POST /oauth/v1/token redeems it once and refreshes', 'api', 'common', async () => {
1064
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-oauth-'));
1065
+ try {
1066
+ const at = () => AT;
1067
+ const form = (fields) => new Request('https://api.hubapi.com/oauth/v1/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString() });
1068
+ const page = await hubspotOAuth(new Request('https://app.hubspot.com/oauth/authorize?client_id=app1&redirect_uri=https%3A%2F%2Fapp.test%2Fcb&scope=oauth%20crm.objects.contacts.read&state=s1'), root, at);
1069
+ const allowed = await hubspotOAuth(new Request('https://app.hubspot.com/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'client_id=app1&redirect_uri=https%3A%2F%2Fapp.test%2Fcb&scope=oauth%20crm.objects.contacts.read&state=s1&answer=allow' }), root, at);
1070
+ const location = new URL(allowed?.headers.get('location') ?? 'https://x.invalid');
1071
+ const code = location.searchParams.get('code') ?? '';
1072
+ const exchange = { grant_type: 'authorization_code', client_id: 'app1', client_secret: 's', redirect_uri: 'https://app.test/cb', code };
1073
+ const first = await hubspotOAuth(form(exchange), root, at);
1074
+ const tok = (await first?.json());
1075
+ const again = await hubspotOAuth(form(exchange), root, at);
1076
+ const refreshed = await hubspotOAuth(form({ grant_type: 'refresh_token', client_id: 'app1', client_secret: 's', redirect_uri: 'https://app.test/cb', refresh_token: tok.refresh_token ?? '' }), root, at);
1077
+ const badRefresh = await hubspotOAuth(form({ grant_type: 'refresh_token', client_id: 'app1', client_secret: 's', redirect_uri: 'https://app.test/cb', refresh_token: 'nope' }), root, at);
1078
+ const denied = await hubspotOAuth(new Request('https://app.hubspot.com/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'client_id=app1&redirect_uri=https%3A%2F%2Fapp.test%2Fcb&scope=oauth&answer=cancel' }), root, at);
1079
+ // a code redeemed by another app, at another redirect, or after ten minutes is invalid_grant; another grant type too
1080
+ const codeFor = async () => new URL((await hubspotOAuth(new Request('https://app.hubspot.com/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'client_id=app1&redirect_uri=https%3A%2F%2Fapp.test%2Fcb&scope=oauth&answer=allow' }), root, at))?.headers.get('location') ?? 'https://x.invalid').searchParams.get('code') ?? '';
1081
+ const otherApp = await hubspotOAuth(form({ ...exchange, client_id: 'app2', code: await codeFor() }), root, at);
1082
+ const otherRedirect = await hubspotOAuth(form({ ...exchange, redirect_uri: 'https://evil.test/cb', code: await codeFor() }), root, at);
1083
+ const staleCode = await codeFor();
1084
+ const late = await hubspotOAuth(form({ ...exchange, code: staleCode }), root, () => new Date(Date.parse(AT) + 11 * 60_000).toISOString());
1085
+ const badGrant = await hubspotOAuth(form({ grant_type: 'password', client_id: 'app1' }), root, at);
1086
+ const refreshedTok = refreshed ? (await refreshed.clone().json()) : {};
1087
+ // the consent is HubSpot's app host's, not the API host's
1088
+ const onApiHost = await hubspotOAuth(new Request('https://api.hubapi.com/oauth/authorize?client_id=app1&redirect_uri=https%3A%2F%2Fapp.test%2Fcb&scope=oauth'), root, at);
1089
+ return page?.status === 200 && (await page.text()).includes('crm.objects.contacts.read')
1090
+ && [otherApp, otherRedirect, late].every((r) => r?.status === 400) && badGrant?.status === 400
1091
+ && (await badGrant.json()).error === 'unsupported_grant_type'
1092
+ && !!refreshedTok.access_token && refreshedTok.access_token !== tok.access_token && refreshedTok.hub_id === TWIN_HUB_ID
1093
+ && onApiHost === undefined
1094
+ && allowed?.status === 302 && location.origin === 'https://app.test' && location.searchParams.get('state') === 's1' && !!code
1095
+ && first?.status === 200 && tok.token_type === 'bearer' && tok.hub_id === TWIN_HUB_ID && tok.expires_in === 1800 && JSON.stringify(tok.scopes) === '["oauth","crm.objects.contacts.read"]' && !!tok.access_token && !!tok.refresh_token
1096
+ && again?.status === 400 && (await again.json()).error === 'invalid_grant'
1097
+ && refreshed?.status === 200
1098
+ && badRefresh?.status === 400 && (await badRefresh.json()).status === 'BAD_REFRESH_TOKEN'
1099
+ && new URL(denied?.headers.get('location') ?? 'https://x.invalid').searchParams.get('error') === 'access_denied';
1100
+ }
1101
+ finally {
1102
+ rmSync(root, { recursive: true, force: true });
1103
+ }
1104
+ }),
1105
+ done('hubspot.oauth.access_token_info', 'oauth', 'GET /oauth/v1/access-tokens/{token} — a minted token\'s metadata (hub id, scopes, expires_in)', 'api', 'common', async () => {
1106
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-oauth-info-'));
1107
+ try {
1108
+ const at = () => AT;
1109
+ const allowed = await hubspotOAuth(new Request('https://app.hubspot.com/oauth/authorize', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'client_id=app1&redirect_uri=https%3A%2F%2Fapp.test%2Fcb&scope=oauth&answer=allow' }), root, at);
1110
+ const code = new URL(allowed?.headers.get('location') ?? 'https://x.invalid').searchParams.get('code') ?? '';
1111
+ const tok = (await (await hubspotOAuth(new Request('https://api.hubapi.com/oauth/v1/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: 'app1', client_secret: 's', redirect_uri: 'https://app.test/cb', code }).toString() }), root, at))?.json());
1112
+ const info = await hubspotOAuth(new Request(`https://api.hubapi.com/oauth/v1/access-tokens/${tok.access_token}`), root, at);
1113
+ const meta = (await info?.json());
1114
+ const unknown = await hubspotOAuth(new Request('https://api.hubapi.com/oauth/v1/access-tokens/CNnope'), root, at);
1115
+ // after its 1800 seconds the token has no metadata
1116
+ const expired = await hubspotOAuth(new Request(`https://api.hubapi.com/oauth/v1/access-tokens/${tok.access_token}`), root, () => new Date(Date.parse(AT) + 1801_000).toISOString());
1117
+ return expired?.status === 401 && info?.status === 200 && meta.hub_id === TWIN_HUB_ID && JSON.stringify(meta.scopes) === '["oauth"]' && meta.token_type === 'access' && meta.expires_in === 1800 && unknown?.status === 401;
1118
+ }
1119
+ finally {
1120
+ rmSync(root, { recursive: true, force: true });
1121
+ }
1122
+ }),
1123
+ done('hubspot.deferred.area_routes_refused', 'protocol', 'Every DEFERRED product area\'s routes are REFUSED in the vendor envelope, never faked', 'api', 'core', () => withRoot(async (h) => {
1124
+ // One representative route per DEFERRED family (hubspot-areas.ts) — separate HubSpot
1125
+ // PRODUCTS behind the same host. No /crm/ route appears here: §9 round one pointed out that
1126
+ // probing /crm/v3/objects/notes made this capability "prove" the twin refuses part of its
1127
+ // OWN declared scope. Those areas are plain todos, and
1128
+ // `hubspot.objects.unmodeled_crm_object_types` covers their honest refusal instead.
1129
+ // One representative route per DEFERRED area, KEYED BY AREA ID — and asserted to biject
1130
+ // with the census, so adding a deferred area (or deleting one) reddens the capability whose
1131
+ // title says "Every". §9 round two caught the hardcoded list making that word unenforceable,
1132
+ // the same defect minor 9 had already fixed in `ui.nav`.
1133
+ const routes = {
1134
+ cms: ['GET', '/cms/v3/pages/site-pages'],
1135
+ marketing: ['GET', '/marketing/v3/emails'],
1136
+ automation: ['GET', '/automation/v4/actions/1'],
1137
+ conversations: ['POST', '/conversations/v3/visitor-identification/tokens/create'],
1138
+ files: ['GET', '/files/v3/files'],
1139
+ 'analytics-events': ['POST', '/events/v3/send'],
1140
+ 'communication-preferences': ['GET', '/communication-preferences/v3/definitions'],
1141
+ settings: ['GET', '/settings/v3/users'],
1142
+ webhooks: ['GET', '/webhooks/v3/1/subscriptions'],
1143
+ };
1144
+ const deferredAreas = HUBSPOT_AREAS.filter((x) => x.reason !== undefined).map((x) => x.id).sort();
1145
+ if (JSON.stringify(deferredAreas) !== JSON.stringify(Object.keys(routes).sort()))
1146
+ return false;
1147
+ for (const [m, p] of Object.values(routes)) {
1148
+ const r = await h({ m, p });
1149
+ // A 404 in HubSpot's own envelope, specifically — not "a 4xx of some kind".
1150
+ if (r.status !== 404 || !isHubspotError(r, 'OBJECT_NOT_FOUND'))
1151
+ return false;
1152
+ }
1153
+ return true;
1154
+ })),
1155
+ // The other half of the refusal story: CRM object types HubSpot really HAS and this twin does
1156
+ // not model. They must NOT borrow the vendor's "Unable to infer object type from" grammar —
1157
+ // HubSpot infers `notes` perfectly well (the ahrefs precedent: only an option the endpoint
1158
+ // actually declares can be an unmodeled option).
1159
+ done('hubspot.objects.unmodeled_crm_object_types', 'objects', 'A real-but-unmodeled CRM object type is refused HONESTLY, not with the vendor\'s cannot-infer message', 'api', 'core', () => withRoot(async (h) => {
1160
+ // All three spellings HubSpot accepts in a {objectType} segment. §9 round two caught the
1161
+ // first version driving NAMES only — so `0-46` (notes) and a real custom-object spelling
1162
+ // still got the vendor's cannot-infer sentence — and caught `p_custom_thing` being a
1163
+ // fixture shaped like nothing the vendor emits (the real forms are the objectTypeId
1164
+ // `2-3453932` and the fullyQualifiedName `p2953265_car`).
1165
+ const spellings = [
1166
+ 'notes', 'tasks', 'calls', 'emails', 'meetings', 'line_items', 'products', 'quotes', 'invoices', 'leads',
1167
+ '0-46', '0-27', '0-48', '0-7', '0-8', // objectTypeId form
1168
+ '2-3453932', 'p2953265_car', // custom object: objectTypeId + fullyQualifiedName
1169
+ ];
1170
+ for (const type of spellings) {
1171
+ const r = await h({ m: 'GET', p: `/crm/v3/objects/${type}` });
1172
+ if (r.status !== 404 || !isHubspotError(r, 'OBJECT_NOT_FOUND'))
1173
+ return false;
1174
+ const msg = String(errOf(r).message);
1175
+ if (msg.includes('Unable to infer object type'))
1176
+ return false; // not the vendor's words
1177
+ if (!msg.includes(type) || !msg.includes('does not model'))
1178
+ return false;
1179
+ }
1180
+ // …while a segment that is not well-formed HubSpot addressing at all still gets the
1181
+ // vendor's own 400, on every objectType-bearing route family.
1182
+ for (const p of ['/crm/v3/objects/wombats', '/crm/v3/properties/wombats', '/crm/v3/pipelines/wombats']) {
1183
+ const bad = await h({ m: 'GET', p });
1184
+ if (bad.status !== 400 || !isHubspotError(bad, 'VALIDATION_ERROR') || !String(errOf(bad).message).startsWith('Unable to infer object type from: wombats'))
1185
+ return false;
1186
+ }
1187
+ // …and the honest refusal reaches the OTHER route families too, not just /objects.
1188
+ for (const p of ['/crm/v3/properties/notes', '/crm/v3/pipelines/0-46', `/crm/v4/objects/notes/1/associations/contacts`]) {
1189
+ const r = await h({ m: 'GET', p });
1190
+ if (r.status !== 404 || !String(errOf(r).message).includes('does not model'))
1191
+ return false;
1192
+ }
1193
+ return true;
1194
+ })),
1195
+ todo('hubspot.protocol.paging_next_link', 'protocol', 'paging.next.link — the ABSOLUTE URL HubSpot returns alongside `after` (a twin on an ephemeral port does not know its own public host)', 'api', 'niche'),
1196
+ todo('hubspot.protocol.method_not_allowed', 'protocol', 'A wrong METHOD on a routed path — 405 vs 404 (the SDK carries no oracle for this, so the twin asserts nothing about it today)', 'api', 'niche'),
1197
+ todo('hubspot.protocol.rate_limit_remaining_headers', 'protocol', 'The X-HubSpot-RateLimit-Remaining / -Daily-Remaining COUNTERS (a truthful count needs wall-clock metering the serve path forbids)', 'api', 'common'),
1198
+ todo('hubspot.protocol.rate_limit_429', 'protocol', 'A 429 with HubSpot\'s policyName ("DAILY" / "SECONDLY") body and the retry guidance', 'api', 'common'),
1199
+ todo('hubspot.protocol.auth_401', 'protocol', 'A missing or malformed Bearer token → 401 with category INVALID_AUTHENTICATION', 'api', 'common'),
1200
+ todo('hubspot.protocol.scope_403', 'protocol', 'A token without the required scope → 403 MISSING_SCOPES naming the scope', 'api', 'common'),
1201
+ todo('hubspot.protocol.developer_api_key', 'protocol', 'The developer-API-key auth mode the SDK\'s `developerApiKey` option uses', 'api', 'niche'),
1202
+ todo('hubspot.protocol.cors_and_preflight', 'protocol', 'CORS preflight behaviour for browser-side callers', 'api', 'niche'),
1203
+ // ══ connector ═══════════════════════════════════════════════════════════════════════════
1204
+ done('hubspot.connector.pull', 'connector', 'PULL contacts/companies/deals/tickets + owners into the observed log — idempotent', 'connector', 'core', async () => {
1205
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-pull-'));
1206
+ try {
1207
+ const { twinResources } = await import('@volter/world-core');
1208
+ const { execute } = fakePortal();
1209
+ const r1 = await syncHubspotFromReal(execute, { root, occurredAt: '2026-02-01T00:00:00.000Z' });
1210
+ if (r1.observed !== 4 || r1.deltasAppended === 0)
1211
+ return false;
1212
+ const before = twinResources('hubspot', root).length;
1213
+ // A re-pull of IDENTICAL state must append nothing — but with a MOVING occurredAt, which
1214
+ // is what the connector's own default does (a pinned one hides reverting-value bugs).
1215
+ const r2 = await syncHubspotFromReal(execute, { root, occurredAt: '2026-02-01T00:05:00.000Z' });
1216
+ const contact = twinResources('hubspot', root).find((x) => x.id === 'contacts_1001');
1217
+ return r2.deltasAppended === 0 && !!contact && twinResources('hubspot', root).length === before;
1218
+ }
1219
+ catch (err) {
1220
+ if (isInfrastructureError(err))
1221
+ throw harnessError('hubspot.connector.pull', err);
1222
+ return false;
1223
+ }
1224
+ finally {
1225
+ rmSync(root, { recursive: true, force: true });
1226
+ }
1227
+ }),
1228
+ done('hubspot.connector.pull_follows_paging', 'connector', 'PULL follows paging.next.after until it is absent, and every page lands', 'connector', 'core', async () => {
1229
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-page-'));
1230
+ try {
1231
+ const { execute, calls } = fakePortal();
1232
+ await pullHubspotAll(execute, root);
1233
+ const contactCalls = calls.filter((c) => c.includes('/crm/v3/objects/contacts'));
1234
+ const page1 = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/objects/contacts/1001', root, occurredAt: AT });
1235
+ const page2 = await handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/objects/contacts/1003', root, occurredAt: AT });
1236
+ // …and every page ASKS FOR the properties it needs. HubSpot's list endpoints return only a
1237
+ // small default set otherwise, so a pull that omitted `?properties=` would land near-empty
1238
+ // records against a real portal while a generous fixture hid it (§9 round two: the fix had
1239
+ // no pin at all).
1240
+ const askedFor = contactCalls.every((c) => c.includes('properties=') && c.includes('email') && c.includes('lastmodifieddate'));
1241
+ return contactCalls.length === 2 && contactCalls[1].includes('after=1003') && askedFor
1242
+ && page1.body.properties.email === 'p1@portal.test'
1243
+ && page2.body.properties.email === 'p2@portal.test';
1244
+ }
1245
+ catch (err) {
1246
+ if (isInfrastructureError(err))
1247
+ throw harnessError('hubspot.connector.pull_follows_paging', err);
1248
+ return false;
1249
+ }
1250
+ finally {
1251
+ rmSync(root, { recursive: true, force: true });
1252
+ }
1253
+ }),
1254
+ done('hubspot.connector.pull_refuses_a_failure', 'connector', 'A REFUSED pull THROWS — it never folds an empty account over real observed state', 'connector', 'core', async () => {
1255
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-refuse-'));
1256
+ try {
1257
+ const { twinResources } = await import('@volter/world-core');
1258
+ await pullHubspotAll(fakePortal().execute, root);
1259
+ const before = twinResources('hubspot', root).filter((x) => x.deleted !== true).length;
1260
+ if (before === 0)
1261
+ return false;
1262
+ // (a) a 4xx refusal
1263
+ let threw = false;
1264
+ try {
1265
+ await syncHubspotFromReal(fakePortal({ failOn: '/crm/v3/objects/companies' }).execute, { root });
1266
+ }
1267
+ catch {
1268
+ threw = true;
1269
+ }
1270
+ if (!threw)
1271
+ return false;
1272
+ // (b) HubSpot's error envelope arriving with a 200 — a status check ALONE would miss it
1273
+ let threw200 = false;
1274
+ try {
1275
+ await syncHubspotFromReal(fakePortal({ failOn: '/crm/v3/objects/companies', httpStatus: 200 }).execute, { root });
1276
+ }
1277
+ catch {
1278
+ threw200 = true;
1279
+ }
1280
+ const after = twinResources('hubspot', root).filter((x) => x.deleted !== true).length;
1281
+ return threw200 && after === before;
1282
+ }
1283
+ catch (err) {
1284
+ if (isInfrastructureError(err))
1285
+ throw harnessError('hubspot.connector.pull_refuses_a_failure', err);
1286
+ return false;
1287
+ }
1288
+ finally {
1289
+ rmSync(root, { recursive: true, force: true });
1290
+ }
1291
+ }),
1292
+ done('hubspot.connector.push', 'connector', 'PUSH confirms a pending local action on success and leaves it PENDING on an error', 'connector', 'core', async () => {
1293
+ const { pendingActions } = await import('@volter/world-core');
1294
+ // (a) an ERROR response must leave the action pending (no false confirm)
1295
+ const r1 = mkdtempSync(join(tmpdir(), 'hubspot-cap-push-err-'));
1296
+ try {
1297
+ await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties: { email: 'a@b.test' } }), root: r1, occurredAt: AT });
1298
+ const errExec = async () => ({ httpStatus: 400, body: { status: 'error', message: 'nope', category: 'VALIDATION_ERROR' } });
1299
+ if (await pushPendingHubspotActions(errExec, r1) !== 0)
1300
+ return false;
1301
+ if (pendingActions('hubspot', r1).length === 0)
1302
+ return false;
1303
+ // …and a 200 carrying an error envelope must ALSO not confirm.
1304
+ const sneaky = async () => ({ httpStatus: 200, body: { status: 'error', message: 'nope', category: 'VALIDATION_ERROR' } });
1305
+ if (await pushPendingHubspotActions(sneaky, r1) !== 0)
1306
+ return false;
1307
+ }
1308
+ catch (err) {
1309
+ if (isInfrastructureError(err))
1310
+ throw harnessError('hubspot.connector.push', err);
1311
+ return false;
1312
+ }
1313
+ finally {
1314
+ rmSync(r1, { recursive: true, force: true });
1315
+ }
1316
+ // (b) a SUCCESS response confirms the action and hits the right endpoint
1317
+ const r2 = mkdtempSync(join(tmpdir(), 'hubspot-cap-push-ok-'));
1318
+ try {
1319
+ await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties: { email: 'a@b.test' } }), root: r2, occurredAt: AT });
1320
+ const calls = [];
1321
+ const okExec = async (m, p, b) => { calls.push(`${m} ${p} ${JSON.stringify(b)}`); return { httpStatus: 201, body: { id: '999', properties: {} } }; };
1322
+ const pushed = await pushPendingHubspotActions(okExec, r2);
1323
+ return pushed > 0 && calls.some((c) => c.startsWith('POST /crm/v3/objects/contacts '))
1324
+ && calls.some((c) => c.includes('"email":"a@b.test"') && !c.includes('hs_object_id'))
1325
+ && pendingActions('hubspot', r2).length === 0;
1326
+ }
1327
+ catch (err) {
1328
+ if (isInfrastructureError(err))
1329
+ throw harnessError('hubspot.connector.push', err);
1330
+ return false;
1331
+ }
1332
+ finally {
1333
+ rmSync(r2, { recursive: true, force: true });
1334
+ }
1335
+ }),
1336
+ done('hubspot.connector.push_never_addresses_a_local_id', 'connector', 'A LOCALLY MINTED record id is never sent at a real portal as a real record id', 'connector', 'core', async () => {
1337
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-push-mint-'));
1338
+ try {
1339
+ const { pendingActions } = await import('@volter/world-core');
1340
+ // A locally created record, then locally updated and archived.
1341
+ const c = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties: { email: 'a@b.test' } }), root, occurredAt: '2026-02-01T00:00:00.000Z' });
1342
+ const id = c.body.id;
1343
+ await handleHubspotTwinRequest({ method: 'PATCH', path: `/crm/v3/objects/contacts/${id}`, body: JSON.stringify({ properties: { firstname: 'Ada' } }), root, occurredAt: '2026-02-02T00:00:00.000Z' });
1344
+ await handleHubspotTwinRequest({ method: 'DELETE', path: `/crm/v3/objects/contacts/${id}`, root, occurredAt: '2026-02-03T00:00:00.000Z' });
1345
+ const calls = [];
1346
+ const exec = async (m, p) => { calls.push(`${m} ${p}`); return { httpStatus: 200, body: {} }; };
1347
+ await pushPendingHubspotActions(exec, root);
1348
+ // EVERY call must be a create; not one may address the twin's own id. Without the guard,
1349
+ // the archive action would fire DELETE /crm/v3/objects/contacts/<local id> at a real
1350
+ // portal — a stranger's record (the groq round-two defect class).
1351
+ const leaked = calls.some((c) => c.includes(`/${id}`));
1352
+ const everyCallIsACreate = calls.length > 0 && calls.every((c) => c === 'POST /crm/v3/objects/contacts');
1353
+ // The unpushable archive must still be visible as pending, never silently dropped.
1354
+ const stillPending = pendingActions('hubspot', root).length > 0;
1355
+ const directly = hubspotRequestForAction({ id: 'x', subject: { type: 'crm_object', id: `contacts_${id}` }, fields: { objectType: 'contacts', hsId: id, hsLocalMint: true, archived: true } });
1356
+ return !leaked && everyCallIsACreate && stillPending && directly === null;
1357
+ }
1358
+ catch (err) {
1359
+ if (isInfrastructureError(err))
1360
+ throw harnessError('hubspot.connector.push_never_addresses_a_local_id', err);
1361
+ return false;
1362
+ }
1363
+ finally {
1364
+ rmSync(root, { recursive: true, force: true });
1365
+ }
1366
+ }),
1367
+ done('hubspot.connector.push_addresses_a_pulled_id', 'connector', 'A record OBSERVED from the portal IS addressed by its real id on a later local edit', 'connector', 'common', async () => {
1368
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-cap-push-pulled-'));
1369
+ try {
1370
+ await pullHubspotAll(fakePortal().execute, root);
1371
+ await handleHubspotTwinRequest({ method: 'PATCH', path: '/crm/v3/objects/contacts/1001', body: JSON.stringify({ properties: { firstname: 'Edited' } }), root, occurredAt: '2026-02-09T00:00:00.000Z' });
1372
+ const calls = [];
1373
+ const exec = async (m, p) => { calls.push(`${m} ${p}`); return { httpStatus: 200, body: {} }; };
1374
+ const pushed = await pushPendingHubspotActions(exec, root);
1375
+ return pushed === 1 && calls.length === 1 && calls[0] === 'PATCH /crm/v3/objects/contacts/1001';
1376
+ }
1377
+ catch (err) {
1378
+ if (isInfrastructureError(err))
1379
+ throw harnessError('hubspot.connector.push_addresses_a_pulled_id', err);
1380
+ return false;
1381
+ }
1382
+ finally {
1383
+ rmSync(root, { recursive: true, force: true });
1384
+ }
1385
+ }),
1386
+ done('hubspot.connector.map_renames_reserved_fields', 'connector', 'The pull mapping renames id/createdAt/updatedAt, which the kernel projection would otherwise DROP', 'connector', 'common', () => {
1387
+ const mapped = mapCrmObject('contacts', { id: '1001', properties: { email: 'p@x.test' }, createdAt: AT, updatedAt: AT, archived: false });
1388
+ const f = mapped.fields;
1389
+ return mapped.type === 'crm_object' && mapped.id === 'contacts_1001'
1390
+ && f.hsId === '1001' && f.hsCreatedAt === AT && f.hsUpdatedAt === AT && f.hsLocalMint === false
1391
+ && f.id === undefined && f.updatedAt === undefined && f.createdAt === undefined;
1392
+ }),
1393
+ todo('hubspot.connector.pull_properties', 'connector', 'Connector: pull the portal\'s property + property-group definitions (GET /crm/v3/properties/{objectType})', 'connector', 'common'),
1394
+ todo('hubspot.connector.pull_pipelines', 'connector', 'Connector: pull the portal\'s real pipelines and stages (GET /crm/v3/pipelines/{objectType})', 'connector', 'common'),
1395
+ todo('hubspot.connector.pull_associations', 'connector', 'Connector: pull existing associations between pulled records', 'connector', 'common'),
1396
+ todo('hubspot.connector.push_associations', 'connector', 'Connector: push a locally created association once both records exist upstream', 'connector', 'common'),
1397
+ todo('hubspot.connector.push_id_reconciliation', 'connector', 'Reconcile a locally minted id with the id the portal assigned on push, so later edits address the real record', 'connector', 'core'),
1398
+ todo('hubspot.connector.pull_incremental', 'connector', 'Incremental pull by hs_lastmodifieddate instead of a full re-page', 'connector', 'common'),
1399
+ todo('hubspot.connector.pull_archived', 'connector', 'Connector: observe archived records (?archived=true) so a remote archive lands locally', 'connector', 'niche'),
1400
+ // ══ the CRM mirror ══════════════════════════════════════════════════════════════════════
1401
+ // HubSpot's core job is done in the browser — a sales rep works a deal on the pipeline board —
1402
+ // so this pack mirrors (docs/contributing/adding-a-twin.md, "Does this vendor get a mirror?"). Every UI
1403
+ // capability is DATA-COUPLED: seed through the twin's write path, fetch the SAME vendor API the
1404
+ // screen reads, render the mirror's OWN component, assert the seeded value survives.
1405
+ done('hubspot.ui.contacts', 'ui', 'The contacts list renders real twin records (data-coupled)', 'ui', 'core', uiDataCoupled({
1406
+ withRoot,
1407
+ seed: async (h) => {
1408
+ await mk(h, 'contacts', { email: 'ada@lovelace.test', firstname: 'Ada', lastname: 'Lovelace' });
1409
+ await mk(h, 'contacts', { email: 'bo@peep.test', firstname: 'Bo', lastname: 'Peep' });
1410
+ },
1411
+ fetch: async (h) => body(await h({ m: 'GET', p: '/crm/v3/objects/contacts?properties=email,firstname,lastname' })).results,
1412
+ render: (rows) => renderToStaticMarkup(createElement(RecordsTable, { section: 'contacts', rows })),
1413
+ assert: (rows, markup) => rows.length === 2 && !!markup
1414
+ && (markup.match(/list-row/g) ?? []).length === 2
1415
+ && markup.includes('Ada Lovelace') && markup.includes('ada@lovelace.test')
1416
+ && rows.every((r) => markup.includes(String(r.id))),
1417
+ })),
1418
+ done('hubspot.ui.deals_board', 'ui', 'The deal pipeline board groups real deals by their dealstage (data-coupled)', 'ui', 'core', uiDataCoupled({
1419
+ withRoot,
1420
+ seed: async (h) => {
1421
+ await mk(h, 'deals', { dealname: 'Won Deal', amount: '5000', dealstage: 'closedwon' });
1422
+ await mk(h, 'deals', { dealname: 'Open Deal', amount: '1250', dealstage: 'qualifiedtobuy' });
1423
+ await mk(h, 'deals', { dealname: 'Lost Deal', amount: '90', dealstage: 'closedlost' });
1424
+ },
1425
+ fetch: async (h) => body(await h({ m: 'GET', p: '/crm/v3/objects/deals?properties=dealname,amount,dealstage' })).results,
1426
+ render: (rows) => renderToStaticMarkup(createElement(DealBoard, { rows })),
1427
+ assert: (rows, markup) => {
1428
+ if (rows.length !== 3 || !markup)
1429
+ return false;
1430
+ const groups = groupByStage(rows);
1431
+ if (groups.length !== 3 || (markup.match(/deal-card/g) ?? []).length !== 3)
1432
+ return false;
1433
+ // MEMBERSHIP, not just cardinality (§9 round one): slice the markup at each column's own
1434
+ // `data-stage` landmark and require THAT column to carry the deal whose stage names it —
1435
+ // and not the others. A board that rendered every card in one column passes a count check
1436
+ // and fails this one.
1437
+ const column = (stage) => {
1438
+ const at = markup.indexOf(`data-stage="${stage}"`);
1439
+ if (at === -1)
1440
+ return '';
1441
+ const next = markup.indexOf('data-stage="', at + 1);
1442
+ return markup.slice(at, next === -1 ? markup.length : next);
1443
+ };
1444
+ const won = column('closedwon');
1445
+ const open = column('qualifiedtobuy');
1446
+ const lost = column('closedlost');
1447
+ return won.includes('Won Deal') && !won.includes('Open Deal') && !won.includes('Lost Deal')
1448
+ && open.includes('Open Deal') && !open.includes('Won Deal')
1449
+ && lost.includes('Lost Deal') && !lost.includes('Won Deal')
1450
+ // the mirror's own amount formatter, over the seeded values, in the right columns
1451
+ && won.includes('$5,000') && open.includes('$1,250');
1452
+ },
1453
+ })),
1454
+ done('hubspot.ui.stage_pill', 'ui', 'Deal-stage pills map closedwon/open/closedlost to ok/warn/bad tones (data-coupled)', 'ui', 'common', uiDataCoupled({
1455
+ withRoot,
1456
+ seed: async (h) => {
1457
+ await mk(h, 'deals', { dealname: 'W', dealstage: 'closedwon' });
1458
+ await mk(h, 'deals', { dealname: 'O', dealstage: 'presentationscheduled' });
1459
+ await mk(h, 'deals', { dealname: 'L', dealstage: 'closedlost' });
1460
+ },
1461
+ fetch: async (h) => {
1462
+ const rows = body(await h({ m: 'GET', p: '/crm/v3/objects/deals?properties=dealstage' })).results;
1463
+ const stageOf = (want) => rows.find((r) => r.properties?.dealstage === want)?.properties?.dealstage;
1464
+ // The mirror's OWN tone function, over values that came back through the twin's API.
1465
+ return {
1466
+ won: stageTone(stageOf('closedwon')),
1467
+ open: stageTone(stageOf('presentationscheduled')),
1468
+ lost: stageTone(stageOf('closedlost')),
1469
+ };
1470
+ },
1471
+ // On an unseeded root every lookup is undefined ⇒ stageTone('') ⇒ '' ⇒ this is false.
1472
+ assert: (tones) => tones.won === 'ok' && tones.open === 'warn' && tones.lost === 'bad',
1473
+ })),
1474
+ done('hubspot.ui.record_detail', 'ui', 'The record property sheet renders a real record\'s properties (data-coupled)', 'ui', 'common', uiDataCoupled({
1475
+ withRoot,
1476
+ seed: (h) => mk(h, 'companies', { name: 'Acme Rocket Works', domain: 'acme.test', city: 'Portland' }),
1477
+ fetch: async (h) => body(await h({ m: 'GET', p: '/crm/v3/objects/companies' })).results,
1478
+ render: (rows) => renderToStaticMarkup(createElement(RecordDetail, { section: 'companies', rows })),
1479
+ assert: (rows, markup) => rows.length === 1 && !!markup
1480
+ && markup.includes('Acme Rocket Works') && markup.includes('acme.test') && markup.includes('Portland')
1481
+ && markup.includes(recordTitle('companies', rows[0].properties))
1482
+ && markup.includes(String(rows[0].id)),
1483
+ })),
1484
+ done('hubspot.ui.nav', 'ui', 'Every nav destination (Contacts / Companies / Deals / Tickets) reads live twin data (data-coupled)', 'ui', 'common', uiDataCoupled({
1485
+ withRoot,
1486
+ seed: async (h) => {
1487
+ await mk(h, 'contacts', { email: 'nav-contact@x.test' });
1488
+ await mk(h, 'companies', { name: 'Nav Company' });
1489
+ await mk(h, 'deals', { dealname: 'Nav Deal' });
1490
+ await mk(h, 'tickets', { subject: 'Nav Ticket' });
1491
+ },
1492
+ fetch: async (h) => {
1493
+ // The nav's destinations are read FROM the mirror's own MIRROR_SECTIONS — the exact paths
1494
+ // the client fetches — and each is driven for real. Reading the section list rather than
1495
+ // re-typing it is what makes this a bijection: §9 round one noted the literal-only version
1496
+ // stayed green if a section were DELETED from the nav, against a title that says "every".
1497
+ const expected = {
1498
+ contacts: ['email', 'nav-contact@x.test'],
1499
+ companies: ['name', 'Nav Company'],
1500
+ deals: ['dealname', 'Nav Deal'],
1501
+ tickets: ['subject', 'Nav Ticket'],
1502
+ };
1503
+ const hits = {};
1504
+ for (const section of MIRROR_SECTIONS) {
1505
+ const pair = expected[section.key];
1506
+ if (!pair) {
1507
+ hits[section.key] = false;
1508
+ continue;
1509
+ }
1510
+ const rows = body(await h({ m: 'GET', p: section.path })).results;
1511
+ hits[section.key] = rows.some((r) => r.properties?.[pair[0]] === pair[1]);
1512
+ }
1513
+ // the four destinations the product HAS must all be present in the shipped nav
1514
+ hits.__bijection = Object.keys(expected).every((k) => MIRROR_SECTIONS.some((s) => s.key === k))
1515
+ && MIRROR_SECTIONS.length === Object.keys(expected).length;
1516
+ return hits;
1517
+ },
1518
+ assert: (hits) => hits.__bijection === true && ['contacts', 'companies', 'deals', 'tickets'].every((k) => hits[k] === true),
1519
+ })),
1520
+ done('hubspot.ui.empty_state', 'ui', 'An empty section renders the empty state — and gives way the moment a record exists', 'ui', 'niche', async () => {
1521
+ const empty = renderToStaticMarkup(createElement(RecordsTable, { section: 'contacts', rows: [] }));
1522
+ if (!empty.includes('No contacts yet.'))
1523
+ return false;
1524
+ return withRoot(async (h) => {
1525
+ await mk(h, 'contacts', { email: 'a@b.test', firstname: 'Ada' });
1526
+ const rows = body(await h({ m: 'GET', p: '/crm/v3/objects/contacts' })).results;
1527
+ const filled = renderToStaticMarkup(createElement(RecordsTable, { section: 'contacts', rows }));
1528
+ return !filled.includes('No contacts yet.') && filled.includes('list-row') && filled.includes('Ada');
1529
+ });
1530
+ }),
1531
+ done('hubspot.ui.mirror_write_path', 'ui', 'A UI write goes through the vendor\'s own endpoint on the mirror server, and a REVERTED value still lands', 'ui', 'core', async () => {
1532
+ const root = mkdtempSync(join(tmpdir(), 'hubspot-ui-write-'));
1533
+ const server = await createHubspotMirrorServer({ root, port: 0 });
1534
+ try {
1535
+ const post = (path, b) => fetch(`${server.url}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
1536
+ const patch = (path, b) => fetch(`${server.url}${path}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
1537
+ // The mirror has NO write path of its own: this is the vendor's real endpoint, on the same
1538
+ // handler the API serves.
1539
+ const created = await post('/crm/v3/objects/deals', { properties: { dealname: 'D' } });
1540
+ if (created.status !== 201)
1541
+ return false;
1542
+ const id = (await created.json()).id;
1543
+ // A GENUINE REVERT through the UI: X -> Y -> X. The kernel's action id is (content +
1544
+ // occurredAt MILLISECOND), so the third write's content is byte-identical to the first's —
1545
+ // and if the mirror server stamped a PINNED constant instead of the world clock, its
1546
+ // timestamp would be identical too, the action would be swallowed as `replayed`, and the
1547
+ // projection would still serve `closedlost` while the reply claimed otherwise.
1548
+ // (A -> B -> C would NOT catch that: only a value the subject previously HELD collides.
1549
+ // §9's revert matrix found the first version of this pin hollow for exactly that reason.)
1550
+ for (const dealstage of ['closedwon', 'closedlost', 'closedwon']) {
1551
+ const r = await patch(`/crm/v3/objects/deals/${id}`, { properties: { dealstage } });
1552
+ if (r.status !== 200)
1553
+ return false;
1554
+ }
1555
+ const back = await fetch(`${server.url}/crm/v3/objects/deals/${id}`);
1556
+ const seen = (await back.json()).properties.dealstage;
1557
+ // …and the same value is what the API path serves, because there is only one of them.
1558
+ const viaApi = await handleHubspotTwinRequest({ method: 'GET', path: `/crm/v3/objects/deals/${id}`, root, occurredAt: AT });
1559
+ return seen === 'closedwon' && viaApi.body.properties.dealstage === 'closedwon';
1560
+ }
1561
+ catch (err) {
1562
+ if (isInfrastructureError(err))
1563
+ throw harnessError('hubspot.ui.mirror_write_path', err);
1564
+ return false;
1565
+ }
1566
+ finally {
1567
+ server.stop();
1568
+ rmSync(root, { recursive: true, force: true });
1569
+ }
1570
+ }),
1571
+ todo('hubspot.ui.mirror_write_same_millisecond', 'ui', 'Two UI writes inside ONE millisecond still collide — worldNow() is not forced strictly increasing the way the connector\'s pollTimestamp() is', 'ui', 'niche'),
1572
+ todo('hubspot.ui.record_timeline', 'ui', 'The activity timeline on a record (engagements — a deferred area)', 'ui', 'common'),
1573
+ todo('hubspot.ui.board_drag_to_stage', 'ui', 'Dragging a deal card to another stage, writing back through PATCH /crm/v3/objects/deals/{id}', 'ui', 'core'),
1574
+ todo('hubspot.ui.saved_views_and_filters', 'ui', 'Saved views, column selection and per-view filters on a record list', 'ui', 'common'),
1575
+ todo('hubspot.ui.search_bar', 'ui', 'The global search bar backed by POST /crm/v3/objects/{objectType}/search', 'ui', 'common'),
1576
+ todo('hubspot.ui.association_panel', 'ui', 'The associated-records panel on a record detail screen', 'ui', 'common'),
1577
+ todo('hubspot.ui.inline_property_edit', 'ui', 'Inline property editing on the record sheet, writing through PATCH', 'ui', 'common'),
1578
+ todo('hubspot.ui.pipeline_switcher', 'ui', 'Switching the board between pipelines', 'ui', 'niche'),
1579
+ todo('hubspot.ui.bulk_actions', 'ui', 'Bulk select + bulk edit/delete backed by the batch endpoints', 'ui', 'niche'),
1580
+ todo('hubspot.marketing.ai_content_assistant', 'marketing', 'Breeze / AI content assistant endpoints: the documented request/response envelope with a deterministic, labeled twin-stub result', 'api', 'niche'),
1581
+ ...HUBSPOT_UNMODELED_CRM_CAPABILITIES,
1582
+ ...HUBSPOT_DEFERRED_AREA_CAPABILITIES,
1583
+ ];
1584
+ // The area census is asserted at module load so a stray or missing `area` is loud, not silent.
1585
+ assertAreaCensus('hubspot', HUBSPOT_AREA_IDS, HUBSPOT_CAPABILITIES);
1586
+ export async function hubspotCapabilities() {
1587
+ return checkCapabilities('hubspot', HUBSPOT_CAPABILITIES);
1588
+ }