@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.
- package/LICENSE +202 -0
- package/README.md +197 -0
- package/client/hubspot-mirror.css +43 -0
- package/client/hubspot-mirror.tsx +132 -0
- package/dist/client/hubspot-mirror.bundle.js +449 -0
- package/dist/client/hubspot-mirror.css +43 -0
- package/dist/client/hubspot-mirror.d.ts +15 -0
- package/dist/client/hubspot-mirror.js +59 -0
- package/dist/client/hubspot-mirror.tsx +132 -0
- package/dist/src/accounts.d.ts +30 -0
- package/dist/src/accounts.js +122 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/hubspot-areas.d.ts +10 -0
- package/dist/src/hubspot-areas.js +114 -0
- package/dist/src/hubspot-budget.d.ts +58 -0
- package/dist/src/hubspot-budget.js +176 -0
- package/dist/src/hubspot-capabilities.d.ts +3 -0
- package/dist/src/hubspot-capabilities.js +1588 -0
- package/dist/src/hubspot-conformance.d.ts +16 -0
- package/dist/src/hubspot-conformance.js +523 -0
- package/dist/src/hubspot-connector.d.ts +125 -0
- package/dist/src/hubspot-connector.js +390 -0
- package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
- package/dist/src/hubspot-deferred-capabilities.js +64 -0
- package/dist/src/hubspot-mirror-ui.d.ts +62 -0
- package/dist/src/hubspot-mirror-ui.js +152 -0
- package/dist/src/hubspot-oauth.d.ts +8 -0
- package/dist/src/hubspot-oauth.js +291 -0
- package/dist/src/hubspot-server.d.ts +24 -0
- package/dist/src/hubspot-server.js +116 -0
- package/dist/src/hubspot-twin.d.ts +65 -0
- package/dist/src/hubspot-twin.js +1558 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +94 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +68 -0
- package/dist/src/portal.d.ts +20 -0
- package/dist/src/portal.js +30 -0
- package/dist/src/screens/account.d.ts +1 -0
- package/dist/src/screens/account.js +139 -0
- package/dist/src/screens/crm.d.ts +2 -0
- package/dist/src/screens/crm.js +153 -0
- package/dist/src/screens/developer.d.ts +4 -0
- package/dist/src/screens/developer.js +191 -0
- package/dist/src/screens/forms.d.ts +5 -0
- package/dist/src/screens/forms.js +126 -0
- package/dist/src/screens/page.d.ts +21 -0
- package/dist/src/screens/page.js +49 -0
- package/dist/src/screens/session.d.ts +1 -0
- package/dist/src/screens/session.js +32 -0
- package/dist/src/semantics/crm.d.ts +8 -0
- package/dist/src/semantics/crm.js +101 -0
- package/dist/src/webhooks.d.ts +12 -0
- package/dist/src/webhooks.js +77 -0
- package/package.json +75 -0
- package/src/accounts.ts +127 -0
- package/src/cli.ts +29 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/hubspot-areas.ts +155 -0
- package/src/hubspot-budget.ts +202 -0
- package/src/hubspot-capabilities.ts +1523 -0
- package/src/hubspot-conformance.ts +537 -0
- package/src/hubspot-connector.ts +419 -0
- package/src/hubspot-deferred-capabilities.ts +99 -0
- package/src/hubspot-journey.uitest.ts +104 -0
- package/src/hubspot-mirror-ui.ts +166 -0
- package/src/hubspot-oauth.tsx +296 -0
- package/src/hubspot-server.ts +115 -0
- package/src/hubspot-twin.ts +1534 -0
- package/src/index.ts +152 -0
- package/src/manifest.ts +96 -0
- package/src/portal.ts +40 -0
- package/src/screens/account.tsx +129 -0
- package/src/screens/crm.tsx +154 -0
- package/src/screens/developer.tsx +181 -0
- package/src/screens/forms.tsx +117 -0
- package/src/screens/page.tsx +55 -0
- package/src/screens/session.tsx +36 -0
- package/src/semantics/crm.ts +116 -0
- package/src/webhooks.ts +80 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type HubspotConformanceReport = {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
endpointsClaimed: number;
|
|
4
|
+
endpointsProbed: number;
|
|
5
|
+
routerRoutesChecked: number;
|
|
6
|
+
violations: string[];
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Map a CONCRETE request path back to the canonical `{placeholder}` template the endpoint census
|
|
10
|
+
* names. Hand-written from the route shapes — a SECOND encoding, so a probe (or a router-census
|
|
11
|
+
* entry) that drifts onto a different route is caught rather than silently accepted.
|
|
12
|
+
*/
|
|
13
|
+
export declare function canonicalPath(path: string): string;
|
|
14
|
+
export declare function checkHubspotConformance(opts?: {
|
|
15
|
+
root?: string;
|
|
16
|
+
}): Promise<HubspotConformanceReport>;
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// HubSpot twin CONFORMANCE (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime).
|
|
2
|
+
//
|
|
3
|
+
// ── THIS CHECK DRIVES THE ROUTER AND ASSERTS WHAT CAME BACK ─────────────────────────────────
|
|
4
|
+
// Held to docs/contributing/adding-a-twin.md §6's bar and to the two escalating false-greens two packs' §9
|
|
5
|
+
// reviews found there:
|
|
6
|
+
// • two constants asserting about each other is not a check — so every expectation below is a
|
|
7
|
+
// LITERAL written here, never a value read back out of the handler's own module. The status
|
|
8
|
+
// codes are the ones @hubspot/api-client 14.0.1's GENERATED response processors accept
|
|
9
|
+
// (201 create, 200 read/update, 204 archive, 207 partial batch), transcribed from the
|
|
10
|
+
// published tarball, not from this twin;
|
|
11
|
+
// • "not the router's own miss" has teeth at the dispatch and nowhere deeper — so each probe
|
|
12
|
+
// declares the STATUS SET and a PREDICATE over the body a LIVE handler produces, and the
|
|
13
|
+
// hand-written ROUTER_SURFACE closes the third direction in BOTH directions: a served route
|
|
14
|
+
// the census does not claim is a violation, AND a census route the router no longer reaches
|
|
15
|
+
// is a violation. That second half is what makes deleting a handler branch redden the entry
|
|
16
|
+
// that NAMES it, rather than only the probe that happens to drive it.
|
|
17
|
+
//
|
|
18
|
+
// ROUTER_SURFACE is deliberately NOT a copy of the probe table's keys (the tautology the groq
|
|
19
|
+
// build wrote and §9 rejected). It drives object types and route families the probes never
|
|
20
|
+
// touch — companies, deals, tickets, the numeric `0-1` objectTypeId alias, the ticket pipeline —
|
|
21
|
+
// and each is mapped back to its canonical template by `canonicalPath`, a hand-written second
|
|
22
|
+
// encoding of the route shapes that the handler itself does not export.
|
|
23
|
+
//
|
|
24
|
+
// The field-name renames are checked as a round trip, not as a status: HubSpot's record carries
|
|
25
|
+
// `id`, `createdAt` and `updatedAt`, all three of which the kernel's META set silently DROPS from
|
|
26
|
+
// a stored resource, so every record probe asserts those three came BACK — a status-only check
|
|
27
|
+
// sails straight past that class (mistral's dropped `type` field is the precedent).
|
|
28
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { handleHubspotTwinRequest, hubspotTwinSnapshot } from "./hubspot-twin.js";
|
|
32
|
+
const isObject = (b) => !!b && typeof b === 'object' && !Array.isArray(b);
|
|
33
|
+
const AT = '2026-02-01T00:00:00.000Z';
|
|
34
|
+
// Sentinels substituted with the ids the seeded fixtures actually minted. Shaped so that no real
|
|
35
|
+
// HubSpot id could collide, and so a missed substitution fails loudly rather than silently.
|
|
36
|
+
const CONTACT = 'SENTINEL_CONTACT_ID';
|
|
37
|
+
const CONTACT2 = 'SENTINEL_CONTACT2_ID';
|
|
38
|
+
const COMPANY = 'SENTINEL_COMPANY_ID';
|
|
39
|
+
const DEAL = 'SENTINEL_DEAL_ID';
|
|
40
|
+
const TICKET = 'SENTINEL_TICKET_ID';
|
|
41
|
+
const PIPELINE = 'SENTINEL_PIPELINE_ID';
|
|
42
|
+
const STAGE_PIPELINE = 'SENTINEL_STAGE_PIPELINE';
|
|
43
|
+
const STAGE = 'SENTINEL_STAGE_ID';
|
|
44
|
+
/**
|
|
45
|
+
* Map a CONCRETE request path back to the canonical `{placeholder}` template the endpoint census
|
|
46
|
+
* names. Hand-written from the route shapes — a SECOND encoding, so a probe (or a router-census
|
|
47
|
+
* entry) that drifts onto a different route is caught rather than silently accepted.
|
|
48
|
+
*/
|
|
49
|
+
export function canonicalPath(path) {
|
|
50
|
+
const p = path.split('?')[0];
|
|
51
|
+
const s = p.replace(/^\/+|\/+$/g, '').split('/');
|
|
52
|
+
if (s[0] !== 'crm')
|
|
53
|
+
return p;
|
|
54
|
+
if (s[1] === 'v3' && s[2] === 'objects') {
|
|
55
|
+
if (s.length === 4)
|
|
56
|
+
return '/crm/v3/objects/{objectType}';
|
|
57
|
+
if (s.length === 5 && (s[4] === 'search' || s[4] === 'merge'))
|
|
58
|
+
return `/crm/v3/objects/{objectType}/${s[4]}`;
|
|
59
|
+
if (s.length === 6 && s[4] === 'batch')
|
|
60
|
+
return `/crm/v3/objects/{objectType}/batch/${s[5]}`;
|
|
61
|
+
if (s.length === 5)
|
|
62
|
+
return '/crm/v3/objects/{objectType}/{objectId}';
|
|
63
|
+
}
|
|
64
|
+
if (s[1] === 'v3' && s[2] === 'properties') {
|
|
65
|
+
if (s.length === 4)
|
|
66
|
+
return '/crm/v3/properties/{objectType}';
|
|
67
|
+
if (s.length === 5 && s[4] === 'groups')
|
|
68
|
+
return '/crm/v3/properties/{objectType}/groups';
|
|
69
|
+
if (s.length === 6 && s[4] === 'groups')
|
|
70
|
+
return '/crm/v3/properties/{objectType}/groups/{groupName}';
|
|
71
|
+
if (s.length === 6 && s[4] === 'batch' && s[5] === 'create')
|
|
72
|
+
return '/crm/v3/properties/{objectType}/batch/create';
|
|
73
|
+
if (s.length === 5)
|
|
74
|
+
return '/crm/v3/properties/{objectType}/{propertyName}';
|
|
75
|
+
}
|
|
76
|
+
if (s[1] === 'v3' && s[2] === 'pipelines') {
|
|
77
|
+
if (s.length === 4)
|
|
78
|
+
return '/crm/v3/pipelines/{objectType}';
|
|
79
|
+
if (s.length === 5)
|
|
80
|
+
return '/crm/v3/pipelines/{objectType}/{pipelineId}';
|
|
81
|
+
if (s.length === 6 && s[5] === 'stages')
|
|
82
|
+
return '/crm/v3/pipelines/{objectType}/{pipelineId}/stages';
|
|
83
|
+
if (s.length === 7 && s[5] === 'stages')
|
|
84
|
+
return '/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}';
|
|
85
|
+
}
|
|
86
|
+
if (s[1] === 'v3' && s[2] === 'owners') {
|
|
87
|
+
if (s.length === 3)
|
|
88
|
+
return '/crm/v3/owners';
|
|
89
|
+
if (s.length === 4)
|
|
90
|
+
return '/crm/v3/owners/{ownerId}';
|
|
91
|
+
}
|
|
92
|
+
if (s[1] === 'v4' && s[2] === 'objects' && s[4] !== undefined && s[5] === 'associations') {
|
|
93
|
+
if (s.length === 7)
|
|
94
|
+
return '/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}';
|
|
95
|
+
if (s.length === 9 && s[6] === 'default')
|
|
96
|
+
return '/crm/v4/objects/{objectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}';
|
|
97
|
+
if (s.length === 8)
|
|
98
|
+
return '/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}';
|
|
99
|
+
}
|
|
100
|
+
if (s[1] === 'v4' && s[2] === 'associations' && s[5] === 'batch' && s.length === 7) {
|
|
101
|
+
return `/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/${s[6]}`;
|
|
102
|
+
}
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
/** A record body must carry the three field names the kernel projection reserves. */
|
|
106
|
+
function isRecord(b) {
|
|
107
|
+
return isObject(b)
|
|
108
|
+
&& typeof b.id === 'string' && b.id !== ''
|
|
109
|
+
&& isObject(b.properties)
|
|
110
|
+
&& typeof b.createdAt === 'string' && b.createdAt !== ''
|
|
111
|
+
&& typeof b.updatedAt === 'string' && b.updatedAt !== '';
|
|
112
|
+
}
|
|
113
|
+
function isCollection(b, minResults = 1) {
|
|
114
|
+
return isObject(b) && Array.isArray(b.results) && b.results.length >= minResults;
|
|
115
|
+
}
|
|
116
|
+
function isBatch(b, minResults = 1) {
|
|
117
|
+
return isObject(b) && b.status === 'COMPLETE' && Array.isArray(b.results) && b.results.length >= minResults
|
|
118
|
+
&& typeof b.startedAt === 'string' && typeof b.completedAt === 'string';
|
|
119
|
+
}
|
|
120
|
+
const any_ = (b) => b;
|
|
121
|
+
/** A representative request per claimed endpoint, with the outcome a LIVE handler produces. */
|
|
122
|
+
const PROBES = {
|
|
123
|
+
// ── CRM objects ──
|
|
124
|
+
'POST /crm/v3/objects/{objectType}': {
|
|
125
|
+
method: 'POST', path: '/crm/v3/objects/contacts', body: { properties: { email: 'probe@twin.test', firstname: 'Probe' } },
|
|
126
|
+
status: [201],
|
|
127
|
+
expect: (b) => isRecord(b) && any_(b).properties.email === 'probe@twin.test' && any_(b).archived === false,
|
|
128
|
+
},
|
|
129
|
+
'GET /crm/v3/objects/{objectType}': {
|
|
130
|
+
method: 'GET', path: '/crm/v3/objects/contacts?limit=1', status: [200],
|
|
131
|
+
expect: (b) => isCollection(b) && isRecord(any_(b).results[0]),
|
|
132
|
+
},
|
|
133
|
+
'GET /crm/v3/objects/{objectType}/{objectId}': {
|
|
134
|
+
method: 'GET', path: `/crm/v3/objects/contacts/${CONTACT}`, status: [200],
|
|
135
|
+
expect: (b) => isRecord(b) && any_(b).properties.hs_object_id === any_(b).id,
|
|
136
|
+
},
|
|
137
|
+
'PATCH /crm/v3/objects/{objectType}/{objectId}': {
|
|
138
|
+
method: 'PATCH', path: `/crm/v3/objects/contacts/${CONTACT}`, body: { properties: { lastname: 'Patched' } },
|
|
139
|
+
status: [200], expect: (b) => isRecord(b) && any_(b).properties.lastname === 'Patched',
|
|
140
|
+
},
|
|
141
|
+
'DELETE /crm/v3/objects/{objectType}/{objectId}': {
|
|
142
|
+
method: 'DELETE', path: `/crm/v3/objects/contacts/${CONTACT2}`, status: [204], expect: (b) => b === null,
|
|
143
|
+
},
|
|
144
|
+
'POST /crm/v3/objects/{objectType}/search': {
|
|
145
|
+
method: 'POST', path: '/crm/v3/objects/contacts/search',
|
|
146
|
+
body: { filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: 'probe@twin.test' }] }] },
|
|
147
|
+
status: [200],
|
|
148
|
+
expect: (b) => isObject(b) && typeof b.total === 'number' && b.total >= 1 && Array.isArray(b.results) && isRecord(b.results[0]),
|
|
149
|
+
},
|
|
150
|
+
'POST /crm/v3/objects/{objectType}/merge': {
|
|
151
|
+
method: 'POST', path: '/crm/v3/objects/contacts/merge', body: { primaryObjectId: CONTACT, objectIdToMerge: 'SENTINEL_MERGE_ID' },
|
|
152
|
+
status: [200],
|
|
153
|
+
// HubSpot keeps the PRIMARY's values and fills only its blanks from the secondary: the
|
|
154
|
+
// primary's e-mail survives while the secondary's phone (which the primary lacks) arrives.
|
|
155
|
+
expect: (b) => isRecord(b) && any_(b).properties.email === 'probe@twin.test' && any_(b).properties.phone === '+15550199',
|
|
156
|
+
},
|
|
157
|
+
'POST /crm/v3/objects/{objectType}/batch/create': {
|
|
158
|
+
method: 'POST', path: '/crm/v3/objects/contacts/batch/create', body: { inputs: [{ properties: { email: 'batch1@twin.test' } }] },
|
|
159
|
+
status: [201], expect: (b) => isBatch(b) && isRecord(any_(b).results[0]),
|
|
160
|
+
},
|
|
161
|
+
'POST /crm/v3/objects/{objectType}/batch/read': {
|
|
162
|
+
method: 'POST', path: '/crm/v3/objects/contacts/batch/read', body: { properties: ['email'], inputs: [{ id: CONTACT }] },
|
|
163
|
+
status: [200, 207], expect: (b) => isBatch(b) && any_(b).results[0].properties.email === 'probe@twin.test',
|
|
164
|
+
},
|
|
165
|
+
'POST /crm/v3/objects/{objectType}/batch/update': {
|
|
166
|
+
method: 'POST', path: '/crm/v3/objects/contacts/batch/update', body: { inputs: [{ id: CONTACT, properties: { phone: '+15550100' } }] },
|
|
167
|
+
status: [200, 207], expect: (b) => isBatch(b) && any_(b).results[0].properties.phone === '+15550100',
|
|
168
|
+
},
|
|
169
|
+
'POST /crm/v3/objects/{objectType}/batch/upsert': {
|
|
170
|
+
method: 'POST', path: '/crm/v3/objects/contacts/batch/upsert', body: { inputs: [{ idProperty: 'email', id: 'upsert@twin.test', properties: { firstname: 'Ups' } }] },
|
|
171
|
+
status: [200, 207], expect: (b) => isBatch(b) && any_(b).results[0].new === true,
|
|
172
|
+
},
|
|
173
|
+
'POST /crm/v3/objects/{objectType}/batch/archive': {
|
|
174
|
+
method: 'POST', path: '/crm/v3/objects/contacts/batch/archive', body: { inputs: [{ id: 'SENTINEL_BATCH_ARCHIVE_ID' }] },
|
|
175
|
+
status: [204], expect: (b) => b === null,
|
|
176
|
+
},
|
|
177
|
+
// ── properties ──
|
|
178
|
+
'GET /crm/v3/properties/{objectType}': {
|
|
179
|
+
method: 'GET', path: '/crm/v3/properties/contacts', status: [200],
|
|
180
|
+
// HubSpot-defined contact property names — literals, so the assertion cannot drift with the
|
|
181
|
+
// handler's own seed table.
|
|
182
|
+
expect: (b) => isCollection(b, 5) && ['email', 'firstname', 'lastname', 'hs_object_id'].every((n) => any_(b).results.some((p) => p.name === n && p.hubspotDefined === true)),
|
|
183
|
+
},
|
|
184
|
+
'POST /crm/v3/properties/{objectType}': {
|
|
185
|
+
method: 'POST', path: '/crm/v3/properties/contacts',
|
|
186
|
+
body: { name: 'probe_custom', label: 'Probe Custom', type: 'string', fieldType: 'text', groupName: 'contactinformation' },
|
|
187
|
+
status: [201], expect: (b) => isObject(b) && b.name === 'probe_custom' && b.fieldType === 'text' && b.hubspotDefined === false,
|
|
188
|
+
},
|
|
189
|
+
'POST /crm/v3/properties/{objectType}/batch/create': {
|
|
190
|
+
method: 'POST', path: '/crm/v3/properties/contacts/batch/create',
|
|
191
|
+
body: { inputs: [{ name: 'probe_batch_a', label: 'Probe Batch A', type: 'string', fieldType: 'text', groupName: 'contactinformation', formField: true }] },
|
|
192
|
+
status: [201], expect: (b) => isBatch(b) && any_(b).results[0].name === 'probe_batch_a' && any_(b).results[0].formField === true,
|
|
193
|
+
},
|
|
194
|
+
'GET /crm/v3/properties/{objectType}/{propertyName}': {
|
|
195
|
+
method: 'GET', path: '/crm/v3/properties/contacts/email', status: [200],
|
|
196
|
+
expect: (b) => isObject(b) && b.name === 'email' && b.hubspotDefined === true && b.groupName === 'contactinformation',
|
|
197
|
+
},
|
|
198
|
+
'PATCH /crm/v3/properties/{objectType}/{propertyName}': {
|
|
199
|
+
method: 'PATCH', path: '/crm/v3/properties/contacts/email', body: { label: 'Probe Relabelled' },
|
|
200
|
+
status: [200], expect: (b) => isObject(b) && b.label === 'Probe Relabelled' && b.name === 'email',
|
|
201
|
+
},
|
|
202
|
+
'DELETE /crm/v3/properties/{objectType}/{propertyName}': {
|
|
203
|
+
method: 'DELETE', path: '/crm/v3/properties/contacts/probe_deletable', status: [204], expect: (b) => b === null,
|
|
204
|
+
},
|
|
205
|
+
'GET /crm/v3/properties/{objectType}/groups': {
|
|
206
|
+
method: 'GET', path: '/crm/v3/properties/contacts/groups', status: [200],
|
|
207
|
+
expect: (b) => isCollection(b) && any_(b).results.some((g) => g.name === 'contactinformation'),
|
|
208
|
+
},
|
|
209
|
+
'POST /crm/v3/properties/{objectType}/groups': {
|
|
210
|
+
method: 'POST', path: '/crm/v3/properties/contacts/groups', body: { name: 'probe_group', label: 'Probe Group' },
|
|
211
|
+
status: [201], expect: (b) => isObject(b) && b.name === 'probe_group' && b.label === 'Probe Group',
|
|
212
|
+
},
|
|
213
|
+
'GET /crm/v3/properties/{objectType}/groups/{groupName}': {
|
|
214
|
+
method: 'GET', path: '/crm/v3/properties/contacts/groups/contactinformation', status: [200],
|
|
215
|
+
expect: (b) => isObject(b) && b.name === 'contactinformation' && typeof b.label === 'string',
|
|
216
|
+
},
|
|
217
|
+
'PATCH /crm/v3/properties/{objectType}/groups/{groupName}': {
|
|
218
|
+
method: 'PATCH', path: '/crm/v3/properties/contacts/groups/contactinformation', body: { label: 'Probe Group Relabelled' },
|
|
219
|
+
status: [200], expect: (b) => isObject(b) && b.label === 'Probe Group Relabelled',
|
|
220
|
+
},
|
|
221
|
+
'DELETE /crm/v3/properties/{objectType}/groups/{groupName}': {
|
|
222
|
+
method: 'DELETE', path: '/crm/v3/properties/contacts/groups/probe_deletable_group', status: [204], expect: (b) => b === null,
|
|
223
|
+
},
|
|
224
|
+
// ── pipelines ──
|
|
225
|
+
'GET /crm/v3/pipelines/{objectType}': {
|
|
226
|
+
method: 'GET', path: '/crm/v3/pipelines/deals', status: [200],
|
|
227
|
+
// HubSpot's out-of-the-box deal pipeline id and two of its stage ids — literals.
|
|
228
|
+
expect: (b) => isCollection(b) && any_(b).results.some((p) => p.id === 'default' && Array.isArray(p.stages)
|
|
229
|
+
&& p.stages.some((s) => s.id === 'closedwon') && p.stages.some((s) => s.id === 'appointmentscheduled')),
|
|
230
|
+
},
|
|
231
|
+
'POST /crm/v3/pipelines/{objectType}': {
|
|
232
|
+
method: 'POST', path: '/crm/v3/pipelines/deals', body: { label: 'Probe Pipeline', displayOrder: 3, stages: [{ label: 'Probe Stage', displayOrder: 0 }] },
|
|
233
|
+
status: [201], expect: (b) => isObject(b) && b.label === 'Probe Pipeline' && Array.isArray(b.stages) && b.stages.length === 1,
|
|
234
|
+
},
|
|
235
|
+
'GET /crm/v3/pipelines/{objectType}/{pipelineId}': {
|
|
236
|
+
method: 'GET', path: `/crm/v3/pipelines/deals/${PIPELINE}`, status: [200],
|
|
237
|
+
expect: (b) => isObject(b) && b.label === 'Probe Fixture Pipeline' && Array.isArray(b.stages),
|
|
238
|
+
},
|
|
239
|
+
'PATCH /crm/v3/pipelines/{objectType}/{pipelineId}': {
|
|
240
|
+
method: 'PATCH', path: `/crm/v3/pipelines/deals/${PIPELINE}`, body: { label: 'Probe Pipeline Renamed' },
|
|
241
|
+
status: [200], expect: (b) => isObject(b) && b.label === 'Probe Pipeline Renamed',
|
|
242
|
+
},
|
|
243
|
+
'PUT /crm/v3/pipelines/{objectType}/{pipelineId}': {
|
|
244
|
+
method: 'PUT', path: `/crm/v3/pipelines/deals/${PIPELINE}`, body: { label: 'Probe Pipeline Replaced', displayOrder: 4, stages: [{ label: 'Only Stage', displayOrder: 0 }] },
|
|
245
|
+
status: [200], expect: (b) => isObject(b) && b.label === 'Probe Pipeline Replaced' && b.stages.length === 1,
|
|
246
|
+
},
|
|
247
|
+
'DELETE /crm/v3/pipelines/{objectType}/{pipelineId}': {
|
|
248
|
+
method: 'DELETE', path: '/crm/v3/pipelines/deals/SENTINEL_DELETABLE_PIPELINE', status: [204], expect: (b) => b === null,
|
|
249
|
+
},
|
|
250
|
+
// The stage family drives its OWN pipeline: the `PUT /pipelines/{id}` probe above REPLACES
|
|
251
|
+
// that pipeline's stages, which would invalidate a stage id captured before it ran.
|
|
252
|
+
'GET /crm/v3/pipelines/{objectType}/{pipelineId}/stages': {
|
|
253
|
+
method: 'GET', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages`, status: [200],
|
|
254
|
+
expect: (b) => isCollection(b, 2) && any_(b).results.every((s) => typeof s.id === 'string' && typeof s.label === 'string'),
|
|
255
|
+
},
|
|
256
|
+
'POST /crm/v3/pipelines/{objectType}/{pipelineId}/stages': {
|
|
257
|
+
method: 'POST', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages`, body: { label: 'Added Stage', displayOrder: 9 },
|
|
258
|
+
status: [201], expect: (b) => isObject(b) && b.label === 'Added Stage' && b.displayOrder === 9,
|
|
259
|
+
},
|
|
260
|
+
'GET /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
|
|
261
|
+
method: 'GET', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/${STAGE}`, status: [200],
|
|
262
|
+
expect: (b) => isObject(b) && b.label === 'S1' && typeof b.displayOrder === 'number',
|
|
263
|
+
},
|
|
264
|
+
'PATCH /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
|
|
265
|
+
method: 'PATCH', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/${STAGE}`, body: { label: 'Stage Renamed' },
|
|
266
|
+
status: [200], expect: (b) => isObject(b) && b.label === 'Stage Renamed',
|
|
267
|
+
},
|
|
268
|
+
'PUT /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
|
|
269
|
+
method: 'PUT', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/${STAGE}`, body: { label: 'Stage Replaced', displayOrder: 1 },
|
|
270
|
+
status: [200], expect: (b) => isObject(b) && b.label === 'Stage Replaced' && b.displayOrder === 1,
|
|
271
|
+
},
|
|
272
|
+
'DELETE /crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}': {
|
|
273
|
+
method: 'DELETE', path: `/crm/v3/pipelines/deals/${STAGE_PIPELINE}/stages/SENTINEL_DELETABLE_STAGE`, status: [204], expect: (b) => b === null,
|
|
274
|
+
},
|
|
275
|
+
// ── owners ──
|
|
276
|
+
'GET /crm/v3/owners': {
|
|
277
|
+
method: 'GET', path: '/crm/v3/owners', status: [200],
|
|
278
|
+
expect: (b) => isCollection(b) && any_(b).results.every((o) => typeof o.id === 'string' && ['PERSON', 'QUEUE'].includes(o.type)),
|
|
279
|
+
},
|
|
280
|
+
'GET /crm/v3/owners/{ownerId}': {
|
|
281
|
+
method: 'GET', path: '/crm/v3/owners/1', status: [200],
|
|
282
|
+
expect: (b) => isObject(b) && b.id === '1' && b.type === 'PERSON' && typeof b.email === 'string',
|
|
283
|
+
},
|
|
284
|
+
// ── associations v4 ──
|
|
285
|
+
'GET /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}': {
|
|
286
|
+
// Driven against the fixture contact that ALREADY HAS an association. §9 round one caught the
|
|
287
|
+
// first version pointing at CONTACT, whose associations are only created by probes that run
|
|
288
|
+
// LATER in the census order — so the body was `{results: []}` and `length >= 0` plus
|
|
289
|
+
// `[].every(...)` were both vacuously true. A handler returning a constant empty list passed.
|
|
290
|
+
method: 'GET', path: '/crm/v4/objects/contacts/SENTINEL_ASSOC_CONTACT/associations/companies', status: [200],
|
|
291
|
+
expect: (b) => isCollection(b, 1)
|
|
292
|
+
&& any_(b).results.some((r) => typeof r.toObjectId === 'string' && r.toObjectId !== ''
|
|
293
|
+
&& Array.isArray(r.associationTypes) && r.associationTypes.some((t) => t.typeId === 279 && t.category === 'HUBSPOT_DEFINED')),
|
|
294
|
+
},
|
|
295
|
+
'PUT /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}': {
|
|
296
|
+
method: 'PUT', path: `/crm/v4/objects/contacts/${CONTACT}/associations/companies/${COMPANY}`,
|
|
297
|
+
// `AssociationSpec` is a CLOSED two-key shape — there is no `label` on the request; HubSpot
|
|
298
|
+
// resolves labels from the association-type DEFINITION, which this twin does not model, so
|
|
299
|
+
// `labels` is empty and `fromObjectTypeId`/`toObjectTypeId` carry the NUMERIC objectTypeIds.
|
|
300
|
+
body: [{ associationCategory: 'USER_DEFINED', associationTypeId: 42 }],
|
|
301
|
+
status: [201],
|
|
302
|
+
expect: (b) => isObject(b) && Array.isArray(b.labels) && b.labels.length === 0
|
|
303
|
+
&& b.fromObjectTypeId === '0-1' && b.toObjectTypeId === '0-2' && typeof b.toObjectId === 'string',
|
|
304
|
+
},
|
|
305
|
+
'DELETE /crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}': {
|
|
306
|
+
method: 'DELETE', path: '/crm/v4/objects/contacts/SENTINEL_ASSOC_CONTACT/associations/companies/SENTINEL_ASSOC_COMPANY',
|
|
307
|
+
status: [204], expect: (b) => b === null,
|
|
308
|
+
},
|
|
309
|
+
'PUT /crm/v4/objects/{objectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}': {
|
|
310
|
+
method: 'PUT', path: `/crm/v4/objects/contacts/${CONTACT}/associations/default/companies/${COMPANY}`, status: [200],
|
|
311
|
+
// 279 is HubSpot's documented contact→company default association type id — a literal.
|
|
312
|
+
expect: (b) => isObject(b) && Array.isArray(b.results) && b.results[0]?.associationSpec?.associationTypeId === 279,
|
|
313
|
+
},
|
|
314
|
+
'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read': {
|
|
315
|
+
method: 'POST', path: '/crm/v4/associations/contacts/companies/batch/read', body: { inputs: [{ id: CONTACT }] },
|
|
316
|
+
status: [200], expect: (b) => isBatch(b) && Array.isArray(any_(b).results[0].to),
|
|
317
|
+
},
|
|
318
|
+
'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create': {
|
|
319
|
+
method: 'POST', path: '/crm/v4/associations/contacts/companies/batch/create',
|
|
320
|
+
body: { inputs: [{ from: { id: CONTACT }, to: { id: COMPANY }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 279 }] }] },
|
|
321
|
+
status: [201], expect: (b) => isBatch(b) && any_(b).results[0].fromObjectTypeId === '0-1' && any_(b).results[0].toObjectTypeId === '0-2',
|
|
322
|
+
},
|
|
323
|
+
'POST /crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive': {
|
|
324
|
+
method: 'POST', path: '/crm/v4/associations/contacts/companies/batch/archive',
|
|
325
|
+
body: { inputs: [{ from: { id: 'SENTINEL_ASSOC2_CONTACT' }, to: [{ id: 'SENTINEL_ASSOC2_COMPANY' }] }] },
|
|
326
|
+
status: [204], expect: (b) => b === null,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const ROUTER_SURFACE = [
|
|
330
|
+
// Each entry declares the STATUS a LIVE branch answers with. §9 round one caught the first
|
|
331
|
+
// version grading only "is this the generic 404 miss?", which meant a branch that degraded to
|
|
332
|
+
// a 400/405/500 — or one whose objectType alias table was deleted, so `GET /crm/v3/objects/0-1`
|
|
333
|
+
// started answering "Unable to infer object type from: 0-1" — stayed GREEN in the census that
|
|
334
|
+
// names it. That is the tinybird "teeth at the dispatch and nowhere deeper" trap, surviving in
|
|
335
|
+
// the census half. The status set closes it.
|
|
336
|
+
['POST', '/crm/v3/objects/companies', [400]], // no properties in the body
|
|
337
|
+
['GET', '/crm/v3/objects/companies', [200]],
|
|
338
|
+
['GET', `/crm/v3/objects/companies/${COMPANY}`, [200]],
|
|
339
|
+
['PATCH', `/crm/v3/objects/companies/${COMPANY}`, [400]], // no properties in the body
|
|
340
|
+
['GET', '/crm/v3/objects/deals', [200]],
|
|
341
|
+
['GET', `/crm/v3/objects/deals/${DEAL}`, [200]],
|
|
342
|
+
['GET', '/crm/v3/objects/tickets', [200]],
|
|
343
|
+
['GET', `/crm/v3/objects/tickets/${TICKET}`, [200]],
|
|
344
|
+
['POST', '/crm/v3/objects/deals/search', [200]],
|
|
345
|
+
// A REAL inputs array, so the request reaches the `op === 'read'` branch rather than
|
|
346
|
+
// stopping at the shared `inputs must be an array` guard above it. §9 round two: the earlier
|
|
347
|
+
// `[400]` entries were satisfied by that guard, so the branch they NAME stayed deletable.
|
|
348
|
+
['POST', '/crm/v3/objects/tickets/batch/read', [200, 207], { properties: [], inputs: [{ id: '424242' }] }],
|
|
349
|
+
// the numeric objectTypeId alias HubSpot also accepts for contacts — a 200 here, so deleting
|
|
350
|
+
// OBJECT_TYPE_IDS reddens THIS entry rather than only a capability elsewhere
|
|
351
|
+
['GET', '/crm/v3/objects/0-1', [200]],
|
|
352
|
+
// properties + groups on a second object type
|
|
353
|
+
['GET', '/crm/v3/properties/deals', [200]],
|
|
354
|
+
['GET', '/crm/v3/properties/deals/groups', [200]],
|
|
355
|
+
['GET', '/crm/v3/properties/deals/dealstage', [200]],
|
|
356
|
+
['POST', '/crm/v3/properties/deals/batch/create', [400]], // no inputs array
|
|
357
|
+
// the pipelines family on tickets — HubSpot's other pipelined object
|
|
358
|
+
['GET', '/crm/v3/pipelines/tickets', [200]],
|
|
359
|
+
['GET', '/crm/v3/pipelines/tickets/0', [200]],
|
|
360
|
+
['GET', '/crm/v3/pipelines/tickets/0/stages', [200]],
|
|
361
|
+
['GET', '/crm/v3/pipelines/tickets/0/stages/1', [200]],
|
|
362
|
+
['GET', '/crm/v3/owners', [200]],
|
|
363
|
+
['GET', '/crm/v3/owners/1', [200]],
|
|
364
|
+
// associations in the other direction
|
|
365
|
+
// The company that HAS an association — a 200-on-empty would be the same vacuity round one
|
|
366
|
+
// removed from the probe half.
|
|
367
|
+
['GET', '/crm/v4/objects/companies/SENTINEL_ASSOC_COMPANY/associations/contacts', [200]],
|
|
368
|
+
['POST', '/crm/v4/associations/deals/contacts/batch/read', [404], { inputs: [{ id: '424242' }] }], // reaches the branch; the record miss is its own 404
|
|
369
|
+
];
|
|
370
|
+
/** The generic router miss — distinct in MESSAGE from a real record-not-found 404. */
|
|
371
|
+
function isRouterMiss(res) {
|
|
372
|
+
return res.status === 404
|
|
373
|
+
&& isObject(res.body)
|
|
374
|
+
&& typeof res.body.message === 'string'
|
|
375
|
+
&& res.body.message.startsWith('resource not found: ');
|
|
376
|
+
}
|
|
377
|
+
export async function checkHubspotConformance(opts = {}) {
|
|
378
|
+
void opts; // always a THROWAWAY root: the probes create, archive and merge real records.
|
|
379
|
+
const snapshot = hubspotTwinSnapshot();
|
|
380
|
+
const claimed = new Set(snapshot.implementedEndpoints);
|
|
381
|
+
const violations = [];
|
|
382
|
+
const root = mkdtempSync(join(tmpdir(), 'hubspot-conformance-'));
|
|
383
|
+
const call = (method, path, body) => handleHubspotTwinRequest({ method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }), root, occurredAt: AT });
|
|
384
|
+
let probed = 0;
|
|
385
|
+
try {
|
|
386
|
+
// ── fixtures the probes address ───────────────────────────────────────────────────────
|
|
387
|
+
const mk = async (type, props) => {
|
|
388
|
+
const res = await call('POST', `/crm/v3/objects/${type}`, { properties: props });
|
|
389
|
+
const id = isObject(res.body) ? String(res.body.id ?? '') : '';
|
|
390
|
+
if (res.status !== 201 || !id)
|
|
391
|
+
violations.push(`fixture: POST /crm/v3/objects/${type} answered ${res.status}, so every probe addressing it is unverified`);
|
|
392
|
+
return id;
|
|
393
|
+
};
|
|
394
|
+
const contactId = await mk('contacts', { email: 'probe@twin.test', firstname: 'Probe' });
|
|
395
|
+
const contact2Id = await mk('contacts', { email: 'archivable@twin.test' });
|
|
396
|
+
const mergeId = await mk('contacts', { email: 'mergeable@twin.test', phone: '+15550199' });
|
|
397
|
+
const batchArchiveId = await mk('contacts', { email: 'batch-archivable@twin.test' });
|
|
398
|
+
const assocContactId = await mk('contacts', { email: 'assoc@twin.test' });
|
|
399
|
+
const assoc2ContactId = await mk('contacts', { email: 'assoc2@twin.test' });
|
|
400
|
+
const companyId = await mk('companies', { name: 'Probe Co', domain: 'probe.test' });
|
|
401
|
+
const assocCompanyId = await mk('companies', { name: 'Assoc Co' });
|
|
402
|
+
const assoc2CompanyId = await mk('companies', { name: 'Assoc2 Co' });
|
|
403
|
+
const dealId = await mk('deals', { dealname: 'Probe Deal', amount: '1200', dealstage: 'closedwon' });
|
|
404
|
+
const ticketId = await mk('tickets', { subject: 'Probe Ticket', hs_pipeline_stage: '1' });
|
|
405
|
+
// Each destructive probe gets its OWN fixture, so probe ORDER cannot make this check lie.
|
|
406
|
+
await call('PUT', `/crm/v4/objects/contacts/${assocContactId}/associations/default/companies/${assocCompanyId}`);
|
|
407
|
+
await call('PUT', `/crm/v4/objects/contacts/${assoc2ContactId}/associations/default/companies/${assoc2CompanyId}`);
|
|
408
|
+
await call('POST', '/crm/v3/properties/contacts', { name: 'probe_deletable', label: 'Deletable', type: 'string', fieldType: 'text', groupName: 'contactinformation' });
|
|
409
|
+
await call('POST', '/crm/v3/properties/contacts/groups', { name: 'probe_deletable_group', label: 'Deletable Group' });
|
|
410
|
+
const mkPipeline = async (label, stages) => {
|
|
411
|
+
const res = await call('POST', '/crm/v3/pipelines/deals', { label, displayOrder: 5, stages });
|
|
412
|
+
return isObject(res.body) ? String(res.body.id ?? '') : '';
|
|
413
|
+
};
|
|
414
|
+
const pipelineId = await mkPipeline('Probe Fixture Pipeline', [{ label: 'S1', displayOrder: 0 }]);
|
|
415
|
+
const deletablePipelineId = await mkPipeline('Deletable Pipeline', [{ label: 'S1', displayOrder: 0 }]);
|
|
416
|
+
const stagePipelineId = await mkPipeline('Stage Fixture Pipeline', [{ label: 'S1', displayOrder: 0 }, { label: 'S2', displayOrder: 1 }]);
|
|
417
|
+
const stagesOf = async (pid) => {
|
|
418
|
+
const res = await call('GET', `/crm/v3/pipelines/deals/${pid}`);
|
|
419
|
+
return isObject(res.body) && Array.isArray(res.body.stages) ? res.body.stages.map((s) => String(s.id)) : [];
|
|
420
|
+
};
|
|
421
|
+
const fixtureStages = await stagesOf(stagePipelineId);
|
|
422
|
+
const stageId = fixtureStages[0] ?? '';
|
|
423
|
+
const deletableStageId = fixtureStages[1] ?? '';
|
|
424
|
+
// Longest sentinel first: CONTACT is a prefix of nothing, but CONTACT2 contains CONTACT's
|
|
425
|
+
// stem, so order matters (the §0.5 longest-match-first rule, applied to substitution).
|
|
426
|
+
const substitute = (s) => s
|
|
427
|
+
.replaceAll(CONTACT2, contact2Id)
|
|
428
|
+
.replaceAll(CONTACT, contactId)
|
|
429
|
+
.replaceAll(COMPANY, companyId)
|
|
430
|
+
.replaceAll(DEAL, dealId)
|
|
431
|
+
.replaceAll(TICKET, ticketId)
|
|
432
|
+
.replaceAll('SENTINEL_MERGE_ID', mergeId)
|
|
433
|
+
.replaceAll('SENTINEL_BATCH_ARCHIVE_ID', batchArchiveId)
|
|
434
|
+
.replaceAll('SENTINEL_ASSOC2_CONTACT', assoc2ContactId)
|
|
435
|
+
.replaceAll('SENTINEL_ASSOC2_COMPANY', assoc2CompanyId)
|
|
436
|
+
.replaceAll('SENTINEL_ASSOC_CONTACT', assocContactId)
|
|
437
|
+
.replaceAll('SENTINEL_ASSOC_COMPANY', assocCompanyId)
|
|
438
|
+
.replaceAll('SENTINEL_DELETABLE_PIPELINE', deletablePipelineId)
|
|
439
|
+
.replaceAll(STAGE_PIPELINE, stagePipelineId)
|
|
440
|
+
.replaceAll('SENTINEL_DELETABLE_STAGE', deletableStageId)
|
|
441
|
+
.replaceAll(PIPELINE, pipelineId)
|
|
442
|
+
.replaceAll(STAGE, stageId);
|
|
443
|
+
const substituteBody = (b) => (b === undefined ? undefined : JSON.parse(substitute(JSON.stringify(b))));
|
|
444
|
+
// ── the ROUTER CENSUS, both directions ────────────────────────────────────────────────
|
|
445
|
+
for (const [method, rawPath, expected, routeBody] of ROUTER_SURFACE) {
|
|
446
|
+
const res = await call(method, substitute(rawPath), substituteBody(routeBody));
|
|
447
|
+
const key = `${method} ${canonicalPath(rawPath)}`;
|
|
448
|
+
if (isRouterMiss(res)) {
|
|
449
|
+
violations.push(`the router census names '${method} ${rawPath}' (canonically '${key}') but the router no longer REACHES it — it fell through to the generic miss, so the handler branch that served it is gone`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (!expected.includes(res.status)) {
|
|
453
|
+
violations.push(`the router census names '${method} ${rawPath}' and a live branch answers [${expected.join(', ')}] there, but it answered ${res.status} — ${JSON.stringify(res.body).slice(0, 200)}`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (!claimed.has(key)) {
|
|
457
|
+
violations.push(`the router answers '${method} ${rawPath}' (${res.status}) whose canonical form '${key}' the endpoint census does not claim — served surface outside the census is deletable without this check noticing`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// ── probe⇄snapshot bijection, then one LIVE request per claimed endpoint ──────────────
|
|
461
|
+
for (const endpoint of snapshot.implementedEndpoints) {
|
|
462
|
+
const probe = PROBES[endpoint];
|
|
463
|
+
if (!probe) {
|
|
464
|
+
violations.push(`endpoint '${endpoint}' is claimed but has no conformance probe — the claim is unverified`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const [claimedMethod, claimedPath] = endpoint.split(' ');
|
|
468
|
+
if (probe.method !== claimedMethod) {
|
|
469
|
+
violations.push(`probe '${endpoint}' drives ${probe.method}, not ${claimedMethod}`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (canonicalPath(probe.path) !== claimedPath) {
|
|
473
|
+
violations.push(`probe '${endpoint}' drives ${probe.path}, whose canonical form is ${canonicalPath(probe.path)} — not the claimed path ${claimedPath}`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const res = await call(probe.method, substitute(probe.path), substituteBody(probe.body));
|
|
477
|
+
probed += 1;
|
|
478
|
+
if (!probe.status.includes(res.status)) {
|
|
479
|
+
violations.push(`${endpoint}: answered ${res.status}, expected one of [${probe.status.join(', ')}] — body ${JSON.stringify(res.body).slice(0, 200)}`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (probe.expect && !probe.expect(res.body)) {
|
|
483
|
+
violations.push(`${endpoint}: status ${res.status} was right but the body is not what a live handler produces — ${JSON.stringify(res.body).slice(0, 240)}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
for (const key of Object.keys(PROBES)) {
|
|
487
|
+
if (!claimed.has(key))
|
|
488
|
+
violations.push(`probe '${key}' exercises an endpoint the census does not claim — the two must biject`);
|
|
489
|
+
}
|
|
490
|
+
// ── the error envelope + the determinism invariant, driven for real ───────────────────
|
|
491
|
+
const miss = await call('GET', '/crm/v3/objects/contacts/99999999');
|
|
492
|
+
const missBody = miss.body;
|
|
493
|
+
if (miss.status !== 404 || !isObject(missBody) || missBody.status !== 'error' || missBody.category !== 'OBJECT_NOT_FOUND'
|
|
494
|
+
|| typeof missBody.message !== 'string'
|
|
495
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(String(missBody.correlationId))) {
|
|
496
|
+
violations.push(`an unknown record id must answer HubSpot's documented error envelope { status:'error', message, correlationId (uuid-shaped), category:'OBJECT_NOT_FOUND' } — got ${miss.status} ${JSON.stringify(missBody)}`);
|
|
497
|
+
}
|
|
498
|
+
const badType = await call('GET', '/crm/v3/objects/wombats');
|
|
499
|
+
if (badType.status !== 400 || !isObject(badType.body) || badType.body.category !== 'VALIDATION_ERROR'
|
|
500
|
+
|| !String(badType.body.message).startsWith('Unable to infer object type from:')) {
|
|
501
|
+
violations.push(`an unknown objectType must answer 400 VALIDATION_ERROR "Unable to infer object type from: …" — got ${badType.status} ${JSON.stringify(badType.body)}`);
|
|
502
|
+
}
|
|
503
|
+
const headed = await call('GET', '/crm/v3/objects/contacts');
|
|
504
|
+
if (headed.headers?.['x-hubspot-ratelimit-max'] !== '190' || headed.headers?.['x-hubspot-ratelimit-interval-milliseconds'] !== '10000' || headed.headers?.['x-hubspot-ratelimit-daily'] !== '625000') {
|
|
505
|
+
violations.push(`every response must carry HubSpot's documented rate-limit policy headers (Max 190 / Interval 10000 / Daily 625000) — got ${JSON.stringify(headed.headers)}`);
|
|
506
|
+
}
|
|
507
|
+
const a = await call('GET', '/crm/v3/objects/contacts/99999999');
|
|
508
|
+
const b = await call('GET', '/crm/v3/objects/contacts/99999999');
|
|
509
|
+
if (JSON.stringify(a.body) !== JSON.stringify(b.body)) {
|
|
510
|
+
violations.push('two identical requests produced different bodies — the correlationId (or something else) is not derived, and the serve path is not replayable');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
rmSync(root, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
ok: violations.length === 0,
|
|
518
|
+
endpointsClaimed: snapshot.implementedEndpoints.length,
|
|
519
|
+
endpointsProbed: probed,
|
|
520
|
+
routerRoutesChecked: ROUTER_SURFACE.length,
|
|
521
|
+
violations,
|
|
522
|
+
};
|
|
523
|
+
}
|