@syncmatters/connector-sdk 1.0.0 → 1.0.2

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.
@@ -0,0 +1,565 @@
1
+ # Acme — the worked test-file pair
2
+
3
+ The test-file counterpart to [12-example-connector.md](./12-example-connector.md): the complete
4
+ `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector, on one page. The
5
+ platform's connector test suite AND `sm run test|meta|query` both require a file of sidecar
6
+ type `"connectortest"` in the connector's folder — **a connector without one cannot be
7
+ run-tested at all**. Harness conventions and hook semantics live in
8
+ [09-testing.md](./09-testing.md); this page shows them applied end to end against Acme's three
9
+ objects: `companies`, `contacts` (email + first/last-name matching, `upsertClean`, custom
10
+ fields) and `salesorders` (the `$compositeKey` composite-key object).
11
+
12
+ Two files, two sidecars. `mod Test.mjs`'s sidecar is type `"connectortest"` and wires in the
13
+ generated module via `module_script_file_paths`:
14
+
15
+ `meta/Connectors/Acme/mod ObjectTestFeatures.mjs.meta.json`
16
+
17
+ ```json
18
+ {
19
+ "runnable": false,
20
+ "type": "module",
21
+ "cpu_architecture": "",
22
+ "engine": 0,
23
+ "resource_size": "medium",
24
+ "name": "mod ObjectTestFeatures.mjs"
25
+ }
26
+ ```
27
+
28
+ `meta/Connectors/Acme/mod Test.mjs.meta.json`
29
+
30
+ ```json
31
+ {
32
+ "runnable": false,
33
+ "type": "connectortest",
34
+ "cpu_architecture": "",
35
+ "engine": 0,
36
+ "resource_size": "medium",
37
+ "name": "mod Test.mjs",
38
+ "module_script_file_paths": ["Connectors/Acme/mod ObjectTestFeatures.mjs"]
39
+ }
40
+ ```
41
+
42
+ Keep exactly one `"connectortest"` file per connector folder — `sm run test` resolves the test
43
+ module from the connector's folder and several candidates are ambiguous.
44
+
45
+ ## `files/Connectors/Acme/mod ObjectTestFeatures.mjs` (generated)
46
+
47
+ Generated from the sandbox connection's cached `meta()` — regenerate it whenever `meta()`
48
+ changes; never hand-edit it. Note there are **no** `cannotTestReason` / `sameAsObjectId`
49
+ entries here: the generator cannot know those, so the fine-grained scoping knobs are applied
50
+ from `mod Test.mjs`'s `before()` (below), on top of this file.
51
+
52
+ ```js
53
+ /**
54
+ * Do not edit. This file is automatically generated using cached metadata from a test connection.
55
+ *
56
+ * This automatically generated object feature list can be extended in your main test module file; connector metadata
57
+ * does not contain sufficient detail to fully describe all connector features.
58
+ */
59
+ import API from "@syncmatters/script-api";
60
+ import SDKTest from "@syncmatters/script-api/sdk-test";
61
+ const toJsonPath = (/** @type string */ fieldPath) => API.utilities.json.stringToJsonValuePath(fieldPath, "all");
62
+ const toJsonPaths = (/** @type string[] */ fieldPaths) => API.utilities.json.stringsToJsonValuePaths(fieldPaths, "all");
63
+
64
+ /** @type Record<string, SDKTest.ObjectFeatures> */
65
+ export const objectFeatures = {
66
+ companies: {
67
+ query: {
68
+ list: true,
69
+ idsFilter: true,
70
+ checkpointFilter: true,
71
+ matchRules: [{"rule":"id"},{"rule":"name[ci]"}],
72
+ canMatchFields: toJsonPaths(["id", "name", "domain"])
73
+ },
74
+ upsert: {
75
+ canUpsert: true,
76
+ key: {
77
+ path: toJsonPath("id")
78
+ },
79
+ constraints: [{"constraints":{"mandatory":"add"},"fieldPath":[{"path":"name"}]}]
80
+ },
81
+ delete: {
82
+ canDelete: true,
83
+ key: {
84
+ path: toJsonPath("id")
85
+ }
86
+ }
87
+ },
88
+ contacts: {
89
+ query: {
90
+ list: true,
91
+ idsFilter: true,
92
+ checkpointFilter: true,
93
+ matchRules: [{"rule":"id"},{"rule":"email[ci]"},{"rule":"first_and_last_name[ci]"}],
94
+ canMatchFields: toJsonPaths(["id", "email", "first_name", "last_name"]),
95
+ relationshipsTo: [{"id":"companies-company_id","relObjectId":"companies"}]
96
+ },
97
+ upsert: {
98
+ canUpsert: true,
99
+ key: {
100
+ path: toJsonPath("id")
101
+ },
102
+ canUpsertClean: true,
103
+ constraints: [{"constraints":{"mandatory":"add","email":true},"fieldPath":[{"path":"email"}]}]
104
+ },
105
+ delete: {
106
+ canDelete: true,
107
+ key: {
108
+ path: toJsonPath("id")
109
+ }
110
+ }
111
+ },
112
+ salesorders: {
113
+ query: {
114
+ list: true,
115
+ idsFilter: true
116
+ },
117
+ upsert: {
118
+ canUpsert: true,
119
+ key: {
120
+ path: toJsonPath("$compositeKey")
121
+ },
122
+ constraints: [{"constraints":{"mandatory":"add"},"fieldPath":[{"path":"business_unit"}]}]
123
+ },
124
+ delete: {
125
+ canDelete: true,
126
+ key: {
127
+ path: toJsonPath("$compositeKey")
128
+ }
129
+ }
130
+ }
131
+ };
132
+ ```
133
+
134
+ ## `files/Connectors/Acme/mod Test.mjs` (hand-written)
135
+
136
+ Implements `SDKTest.TestFeatureProvider`. Every field/type below is from
137
+ `@syncmatters/script-api/sdk-test`; topic-file cross-references are in comments.
138
+
139
+ ```js
140
+ import API from "@syncmatters/script-api";
141
+ import SDKTest from "@syncmatters/script-api/sdk-test";
142
+ // the auto-generated feature list - regenerate after meta() changes, never hand-edit (09)
143
+ import { objectFeatures } from "./mod ObjectTestFeatures";
144
+
145
+ // Typed test connection: typed query/queryOne/upsert/delete accessors over the sandbox
146
+ // connection. "acme_test" is YOUR test connection's name. (Fleet files that predate the
147
+ // @syncmatters packages import the same types from the legacy "@ihq/script-api" scope -
148
+ // new code always imports @syncmatters/*.)
149
+ /** @typedef {API.connections.acme_test.Connection} TestConnection */
150
+ /** @typedef {TestConnection["upsert"]} Upsert */
151
+ /** @typedef {TestConnection["queryOne"]} Query */
152
+ // With the ^^above^^ typedefs:
153
+ // - upsert row type for "contacts": {Parameters<Upsert["contacts"]>[0][0]}
154
+ // - query result row type for "contacts": {Awaited<ReturnType<Query["contacts"]>>}
155
+
156
+ // prefix EVERY record the tests create, so leftovers from crashed runs stay findable
157
+ const testRecordPrefix = "sm_test";
158
+
159
+ /** @implements SDKTest.TestFeatureProvider */
160
+ export default class TestFeatureProvider {
161
+ // must equal the sandbox connection's meta().identity - pins the account, so re-pointed
162
+ // credentials fail loudly instead of trashing another account's data (09, 12)
163
+ /** @type SDKTest.TestFeatureProvider["expectedIdentity"] */
164
+ expectedIdentity = "Acme Sandbox|integration@example.com";
165
+
166
+ /** @type API.Logger */
167
+ #log;
168
+ /** @type TestConnection */
169
+ #connection;
170
+ /** @type string */
171
+ #fixtureCompanyId = ""; // shared parent row for contact test data
172
+
173
+ /** @type SDKTest.TestFeatureProvider["before"] */
174
+ async before(ctx) {
175
+ this.#log = ctx.log;
176
+ this.#connection = /** @type any */ (ctx.connection);
177
+
178
+ // === extend the GENERATED features here - never by editing the generated file (09).
179
+ // contacts delete runs the exact same batch/archive code path as companies delete,
180
+ // so test it once:
181
+ if (objectFeatures.contacts.delete) {
182
+ objectFeatures.contacts.delete.sameAsObjectId = "companies";
183
+ }
184
+ // per-rule scoping: Acme's /search cannot search by record id - the "id" rule resolves
185
+ // via get-by-id, which the idsFilter test already covers:
186
+ for (const objectId of ["companies", "contacts"]) {
187
+ const idRule = (objectFeatures[objectId].query?.matchRules || []).find((r) => r.rule === "id");
188
+ if (idRule) idRule.cannotTestReason = "id matches resolve via get-by-id; covered by the idsFilter test";
189
+ }
190
+ // Other scoping knobs, when you need them:
191
+ // objectFeatures.salesorders.cannotTestReason = "<why the whole object cannot be tested>";
192
+ // objectFeatures.salesorders.delete.cannotTestReason = "<why just this feature cannot>";
193
+ // If Acme's archive endpoint kept returning archived rows (a soft delete), you would
194
+ // declare the flag field instead of expecting row absence, and allow for slow indexes:
195
+ // objectFeatures.companies.delete.isDeleted = { path: [{ path: "archived" }], valueWhenDeleted: true };
196
+ // objectFeatures.companies.delete.afterDeleteMaxIndexWaitTimeMs = 30000;
197
+
198
+ // === sweep sm_test* leftovers from crashed runs
199
+ await this.#sweepTestData();
200
+
201
+ // === shared fixtures
202
+ const company = await this.#connection.queryOne.companies();
203
+ if (!company) throw new Error("Sandbox has no companies - seed at least one before testing");
204
+ this.#fixtureCompanyId = company.meta.key;
205
+ }
206
+
207
+ /** @type SDKTest.TestFeatureProvider["after"] */
208
+ async after() {
209
+ await this.#sweepTestData();
210
+ }
211
+
212
+ /** @type SDKTest.TestFeatureProvider["objectIds"] */
213
+ async objectIds() {
214
+ return Object.keys(objectFeatures);
215
+ }
216
+
217
+ /** @type SDKTest.TestFeatureProvider["objectFeatures"] */
218
+ async objectFeatures(objectId) {
219
+ return objectFeatures[objectId];
220
+ }
221
+
222
+ /** @type SDKTest.TestFeatureProvider["listPrepare"] */
223
+ async listPrepare(options) {
224
+ // returning undefined here would mean "run the list, just verify SOME rows come back";
225
+ // a small pageSize forces real pagination through Acme's cursor (05)
226
+ switch (options.meta.id) {
227
+ case "salesorders":
228
+ return { pageSize: 25, fields: [[{ path: "$compositeKey" }], [{ path: "status" }]] };
229
+ default:
230
+ return { pageSize: 25 };
231
+ }
232
+ }
233
+
234
+ /** @type SDKTest.TestFeatureProvider["idsFilterPrepare"] */
235
+ async idsFilterPrepare(options) {
236
+ // list-capable objects can skip this hook - undefined tells the harness to find two
237
+ // ids via a list query itself
238
+ if (options.meta.id !== "salesorders") return undefined;
239
+
240
+ // implemented for salesorders to make composite keys concrete: the ids ARE the
241
+ // connector's JSON key strings (row.meta.key), e.g.
242
+ // '{"business_unit":"emea","order_id":1041}'
243
+ // NEVER hand-assemble them - collect real rows and reuse their keys verbatim (11, 12)
244
+ /** @type SDKTest.IdsFilterData */
245
+ const result = { id1: "", id2: "" };
246
+ for await (const order of this.#connection.query.salesorders()) {
247
+ if (!result.id1) {
248
+ result.id1 = order.meta.key;
249
+ } else if (order.meta.key !== result.id1) {
250
+ result.id2 = order.meta.key;
251
+ break;
252
+ }
253
+ }
254
+ return result;
255
+ }
256
+
257
+ /** @type SDKTest.TestFeatureProvider["checkpointFilterStep1Prepare"] */
258
+ async checkpointFilterStep1Prepare(options) {
259
+ // step 1: pick a starting checkpoint and report every row the differential query
260
+ // should return for it - then guarantee at least one by seeding a row
261
+ const start = new Date(Date.now() - 60000).toISOString();
262
+ /** @type SDKTest.CheckpointStep1Data */
263
+ const result = { nextCheckpoint: start, expectedRowIds: [] };
264
+
265
+ // a shared sandbox is never quiet: rows already modified inside the window are
266
+ // expected too, so collect them rather than assuming zero
267
+ // @ts-ignore - objectId is dynamic
268
+ for await (const row of this.#connection.query[options.meta.id]({ checkpointFilter: { value: start } })) {
269
+ result.expectedRowIds.push(row.meta.key);
270
+ }
271
+ if (result.expectedRowIds.length === 0) {
272
+ result.expectedRowIds.push(await this.#createTestRow(options.meta.id));
273
+ }
274
+ return result;
275
+ }
276
+
277
+ /** @type SDKTest.TestFeatureProvider["checkpointFilterStep2Prepare"] */
278
+ async checkpointFilterStep2Prepare(options) {
279
+ // step 2: the harness hands back the checkpoint the CONNECTOR emitted in step 1;
280
+ // mutate the data and report what the second differential query must surface
281
+ /** @type SDKTest.CheckpointStep2Data */
282
+ const result = { expectedRowIds: [] };
283
+
284
+ // checkpoints are emitted with an overlap window (05, 12), so step-1 rows may
285
+ // legitimately reappear - include everything currently inside the window...
286
+ // @ts-ignore - objectId is dynamic
287
+ for await (const row of this.#connection.query[options.meta.id]({ checkpointFilter: { value: options.checkpointReceived } })) {
288
+ result.expectedRowIds.push(row.meta.key);
289
+ }
290
+ // ...then make one fresh modification the query MUST pick up
291
+ result.expectedRowIds.push(await this.#createTestRow(options.meta.id));
292
+ return result;
293
+ }
294
+
295
+ /** @type SDKTest.TestFeatureProvider["matchFilterPrepare"] */
296
+ async matchFilterPrepare(options) {
297
+ const uid = SDKTest.makeUid();
298
+ /** @type SDKTest.MatchData[] */
299
+ const results = [];
300
+
301
+ switch (options.meta.id) {
302
+ case "companies": {
303
+ if (options.rule !== "name[ci]") return undefined; // undefined = rule cannot be tested
304
+ const name = `${testRecordPrefix} Match Co ${uid}`;
305
+ const [company] = await this.#connection.upsert.companies([{ name }]);
306
+ results.push({
307
+ // a [ci] rule: deliberately change the case - the match must still land
308
+ srcData: [{ srcRowId: "src-1", match: { name: name.toUpperCase() } }],
309
+ destFieldPath: [{ path: "name" }],
310
+ expectedMatchRowIds: [company.meta.key],
311
+ });
312
+ break;
313
+ }
314
+ case "contacts": {
315
+ const email = `${testRecordPrefix}.${uid}@example.com`;
316
+ const first = `${testRecordPrefix}-First-${uid}`;
317
+ const last = `Last-${uid}`;
318
+ const [contact] = await this.#connection.upsert.contacts([
319
+ { email, first_name: first, last_name: last, company_id: this.#fixtureCompanyId },
320
+ ]);
321
+ switch (options.rule) {
322
+ case "email[ci]": {
323
+ results.push({
324
+ srcData: [{ srcRowId: "src-1", match: { email: email.toUpperCase() } }],
325
+ destFieldPath: [{ path: "email" }],
326
+ expectedMatchRowIds: [contact.meta.key],
327
+ });
328
+ break;
329
+ }
330
+ case "first_and_last_name[ci]": {
331
+ // RowMatchData carries the parts as firstname/lastname - the rule needs BOTH
332
+ results.push({
333
+ srcData: [{ srcRowId: "src-1", match: { firstname: first.toUpperCase(), lastname: last.toUpperCase() } }],
334
+ expectedMatchRowIds: [contact.meta.key],
335
+ });
336
+ break;
337
+ }
338
+ default:
339
+ return undefined;
340
+ }
341
+ break;
342
+ }
343
+ default:
344
+ return undefined;
345
+ }
346
+ return results;
347
+ }
348
+
349
+ /** @type SDKTest.TestFeatureProvider["matchFilterCleanup"] */
350
+ async matchFilterCleanup(options) {
351
+ // the rows we created ARE the expected matches - delete them
352
+ const keys = options.testData.flatMap((d) => d.expectedMatchRowIds);
353
+ await this.#deleteRows(options.meta.id, keys);
354
+ }
355
+
356
+ /** @type SDKTest.TestFeatureProvider["relatedFilterPrepare"] */
357
+ async relatedFilterPrepare(options) {
358
+ // contacts declares relationship "companies-company_id" (12). The harness will
359
+ // 1) collect the 'from' contact via idsFilter (srcRowId), then 2) query companies with
360
+ // relatedFilter { otherObjectId: "contacts", otherRelationshipId, otherRows: [contact] }
361
+ // and expect EXACTLY expectedRelatedRowIds back.
362
+ if (options.fromObjectMeta.id !== "contacts" || options.fromRelationshipId !== "companies-company_id") {
363
+ return undefined;
364
+ }
365
+ const uid = SDKTest.makeUid();
366
+ const [company] = await this.#connection.upsert.companies([{ name: `${testRecordPrefix} Rel Co ${uid}` }]);
367
+ const [contact] = await this.#connection.upsert.contacts([
368
+ {
369
+ email: `${testRecordPrefix}.rel.${uid}@example.com`,
370
+ first_name: testRecordPrefix,
371
+ last_name: `Rel ${uid}`,
372
+ company_id: company.meta.key, // the FK that drives the relationship
373
+ },
374
+ ]);
375
+ // OneToOne cardinality: exactly one related company expected
376
+ return [{ srcRowId: contact.meta.key, expectedRelatedRowIds: [company.meta.key] }];
377
+ }
378
+
379
+ /** @type SDKTest.TestFeatureProvider["relatedFilterCleanup"] */
380
+ async relatedFilterCleanup(options) {
381
+ for (const testData of options.testDatas) {
382
+ await this.#deleteRows("contacts", [testData.srcRowId]); // child first...
383
+ await this.#deleteRows("companies", testData.expectedRelatedRowIds); // ...then parent
384
+ }
385
+ }
386
+
387
+ /** @type SDKTest.TestFeatureProvider["upsertPrepare"] */
388
+ async upsertPrepare(options) {
389
+ // This hook also feeds the DELETE test: there is no deletePrepare - the harness calls
390
+ // upsertPrepare twice, inserts both rows, deletes one and verifies exactly that one
391
+ // is gone. Keep inserts repeatable and unique (makeUid).
392
+ const uid = SDKTest.makeUid();
393
+ /** @type SDKTest.UpsertData */
394
+ const result = { insert: {}, update: {}, verifyFields: [], issueSimulators: [] };
395
+
396
+ switch (options.meta.id) {
397
+ case "companies": {
398
+ /** @type Parameters<Upsert["companies"]>[0][0] */
399
+ const insert = { name: `${testRecordPrefix} Co ${uid}`, domain: `${uid}.example.com` };
400
+ /** @type Parameters<Upsert["companies"]>[0][0] */
401
+ const update = { domain: `${uid}-updated.example.com` };
402
+ result.insert = insert;
403
+ result.update = update;
404
+ // the harness re-queries the row after each write and compares these paths
405
+ result.verifyFields.push([{ path: "domain" }]);
406
+ break;
407
+ }
408
+ case "contacts": {
409
+ /** @type Parameters<Upsert["contacts"]>[0][0] */
410
+ const insert = {
411
+ email: `${testRecordPrefix}.${uid}@example.com`,
412
+ first_name: testRecordPrefix,
413
+ last_name: `Contact ${uid}`,
414
+ company_id: this.#fixtureCompanyId,
415
+ };
416
+ /** @type Parameters<Upsert["contacts"]>[0][0] */
417
+ const update = { last_name: `Contact ${uid} updated` };
418
+ result.insert = insert;
419
+ result.update = update;
420
+ result.verifyFields.push([{ path: "last_name" }]);
421
+
422
+ // contacts declares canUpsertClean (12): when asked, supply deliberately-broken
423
+ // inserts stating EXACTLY which issue upsertClean must report and whether it is
424
+ // fatal. The harness applies each simulator's fields over the insert row.
425
+ if (options.withIssueSimulators) {
426
+ result.issueSimulators = [
427
+ // email is mandatory on add -> ValueRequired, fatal
428
+ { type: "ValueRequired", upsert: { email: undefined }, fatal: true },
429
+ // an invalid email is cleansed (the field is REMOVED)... which then violates
430
+ // the mandatory-on-add constraint, so this issue escalates to fatal
431
+ { type: "EmailInvalid", upsert: { email: "not-an-email" }, fatal: true },
432
+ // unknown option on a sandbox custom picklist field: upsertClean's
433
+ // cleanAction "DeleteUpsertField" drops the field and the row still
434
+ // writes -> NOT fatal (06, 12)
435
+ { type: "OptionNotFound", upsert: { t_shirt_size: "no-such-option" }, fatal: false },
436
+ ];
437
+ }
438
+ break;
439
+ }
440
+ case "salesorders": {
441
+ // composite-key object: the ADD carries the parent part (business_unit) as a
442
+ // plain mandatory field - there is NO key yet. The harness copies the key the
443
+ // insert response returns ($compositeKey) onto the update row for us (11, 12).
444
+ // "emea" is an existing business unit on the Acme sandbox (units are fixtures).
445
+ /** @type Parameters<Upsert["salesorders"]>[0][0] */
446
+ const insert = { business_unit: "emea", amount: 42, status: "draft" };
447
+ /** @type Parameters<Upsert["salesorders"]>[0][0] */
448
+ const update = { status: "confirmed" };
449
+ result.insert = insert;
450
+ result.update = update;
451
+ result.verifyFields.push([{ path: "status" }]);
452
+ break;
453
+ }
454
+ }
455
+ return result;
456
+ }
457
+
458
+ /** @type SDKTest.TestFeatureProvider["upsertCleanup"] */
459
+ async upsertCleanup(options) {
460
+ // the harness hands back the rows it inserted - delete them right away. This is the
461
+ // ONLY cleanup path for salesorders test rows: they have no prefixable text field,
462
+ // so the after() sweep cannot find them.
463
+ await this.#deleteRows(options.meta.id, options.inserted.map((r) => r.meta.key));
464
+ }
465
+
466
+ // === private helpers ======================================================
467
+
468
+ /** Create one prefixed row; creating counts as a modification for checkpoint tests.
469
+ * @param {string} objectId @returns {Promise<string>} the new row's key */
470
+ async #createTestRow(objectId) {
471
+ const uid = SDKTest.makeUid();
472
+ switch (objectId) {
473
+ case "companies": {
474
+ const [row] = await this.#connection.upsert.companies([{ name: `${testRecordPrefix} Co ${uid}` }]);
475
+ return row.meta.key;
476
+ }
477
+ case "contacts": {
478
+ const [row] = await this.#connection.upsert.contacts([
479
+ {
480
+ email: `${testRecordPrefix}.${uid}@example.com`,
481
+ first_name: testRecordPrefix,
482
+ last_name: `Contact ${uid}`,
483
+ company_id: this.#fixtureCompanyId,
484
+ },
485
+ ]);
486
+ return row.meta.key;
487
+ }
488
+ default:
489
+ throw new Error(`#createTestRow not implemented for ${objectId}`);
490
+ }
491
+ }
492
+
493
+ /** Delete rows by key. @param {string} objectId @param {string[]} keys */
494
+ async #deleteRows(objectId, keys) {
495
+ if (keys.length === 0) return;
496
+ switch (objectId) {
497
+ case "companies":
498
+ await this.#connection.delete.companies(keys.map((id) => ({ id })));
499
+ break;
500
+ case "contacts":
501
+ await this.#connection.delete.contacts(keys.map((id) => ({ id })));
502
+ break;
503
+ case "salesorders":
504
+ // composite-key deletes address rows by the SAME key string queries returned (11)
505
+ await this.#connection.delete.salesorders(keys.map((k) => ({ "$compositeKey": k })));
506
+ break;
507
+ }
508
+ }
509
+
510
+ /** Sweep sm_test* leftovers - children (contacts) before parents (companies). */
511
+ async #sweepTestData() {
512
+ /** @type string[] */
513
+ const staleContacts = [];
514
+ for await (const row of this.#connection.query.contacts()) {
515
+ if (String(row.data.email || "").startsWith(`${testRecordPrefix}.`)) staleContacts.push(row.meta.key);
516
+ }
517
+ /** @type string[] */
518
+ const staleCompanies = [];
519
+ for await (const row of this.#connection.query.companies()) {
520
+ if (String(row.data.name || "").startsWith(testRecordPrefix)) staleCompanies.push(row.meta.key);
521
+ }
522
+ if (staleContacts.length || staleCompanies.length) {
523
+ this.#log.info(`Sweeping ${staleContacts.length} test contact(s), ${staleCompanies.length} test company(ies)`);
524
+ }
525
+ await this.#deleteRows("contacts", staleContacts);
526
+ await this.#deleteRows("companies", staleCompanies);
527
+ }
528
+ }
529
+ ```
530
+
531
+ ## What to notice (the parts assistants most often get wrong)
532
+
533
+ 1. **Every created record is prefixed** (`sm_test`) and `before()`/`after()` sweep the
534
+ prefix — a crashed run never pollutes the sandbox, and the next run self-heals. Objects
535
+ with no prefixable text field (salesorders) must be cleaned in `upsertCleanup` via
536
+ `options.inserted` instead.
537
+ 2. **`expectedIdentity` pins the account** — it must equal the sandbox connection's
538
+ `meta().identity` string exactly; re-pointed credentials then fail loudly instead of
539
+ letting the suite write into a real account.
540
+ 3. **`undefined` from a prepare hook is a decision, not a default.** For
541
+ `checkpointFilterStep1/2Prepare`, `matchFilterPrepare`, `relatedFilterPrepare`,
542
+ `rowFiltersPrepare` and `upsertPrepare` it means "this cannot be tested" (the feature is
543
+ skipped); but for `listPrepare` it means "run the list and just verify some rows", and for
544
+ `idsFilterPrepare` it means "find two ids via a list query". Returning `undefined` by
545
+ accident silently drops coverage.
546
+ 4. **`mod ObjectTestFeatures.mjs` is generated — regenerate, don't edit.** Scoping
547
+ (`cannotTestReason`, `sameAsObjectId` and their per-feature/per-rule/per-relationship
548
+ variants) is layered on from `mod Test.mjs`'s `before()`, so a regeneration never loses it.
549
+ 5. **Composite-key objects use the connector's own key strings.** Test ids come from
550
+ `row.meta.key` verbatim — never hand-assembled; the upsert `insert` has no key at all (the
551
+ parent part travels as a regular mandatory field) and the harness copies the returned
552
+ `$compositeKey` onto the `update` row.
553
+ 6. **Issue simulators are how `upsertClean` gets exercised.** Each simulator names the exact
554
+ `UpsertIssueType` and its fatality, and cleansing cascades: an invalid email on a
555
+ mandatory-on-add field is removed by the cleanse, which then trips `ValueRequired` — so
556
+ the `EmailInvalid` issue is reported fatal.
557
+ 7. **There is no `deletePrepare`.** The delete test calls `upsertPrepare` twice, inserts both
558
+ rows, deletes one and verifies exactly that one disappeared — so inserts must be
559
+ repeatable and unique. Soft-deleting APIs declare
560
+ `delete.isDeleted { path, valueWhenDeleted }` (plus `afterDeleteMaxIndexWaitTimeMs`)
561
+ instead of expecting row absence.
562
+ 8. **Checkpoint tests assume a noisy sandbox.** Both steps collect everything already inside
563
+ the differential window into `expectedRowIds` before seeding one guaranteed change, and
564
+ step 2 expects step-1 rows to reappear because the connector's checkpoint deliberately
565
+ overlaps (05, 12).