@syncmatters/connector-sdk 1.0.1 → 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.
|
@@ -10,7 +10,7 @@ meta/Connectors/<ConnectorName>/ # one *.meta.json sidecar per source file
|
|
|
10
10
|
- **Entry file**: `<ConnectorName>.mjs`, default-exporting the connector class. Its sidecar has
|
|
11
11
|
`"type": "connector"` and carries the `connector_metadata` block.
|
|
12
12
|
- **Module files**: additional `.mjs` files in the same folder, imported by the entry (or by
|
|
13
|
-
other modules). Sidecar `"type": "module"`.
|
|
13
|
+
other modules). Sidecar `"type": "module"`. Modules **must** be placed in the same folder as the file that imports the module.
|
|
14
14
|
- **Resource files**: e.g. a bundled OpenAPI spec (`acme-api.yaml`). Sidecar type `"yaml"` /
|
|
15
15
|
`"json"`. Load big specs lazily (dynamic `import()`), not at module top level.
|
|
16
16
|
- **Test harness files**: `mod Test.mjs` (sidecar type `"connectortest"`) and the generated
|
|
@@ -50,37 +50,40 @@ switch (version) {
|
|
|
50
50
|
break;
|
|
51
51
|
}
|
|
52
52
|
default:
|
|
53
|
-
throw new SDK.ConnectorError(
|
|
53
|
+
throw new SDK.ConnectorError(
|
|
54
|
+
SDK.ErrorCode.InvalidOptionValue,
|
|
55
|
+
`Connector version '${version}' not recognized`,
|
|
56
|
+
);
|
|
54
57
|
}
|
|
55
58
|
await this.#core.init(args);
|
|
56
59
|
```
|
|
57
60
|
|
|
58
61
|
## Sidecar top-level fields
|
|
59
62
|
|
|
60
|
-
| Field
|
|
61
|
-
|
|
62
|
-
| `name`
|
|
63
|
-
| `type`
|
|
64
|
-
| `runnable`
|
|
65
|
-
| `engine`
|
|
66
|
-
| `resource_size`
|
|
67
|
-
| `cpu_architecture`
|
|
68
|
-
| `module_script_file_paths` | see above
|
|
69
|
-
| `npm_packages`
|
|
70
|
-
| `connector_metadata`
|
|
63
|
+
| Field | Values / meaning |
|
|
64
|
+
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `name` | the file name, e.g. `"Acme.mjs"` |
|
|
66
|
+
| `type` | `"connector"` (entry) · `"module"` · `"connectortest"` · `"json"` · `"yaml"` |
|
|
67
|
+
| `runnable` | `false` for connector files (they are invoked by the platform, not run directly) |
|
|
68
|
+
| `engine` | runtime engine version int; leave as scaffolded unless told otherwise |
|
|
69
|
+
| `resource_size` | `""` or `"medium"` — runtime resource class hint |
|
|
70
|
+
| `cpu_architecture` | normally `""` |
|
|
71
|
+
| `module_script_file_paths` | see above |
|
|
72
|
+
| `npm_packages` | name→version map of npm packages this file needs at runtime, e.g. `{"box-node-sdk": "2.4.0"}`. The `sm` CLI unions these into the workspace `package.json` so IntelliSense sees them too. Prefer the SDK's built-in http/utilities over adding packages. |
|
|
73
|
+
| `connector_metadata` | entry sidecar only — everything below |
|
|
71
74
|
|
|
72
75
|
## `connector_metadata` reference
|
|
73
76
|
|
|
74
|
-
| Field
|
|
75
|
-
|
|
76
|
-
| `categories`
|
|
77
|
-
| `agent_mode`
|
|
78
|
-
| `oauth2_mode`
|
|
79
|
-
| `oauth2_provider`
|
|
80
|
-
| `webhooks_provider`
|
|
81
|
-
| `settings`
|
|
82
|
-
| `icon_url`
|
|
83
|
-
| `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded
|
|
77
|
+
| Field | Meaning |
|
|
78
|
+
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `categories` | discovery taxonomy, e.g. `["sales"]`, `["file"]`, `["marketing","sales"]` |
|
|
80
|
+
| `agent_mode` | `"never"` = runs in cloud · `"always"` = runs on the customer's on-premise agent · `"optional"` = per-connection (agent used when the connection selects one) |
|
|
81
|
+
| `oauth2_mode` | `"connector"` = one shared OAuth app for every account · `"connection"` = each connection supplies its own client id/secret. Omit for non-OAuth connectors. |
|
|
82
|
+
| `oauth2_provider` | the OAuth provider definition (below) |
|
|
83
|
+
| `webhooks_provider` | `{"mode": "custom.perconnection"}` (each connection registers its own webhook) or `{"mode": "custom.perconnector", "url": ..., "webhook_uid": ...}` (one shared endpoint) |
|
|
84
|
+
| `settings` | the connection-settings form (below) |
|
|
85
|
+
| `icon_url` | connector logo URL |
|
|
86
|
+
| `connector_name`, `type_id`, `application_name`, `application_icon_url` | platform/branding ids — usually left as scaffolded |
|
|
84
87
|
|
|
85
88
|
### `settings`
|
|
86
89
|
|
package/docs/05-query.md
CHANGED
|
@@ -124,11 +124,16 @@ candidates }])` — the platform reserves un-mapped matches and returns the sele
|
|
|
124
124
|
|
|
125
125
|
Receives rows from a source object (`otherObjectId`) that has a defined relationship to this object, and collect the related rows from this object.
|
|
126
126
|
|
|
127
|
-
`{ otherObjectId, otherRelationshipId, otherRows }`.
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
`{ otherObjectId, otherRelationshipId, otherRows }`. DIRECTION: the platform queries the
|
|
128
|
+
relationship's TARGET object and passes the DECLARING object's rows — contacts declare
|
|
129
|
+
contact→company, so a related query arrives on `companies` with `otherObjectId: "contacts"`.
|
|
130
|
+
Resolve the relationship from the OTHER object's metadata
|
|
131
|
+
(`await options.objectMeta(related.otherObjectId)` — it is NOT in `options.meta`; your
|
|
132
|
+
`relationship.data` typically holds the FK field name), collect the FK values from
|
|
133
|
+
`otherRows` (they name THIS object's keys), fetch those rows, and return them tagged with
|
|
130
134
|
`relationship: { srcRowId, srcObjectId: otherObjectId, relationshipId: otherRelationshipId }` —
|
|
131
|
-
one row instance per source row it relates to.
|
|
135
|
+
one row instance per source row it relates to. See the worked implementation in
|
|
136
|
+
[12-example-connector.md](./12-example-connector.md).
|
|
132
137
|
|
|
133
138
|
## Cross-page scratch state: `kvStore`
|
|
134
139
|
|
package/docs/09-testing.md
CHANGED
|
@@ -11,6 +11,9 @@ space, is the convention):
|
|
|
11
11
|
The platform's connector test suite (script type `"connectorTest"`) drives your connector
|
|
12
12
|
through every capability its `meta()` declares — list/ids/checkpoint/match/related queries,
|
|
13
13
|
upserts, deletes — using these two files to know what to expect and how to prepare data.
|
|
14
|
+
The complete worked pair for the Acme connector of
|
|
15
|
+
[12-example-connector.md](./12-example-connector.md) is in
|
|
16
|
+
[15-example-test-file.md](./15-example-test-file.md).
|
|
14
17
|
|
|
15
18
|
## `mod ObjectTestFeatures.mjs` (generated)
|
|
16
19
|
|
|
@@ -98,8 +101,12 @@ objects.
|
|
|
98
101
|
|
|
99
102
|
## Prepare/cleanup hooks and their data types
|
|
100
103
|
|
|
101
|
-
Every testable feature has a `*Prepare` hook (and usually a `*Cleanup` counterpart)
|
|
102
|
-
|
|
104
|
+
Every testable feature has a `*Prepare` hook (and usually a `*Cleanup` counterpart). Most
|
|
105
|
+
prepare hooks skip their test when they return `undefined` — with two exceptions:
|
|
106
|
+
`listPrepare` returning `undefined` still runs the list test (it just expects some rows), and
|
|
107
|
+
`idsFilterPrepare` returning `undefined` makes the harness find ids via a list query itself.
|
|
108
|
+
To disable those tests use `cannotTestReason` instead
|
|
109
|
+
([15-example-test-file.md](./15-example-test-file.md) shows the worked semantics):
|
|
103
110
|
|
|
104
111
|
- `listPrepare` / `idsFilterPrepare` / `rowFiltersPrepare` — arrange rows and return what the
|
|
105
112
|
suite should expect (row-filter testing is easy to forget: provide it when `meta()` exposes
|
|
@@ -5,6 +5,9 @@ One canonical, end-to-end connector on a single page. Three objects — `compani
|
|
|
5
5
|
business unit + order id) — plus a contact→company relationship, static + API-discovered
|
|
6
6
|
custom fields, and full `query` / `upsert` / `delete`. Every pattern here follows the
|
|
7
7
|
production fleet; copy this shape and adapt it. Topic-file cross-references are in comments.
|
|
8
|
+
Its test-file counterpart — the `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair the
|
|
9
|
+
platform test suite and `sm run test` require — is
|
|
10
|
+
[15-example-test-file.md](./15-example-test-file.md).
|
|
8
11
|
|
|
9
12
|
The imaginary Acme API: bearer-token REST at `https://api.example.com/v1/`, cursor pagination
|
|
10
13
|
(`?after=<id>`), `modified_since` differential filter, a `/search` endpoint taking field
|
|
@@ -211,6 +214,7 @@ export default class Acme {
|
|
|
211
214
|
}
|
|
212
215
|
|
|
213
216
|
if (qo.matchFilter) return await this.#queryMatch(options, qo.matchFilter);
|
|
217
|
+
if (qo.relatedFilter) return await this.#queryRelated(options, qo.relatedFilter);
|
|
214
218
|
// composite-key ids arrive as key STRINGS - parse each and fetch directly (11)
|
|
215
219
|
if (options.id === "salesorders" && qo.idsFilter) {
|
|
216
220
|
return await this.#querySalesOrdersByIds(qo.idsFilter);
|
|
@@ -231,13 +235,10 @@ export default class Acme {
|
|
|
231
235
|
// undefined value = first differential run: return everything (05)
|
|
232
236
|
params.push(`modified_since=${encodeURIComponent(qo.checkpointFilter.value || "1970-01-01T00:00:00Z")}`);
|
|
233
237
|
}
|
|
234
|
-
if (qo.relatedFilter) params.push(this.#relatedWhere(options, qo.relatedFilter));
|
|
235
|
-
|
|
236
238
|
const resp = await this.#call({ method: "GET", url: `${options.id}?${params.join("&")}` });
|
|
237
239
|
|
|
238
240
|
/** @type {SDK.Row[]} */
|
|
239
241
|
let rows = resp.items.map((/** @type {any} */ item) => this.#row(options.id, item));
|
|
240
|
-
if (qo.relatedFilter) rows = this.#tagRelationships(rows, qo.relatedFilter);
|
|
241
242
|
|
|
242
243
|
queryState.after = resp.next_cursor || undefined;
|
|
243
244
|
queryState.rowCount += rows.length;
|
|
@@ -334,35 +335,52 @@ export default class Acme {
|
|
|
334
335
|
}
|
|
335
336
|
}
|
|
336
337
|
|
|
337
|
-
/**
|
|
338
|
-
*
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
/** Fetch rows of THIS object that other-object rows relate to. DIRECTION MATTERS:
|
|
339
|
+
* the platform queries the relationship's TARGET (here: companies) and passes the
|
|
340
|
+
* DECLARING object's rows (contacts) - so the relationship is resolved from the
|
|
341
|
+
* OTHER object's metadata via options.objectMeta, never options.meta (05).
|
|
342
|
+
* @param {SDK.QueryOptions} options @param {SDK.QueryRelatedFilter} related
|
|
343
|
+
* @returns {Promise<SDK.QueryPage>} */
|
|
344
|
+
async #queryRelated(options, related) {
|
|
345
|
+
const otherMeta = await options.objectMeta(related.otherObjectId);
|
|
346
|
+
const rel = (otherMeta.relationships || []).find(
|
|
347
|
+
(r) => r.id === related.otherRelationshipId,
|
|
348
|
+
);
|
|
341
349
|
if (!rel) {
|
|
342
350
|
throw new SDK.ConnectorError(SDK.ErrorCode.InvalidFilterParameter,
|
|
343
|
-
`object ${
|
|
351
|
+
`object ${related.otherObjectId} has no relationship '${related.otherRelationshipId}'`);
|
|
344
352
|
}
|
|
353
|
+
// the FK lives on the DECLARING object's rows (contact.company_id names a company);
|
|
354
|
+
// rel.data is OUR round-trip payload from meta() (04)
|
|
345
355
|
const fk = /** @type {{idField: string}} */ (rel.data).idField;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
/** @type {Map<string, SDK.Row[]>} this-object key -> source rows referencing it */
|
|
357
|
+
const wanted = new Map();
|
|
358
|
+
for (const src of related.otherRows) {
|
|
359
|
+
const key = src.data?.[fk] != null ? String(src.data[fk]) : "";
|
|
360
|
+
if (!key) continue; // source row without the FK - skip, never guess (10)
|
|
361
|
+
if (!wanted.has(key)) wanted.set(key, []);
|
|
362
|
+
/** @type {SDK.Row[]} */ (wanted.get(key)).push(src);
|
|
363
|
+
}
|
|
364
|
+
if (wanted.size === 0) return { rows: [], finalPage: true };
|
|
349
365
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
366
|
+
const resp = await this.#call({
|
|
367
|
+
method: "GET",
|
|
368
|
+
url: `${options.id}?ids=${[...wanted.keys()].map(encodeURIComponent).join(",")}`,
|
|
369
|
+
});
|
|
370
|
+
/** @type {SDK.Row[]} */
|
|
371
|
+
const rows = [];
|
|
372
|
+
for (const item of resp.items) {
|
|
373
|
+
const base = this.#row(options.id, item);
|
|
374
|
+
// one row INSTANCE per source row it relates to (05)
|
|
375
|
+
for (const src of wanted.get(base.meta.key) || []) {
|
|
376
|
+
rows.push({ ...base, relationship: {
|
|
377
|
+
srcRowId: src.meta.key,
|
|
378
|
+
srcObjectId: related.otherObjectId,
|
|
379
|
+
relationshipId: related.otherRelationshipId,
|
|
380
|
+
} });
|
|
363
381
|
}
|
|
364
382
|
}
|
|
365
|
-
return
|
|
383
|
+
return { rows, finalPage: true };
|
|
366
384
|
}
|
|
367
385
|
|
|
368
386
|
// ---------------------------------------------------------------- upsert / delete (06)
|
|
@@ -548,8 +566,11 @@ function parseSalesOrderKey(compositeKey) {
|
|
|
548
566
|
verbatim** (never re-encoded) on update/delete.
|
|
549
567
|
4. **The composite `$compositeKey` pseudo-field carries `isKey`** and is stripped from every
|
|
550
568
|
API body; adds supply the parent part (`business_unit`) as a regular mandatory field.
|
|
551
|
-
5.
|
|
552
|
-
|
|
569
|
+
5. **Related queries run on the relationship's TARGET object** — the platform queries
|
|
570
|
+
`companies` passing `contacts` rows, so the relationship is resolved from the OTHER
|
|
571
|
+
object's metadata (`options.objectMeta(otherObjectId)`), never `options.meta`; the FK
|
|
572
|
+
values on the source rows name the queried object's keys. `relationships[].data` is the
|
|
573
|
+
connector's own round-trip payload carrying that FK field name.
|
|
553
574
|
6. **`query()` dispatches filters in one place**, throws `InvalidFilterParameter` for anything
|
|
554
575
|
undeclared, and shares the pagination path across list/ids/checkpoint variants — except
|
|
555
576
|
composite-key `idsFilter`, which parses each key and fetches directly.
|
|
@@ -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).
|
|
@@ -25,9 +25,10 @@ locally — the local workspace is for editing, type-checking and pushing via th
|
|
|
25
25
|
| [09-testing.md](./09-testing.md) | the test harness: `mod Test.mjs` + `mod ObjectTestFeatures.mjs` |
|
|
26
26
|
| [10-style-and-pitfalls.md](./10-style-and-pitfalls.md) | style rules, deprecated APIs to avoid, known traps — skim this once regardless |
|
|
27
27
|
| [11-composite-keys.md](./11-composite-keys.md) | objects with multi-part identity (child REST resources, junction rows) — key encoding across query/upsert/delete |
|
|
28
|
-
| [12-example-connector.md](./12-example-connector.md) | **the canonical worked
|
|
28
|
+
| [12-example-connector.md](./12-example-connector.md) | **the canonical worked connector** — a complete three-object connector (relationships, matching, composite keys, custom fields, upsertClean, query/upsert/delete) on one page; its test-file counterpart is 15 |
|
|
29
29
|
| [13-file-fields.md](./13-file-fields.md) | objects carrying file content — fields of `type: "file"`, FileProvider, fields-driven inclusion (do NOT copy the fleet's legacy `row.file`) |
|
|
30
30
|
| [14-vendor-sdks-and-non-http.md](./14-vendor-sdks-and-non-http.md) | vendor npm SDKs (googleapis, AWS, box), database/on-prem `agent_mode: "always"` connectors, SOAP/XML, self-managed rate limiting |
|
|
31
|
+
| [15-example-test-file.md](./15-example-test-file.md) | **the canonical worked test file** — the complete `mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector of 12 (TestFeatureProvider, prepare/cleanup hooks, issue simulators, feature scoping) |
|
|
31
32
|
|
|
32
33
|
## The 60-second version
|
|
33
34
|
|
|
@@ -151,7 +152,7 @@ Implementing more methods unlocks more platform capability:
|
|
|
151
152
|
7. **Validate + push**: `sm validate`, `sm status` / `sm diff`, `sm push -m "message"`.
|
|
152
153
|
A push 409 means the platform copy changed: `sm pull`, merge, push again — never force.
|
|
153
154
|
8. **Test harness**: add `mod Test.mjs` (+ generated `mod ObjectTestFeatures.mjs`) so the
|
|
154
|
-
platform's connector test suite can exercise your objects (09).
|
|
155
|
+
platform's connector test suite can exercise your objects (09; worked example: 15).
|
|
155
156
|
|
|
156
157
|
## Support
|
|
157
158
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncmatters/connector-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "TypeScript type definitions for the SyncMatters connector SDK (types only - connectors execute on the SyncMatters platform)",
|
|
5
5
|
"types": "./index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": "SyncMatters",
|
|
14
14
|
"homepage": "https://syncmatters.com",
|
|
15
|
-
"typesContentHash": "
|
|
15
|
+
"typesContentHash": "1e322fcb4cda8d7a31d350d575470adeb2402009ea1929274efe27d7ccb7e041",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@types/node": "*",
|
|
18
18
|
"@syncmatters/script-api": "^1.0.1"
|