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