@deployfoundation/foundation-deploy 0.2.50 → 0.2.52
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/README.md +4 -1
- package/agent-image/Dockerfile +36 -1
- package/dist/bin/app.js +6 -3
- package/dist/bin/foundation-deploy.js +139 -305
- package/dist/bin/release-account.js +1 -0
- package/dist/chunk-3614v9rr.js +907 -0
- package/dist/{chunk-efqdhd2e.js → chunk-59dpqyap.js} +254 -90
- package/dist/chunk-5wgwscd0.js +4 -0
- package/dist/{chunk-gnz747z2.js → chunk-96fjaysn.js} +204 -884
- package/dist/chunk-9zx95xs6.js +259 -0
- package/dist/chunk-jf5rw6h4.js +1117 -0
- package/dist/chunk-m76jt5rc.js +376 -0
- package/dist/chunk-mh1206ne.js +1764 -0
- package/dist/chunk-p7zkm4pp.js +151 -0
- package/dist/chunk-pxhftba1.js +336 -0
- package/dist/chunk-rxetj2p9.js +466 -0
- package/dist/chunk-sgdcmme0.js +301 -0
- package/dist/chunk-tey0gh86.js +38 -0
- package/dist/src/index.js +17 -13
- package/package.json +3 -1
- package/src/deploy/cli.ts +71 -0
- package/src/deploy/crm.ts +491 -0
- package/src/stacks/agent-stack.ts +34 -11
- package/src/stacks/api-stack.ts +340 -87
- package/src/stacks/data-stack.ts +27 -1
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CRM_HTTP_CONTRACT,
|
|
3
|
+
CRM_HTTP_HEADERS,
|
|
4
|
+
CRM_HTTP_ROUTES,
|
|
5
|
+
CrmHttpError,
|
|
6
|
+
createCrmHttpClient,
|
|
7
|
+
encodeCrmActor,
|
|
8
|
+
readCrmHttpBody
|
|
9
|
+
} from "./chunk-m76jt5rc.js";
|
|
10
|
+
import {
|
|
11
|
+
outreachWeekStart,
|
|
12
|
+
personOwnedStatuses
|
|
13
|
+
} from "./chunk-p7zkm4pp.js";
|
|
14
|
+
import {
|
|
15
|
+
CRM_NOTE_ACTIVITY,
|
|
16
|
+
CRM_OUTREACH_DRAFTED_ACTIVITY,
|
|
17
|
+
CRM_REQUIRED_PROVIDER_OPERATIONS
|
|
18
|
+
} from "./chunk-3614v9rr.js";
|
|
19
|
+
import"./chunk-96fjaysn.js";
|
|
20
|
+
import"./chunk-5wgwscd0.js";
|
|
21
|
+
|
|
22
|
+
// ../capability-crm/src/contract/conformance.ts
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
class Skip extends Error {
|
|
25
|
+
reason;
|
|
26
|
+
needs;
|
|
27
|
+
constructor(reason, needs = "other") {
|
|
28
|
+
super(reason);
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
this.needs = needs;
|
|
31
|
+
this.name = "Skip";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class Failure extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "Failure";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
var USER_AGENT = "foundation-crm-conformance/1";
|
|
42
|
+
var RAW_TIMEOUT_MS = 1e4;
|
|
43
|
+
var MAX_RAW_BODY_BYTES = 64 * 1024;
|
|
44
|
+
var CANDIDATE_PAGES = 20;
|
|
45
|
+
async function runCrmConformance(target) {
|
|
46
|
+
const suite = new Suite(target);
|
|
47
|
+
return suite.run();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class Suite {
|
|
51
|
+
target;
|
|
52
|
+
cases = [];
|
|
53
|
+
client;
|
|
54
|
+
runId;
|
|
55
|
+
fetchImpl;
|
|
56
|
+
meta;
|
|
57
|
+
sequence = 0;
|
|
58
|
+
firstTouches = 0;
|
|
59
|
+
followUps = 0;
|
|
60
|
+
constructor(target) {
|
|
61
|
+
this.target = target;
|
|
62
|
+
this.fetchImpl = target.fetchImpl ?? fetch;
|
|
63
|
+
this.runId = target.runId ?? `fcrm-${randomUUID().slice(0, 8)}`;
|
|
64
|
+
this.client = this.clientFor(target.apiKey);
|
|
65
|
+
}
|
|
66
|
+
async run() {
|
|
67
|
+
await this.check("meta", "GET /meta is valid and names the expected tenant", () => this.metaCase());
|
|
68
|
+
await this.check("auth_401", "an unknown key is 401", () => this.authCase());
|
|
69
|
+
await this.check("origin_403", "a request with an Origin header is 403 forbidden", () => this.originCase());
|
|
70
|
+
await this.check("contract_header", "a missing or other contract header is 400", () => this.contractHeaderCase());
|
|
71
|
+
await this.check("unknown_field", "an unknown request field is invalid_input", () => this.unknownFieldCase());
|
|
72
|
+
await this.check("search_bounds", "search enforces and honours its limit", () => this.searchBoundsCase());
|
|
73
|
+
await this.check("get_not_found", "an unknown record is 404 with contract and tenant headers", () => this.notFoundCase());
|
|
74
|
+
await this.check("delete_unsupported", "a server without deletes answers 501", () => this.deleteUnsupportedCase());
|
|
75
|
+
await this.writeCheck("upsert_and_read", "upserts create records; reads carry provenance", () => this.upsertCase());
|
|
76
|
+
await this.writeCheck("idempotent_replay", "a repeated write replays; a changed one is 409", () => this.replayCase());
|
|
77
|
+
await this.writeCheck("idempotency_header_mismatch", "Idempotency-Key must equal the body's key", () => this.headerMismatchCase());
|
|
78
|
+
await this.writeCheck("company_unknown", "a contact's unknown company is refused", () => this.companyUnknownCase());
|
|
79
|
+
await this.writeCheck("batch_bound", "an upsert over maxBatchSize is invalid_input", () => this.batchBoundCase());
|
|
80
|
+
await this.writeCheck("status_cas", "a status change compares and sets; a stale one is 412", () => this.statusCasCase());
|
|
81
|
+
await this.writeCheck("status_cas_version", "statusVersion is compared, not only the status", () => this.statusCasVersionCase());
|
|
82
|
+
await this.writeCheck("status_replay", "a status retry with a fresh expect replays", () => this.statusReplayCase());
|
|
83
|
+
await this.writeCheck("transition_not_allowed", "a disallowed status change is refused", () => this.transitionCase());
|
|
84
|
+
await this.writeCheck("status_locked", "a status a person set is not re-set by the agent", () => this.statusLockedCase());
|
|
85
|
+
await this.writeCheck("person_stage_rules", "a person's status moves only as the deployment's policy allows", () => this.personStageCase());
|
|
86
|
+
await this.writeCheck("attributes_by_reserved", "attributes.by is the server's to set", () => this.attributesByCase());
|
|
87
|
+
await this.writeCheck("note_log_and_edit", "the agent's note is versioned and editable", () => this.noteEditCase());
|
|
88
|
+
await this.writeCheck("note_not_editable", "other activities and a person's note are not", () => this.noteNotEditableCase());
|
|
89
|
+
await this.writeCheck("outreach_first_touch", "a first touch counts once per contact", () => this.firstTouchCase());
|
|
90
|
+
await this.writeCheck("outreach_not_contacted", "a follow-up needs a first touch", () => this.notContactedCase());
|
|
91
|
+
await this.writeCheck("outreach_per_email", "one first touch per email address", () => this.perEmailCase());
|
|
92
|
+
await this.writeCheck("outreach_company_draft", "a draft on a company is invalid_input", () => this.companyDraftCase());
|
|
93
|
+
await this.writeCheck("outreach_candidates", "candidates carry touch and rank, and page", () => this.candidatesCase());
|
|
94
|
+
await this.writeCheck("outreach_suppressed", "do-not-contact and suppressed addresses refuse drafts", () => this.suppressedCase());
|
|
95
|
+
await this.writeCheck("max_follow_ups", "follow-ups stop at maxFollowUps", () => this.maxFollowUpsCase());
|
|
96
|
+
await this.writeCheck("follow_up_limit", "follow-ups stop at the weekly follow-up limit", () => this.followUpLimitCase());
|
|
97
|
+
await this.writeCheck("read_only_scope", "a read-only key cannot write", () => this.readOnlyScopeCase());
|
|
98
|
+
await this.writeCheck("weekly_limit", "first touches stop at the weekly limit", () => this.weeklyLimitCase());
|
|
99
|
+
const count = (outcome) => this.cases.filter((entry) => entry.outcome === outcome).length;
|
|
100
|
+
return {
|
|
101
|
+
contract: CRM_HTTP_CONTRACT,
|
|
102
|
+
passed: count("pass"),
|
|
103
|
+
failed: count("fail"),
|
|
104
|
+
skipped: count("skip"),
|
|
105
|
+
cases: this.cases
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async check(id, title, body) {
|
|
109
|
+
try {
|
|
110
|
+
const note = await body();
|
|
111
|
+
this.cases.push({
|
|
112
|
+
id,
|
|
113
|
+
title,
|
|
114
|
+
outcome: "pass",
|
|
115
|
+
...note === undefined ? {} : { detail: note }
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof Skip && error.needs === "harness" && this.target.strict === true) {
|
|
119
|
+
this.cases.push({
|
|
120
|
+
id,
|
|
121
|
+
title,
|
|
122
|
+
outcome: "fail",
|
|
123
|
+
detail: `not run: ${error.reason} (strict: person-only cases need a harness)`
|
|
124
|
+
});
|
|
125
|
+
} else if (error instanceof Skip) {
|
|
126
|
+
this.cases.push({ id, title, outcome: "skip", detail: error.reason });
|
|
127
|
+
} else {
|
|
128
|
+
this.cases.push({ id, title, outcome: "fail", detail: describe(error) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async writeCheck(id, title, body) {
|
|
133
|
+
await this.check(id, title, async () => {
|
|
134
|
+
if (this.target.write !== true)
|
|
135
|
+
throw new Skip("write cases are off (write: false)");
|
|
136
|
+
return body();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async metaCase() {
|
|
140
|
+
const meta = await this.client.meta();
|
|
141
|
+
if (meta.contract !== CRM_HTTP_CONTRACT)
|
|
142
|
+
throw new Failure("contract is not crm/1");
|
|
143
|
+
if (meta.tenant.id !== this.target.tenantId) {
|
|
144
|
+
throw new Failure("tenant.id is not the expected tenant");
|
|
145
|
+
}
|
|
146
|
+
const missing = CRM_REQUIRED_PROVIDER_OPERATIONS.filter((name) => !meta.operations.includes(name));
|
|
147
|
+
if (missing.length > 0) {
|
|
148
|
+
throw new Failure(`required operations missing: ${missing.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
if (meta.statuses.company.length === 0 || meta.statuses.contact.length === 0) {
|
|
151
|
+
throw new Failure("each record type needs at least one status");
|
|
152
|
+
}
|
|
153
|
+
if (meta.operations.includes("outreach_state")) {
|
|
154
|
+
const policy = meta.policy;
|
|
155
|
+
const missing2 = ["firstTouchWeeklyLimit", "followUpWeeklyLimit", "maxFollowUps"].filter((field) => policy?.[field] === undefined).join(", ");
|
|
156
|
+
if (missing2 !== "")
|
|
157
|
+
throw new Failure(`/meta policy is missing ${missing2}`);
|
|
158
|
+
}
|
|
159
|
+
this.meta = meta;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
async authCase() {
|
|
163
|
+
const reply = await this.raw("meta", undefined, {
|
|
164
|
+
headers: { authorization: "Bearer conformance-invalid-key-000000" }
|
|
165
|
+
});
|
|
166
|
+
expectStatus(reply, [401]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
async originCase() {
|
|
170
|
+
const reply = await this.raw("search", { limit: 1 }, {
|
|
171
|
+
headers: { origin: "https://conformance.example" }
|
|
172
|
+
});
|
|
173
|
+
expectStatus(reply, [403]);
|
|
174
|
+
expectErrorBody(reply, "forbidden");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
async contractHeaderCase() {
|
|
178
|
+
const missing = await this.raw("meta", undefined, { omit: [CRM_HTTP_HEADERS.contract] });
|
|
179
|
+
expectStatus(missing, [400]);
|
|
180
|
+
expectErrorBody(missing, "invalid_input");
|
|
181
|
+
const other = await this.raw("meta", undefined, {
|
|
182
|
+
headers: { [CRM_HTTP_HEADERS.contract]: "crm/2" }
|
|
183
|
+
});
|
|
184
|
+
expectStatus(other, [400]);
|
|
185
|
+
expectErrorBody(other, "invalid_input");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
async unknownFieldCase() {
|
|
189
|
+
const reply = await this.raw("search", { limit: 1, customerId: "conformance" });
|
|
190
|
+
expectStatus(reply, [400, 422]);
|
|
191
|
+
expectErrorBody(reply, "invalid_input");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
async searchBoundsCase() {
|
|
195
|
+
await expectError(this.client.search({ limit: 101 }), "invalid_input");
|
|
196
|
+
const page = await this.client.search({ limit: 1 });
|
|
197
|
+
if (page.records.length > 1)
|
|
198
|
+
throw new Failure("search returned more records than its limit");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
async notFoundCase() {
|
|
202
|
+
const body = { recordType: "contact", recordId: `${this.runId}-missing` };
|
|
203
|
+
const reply = await this.raw("get_record", body);
|
|
204
|
+
expectStatus(reply, [404]);
|
|
205
|
+
expectErrorBody(reply, "not_found");
|
|
206
|
+
if (reply.headers.get(CRM_HTTP_HEADERS.contract) === null) {
|
|
207
|
+
throw new Failure("the 404 has no Foundation-Contract header");
|
|
208
|
+
}
|
|
209
|
+
if (reply.headers.get(CRM_HTTP_HEADERS.tenant) !== this.target.tenantId) {
|
|
210
|
+
throw new Failure("the 404 does not name the expected tenant");
|
|
211
|
+
}
|
|
212
|
+
await expectError(this.client.getRecord({ recordType: "contact", recordId: `${this.runId}-missing` }), "not_found");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
async deleteUnsupportedCase() {
|
|
216
|
+
const meta = this.requireMeta();
|
|
217
|
+
if (meta.operations.includes("delete_record")) {
|
|
218
|
+
throw new Skip("the server offers delete_record");
|
|
219
|
+
}
|
|
220
|
+
const key = this.key();
|
|
221
|
+
const reply = await this.raw("delete_record", { recordType: "contact", recordId: `${this.runId}-missing`, idempotencyKey: key }, { headers: { [CRM_HTTP_HEADERS.idempotencyKey]: key } });
|
|
222
|
+
expectStatus(reply, [501]);
|
|
223
|
+
expectErrorBody(reply, "unsupported");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
async upsertCase() {
|
|
227
|
+
const meta = this.requireMeta();
|
|
228
|
+
const fixture = await this.fixture("upsert", { title: "Head of Growth" });
|
|
229
|
+
const contact = await this.profile("contact", fixture.contactId);
|
|
230
|
+
const company = await this.profile("company", fixture.companyId);
|
|
231
|
+
if (contact.statusSetBy === undefined)
|
|
232
|
+
throw new Failure("contact profile has no statusSetBy");
|
|
233
|
+
if (contact.statusVersion === undefined) {
|
|
234
|
+
throw new Failure("contact profile has no statusVersion");
|
|
235
|
+
}
|
|
236
|
+
if (contact.doNotContact === undefined) {
|
|
237
|
+
throw new Failure("contact profile has no doNotContact");
|
|
238
|
+
}
|
|
239
|
+
if (contact.status !== meta.statuses.contact[0]) {
|
|
240
|
+
throw new Failure("a new contact is not in the first contact status");
|
|
241
|
+
}
|
|
242
|
+
if (company.status !== meta.statuses.company[0]) {
|
|
243
|
+
throw new Failure("a new company is not in the first company status");
|
|
244
|
+
}
|
|
245
|
+
await this.client.upsertRecords({
|
|
246
|
+
idempotencyKey: this.key(),
|
|
247
|
+
contacts: [
|
|
248
|
+
{
|
|
249
|
+
externalId: fixture.contactExternalId,
|
|
250
|
+
email: fixture.email,
|
|
251
|
+
title: "Director of Growth",
|
|
252
|
+
companyExternalId: fixture.companyExternalId
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
});
|
|
256
|
+
const again = await this.profile("contact", fixture.contactId);
|
|
257
|
+
if (again.title !== "Director of Growth")
|
|
258
|
+
throw new Failure("a second upsert did not update");
|
|
259
|
+
if (again.status !== contact.status || again.statusVersion !== contact.statusVersion) {
|
|
260
|
+
throw new Failure("a second upsert changed the status");
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
async replayCase() {
|
|
265
|
+
const key = this.key();
|
|
266
|
+
const body = {
|
|
267
|
+
idempotencyKey: key,
|
|
268
|
+
companies: [{ externalId: this.externalId("replay"), name: this.name("replay") }]
|
|
269
|
+
};
|
|
270
|
+
const first = await this.raw("upsert_records", body, {
|
|
271
|
+
headers: { [CRM_HTTP_HEADERS.idempotencyKey]: key }
|
|
272
|
+
});
|
|
273
|
+
expectStatus(first, [200, 201]);
|
|
274
|
+
const second = await this.raw("upsert_records", body, {
|
|
275
|
+
headers: { [CRM_HTTP_HEADERS.idempotencyKey]: key }
|
|
276
|
+
});
|
|
277
|
+
expectStatus(second, [200, 201]);
|
|
278
|
+
if (second.headers.get(CRM_HTTP_HEADERS.replayed) !== "true") {
|
|
279
|
+
throw new Failure("the replay has no Idempotent-Replayed: true header");
|
|
280
|
+
}
|
|
281
|
+
if (JSON.stringify(first.json) !== JSON.stringify(second.json)) {
|
|
282
|
+
throw new Failure("the replay's acknowledgement differs from the original");
|
|
283
|
+
}
|
|
284
|
+
await expectError(this.client.upsertRecords({
|
|
285
|
+
idempotencyKey: key,
|
|
286
|
+
companies: [{ externalId: this.externalId("replay"), name: `${this.name("replay")} 2` }]
|
|
287
|
+
}), "conflict");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
async headerMismatchCase() {
|
|
291
|
+
const reply = await this.raw("upsert_records", {
|
|
292
|
+
idempotencyKey: this.key(),
|
|
293
|
+
companies: [{ externalId: this.externalId("mismatch"), name: this.name("mismatch") }]
|
|
294
|
+
}, { headers: { [CRM_HTTP_HEADERS.idempotencyKey]: this.key() } });
|
|
295
|
+
expectStatus(reply, [400]);
|
|
296
|
+
expectErrorBody(reply, "invalid_input");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
async companyUnknownCase() {
|
|
300
|
+
try {
|
|
301
|
+
await this.client.upsertRecords({
|
|
302
|
+
idempotencyKey: this.key(),
|
|
303
|
+
contacts: [
|
|
304
|
+
{
|
|
305
|
+
externalId: this.externalId("orphan"),
|
|
306
|
+
email: this.email("orphan"),
|
|
307
|
+
companyExternalId: this.externalId("no-such-company")
|
|
308
|
+
}
|
|
309
|
+
]
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error instanceof CrmHttpError && error.code === "invalid_input" && error.detailCode === "contact_company_unknown") {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
throw new Skip("the server accepts a contact whose company is unknown");
|
|
318
|
+
}
|
|
319
|
+
async batchBoundCase() {
|
|
320
|
+
const meta = this.requireMeta();
|
|
321
|
+
if (meta.maxBatchSize >= 1000)
|
|
322
|
+
throw new Skip("maxBatchSize is too large to exceed here");
|
|
323
|
+
const companies = Array.from({ length: meta.maxBatchSize + 1 }, (_, index) => ({
|
|
324
|
+
externalId: this.externalId(`batch-${index}`),
|
|
325
|
+
name: this.name(`batch-${index}`)
|
|
326
|
+
}));
|
|
327
|
+
await expectError(this.client.upsertRecords({ idempotencyKey: this.key(), companies }), "invalid_input");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
async statusCasCase() {
|
|
331
|
+
const fixture = await this.fixture("cas");
|
|
332
|
+
const before = await this.profile("company", fixture.companyId);
|
|
333
|
+
const move = await this.legalMove("company", fixture.companyId, before);
|
|
334
|
+
const after = await this.profile("company", fixture.companyId);
|
|
335
|
+
if (after.status !== move.status)
|
|
336
|
+
throw new Failure("the status was not applied");
|
|
337
|
+
if ((after.statusVersion ?? -1) <= (before.statusVersion ?? -1)) {
|
|
338
|
+
throw new Failure("statusVersion did not increase");
|
|
339
|
+
}
|
|
340
|
+
if (after.statusSetBy !== "agent")
|
|
341
|
+
throw new Failure("the agent's change is not stamped agent");
|
|
342
|
+
await expectError(this.client.updateStatus({
|
|
343
|
+
recordType: "company",
|
|
344
|
+
recordId: fixture.companyId,
|
|
345
|
+
status: before.status,
|
|
346
|
+
idempotencyKey: this.key()
|
|
347
|
+
}, { status: before.status, statusVersion: before.statusVersion ?? 0 }), "stale_status");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
async statusCasVersionCase() {
|
|
351
|
+
const harness = this.requireHarness();
|
|
352
|
+
const second = await this.fixture("cas-version");
|
|
353
|
+
const read = await this.profile("company", second.companyId);
|
|
354
|
+
await harness.setStatusAsPerson("company", second.companyId, read.status);
|
|
355
|
+
const target = this.requireMeta().statuses.company.find((status) => status !== read.status);
|
|
356
|
+
if (target === undefined)
|
|
357
|
+
return;
|
|
358
|
+
await expectError(this.client.updateStatus({
|
|
359
|
+
recordType: "company",
|
|
360
|
+
recordId: second.companyId,
|
|
361
|
+
status: target,
|
|
362
|
+
idempotencyKey: this.key()
|
|
363
|
+
}, { status: read.status, statusVersion: read.statusVersion ?? 0 }), "stale_status");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
async statusReplayCase() {
|
|
367
|
+
const fixture = await this.fixture("status-replay");
|
|
368
|
+
const before = await this.profile("company", fixture.companyId);
|
|
369
|
+
const move = await this.legalMove("company", fixture.companyId, before);
|
|
370
|
+
const after = await this.profile("company", fixture.companyId);
|
|
371
|
+
const reply = await this.raw("update_status", {
|
|
372
|
+
recordType: "company",
|
|
373
|
+
recordId: fixture.companyId,
|
|
374
|
+
status: move.status,
|
|
375
|
+
idempotencyKey: move.key,
|
|
376
|
+
expect: { status: after.status, statusVersion: after.statusVersion ?? 0 }
|
|
377
|
+
}, { headers: { [CRM_HTTP_HEADERS.idempotencyKey]: move.key } });
|
|
378
|
+
expectStatus(reply, [200, 201]);
|
|
379
|
+
if (reply.headers.get(CRM_HTTP_HEADERS.replayed) !== "true") {
|
|
380
|
+
throw new Failure("the retry was not replayed (no Idempotent-Replayed: true)");
|
|
381
|
+
}
|
|
382
|
+
const latest = await this.profile("company", fixture.companyId);
|
|
383
|
+
if (latest.statusVersion !== after.statusVersion) {
|
|
384
|
+
throw new Failure("the replay changed the status again");
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
async transitionCase() {
|
|
389
|
+
const transition = this.target.disallowedTransition;
|
|
390
|
+
if (transition === undefined)
|
|
391
|
+
throw new Skip("no disallowedTransition given");
|
|
392
|
+
const fixture = await this.fixture("transition");
|
|
393
|
+
const recordId = transition.recordType === "company" ? fixture.companyId : fixture.contactId;
|
|
394
|
+
const current = await this.profile(transition.recordType, recordId);
|
|
395
|
+
await expectError(this.client.updateStatus({
|
|
396
|
+
recordType: transition.recordType,
|
|
397
|
+
recordId,
|
|
398
|
+
status: transition.to,
|
|
399
|
+
idempotencyKey: this.key()
|
|
400
|
+
}, { status: current.status, statusVersion: current.statusVersion ?? 0 }), "refused", "transition_not_allowed");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
async statusLockedCase() {
|
|
404
|
+
const harness = this.requireHarness();
|
|
405
|
+
const fixture = await this.fixture("locked");
|
|
406
|
+
const initial = await this.profile("company", fixture.companyId);
|
|
407
|
+
await harness.setStatusAsPerson("company", fixture.companyId, initial.status);
|
|
408
|
+
const current = await this.profile("company", fixture.companyId);
|
|
409
|
+
if (current.statusSetBy !== "person")
|
|
410
|
+
throw new Failure("a person's change is not stamped person");
|
|
411
|
+
if ((current.statusVersion ?? -1) <= (initial.statusVersion ?? -1)) {
|
|
412
|
+
throw new Failure("a person's change did not bump statusVersion");
|
|
413
|
+
}
|
|
414
|
+
await expectError(this.client.updateStatus({
|
|
415
|
+
recordType: "company",
|
|
416
|
+
recordId: fixture.companyId,
|
|
417
|
+
status: current.status,
|
|
418
|
+
idempotencyKey: this.key()
|
|
419
|
+
}, { status: current.status, statusVersion: current.statusVersion ?? 0 }), "refused", "status_locked");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
async personStageCase() {
|
|
423
|
+
const harness = this.requireHarness();
|
|
424
|
+
const policy = this.target.statusPolicy;
|
|
425
|
+
if (policy === undefined)
|
|
426
|
+
throw new Skip("no statusPolicy given");
|
|
427
|
+
const meta = this.requireMeta();
|
|
428
|
+
const checked = [];
|
|
429
|
+
for (const recordType of ["company", "contact"]) {
|
|
430
|
+
const rule = policy[recordType];
|
|
431
|
+
if (rule === undefined)
|
|
432
|
+
continue;
|
|
433
|
+
const exits = (from) => Object.hasOwn(rule.agentTransitions, from) ? rule.agentTransitions[from] ?? [] : [];
|
|
434
|
+
const fresh = async (label) => {
|
|
435
|
+
const fixture = await this.fixture(`${recordType}-${label}`);
|
|
436
|
+
return recordType === "company" ? fixture.companyId : fixture.contactId;
|
|
437
|
+
};
|
|
438
|
+
const personOnly = personOwnedStatuses(rule, meta.statuses[recordType]);
|
|
439
|
+
if (personOnly.length > 0) {
|
|
440
|
+
const recordId = await fresh("into-person-only");
|
|
441
|
+
for (const status of personOnly) {
|
|
442
|
+
await this.expectMoveRefused(recordType, recordId, status, "transition_not_allowed");
|
|
443
|
+
}
|
|
444
|
+
const owned = personOnly[0];
|
|
445
|
+
const outId = await fresh("out-of-person-only");
|
|
446
|
+
await harness.setStatusAsPerson(recordType, outId, owned);
|
|
447
|
+
const away = meta.statuses[recordType].find((status) => status !== owned);
|
|
448
|
+
if (away !== undefined)
|
|
449
|
+
await this.expectMoveRefused(recordType, outId, away);
|
|
450
|
+
checked.push(`${recordType}: person-only`);
|
|
451
|
+
}
|
|
452
|
+
if (!rule.respectHumanChanges)
|
|
453
|
+
continue;
|
|
454
|
+
if (rule.personSet === "advance") {
|
|
455
|
+
const step = rule.ladder.findIndex((status, index) => index + 1 < rule.ladder.length && exits(status).includes(rule.ladder[index + 1]));
|
|
456
|
+
if (step < 0)
|
|
457
|
+
continue;
|
|
458
|
+
const from = rule.ladder[step];
|
|
459
|
+
const forward = rule.ladder[step + 1];
|
|
460
|
+
const forwardId = await fresh("advance");
|
|
461
|
+
await harness.setStatusAsPerson(recordType, forwardId, from);
|
|
462
|
+
await this.move(recordType, forwardId, forward);
|
|
463
|
+
const offLadder = exits(from).filter((status) => !rule.ladder.includes(status));
|
|
464
|
+
const offId = await fresh("off-ladder");
|
|
465
|
+
await harness.setStatusAsPerson(recordType, offId, from);
|
|
466
|
+
for (const status of offLadder) {
|
|
467
|
+
await this.expectMoveRefused(recordType, offId, status, "status_locked");
|
|
468
|
+
}
|
|
469
|
+
checked.push(`${recordType}: advance`);
|
|
470
|
+
} else {
|
|
471
|
+
const from = meta.statuses[recordType].find((status) => exits(status).length > 0);
|
|
472
|
+
if (from === undefined)
|
|
473
|
+
continue;
|
|
474
|
+
const lockedId = await fresh("lock");
|
|
475
|
+
await harness.setStatusAsPerson(recordType, lockedId, from);
|
|
476
|
+
await this.expectMoveRefused(recordType, lockedId, exits(from)[0], "status_locked");
|
|
477
|
+
checked.push(`${recordType}: lock`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (checked.length === 0)
|
|
481
|
+
throw new Skip("the statusPolicy has no rule a person's change affects");
|
|
482
|
+
return checked.join("; ");
|
|
483
|
+
}
|
|
484
|
+
async move(recordType, recordId, status) {
|
|
485
|
+
const current = await this.profile(recordType, recordId);
|
|
486
|
+
try {
|
|
487
|
+
await this.client.updateStatus({ recordType, recordId, status, idempotencyKey: this.key() }, { status: current.status, statusVersion: current.statusVersion ?? 0 });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
throw new Failure(`${current.status} → ${status} was refused (${describe(error)})`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async expectMoveRefused(recordType, recordId, status, reason) {
|
|
493
|
+
const current = await this.profile(recordType, recordId);
|
|
494
|
+
try {
|
|
495
|
+
await this.client.updateStatus({ recordType, recordId, status, idempotencyKey: this.key() }, { status: current.status, statusVersion: current.statusVersion ?? 0 });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error instanceof CrmHttpError && error.code === "refused" && (reason === undefined ? error.reason === "status_locked" || error.reason === "transition_not_allowed" : error.reason === reason)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
throw new Failure(`${current.status} → ${status} should be refused${reason === undefined ? "" : ` (${reason})`}, got ${describe(error)}`);
|
|
501
|
+
}
|
|
502
|
+
throw new Failure(`${current.status} → ${status} was accepted`);
|
|
503
|
+
}
|
|
504
|
+
async attributesByCase() {
|
|
505
|
+
const meta = this.requireMeta();
|
|
506
|
+
const activityType = meta.activityTypes.includes(CRM_NOTE_ACTIVITY) ? CRM_NOTE_ACTIVITY : meta.activityTypes[0];
|
|
507
|
+
const fixture = await this.fixture("by");
|
|
508
|
+
const key = this.key();
|
|
509
|
+
const reply = await this.raw("log_activity", {
|
|
510
|
+
recordType: "company",
|
|
511
|
+
recordId: fixture.companyId,
|
|
512
|
+
activityType,
|
|
513
|
+
summary: "Conformance: a caller may not claim authorship.",
|
|
514
|
+
attributes: { by: "agent" },
|
|
515
|
+
idempotencyKey: key
|
|
516
|
+
}, { headers: { [CRM_HTTP_HEADERS.idempotencyKey]: key } });
|
|
517
|
+
expectStatus(reply, [400, 422]);
|
|
518
|
+
expectErrorBody(reply, "invalid_input");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
async noteEditCase() {
|
|
522
|
+
this.requireNotes();
|
|
523
|
+
const fixture = await this.fixture("note");
|
|
524
|
+
await this.client.logActivity({
|
|
525
|
+
recordType: "company",
|
|
526
|
+
recordId: fixture.companyId,
|
|
527
|
+
activityType: CRM_NOTE_ACTIVITY,
|
|
528
|
+
summary: "Conformance note, first text.",
|
|
529
|
+
idempotencyKey: this.key()
|
|
530
|
+
});
|
|
531
|
+
const note = await this.findActivity("company", fixture.companyId, (activity) => activity.summary === "Conformance note, first text.");
|
|
532
|
+
if (note.attributes?.by !== "agent")
|
|
533
|
+
throw new Failure("the agent's note is not by: agent");
|
|
534
|
+
if (note.version !== 1)
|
|
535
|
+
throw new Failure("a new note is not version 1");
|
|
536
|
+
const edit = {
|
|
537
|
+
recordType: "company",
|
|
538
|
+
recordId: fixture.companyId,
|
|
539
|
+
activityId: note.id,
|
|
540
|
+
summary: "Conformance note, edited.",
|
|
541
|
+
version: 1,
|
|
542
|
+
idempotencyKey: this.key()
|
|
543
|
+
};
|
|
544
|
+
const ack = await this.client.updateNote(edit);
|
|
545
|
+
if (ack.operation !== "update_note" || ack.version !== 2) {
|
|
546
|
+
throw new Failure("the edit did not answer version 2");
|
|
547
|
+
}
|
|
548
|
+
const read = await this.client.getActivity({
|
|
549
|
+
recordType: "company",
|
|
550
|
+
recordId: fixture.companyId,
|
|
551
|
+
activityId: note.id
|
|
552
|
+
});
|
|
553
|
+
if (read.summary !== edit.summary || read.version !== 2) {
|
|
554
|
+
throw new Failure("get_activity does not show the edit");
|
|
555
|
+
}
|
|
556
|
+
await expectError(this.client.updateNote({
|
|
557
|
+
...edit,
|
|
558
|
+
summary: "Conformance note, stale.",
|
|
559
|
+
idempotencyKey: this.key()
|
|
560
|
+
}), "stale_version");
|
|
561
|
+
const replay = await this.client.updateNote(edit);
|
|
562
|
+
if (replay.operation !== "update_note" || replay.version !== 2) {
|
|
563
|
+
throw new Failure("the replayed edit did not answer the original acknowledgement");
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
async noteNotEditableCase() {
|
|
568
|
+
const meta = this.requireNotes();
|
|
569
|
+
const other = meta.activityTypes.find((type) => type !== CRM_NOTE_ACTIVITY && type !== CRM_OUTREACH_DRAFTED_ACTIVITY);
|
|
570
|
+
if (other === undefined)
|
|
571
|
+
throw new Skip("no activity type besides note and email_drafted");
|
|
572
|
+
const fixture = await this.fixture("not-editable");
|
|
573
|
+
await this.client.logActivity({
|
|
574
|
+
recordType: "company",
|
|
575
|
+
recordId: fixture.companyId,
|
|
576
|
+
activityType: other,
|
|
577
|
+
summary: "Conformance: not a note.",
|
|
578
|
+
idempotencyKey: this.key()
|
|
579
|
+
});
|
|
580
|
+
const activity = await this.findActivity("company", fixture.companyId, (entry) => entry.activityType === other);
|
|
581
|
+
await expectError(this.client.updateNote({
|
|
582
|
+
recordType: "company",
|
|
583
|
+
recordId: fixture.companyId,
|
|
584
|
+
activityId: activity.id,
|
|
585
|
+
summary: "Conformance: an edit that must be refused.",
|
|
586
|
+
version: activity.version ?? 1,
|
|
587
|
+
idempotencyKey: this.key()
|
|
588
|
+
}), "not_editable");
|
|
589
|
+
const harness = this.target.harness;
|
|
590
|
+
if (harness === undefined) {
|
|
591
|
+
throw new Skip("a non-note was refused, but a person's note needs a harness", "harness");
|
|
592
|
+
}
|
|
593
|
+
const personNoteId = await harness.addPersonNote("company", fixture.companyId, `Conformance: a person's long note. ${"x".repeat(4500)}`);
|
|
594
|
+
const personNote = await this.findActivity("company", fixture.companyId, (entry) => entry.id === personNoteId);
|
|
595
|
+
if (personNote.attributes?.by !== "person")
|
|
596
|
+
throw new Failure("a person's note is not by: person");
|
|
597
|
+
await expectError(this.client.updateNote({
|
|
598
|
+
recordType: "company",
|
|
599
|
+
recordId: fixture.companyId,
|
|
600
|
+
activityId: personNoteId,
|
|
601
|
+
summary: "Conformance: an edit of a person's note.",
|
|
602
|
+
version: personNote.version ?? 1,
|
|
603
|
+
idempotencyKey: this.key()
|
|
604
|
+
}), "not_editable");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
async firstTouchCase() {
|
|
608
|
+
this.requireOutreach();
|
|
609
|
+
const fixture = await this.fixture("first-touch");
|
|
610
|
+
await this.draft(fixture.contactId, "first_touch", { threadId: `${this.runId}-thread` });
|
|
611
|
+
const state = await this.client.outreachState({ contactId: fixture.contactId });
|
|
612
|
+
if (state.touch.firstTouchAt === undefined)
|
|
613
|
+
throw new Failure("no firstTouchAt after a first touch");
|
|
614
|
+
if (state.touch.followUps !== 0)
|
|
615
|
+
throw new Failure("followUps is not 0 after a first touch");
|
|
616
|
+
if (state.touch.lastThreadId !== `${this.runId}-thread`) {
|
|
617
|
+
throw new Failure("the draft's thread was not kept");
|
|
618
|
+
}
|
|
619
|
+
if (state.companyId !== fixture.companyId)
|
|
620
|
+
throw new Failure("outreach state names another company");
|
|
621
|
+
await expectError(this.draft(fixture.contactId, "first_touch"), "refused", "already_contacted");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
async notContactedCase() {
|
|
625
|
+
this.requireOutreach();
|
|
626
|
+
const fixture = await this.fixture("not-contacted");
|
|
627
|
+
await expectError(this.draft(fixture.contactId, "follow_up"), "refused", "not_contacted");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
async perEmailCase() {
|
|
631
|
+
this.requireOutreach();
|
|
632
|
+
const email = this.email("shared");
|
|
633
|
+
const first = await this.fixture("shared-a", { email });
|
|
634
|
+
const second = await this.fixture("shared-b", { email });
|
|
635
|
+
await this.draft(first.contactId, "first_touch");
|
|
636
|
+
await expectError(this.draft(second.contactId, "first_touch"), "refused", "already_contacted");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
async companyDraftCase() {
|
|
640
|
+
this.requireOutreach();
|
|
641
|
+
const fixture = await this.fixture("company-draft");
|
|
642
|
+
await expectError(this.client.logActivity({
|
|
643
|
+
recordType: "company",
|
|
644
|
+
recordId: fixture.companyId,
|
|
645
|
+
activityType: CRM_OUTREACH_DRAFTED_ACTIVITY,
|
|
646
|
+
summary: "Conformance: a draft logged on a company.",
|
|
647
|
+
attributes: { kind: "first_touch" },
|
|
648
|
+
idempotencyKey: this.key()
|
|
649
|
+
}, this.outreach("drafted", "first_touch")), "invalid_input");
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
async candidatesCase() {
|
|
653
|
+
const meta = this.requireOutreach();
|
|
654
|
+
const status = meta.statuses.contact[0];
|
|
655
|
+
const fixture = await this.fixture("candidate", { attributes: { conformanceRank: 42 } });
|
|
656
|
+
let cursor;
|
|
657
|
+
let found = false;
|
|
658
|
+
for (let page = 0;page < CANDIDATE_PAGES && !found; page += 1) {
|
|
659
|
+
const reply = await this.client.outreachCandidates({
|
|
660
|
+
rankAttribute: "conformanceRank",
|
|
661
|
+
statuses: { contact: [status] },
|
|
662
|
+
limit: 200,
|
|
663
|
+
...cursor === undefined ? {} : { cursor }
|
|
664
|
+
});
|
|
665
|
+
const candidate = reply.candidates.find((entry) => entry.contactId === fixture.contactId);
|
|
666
|
+
if (candidate !== undefined) {
|
|
667
|
+
found = true;
|
|
668
|
+
if (candidate.email.toLowerCase() !== fixture.email.toLowerCase()) {
|
|
669
|
+
throw new Failure("the candidate's email is not the contact's");
|
|
670
|
+
}
|
|
671
|
+
if (candidate.rank !== 42)
|
|
672
|
+
throw new Failure("the candidate's rank is not its rank attribute");
|
|
673
|
+
if (candidate.touch.followUps !== 0 || candidate.touch.firstTouchAt !== undefined) {
|
|
674
|
+
throw new Failure("a never-drafted candidate carries a touch");
|
|
675
|
+
}
|
|
676
|
+
if (candidate.companyId !== undefined && !reply.companies.some((company) => company.companyId === candidate.companyId)) {
|
|
677
|
+
throw new Failure("the page's companies leave out the candidate's company");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
cursor = reply.cursor;
|
|
681
|
+
if (cursor === undefined)
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
if (!found)
|
|
685
|
+
throw new Failure("a fresh contact with an email is not a candidate");
|
|
686
|
+
const first = await this.client.outreachCandidates({
|
|
687
|
+
rankAttribute: "conformanceRank",
|
|
688
|
+
statuses: { contact: [status] },
|
|
689
|
+
limit: 1
|
|
690
|
+
});
|
|
691
|
+
if (first.candidates.length > 1)
|
|
692
|
+
throw new Failure("candidates exceeded the page limit");
|
|
693
|
+
if (first.cursor !== undefined) {
|
|
694
|
+
const next = await this.client.outreachCandidates({
|
|
695
|
+
rankAttribute: "conformanceRank",
|
|
696
|
+
statuses: { contact: [status] },
|
|
697
|
+
limit: 1,
|
|
698
|
+
cursor: first.cursor
|
|
699
|
+
});
|
|
700
|
+
if (next.candidates.length === 1 && next.candidates[0]?.contactId === first.candidates[0]?.contactId) {
|
|
701
|
+
throw new Failure("the next page repeats the first");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
async suppressedCase() {
|
|
707
|
+
this.requireOutreach();
|
|
708
|
+
const harness = this.requireHarness();
|
|
709
|
+
const flagged = await this.fixture("dnc-contact");
|
|
710
|
+
await harness.setDoNotContact("contact", flagged.contactId);
|
|
711
|
+
await this.expectSuppressed(flagged.contactId, "the contact's own flag");
|
|
712
|
+
const company = await this.fixture("dnc-company");
|
|
713
|
+
await harness.setDoNotContact("company", company.companyId);
|
|
714
|
+
await this.expectSuppressed(company.contactId, "the company's flag");
|
|
715
|
+
const address = await this.fixture("dnc-email");
|
|
716
|
+
await harness.suppressEmail(address.email);
|
|
717
|
+
await this.expectSuppressed(address.contactId, "a suppressed address");
|
|
718
|
+
const suppressed = this.target.suppressedStatuses;
|
|
719
|
+
if (suppressed === undefined) {
|
|
720
|
+
return "suppressed statuses were not checked (no suppressedStatuses given)";
|
|
721
|
+
}
|
|
722
|
+
const contactStatus = suppressed.contact[0];
|
|
723
|
+
if (contactStatus !== undefined) {
|
|
724
|
+
const ruledOut = await this.fixture("suppressed-status");
|
|
725
|
+
await harness.setStatusAsPerson("contact", ruledOut.contactId, contactStatus);
|
|
726
|
+
await this.expectSuppressed(ruledOut.contactId, `contact status ${contactStatus}`, {
|
|
727
|
+
status: contactStatus
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const companyStatus = suppressed.company[0];
|
|
731
|
+
if (companyStatus !== undefined) {
|
|
732
|
+
const closed = await this.fixture("suppressed-company-status");
|
|
733
|
+
await harness.setStatusAsPerson("company", closed.companyId, companyStatus);
|
|
734
|
+
await this.expectSuppressed(closed.contactId, `company status ${companyStatus}`, {
|
|
735
|
+
companyStatus
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
async maxFollowUpsCase() {
|
|
741
|
+
const meta = this.requireOutreach();
|
|
742
|
+
const max = meta.policy?.maxFollowUps;
|
|
743
|
+
const weekly = meta.policy?.followUpWeeklyLimit;
|
|
744
|
+
if (max === undefined || weekly === undefined) {
|
|
745
|
+
throw new Skip("/meta policy has no maxFollowUps or followUpWeeklyLimit");
|
|
746
|
+
}
|
|
747
|
+
if (weekly < max + 1)
|
|
748
|
+
throw new Skip("followUpWeeklyLimit is below maxFollowUps + 1");
|
|
749
|
+
const fixture = await this.fixture("follow-ups");
|
|
750
|
+
await this.draft(fixture.contactId, "first_touch");
|
|
751
|
+
for (let index = 0;index < max; index += 1) {
|
|
752
|
+
await this.draft(fixture.contactId, "follow_up");
|
|
753
|
+
}
|
|
754
|
+
const state = await this.client.outreachState({ contactId: fixture.contactId });
|
|
755
|
+
if (state.touch.followUps !== max)
|
|
756
|
+
throw new Failure("followUps does not count the follow-ups");
|
|
757
|
+
await expectError(this.draft(fixture.contactId, "follow_up"), "refused", "max_follow_ups_reached");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
async followUpLimitCase() {
|
|
761
|
+
if (this.target.spendWeeklyAllowance !== true) {
|
|
762
|
+
throw new Skip("spendWeeklyAllowance is off");
|
|
763
|
+
}
|
|
764
|
+
const meta = this.requireOutreach();
|
|
765
|
+
const limit = meta.policy?.followUpWeeklyLimit;
|
|
766
|
+
const max = meta.policy?.maxFollowUps;
|
|
767
|
+
const firstTouchLimit = meta.policy?.firstTouchWeeklyLimit;
|
|
768
|
+
if (limit === undefined || max === undefined || firstTouchLimit === undefined) {
|
|
769
|
+
throw new Skip("/meta policy has no follow-up limits");
|
|
770
|
+
}
|
|
771
|
+
if (max < 1 || firstTouchLimit < 1)
|
|
772
|
+
throw new Skip("the server allows no follow-ups");
|
|
773
|
+
if (limit > 100)
|
|
774
|
+
throw new Skip("followUpWeeklyLimit is too large to spend here");
|
|
775
|
+
let contacts = 0;
|
|
776
|
+
const touched = async () => {
|
|
777
|
+
const fixture = await this.fixture(`follow-up-limit-${contacts}`);
|
|
778
|
+
await this.draft(fixture.contactId, "first_touch", {
|
|
779
|
+
weekStart: this.pastWeek(Math.floor(contacts / firstTouchLimit))
|
|
780
|
+
});
|
|
781
|
+
contacts += 1;
|
|
782
|
+
return fixture.contactId;
|
|
783
|
+
};
|
|
784
|
+
while (this.followUps < limit) {
|
|
785
|
+
const contactId = await touched();
|
|
786
|
+
for (let index = 0;index < max && this.followUps < limit; index += 1) {
|
|
787
|
+
await this.draft(contactId, "follow_up");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
await expectError(this.draft(await touched(), "follow_up"), "refused", "follow_up_limit_reached");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
async readOnlyScopeCase() {
|
|
794
|
+
const key = this.target.readOnlyApiKey;
|
|
795
|
+
if (key === undefined)
|
|
796
|
+
throw new Skip("no readOnlyApiKey given");
|
|
797
|
+
const readOnly = this.clientFor(key);
|
|
798
|
+
await readOnly.search({ limit: 1 });
|
|
799
|
+
await expectError(readOnly.upsertRecords({
|
|
800
|
+
idempotencyKey: this.key(),
|
|
801
|
+
companies: [{ externalId: this.externalId("read-only"), name: this.name("read-only") }]
|
|
802
|
+
}), "forbidden");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
async weeklyLimitCase() {
|
|
806
|
+
if (this.target.spendWeeklyAllowance !== true) {
|
|
807
|
+
throw new Skip("spendWeeklyAllowance is off");
|
|
808
|
+
}
|
|
809
|
+
const meta = this.requireOutreach();
|
|
810
|
+
const limit = meta.policy?.firstTouchWeeklyLimit;
|
|
811
|
+
if (limit === undefined)
|
|
812
|
+
throw new Skip("/meta policy has no firstTouchWeeklyLimit");
|
|
813
|
+
for (let attempt = 0;attempt <= limit; attempt += 1) {
|
|
814
|
+
const fixture = await this.fixture(`weekly-${attempt}`);
|
|
815
|
+
try {
|
|
816
|
+
await this.draft(fixture.contactId, "first_touch");
|
|
817
|
+
} catch (error) {
|
|
818
|
+
if (error instanceof CrmHttpError && error.code === "refused" && error.reason === "weekly_limit_reached") {
|
|
819
|
+
if (this.firstTouches !== limit) {
|
|
820
|
+
throw new Failure(`refused after ${this.firstTouches} first touches this run; the limit is ${limit} (a fresh week and database are assumed)`);
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
throw new Failure(`more than ${limit} first touches were accepted this week`);
|
|
828
|
+
}
|
|
829
|
+
clientFor(apiKey) {
|
|
830
|
+
return createCrmHttpClient({
|
|
831
|
+
baseUrl: this.target.baseUrl,
|
|
832
|
+
credentials: { get: async () => apiKey, refresh: async () => apiKey },
|
|
833
|
+
expectedTenant: this.target.tenantId,
|
|
834
|
+
userAgent: USER_AGENT,
|
|
835
|
+
actor: encodeCrmActor({
|
|
836
|
+
agent: "conformance",
|
|
837
|
+
slackTeamId: "T0CONFORMANCE",
|
|
838
|
+
slackUserId: "config",
|
|
839
|
+
channelId: "C0CONFORMANCE",
|
|
840
|
+
sessionHash: "conformance"
|
|
841
|
+
}),
|
|
842
|
+
fetchImpl: this.fetchImpl
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
requireMeta() {
|
|
846
|
+
if (this.meta === undefined)
|
|
847
|
+
throw new Skip("/meta did not pass");
|
|
848
|
+
return this.meta;
|
|
849
|
+
}
|
|
850
|
+
requireHarness() {
|
|
851
|
+
if (this.target.harness === undefined)
|
|
852
|
+
throw new Skip("no harness given", "harness");
|
|
853
|
+
return this.target.harness;
|
|
854
|
+
}
|
|
855
|
+
requireOperations(...names) {
|
|
856
|
+
const meta = this.requireMeta();
|
|
857
|
+
const missing = names.filter((name) => !meta.operations.includes(name));
|
|
858
|
+
if (missing.length > 0)
|
|
859
|
+
throw new Skip(`the server does not offer ${missing.join(", ")}`);
|
|
860
|
+
return meta;
|
|
861
|
+
}
|
|
862
|
+
requireNotes() {
|
|
863
|
+
const meta = this.requireOperations("get_activity", "update_note");
|
|
864
|
+
if (!meta.activityTypes.includes(CRM_NOTE_ACTIVITY))
|
|
865
|
+
throw new Skip("note is not an activity type");
|
|
866
|
+
return meta;
|
|
867
|
+
}
|
|
868
|
+
requireOutreach() {
|
|
869
|
+
const meta = this.requireOperations("outreach_state", "outreach_candidates");
|
|
870
|
+
if (!meta.activityTypes.includes(CRM_OUTREACH_DRAFTED_ACTIVITY)) {
|
|
871
|
+
throw new Skip("email_drafted is not an activity type");
|
|
872
|
+
}
|
|
873
|
+
return meta;
|
|
874
|
+
}
|
|
875
|
+
key() {
|
|
876
|
+
this.sequence += 1;
|
|
877
|
+
return `${this.runId}-k${this.sequence}`;
|
|
878
|
+
}
|
|
879
|
+
externalId(label) {
|
|
880
|
+
return `${this.runId}-${label}`;
|
|
881
|
+
}
|
|
882
|
+
name(label) {
|
|
883
|
+
return `Conformance ${this.runId} ${label}`;
|
|
884
|
+
}
|
|
885
|
+
email(label) {
|
|
886
|
+
return `${this.runId}-${label}@conformance.example.com`;
|
|
887
|
+
}
|
|
888
|
+
async fixture(label, options = {}) {
|
|
889
|
+
const companyExternalId = this.externalId(`${label}-co`);
|
|
890
|
+
const contactExternalId = this.externalId(`${label}-ct`);
|
|
891
|
+
const email = options.email ?? this.email(label);
|
|
892
|
+
const companyName = this.name(`${label} company`);
|
|
893
|
+
await this.client.upsertRecords({
|
|
894
|
+
idempotencyKey: this.key(),
|
|
895
|
+
companies: [{ externalId: companyExternalId, name: companyName }],
|
|
896
|
+
contacts: [
|
|
897
|
+
{
|
|
898
|
+
externalId: contactExternalId,
|
|
899
|
+
email,
|
|
900
|
+
firstName: "Conformance",
|
|
901
|
+
lastName: label,
|
|
902
|
+
companyExternalId,
|
|
903
|
+
...options.title === undefined ? {} : { title: options.title },
|
|
904
|
+
...options.attributes === undefined ? {} : { attributes: options.attributes }
|
|
905
|
+
}
|
|
906
|
+
]
|
|
907
|
+
});
|
|
908
|
+
return {
|
|
909
|
+
companyId: await this.findId("company", companyName, companyExternalId),
|
|
910
|
+
companyExternalId,
|
|
911
|
+
contactId: await this.findId("contact", email, contactExternalId),
|
|
912
|
+
contactExternalId,
|
|
913
|
+
email
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
async findId(recordType, query, externalId) {
|
|
917
|
+
let cursor;
|
|
918
|
+
for (let page = 0;page < 10; page += 1) {
|
|
919
|
+
const reply = await this.client.search({
|
|
920
|
+
query,
|
|
921
|
+
limit: 100,
|
|
922
|
+
...cursor === undefined ? {} : { cursor }
|
|
923
|
+
});
|
|
924
|
+
const match = reply.records.find((record) => record.recordType === recordType && record.externalId === externalId);
|
|
925
|
+
if (match !== undefined)
|
|
926
|
+
return match.id;
|
|
927
|
+
cursor = reply.cursor;
|
|
928
|
+
if (cursor === undefined)
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
throw new Failure(`a ${recordType} just upserted is not found by search`);
|
|
932
|
+
}
|
|
933
|
+
async profile(recordType, recordId) {
|
|
934
|
+
const reply = await this.client.getRecord({ recordType, recordId, activityLimit: 1 });
|
|
935
|
+
if (reply.profile.id !== recordId || reply.profile.recordType !== recordType) {
|
|
936
|
+
throw new Failure("get_record answered a different record");
|
|
937
|
+
}
|
|
938
|
+
return reply.profile;
|
|
939
|
+
}
|
|
940
|
+
async findActivity(recordType, recordId, matches) {
|
|
941
|
+
const reply = await this.client.getRecord({ recordType, recordId, activityLimit: 100 });
|
|
942
|
+
const activity = reply.activities.find(matches);
|
|
943
|
+
if (activity === undefined)
|
|
944
|
+
throw new Failure("a logged activity is not on its record");
|
|
945
|
+
return activity;
|
|
946
|
+
}
|
|
947
|
+
async legalMove(recordType, recordId, current) {
|
|
948
|
+
const meta = this.requireMeta();
|
|
949
|
+
for (const status of meta.statuses[recordType]) {
|
|
950
|
+
if (status === current.status)
|
|
951
|
+
continue;
|
|
952
|
+
const key = this.key();
|
|
953
|
+
try {
|
|
954
|
+
await this.client.updateStatus({ recordType, recordId, status, idempotencyKey: key }, { status: current.status, statusVersion: current.statusVersion ?? 0 });
|
|
955
|
+
return { status, key };
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (error instanceof CrmHttpError && error.code === "refused")
|
|
958
|
+
continue;
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
throw new Skip(`the agent may not move a fresh ${recordType} to any status`);
|
|
963
|
+
}
|
|
964
|
+
outreach(event, kind, weekStart = this.currentWeek()) {
|
|
965
|
+
const timezone = this.meta?.policy?.timezone ?? "UTC";
|
|
966
|
+
return { event, ...kind === undefined ? {} : { kind }, weekStart, timezone };
|
|
967
|
+
}
|
|
968
|
+
currentWeek() {
|
|
969
|
+
try {
|
|
970
|
+
return outreachWeekStart(new Date, this.meta?.policy?.timezone ?? "UTC");
|
|
971
|
+
} catch {
|
|
972
|
+
return outreachWeekStart(new Date, "UTC");
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
async draft(contactId, kind, extra = {}) {
|
|
976
|
+
const weekStart = extra.weekStart ?? this.currentWeek();
|
|
977
|
+
await this.client.logActivity({
|
|
978
|
+
recordType: "contact",
|
|
979
|
+
recordId: contactId,
|
|
980
|
+
activityType: CRM_OUTREACH_DRAFTED_ACTIVITY,
|
|
981
|
+
summary: `Conformance ${kind} draft.`,
|
|
982
|
+
attributes: { kind, ...extra.threadId === undefined ? {} : { threadId: extra.threadId } },
|
|
983
|
+
idempotencyKey: this.key()
|
|
984
|
+
}, this.outreach("drafted", kind, weekStart));
|
|
985
|
+
if (weekStart !== this.currentWeek())
|
|
986
|
+
return;
|
|
987
|
+
if (kind === "first_touch")
|
|
988
|
+
this.firstTouches += 1;
|
|
989
|
+
else
|
|
990
|
+
this.followUps += 1;
|
|
991
|
+
}
|
|
992
|
+
pastWeek(index) {
|
|
993
|
+
return new Date(Date.UTC(2001, 0, 1 + 7 * index)).toISOString().slice(0, 10);
|
|
994
|
+
}
|
|
995
|
+
async expectSuppressed(contactId, cause, expected = {}) {
|
|
996
|
+
const byFlag = expected.status === undefined && expected.companyStatus === undefined;
|
|
997
|
+
const state = await this.client.outreachState({ contactId });
|
|
998
|
+
if (byFlag && !state.doNotContact)
|
|
999
|
+
throw new Failure(`doNotContact is false after ${cause}`);
|
|
1000
|
+
if (expected.status !== undefined && state.status !== expected.status) {
|
|
1001
|
+
throw new Failure(`outreach state does not show ${cause}`);
|
|
1002
|
+
}
|
|
1003
|
+
if (expected.companyStatus !== undefined && state.companyStatus !== expected.companyStatus) {
|
|
1004
|
+
throw new Failure(`outreach state does not show ${cause}`);
|
|
1005
|
+
}
|
|
1006
|
+
const candidate = await this.findCandidate(contactId, state.status);
|
|
1007
|
+
if (candidate !== undefined && (byFlag && !candidate.doNotContact || expected.status !== undefined && candidate.status !== expected.status || expected.companyStatus !== undefined && candidate.companyStatus !== expected.companyStatus)) {
|
|
1008
|
+
throw new Failure(`outreach candidates list the contact without ${cause}`);
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
await this.draft(contactId, "first_touch");
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (error instanceof CrmHttpError && error.code === "refused" && error.reason === "suppressed") {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
throw new Failure(`a draft after ${cause} was not refused as suppressed (${describe(error)})`);
|
|
1017
|
+
}
|
|
1018
|
+
throw new Failure(`a draft after ${cause} was accepted`);
|
|
1019
|
+
}
|
|
1020
|
+
async findCandidate(contactId, status) {
|
|
1021
|
+
let cursor;
|
|
1022
|
+
for (let page = 0;page < CANDIDATE_PAGES; page += 1) {
|
|
1023
|
+
const reply = await this.client.outreachCandidates({
|
|
1024
|
+
rankAttribute: "conformanceRank",
|
|
1025
|
+
statuses: { contact: [status] },
|
|
1026
|
+
limit: 200,
|
|
1027
|
+
...cursor === undefined ? {} : { cursor }
|
|
1028
|
+
});
|
|
1029
|
+
const found = reply.candidates.find((entry) => entry.contactId === contactId);
|
|
1030
|
+
if (found !== undefined)
|
|
1031
|
+
return found;
|
|
1032
|
+
cursor = reply.cursor;
|
|
1033
|
+
if (cursor === undefined)
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
async raw(route, body, options = {}) {
|
|
1039
|
+
const { method, path } = CRM_HTTP_ROUTES[route];
|
|
1040
|
+
const headers = {
|
|
1041
|
+
authorization: `Bearer ${this.target.apiKey}`,
|
|
1042
|
+
accept: "application/json",
|
|
1043
|
+
"user-agent": USER_AGENT,
|
|
1044
|
+
[CRM_HTTP_HEADERS.contract]: CRM_HTTP_CONTRACT,
|
|
1045
|
+
[CRM_HTTP_HEADERS.requestId]: randomUUID(),
|
|
1046
|
+
...body === undefined ? {} : { "content-type": "application/json" },
|
|
1047
|
+
...options.headers
|
|
1048
|
+
};
|
|
1049
|
+
for (const name of options.omit ?? [])
|
|
1050
|
+
delete headers[name];
|
|
1051
|
+
const signal = AbortSignal.timeout(RAW_TIMEOUT_MS);
|
|
1052
|
+
let response;
|
|
1053
|
+
try {
|
|
1054
|
+
response = await this.fetchImpl(`${this.target.baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
1055
|
+
method,
|
|
1056
|
+
redirect: "error",
|
|
1057
|
+
headers,
|
|
1058
|
+
...body === undefined ? {} : { body: JSON.stringify(body) },
|
|
1059
|
+
signal
|
|
1060
|
+
});
|
|
1061
|
+
} catch {
|
|
1062
|
+
throw new Failure(signal.aborted ? "the server did not answer in time" : "the server is unreachable");
|
|
1063
|
+
}
|
|
1064
|
+
let text;
|
|
1065
|
+
try {
|
|
1066
|
+
text = await readCrmHttpBody(response, MAX_RAW_BODY_BYTES, signal);
|
|
1067
|
+
} catch {
|
|
1068
|
+
throw new Failure("the reply body is too large or could not be read");
|
|
1069
|
+
}
|
|
1070
|
+
let json;
|
|
1071
|
+
try {
|
|
1072
|
+
json = text === "" ? undefined : JSON.parse(text);
|
|
1073
|
+
} catch {
|
|
1074
|
+
json = undefined;
|
|
1075
|
+
}
|
|
1076
|
+
return { status: response.status, headers: response.headers, json };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function expectStatus(reply, allowed) {
|
|
1080
|
+
if (!allowed.includes(reply.status)) {
|
|
1081
|
+
throw new Failure(`expected HTTP ${allowed.join(" or ")}, got ${reply.status}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function expectErrorBody(reply, error) {
|
|
1085
|
+
const body = reply.json;
|
|
1086
|
+
const actual = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" ? body.error : undefined;
|
|
1087
|
+
if (actual !== error) {
|
|
1088
|
+
throw new Failure(actual !== undefined && /^[a-z_]{1,64}$/.test(actual) ? `expected error ${error}, got ${actual}` : `expected error ${error}, got no contract error body`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async function expectError(call, code, reason) {
|
|
1092
|
+
try {
|
|
1093
|
+
await call;
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
if (error instanceof CrmHttpError && error.code === code && (reason === undefined || error.reason === reason)) {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
throw new Failure(`expected ${code}${reason === undefined ? "" : ` (${reason})`}, got ${describe(error)}`);
|
|
1099
|
+
}
|
|
1100
|
+
throw new Failure(`expected ${code}${reason === undefined ? "" : ` (${reason})`}, the call succeeded`);
|
|
1101
|
+
}
|
|
1102
|
+
function describe(error) {
|
|
1103
|
+
if (error instanceof Failure)
|
|
1104
|
+
return error.message;
|
|
1105
|
+
if (error instanceof CrmHttpError) {
|
|
1106
|
+
return [
|
|
1107
|
+
error.code,
|
|
1108
|
+
error.reason === undefined ? undefined : `(${error.reason})`,
|
|
1109
|
+
error.detailCode === undefined ? undefined : `[${error.detailCode}]`,
|
|
1110
|
+
error.status === undefined ? undefined : `HTTP ${error.status}`
|
|
1111
|
+
].filter((part) => part !== undefined).join(" ");
|
|
1112
|
+
}
|
|
1113
|
+
return `unexpected ${error instanceof Error ? error.name : typeof error}`;
|
|
1114
|
+
}
|
|
1115
|
+
export {
|
|
1116
|
+
runCrmConformance
|
|
1117
|
+
};
|