@tailor-platform/sdk 1.45.2 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/dist/{actor-BmxQeMFP.d.mts → actor-DhXSqWTW.d.mts} +2 -2
- package/dist/application-CN9Htzup.mjs +4 -0
- package/dist/{application-B4zVVNRS.mjs → application-TasSqBTD.mjs} +22 -41
- package/dist/application-TasSqBTD.mjs.map +1 -0
- package/dist/cli/index.mjs +101 -61
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/lib.d.mts +13 -13
- package/dist/cli/lib.mjs +4 -4
- package/dist/cli/lib.mjs.map +1 -1
- package/dist/{client-BwXkoiMq.mjs → client-COfsXV69.mjs} +31 -120
- package/dist/client-COfsXV69.mjs.map +1 -0
- package/dist/{client-DTaArWQr.mjs → client-DYSkSLRr.mjs} +1 -1
- package/dist/configure/index.d.mts +4 -4
- package/dist/configure/index.mjs +4 -43
- package/dist/configure/index.mjs.map +1 -1
- package/dist/{crash-report-BUHzuzDn.mjs → crashreport-B8lVOx0U.mjs} +1 -1
- package/dist/{crash-report-CtYCva4d.mjs → crashreport-CKJwnWsX.mjs} +9 -9
- package/dist/crashreport-CKJwnWsX.mjs.map +1 -0
- package/dist/{index-DV-5OIEv.d.mts → index-BRvNi5q9.d.mts} +2 -2
- package/dist/{index-BBvPd9Uv.d.mts → index-BXyS7xKC.d.mts} +2 -2
- package/dist/{index-Dxe6alSZ.d.mts → index-BbOTbZFf.d.mts} +2 -2
- package/dist/{index-DUKJPEwq.d.mts → index-BoU_52Du.d.mts} +6 -6
- package/dist/{index-B5_4Tzm2.d.mts → index-iy-hNfGp.d.mts} +2 -2
- package/dist/{interceptor-CrcDfLPq.mjs → interceptor-CBsqEWDK.mjs} +1 -1
- package/dist/{interceptor-CrcDfLPq.mjs.map → interceptor-CBsqEWDK.mjs.map} +1 -1
- package/dist/mock-BP-9O5On.mjs +796 -0
- package/dist/mock-BP-9O5On.mjs.map +1 -0
- package/dist/plugin/builtin/enum-constants/index.d.mts +1 -1
- package/dist/plugin/builtin/file-utils/index.d.mts +1 -1
- package/dist/plugin/builtin/kysely-type/index.d.mts +1 -1
- package/dist/plugin/builtin/seed/index.d.mts +1 -1
- package/dist/plugin/index.d.mts +2 -2
- package/dist/{repl-editor-BlT2dFtm.mjs → repl-editor-CZpLlOBj.mjs} +1 -1
- package/dist/{repl-editor-BlT2dFtm.mjs.map → repl-editor-CZpLlOBj.mjs.map} +1 -1
- package/dist/{runtime-D97Ydu2S.mjs → runtime-DDYL2Zf1.mjs} +148 -70
- package/dist/runtime-DDYL2Zf1.mjs.map +1 -0
- package/dist/{service-CCgw66c6.mjs → service-obEU5gSM.mjs} +1 -1
- package/dist/{service-CCgw66c6.mjs.map → service-obEU5gSM.mjs.map} +1 -1
- package/dist/{tailor-db-field-Hx9OqPWY.d.mts → tailor-db-field-Bn8ZC5lK.d.mts} +1 -1
- package/dist/{schema-DBq6hr6h.mjs → tailordb-Bg9-TZj1.mjs} +42 -2
- package/dist/tailordb-Bg9-TZj1.mjs.map +1 -0
- package/dist/telemetry-21afNV9_.mjs +4 -0
- package/dist/{telemetry-DXitz4RH.mjs → telemetry-DcL8Fsm_.mjs} +1 -1
- package/dist/{telemetry-DXitz4RH.mjs.map → telemetry-DcL8Fsm_.mjs.map} +1 -1
- package/dist/utils/test/index.d.mts +13 -4
- package/dist/utils/test/index.mjs +12 -3
- package/dist/utils/test/index.mjs.map +1 -1
- package/dist/vitest/environment.d.mts +12 -0
- package/dist/vitest/environment.mjs +44 -0
- package/dist/vitest/environment.mjs.map +1 -0
- package/dist/vitest/index.d.mts +345 -0
- package/dist/vitest/index.mjs +350 -0
- package/dist/vitest/index.mjs.map +1 -0
- package/dist/vitest/setup.d.mts +64 -0
- package/dist/vitest/setup.mjs +141 -0
- package/dist/vitest/setup.mjs.map +1 -0
- package/dist/{workflow.generated-DFljpJh7.d.mts → workflow.generated-i7PK4fg-.d.mts} +2 -2
- package/docs/cli/application.md +19 -17
- package/docs/cli/crashreport.md +119 -0
- package/docs/cli/executor.md +9 -9
- package/docs/cli/function.md +5 -5
- package/docs/cli/setup.md +1 -0
- package/docs/cli/tailordb.md +1 -1
- package/docs/cli/workflow.md +8 -8
- package/docs/cli-reference.md +8 -8
- package/docs/quickstart.md +2 -2
- package/docs/services/auth.md +2 -2
- package/docs/services/secret.md +4 -4
- package/docs/services/tailordb-migration.md +10 -10
- package/docs/services/tailordb.md +44 -13
- package/docs/services/workflow.md +1 -1
- package/docs/testing.md +530 -243
- package/package.json +32 -6
- package/dist/application-B4zVVNRS.mjs.map +0 -1
- package/dist/application-BIzicxMA.mjs +0 -4
- package/dist/client-BwXkoiMq.mjs.map +0 -1
- package/dist/crash-report-CtYCva4d.mjs.map +0 -1
- package/dist/runtime-D97Ydu2S.mjs.map +0 -1
- package/dist/schema-DBq6hr6h.mjs.map +0 -1
- package/dist/telemetry-BvI1EgMG.mjs +0 -4
- package/docs/cli/crash-report.md +0 -118
package/docs/testing.md
CHANGED
|
@@ -1,47 +1,341 @@
|
|
|
1
1
|
# Testing Guide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tailor Platform SDK applications are tested with [Vitest](https://vitest.dev/) at two layers:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| Layer | What it exercises | Deployment required |
|
|
6
|
+
| ---------- | ---------------------------------------------------- | ------------------- |
|
|
7
|
+
| Unit tests | Resolver / workflow job / executor TypeScript source | No |
|
|
8
|
+
| E2E tests | Deployed GraphQL API, TailorDB, and workflows | Yes |
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
Lean on unit tests for the day-to-day feedback loop — they run fast and exercise business logic against real SDK types with no deployment in the loop. Reach for E2E tests to confirm integration against a live platform, where mocked globals can drift from the real GraphQL, TailorDB, and workflow runtime.
|
|
11
|
+
|
|
12
|
+
Unit-test entrypoints exposed by the SDK:
|
|
13
|
+
|
|
14
|
+
- `resolver.body({ input, user, env })` — invoke a resolver
|
|
15
|
+
- `workflowJob.body(input, { env })` / `workflowJob.trigger(input)` — invoke or chain a workflow job
|
|
16
|
+
- `executor.operation.body(args)` — invoke a function-kind executor
|
|
17
|
+
|
|
18
|
+
Helpers under `@tailor-platform/sdk/test`:
|
|
19
|
+
|
|
20
|
+
- `unauthenticatedTailorUser` — default `user` value for resolver contexts
|
|
21
|
+
- `WORKFLOW_TEST_ENV_KEY` — env key consumed by `.trigger()` when run locally
|
|
22
|
+
|
|
23
|
+
Platform API mocks under `@tailor-platform/sdk/vitest` (auto-injected by the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below):
|
|
24
|
+
|
|
25
|
+
- `tailordbMock` — TailorDB query stubs and call recording
|
|
26
|
+
- `workflowMock` — `tailor.workflow` job / wait / resolve mocks
|
|
27
|
+
- `secretmanagerMock`, `authconnectionMock`, `idpMock`, `fileMock`, `iconvMock` — corresponding platform API mocks
|
|
28
|
+
|
|
29
|
+
For tighter alignment with the production runtime — Node.js module blocking, Web-only globals, and platform API mocks — pair the resolver helpers with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below.
|
|
30
|
+
|
|
31
|
+
Three starter templates demonstrate the patterns below in a working project:
|
|
32
|
+
|
|
33
|
+
- `npm create @tailor-platform/sdk -- --template resolver <name>` — resolvers, TailorDB mocking, and DI
|
|
34
|
+
- `npm create @tailor-platform/sdk -- --template executor <name>` — executors with extracted DB helpers
|
|
35
|
+
- `npm create @tailor-platform/sdk -- --template workflow <name>` — workflow jobs, wait points, and an E2E suite
|
|
36
|
+
|
|
37
|
+
## Runtime Environment Emulation (Beta)
|
|
38
|
+
|
|
39
|
+
The Tailor Platform function runtime only provides Web Standard APIs. Node.js built-in modules like `node:crypto` and globals like `Buffer` are not available. The `tailor-runtime` Vitest environment catches these incompatibilities locally before deployment.
|
|
40
|
+
|
|
41
|
+
### Setup
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// vitest.config.ts
|
|
45
|
+
import { defineConfig } from "vitest/config";
|
|
46
|
+
import { tailorRuntime } from "@tailor-platform/sdk/vitest";
|
|
47
|
+
|
|
48
|
+
export default defineConfig({
|
|
49
|
+
plugins: [tailorRuntime()],
|
|
50
|
+
test: {
|
|
51
|
+
environment: "tailor-runtime",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
9
54
|
```
|
|
10
55
|
|
|
56
|
+
`tailorRuntime()` provides:
|
|
57
|
+
|
|
58
|
+
1. **Node.js module blocking** — `import { randomBytes } from "node:crypto"` in production code throws an error with a suggestion for the Web Standard API alternative (`globalThis.crypto`). Test files (`*.test.ts`, `*.spec.ts`) are exempt.
|
|
59
|
+
2. **Node.js globals removal** — Only globals available in the platform runtime are kept (whitelist). `Buffer`, `global`, `setImmediate`, `__dirname`, `__filename`, `performance`, and others are removed.
|
|
60
|
+
3. **Platform API mocks** — `globalThis.tailordb`, `globalThis.tailor`, `TailorErrors`, `TailorErrorMessage`, `TailorDBFileError` are auto-injected with mock control objects for response configuration and call recording.
|
|
61
|
+
|
|
62
|
+
### TailorDB Mock
|
|
63
|
+
|
|
64
|
+
The environment auto-injects a mock `tailordb.Client`. Use `tailordbMock` to configure responses and assert on executed queries:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { tailordbMock } from "@tailor-platform/sdk/vitest";
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
tailordbMock.reset();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("resolver queries the database", async () => {
|
|
74
|
+
// Order-based: stage rows for each upcoming query in one call
|
|
75
|
+
tailordbMock.enqueueResults(
|
|
76
|
+
[], // BEGIN (empty result)
|
|
77
|
+
[{ age: 30 }], // SELECT (one row)
|
|
78
|
+
[], // COMMIT
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = await resolver.body({ input: { email: "test@example.com" } });
|
|
82
|
+
|
|
83
|
+
expect(result).toEqual({ oldAge: 30, newAge: 31 });
|
|
84
|
+
expect(tailordbMock.executedQueries).toHaveLength(3);
|
|
85
|
+
expect(tailordbMock.createdClients).toMatchObject([{ namespace: "tailordb" }]);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Three response modes:
|
|
90
|
+
|
|
91
|
+
- **`enqueueResult(...rows)`** — Order-based, single query. Arguments are the row objects returned by the next `queryObject` call (`enqueueResult()` for empty, `enqueueResult({ id: "1" })` for one row, `enqueueResult({ a: 1 }, { a: 2 })` for multiple rows). Consumed in FIFO order.
|
|
92
|
+
- **`enqueueResults(...rowsArrays)`** — Order-based, multiple queries. Each argument is a rows array for one upcoming query. Equivalent to calling `enqueueResult` for each entry but easier to read for transactional sequences.
|
|
93
|
+
- **`setQueryResolver((query, params) => rows)`** — Content-based fallback. Called when the queue is empty.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
test("content-based mock", async () => {
|
|
97
|
+
tailordbMock.setQueryResolver((query) => {
|
|
98
|
+
if (query.includes("SELECT")) return [{ id: "1", name: "test" }];
|
|
99
|
+
return [];
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const result = await resolver.body({ input: { userId: "1" } });
|
|
103
|
+
|
|
104
|
+
expect(tailordbMock.executedQueries[0].query).toContain("SELECT");
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Workflow Mock
|
|
109
|
+
|
|
110
|
+
The environment auto-injects `tailor.workflow.triggerJobFunction`. Use `workflowMock` to configure job responses:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { workflowMock } from "@tailor-platform/sdk/vitest";
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
workflowMock.reset();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("workflow triggers jobs", async () => {
|
|
120
|
+
workflowMock.setJobHandler((jobName, args) => {
|
|
121
|
+
if (jobName === "validate-order") return { valid: true };
|
|
122
|
+
if (jobName === "process-payment") return { txnId: "txn-1" };
|
|
123
|
+
return null;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = await main({ input: { orderId: "o-1" } });
|
|
127
|
+
|
|
128
|
+
expect(workflowMock.triggeredJobs).toEqual([
|
|
129
|
+
{ jobName: "validate-order", args: { orderId: "o-1" } },
|
|
130
|
+
{ jobName: "process-payment", args: { orderId: "o-1" } },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`workflowMock` also supports order-based responses:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// Single response for the next triggerJobFunction call
|
|
139
|
+
workflowMock.enqueueResult({ valid: true });
|
|
140
|
+
|
|
141
|
+
// Multiple responses for subsequent calls (FIFO)
|
|
142
|
+
workflowMock.enqueueResults({ valid: true }, { txnId: "txn-1" });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### SecretManager Mock
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { secretmanagerMock } from "@tailor-platform/sdk/vitest";
|
|
149
|
+
|
|
150
|
+
beforeEach(() => secretmanagerMock.reset());
|
|
151
|
+
|
|
152
|
+
test("reads secrets from vault", async () => {
|
|
153
|
+
secretmanagerMock.setSecrets({
|
|
154
|
+
"my-vault": { API_KEY: "sk-123", DB_PASS: "secret" },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const key = await tailor.secretmanager.getSecret("my-vault", "API_KEY");
|
|
158
|
+
expect(key).toBe("sk-123");
|
|
159
|
+
expect(secretmanagerMock.calls).toEqual([
|
|
160
|
+
{ method: "getSecret", vault: "my-vault", name: "API_KEY" },
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### AuthConnection Mock
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { authconnectionMock } from "@tailor-platform/sdk/vitest";
|
|
169
|
+
|
|
170
|
+
beforeEach(() => authconnectionMock.reset());
|
|
171
|
+
|
|
172
|
+
test("returns configured token", async () => {
|
|
173
|
+
authconnectionMock.setTokens({
|
|
174
|
+
google: { access_token: "ya29.xxx", expires_in: 3600 },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const token = await tailor.authconnection.getConnectionToken("google");
|
|
178
|
+
expect(token.access_token).toBe("ya29.xxx");
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
When no token is configured for a connection, it returns `{ access_token: "mock-token" }`.
|
|
183
|
+
|
|
184
|
+
### IDP Mock
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import { idpMock } from "@tailor-platform/sdk/vitest";
|
|
188
|
+
|
|
189
|
+
beforeEach(() => idpMock.reset());
|
|
190
|
+
|
|
191
|
+
test("resolver-based", async () => {
|
|
192
|
+
idpMock.setResolver((method, args) => {
|
|
193
|
+
if (method === "user") return { id: "u-1", name: "alice", disabled: false };
|
|
194
|
+
return null; // falls back to defaults
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const client = new tailor.idp.Client({ namespace: "my-ns" });
|
|
198
|
+
const user = await client.user("u-1");
|
|
199
|
+
expect(user.name).toBe("alice");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("queue-based", async () => {
|
|
203
|
+
idpMock.enqueueResult({ id: "u-1", name: "alice", disabled: false });
|
|
204
|
+
|
|
205
|
+
const client = new tailor.idp.Client({ namespace: "my-ns" });
|
|
206
|
+
const user = await client.user("u-1");
|
|
207
|
+
expect(user.name).toBe("alice");
|
|
208
|
+
expect(idpMock.calls).toMatchObject([{ method: "user", namespace: "my-ns" }]);
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### File Mock
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { fileMock } from "@tailor-platform/sdk/vitest";
|
|
216
|
+
|
|
217
|
+
beforeEach(() => fileMock.reset());
|
|
218
|
+
|
|
219
|
+
test("mock file download", async () => {
|
|
220
|
+
fileMock.enqueueResult({
|
|
221
|
+
data: new Uint8Array([1, 2, 3]),
|
|
222
|
+
metadata: { contentType: "image/png", fileSize: 3, sha256sum: "abc", lastUploadedAt: "" },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = await tailordb.file.download("ns", "Doc", "attachment", "r-1");
|
|
226
|
+
expect(result.data).toEqual(new Uint8Array([1, 2, 3]));
|
|
227
|
+
expect(fileMock.calls).toMatchObject([{ method: "download", recordId: "r-1" }]);
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Iconv Mock
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import { iconvMock } from "@tailor-platform/sdk/vitest";
|
|
235
|
+
|
|
236
|
+
beforeEach(() => iconvMock.reset());
|
|
237
|
+
|
|
238
|
+
test("mock encoding conversion", () => {
|
|
239
|
+
iconvMock.setResolver((method, args) => {
|
|
240
|
+
if (method === "decode") return "decoded-text";
|
|
241
|
+
return null; // falls back to default empty string
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = tailor.iconv.decode(new Uint8Array([0x48, 0x69]), "UTF-8");
|
|
245
|
+
expect(result).toBe("decoded-text");
|
|
246
|
+
expect(iconvMock.calls).toMatchObject([{ method: "decode" }]);
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Loading Secrets from Config
|
|
251
|
+
|
|
252
|
+
Pass a config path to load `defineSecretManager()` values into the mock:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
export default defineConfig({
|
|
256
|
+
plugins: [tailorRuntime({ config: "./tailor.config.ts" })],
|
|
257
|
+
test: { environment: "tailor-runtime" },
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
This makes `tailor.secretmanager.getSecret("vault", "key")` return the values defined in your config. You can still override with `secretmanagerMock.setSecrets()` in individual tests.
|
|
262
|
+
|
|
263
|
+
### Per-Project Configuration
|
|
264
|
+
|
|
265
|
+
Apply the runtime environment only to unit tests while keeping other test projects (e.g. e2e) in the default Node.js environment:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
export default defineConfig({
|
|
269
|
+
plugins: [tailorRuntime()],
|
|
270
|
+
test: {
|
|
271
|
+
projects: [
|
|
272
|
+
// `extends: true` is required so each project inherits the root-level
|
|
273
|
+
// `tailorRuntime()` plugin (transform hook + injected setup file).
|
|
274
|
+
// Without it, only the environment name rewrite applies — node:* import
|
|
275
|
+
// blocking and per-test global cleanup will silently not run.
|
|
276
|
+
{
|
|
277
|
+
extends: true,
|
|
278
|
+
test: {
|
|
279
|
+
name: "unit",
|
|
280
|
+
environment: "tailor-runtime",
|
|
281
|
+
include: ["src/**/*.test.ts"],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
extends: true,
|
|
286
|
+
test: {
|
|
287
|
+
name: "e2e",
|
|
288
|
+
include: ["e2e/**/*.test.ts"],
|
|
289
|
+
globalSetup: "e2e/globalSetup.ts",
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Known Limitations
|
|
298
|
+
|
|
299
|
+
- **`process` and `require`** are not removed or blocked. Vitest's internal runner depends on them extensively. On the real platform runtime, they do not exist.
|
|
300
|
+
|
|
11
301
|
## Unit Tests
|
|
12
302
|
|
|
13
|
-
Unit tests
|
|
303
|
+
Unit tests call `.body()` (or `.trigger()`) directly on a resolver, workflow job, or executor and stub any platform-provided globals they touch.
|
|
304
|
+
|
|
305
|
+
### Testing Resolvers
|
|
14
306
|
|
|
15
|
-
|
|
307
|
+
#### Simple resolver
|
|
16
308
|
|
|
17
|
-
|
|
309
|
+
For pure logic with no external dependencies, invoke `.body()` directly:
|
|
18
310
|
|
|
19
311
|
```typescript
|
|
20
|
-
import { unauthenticatedTailorUser } from "@tailor-platform/sdk";
|
|
312
|
+
import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test";
|
|
313
|
+
import { describe, expect, test } from "vitest";
|
|
21
314
|
import resolver from "../src/resolver/add";
|
|
22
315
|
|
|
23
316
|
describe("add resolver", () => {
|
|
24
|
-
test("
|
|
317
|
+
test("adds two numbers", async () => {
|
|
25
318
|
const result = await resolver.body({
|
|
26
319
|
input: { left: 1, right: 2 },
|
|
27
320
|
user: unauthenticatedTailorUser,
|
|
321
|
+
env: {},
|
|
28
322
|
});
|
|
29
323
|
expect(result).toBe(3);
|
|
30
324
|
});
|
|
31
325
|
});
|
|
32
326
|
```
|
|
33
327
|
|
|
34
|
-
**
|
|
328
|
+
**Use when:** calculations, data transformations, anything that does not hit the database.
|
|
35
329
|
|
|
36
|
-
|
|
37
|
-
- **Best for:** Calculations, data transformations without database dependencies
|
|
330
|
+
#### Mocking the TailorDB client
|
|
38
331
|
|
|
39
|
-
|
|
332
|
+
Stub the global `tailordb.Client` and queue raw query results in order. Best for resolvers that issue a short, predictable query sequence:
|
|
40
333
|
|
|
41
|
-
|
|
334
|
+
> If you are running with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta), `tailordb.Client` is auto-injected — drive it with `tailordbMock` instead of `vi.stubGlobal()`.
|
|
42
335
|
|
|
43
336
|
```typescript
|
|
44
|
-
import { unauthenticatedTailorUser } from "@tailor-platform/sdk";
|
|
337
|
+
import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test";
|
|
338
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
|
45
339
|
import resolver from "../src/resolver/incrementUserAge";
|
|
46
340
|
|
|
47
341
|
describe("incrementUserAge resolver", () => {
|
|
@@ -58,25 +352,20 @@ describe("incrementUserAge resolver", () => {
|
|
|
58
352
|
),
|
|
59
353
|
});
|
|
60
354
|
});
|
|
355
|
+
afterAll(() => vi.unstubAllGlobals());
|
|
356
|
+
afterEach(() => mockQueryObject.mockReset());
|
|
61
357
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
mockQueryObject.
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("basic functionality", async () => {
|
|
71
|
-
// Mock database responses for each query in sequence
|
|
72
|
-
mockQueryObject.mockResolvedValueOnce({}); // Begin transaction
|
|
73
|
-
mockQueryObject.mockResolvedValueOnce({ rows: [{ age: 30 }] }); // Select
|
|
74
|
-
mockQueryObject.mockResolvedValueOnce({}); // Update
|
|
75
|
-
mockQueryObject.mockResolvedValueOnce({}); // Commit
|
|
358
|
+
test("increments age inside a transaction", async () => {
|
|
359
|
+
// BEGIN → SELECT → UPDATE → COMMIT
|
|
360
|
+
mockQueryObject.mockResolvedValueOnce({});
|
|
361
|
+
mockQueryObject.mockResolvedValueOnce({ rows: [{ age: 30 }] });
|
|
362
|
+
mockQueryObject.mockResolvedValueOnce({});
|
|
363
|
+
mockQueryObject.mockResolvedValueOnce({});
|
|
76
364
|
|
|
77
365
|
const result = await resolver.body({
|
|
78
366
|
input: { email: "test@example.com" },
|
|
79
367
|
user: unauthenticatedTailorUser,
|
|
368
|
+
env: {},
|
|
80
369
|
});
|
|
81
370
|
|
|
82
371
|
expect(result).toEqual({ oldAge: 30, newAge: 31 });
|
|
@@ -85,114 +374,79 @@ describe("incrementUserAge resolver", () => {
|
|
|
85
374
|
});
|
|
86
375
|
```
|
|
87
376
|
|
|
88
|
-
**
|
|
377
|
+
**Use when:** the business logic runs a few fixed queries and you want to assert the exact call sequence.
|
|
89
378
|
|
|
90
|
-
|
|
91
|
-
- Verify database interaction flow (transactions, queries)
|
|
92
|
-
- Test transaction rollback scenarios
|
|
93
|
-
- **Best for:** Business logic with simple database operations
|
|
379
|
+
#### Extracting DB operations (dependency injection)
|
|
94
380
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Extract database operations into a `DbOperations` interface, allowing business logic to be tested independently from Kysely implementation.
|
|
98
|
-
|
|
99
|
-
First, structure your resolver to accept database operations:
|
|
381
|
+
Once the logic gets more involved, mocking raw SQL calls becomes brittle. Push database access behind a `DbOperations` interface and test the pure function that uses it:
|
|
100
382
|
|
|
101
383
|
```typescript
|
|
102
|
-
|
|
103
|
-
import {
|
|
384
|
+
// src/resolver/decrementUserAge.ts
|
|
385
|
+
import type { Selectable } from "@tailor-platform/sdk/kysely";
|
|
386
|
+
import type { Namespace } from "../generated/db";
|
|
104
387
|
|
|
105
388
|
export interface DbOperations {
|
|
106
|
-
transaction: (fn: (ops: DbOperations) => Promise<
|
|
107
|
-
getUser: (email: string, forUpdate: boolean) => Promise<
|
|
108
|
-
updateUser: (user:
|
|
389
|
+
transaction: <T>(fn: (ops: DbOperations) => Promise<T>) => Promise<T>;
|
|
390
|
+
getUser: (email: string, forUpdate: boolean) => Promise<Selectable<Namespace["main-db"]["User"]>>;
|
|
391
|
+
updateUser: (user: Selectable<Namespace["main-db"]["User"]>) => Promise<void>;
|
|
109
392
|
}
|
|
110
393
|
|
|
111
|
-
export async function decrementUserAge(
|
|
112
|
-
|
|
113
|
-
dbOperations: DbOperations,
|
|
114
|
-
): Promise<{ oldAge: number; newAge: number }> {
|
|
115
|
-
let oldAge: number;
|
|
116
|
-
let newAge: number;
|
|
117
|
-
|
|
118
|
-
await dbOperations.transaction(async (ops) => {
|
|
394
|
+
export async function decrementUserAge(email: string, db: DbOperations) {
|
|
395
|
+
return await db.transaction(async (ops) => {
|
|
119
396
|
const user = await ops.getUser(email, true);
|
|
120
|
-
oldAge = user.age;
|
|
121
|
-
newAge = user.age - 1;
|
|
397
|
+
const oldAge = user.age;
|
|
398
|
+
const newAge = user.age - 1;
|
|
122
399
|
await ops.updateUser({ ...user, age: newAge });
|
|
400
|
+
return { oldAge, newAge };
|
|
123
401
|
});
|
|
124
|
-
|
|
125
|
-
return { oldAge, newAge };
|
|
126
402
|
}
|
|
127
|
-
|
|
128
|
-
export default createResolver({
|
|
129
|
-
name: "decrementUserAge",
|
|
130
|
-
operation: "mutation",
|
|
131
|
-
input: { email: t.string() },
|
|
132
|
-
body: async (context) => {
|
|
133
|
-
const db = getDB("tailordb");
|
|
134
|
-
const dbOperations = createDbOperations(db);
|
|
135
|
-
return await decrementUserAge(context.input.email, dbOperations);
|
|
136
|
-
},
|
|
137
|
-
output: t.object({ oldAge: t.number(), newAge: t.number() }),
|
|
138
|
-
});
|
|
139
403
|
```
|
|
140
404
|
|
|
141
|
-
|
|
405
|
+
The `resolver` template wires this into `createResolver` by implementing a `createDbOperations` helper (backed by Kysely) and passing it to `decrementUserAge`. See `src/resolver/updateUser.ts` in the template for the full file.
|
|
142
406
|
|
|
143
407
|
```typescript
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
),
|
|
408
|
+
// src/resolver/decrementUserAge.test.ts
|
|
409
|
+
import { describe, expect, test, vi } from "vitest";
|
|
410
|
+
import { type DbOperations, decrementUserAge } from "./decrementUserAge";
|
|
411
|
+
|
|
412
|
+
describe("decrementUserAge", () => {
|
|
413
|
+
test("decrements age", async () => {
|
|
414
|
+
const db = {
|
|
415
|
+
transaction: vi.fn(async (fn: (ops: DbOperations) => Promise<unknown>) => await fn(db)),
|
|
153
416
|
getUser: vi.fn().mockResolvedValue({ email: "test@example.com", age: 30 }),
|
|
154
417
|
updateUser: vi.fn(),
|
|
155
418
|
} as DbOperations;
|
|
156
419
|
|
|
157
|
-
const result = await decrementUserAge("test@example.com",
|
|
420
|
+
const result = await decrementUserAge("test@example.com", db);
|
|
158
421
|
|
|
159
422
|
expect(result).toEqual({ oldAge: 30, newAge: 29 });
|
|
160
|
-
expect(
|
|
161
|
-
expect(
|
|
162
|
-
expect.objectContaining({ age: 29 }),
|
|
163
|
-
);
|
|
423
|
+
expect(db.getUser).toHaveBeenCalledExactlyOnceWith("test@example.com", true);
|
|
424
|
+
expect(db.updateUser).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ age: 29 }));
|
|
164
425
|
});
|
|
165
426
|
});
|
|
166
427
|
```
|
|
167
428
|
|
|
168
|
-
**
|
|
429
|
+
**Use when:** multi-step business logic. The tests survive query rewrites because they assert high-level intent, not SQL shape.
|
|
169
430
|
|
|
170
|
-
|
|
171
|
-
- Mock high-level operations instead of low-level SQL queries
|
|
172
|
-
- **Best for:** Complex business logic with multiple database operations
|
|
431
|
+
#### Resolvers that resume a workflow
|
|
173
432
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
Use `setupWaitPointMock` to mock `tailor.workflow.resolve` when testing resolvers that resume a suspended workflow execution.
|
|
433
|
+
Resolvers that call `waitPoint.resolve(...)` delegate to `tailor.workflow.resolve` at runtime. With the `tailor-runtime` environment active, use `workflowMock.setResolveHandler` to drive the user-supplied callback and inspect `workflowMock.resolveCalls`:
|
|
177
434
|
|
|
178
435
|
```typescript
|
|
179
|
-
import {
|
|
180
|
-
import {
|
|
181
|
-
import
|
|
182
|
-
|
|
183
|
-
const TailorGlobal = globalThis as { tailor?: { workflow?: Record<string, unknown> } };
|
|
436
|
+
import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test";
|
|
437
|
+
import { workflowMock } from "@tailor-platform/sdk/vitest";
|
|
438
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
439
|
+
import resolver from "./resolveApproval";
|
|
184
440
|
|
|
185
441
|
describe("resolveApproval resolver", () => {
|
|
186
|
-
|
|
187
|
-
|
|
442
|
+
beforeEach(() => {
|
|
443
|
+
workflowMock.reset();
|
|
188
444
|
});
|
|
189
445
|
|
|
190
|
-
test("resolves approval", async () => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
expect(result).toEqual({ approved: true });
|
|
195
|
-
},
|
|
446
|
+
test("resolves approval with approved=true", async () => {
|
|
447
|
+
workflowMock.setResolveHandler((_executionId, _key, callback) => {
|
|
448
|
+
const result = callback({ message: "Please approve order order-1", orderId: "order-1" });
|
|
449
|
+
expect(result).toEqual({ approved: true });
|
|
196
450
|
});
|
|
197
451
|
|
|
198
452
|
const result = await resolver.body({
|
|
@@ -202,163 +456,195 @@ describe("resolveApproval resolver", () => {
|
|
|
202
456
|
});
|
|
203
457
|
|
|
204
458
|
expect(result).toEqual({ resolved: true });
|
|
205
|
-
expect(resolveCalls).
|
|
206
|
-
expect(resolveCalls[0]).toEqual({ executionId: "exec-1", key: "approval" });
|
|
459
|
+
expect(workflowMock.resolveCalls).toEqual([{ executionId: "exec-1", key: "approval" }]);
|
|
207
460
|
});
|
|
208
461
|
});
|
|
209
462
|
```
|
|
210
463
|
|
|
211
|
-
|
|
464
|
+
`setResolveHandler` receives `(executionId, key, callback)` and decides whether to invoke the callback — that's how you assert the value returned to the suspended job.
|
|
212
465
|
|
|
213
|
-
|
|
214
|
-
- Clean up mocks in `afterEach` by deleting `TailorGlobal.tailor`
|
|
215
|
-
- **Best for:** Resolvers that resume suspended workflow executions
|
|
466
|
+
### Testing Executors
|
|
216
467
|
|
|
217
|
-
|
|
468
|
+
Function-kind executors expose their handler as `executor.operation.body(args)`. The shape of `args` is determined by the trigger — for example, `recordCreatedTrigger({ type: user })` produces `{ newRecord }` typed against the type's output. GraphQL, webhook, and workflow operation kinds are declarative and don't expose a user-authored body to test.
|
|
218
469
|
|
|
219
|
-
|
|
470
|
+
The `executor` template extracts shared DB access into a helper (`shared.ts`) and tests the helper directly against a mocked `tailordb.Client` (same TailorDB-mocking pattern as the resolver section). Executor handlers themselves stay thin and can be tested by spying on the helper:
|
|
220
471
|
|
|
221
472
|
```typescript
|
|
222
|
-
import
|
|
473
|
+
import { describe, expect, test, vi } from "vitest";
|
|
474
|
+
import onUserCreated from "./onUserCreated";
|
|
475
|
+
import * as shared from "./shared";
|
|
476
|
+
|
|
477
|
+
describe("onUserCreated executor", () => {
|
|
478
|
+
test("creates an audit log with the new user's name and email", async () => {
|
|
479
|
+
const createAuditLog = vi.spyOn(shared, "createAuditLog").mockResolvedValue(undefined);
|
|
480
|
+
|
|
481
|
+
if (onUserCreated.operation.kind !== "function") {
|
|
482
|
+
throw new Error("expected function operation");
|
|
483
|
+
}
|
|
484
|
+
await onUserCreated.operation.body({
|
|
485
|
+
newRecord: {
|
|
486
|
+
id: "user-1",
|
|
487
|
+
name: "Alice",
|
|
488
|
+
email: "alice@example.com",
|
|
489
|
+
role: "ADMIN",
|
|
490
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
491
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
492
|
+
},
|
|
493
|
+
});
|
|
223
494
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
495
|
+
expect(createAuditLog).toHaveBeenCalledExactlyOnceWith({
|
|
496
|
+
action: "USER_CREATED",
|
|
497
|
+
entityType: "User",
|
|
498
|
+
entityId: "user-1",
|
|
499
|
+
message: "Admin user created: Alice (alice@example.com)",
|
|
500
|
+
});
|
|
228
501
|
});
|
|
229
502
|
});
|
|
230
503
|
```
|
|
231
504
|
|
|
232
|
-
|
|
505
|
+
To exercise the full chain (executor → helper → TailorDB), drop the spy and stub the global `tailordb.Client` instead, exactly as shown for resolvers.
|
|
233
506
|
|
|
234
|
-
|
|
507
|
+
### Testing Workflow Jobs
|
|
235
508
|
|
|
236
|
-
|
|
237
|
-
import { afterEach, vi } from "vitest";
|
|
238
|
-
import workflow, { addNumbers, calculate, multiplyNumbers } from "./workflows/calculation";
|
|
509
|
+
Workflow jobs expose the same `.body()` entrypoint as resolvers, plus `.trigger()` for calling them from another job or a test.
|
|
239
510
|
|
|
240
|
-
|
|
241
|
-
afterEach(() => {
|
|
242
|
-
vi.restoreAllMocks();
|
|
243
|
-
});
|
|
511
|
+
#### Simple job
|
|
244
512
|
|
|
245
|
-
|
|
246
|
-
// Mock the trigger methods for dependent jobs
|
|
247
|
-
vi.spyOn(addNumbers, "trigger").mockResolvedValue(5);
|
|
248
|
-
vi.spyOn(multiplyNumbers, "trigger").mockResolvedValue(10);
|
|
513
|
+
Call `.body()` with the input and a stub `{ env: {} }`:
|
|
249
514
|
|
|
250
|
-
|
|
515
|
+
```typescript
|
|
516
|
+
import { describe, expect, test } from "vitest";
|
|
517
|
+
import { validateOrder } from "./order-fulfillment";
|
|
251
518
|
|
|
252
|
-
|
|
253
|
-
|
|
519
|
+
describe("validateOrder", () => {
|
|
520
|
+
test("accepts a valid order", () => {
|
|
521
|
+
const result = validateOrder.body({ orderId: "order-1", amount: 100 }, { env: {} });
|
|
522
|
+
expect(result).toEqual({ valid: true, orderId: "order-1" });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("rejects a non-positive amount", () => {
|
|
526
|
+
expect(() => validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {} })).toThrow(
|
|
527
|
+
"Order amount must be positive",
|
|
528
|
+
);
|
|
254
529
|
});
|
|
255
530
|
});
|
|
256
531
|
```
|
|
257
532
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
### Workflow Integration Tests with `.trigger()`
|
|
533
|
+
#### Jobs that trigger other jobs
|
|
261
534
|
|
|
262
|
-
|
|
535
|
+
Spy on each dependent job's `.trigger()` to replace it with a deterministic result:
|
|
263
536
|
|
|
264
537
|
```typescript
|
|
265
|
-
import {
|
|
266
|
-
import {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
538
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
539
|
+
import { fulfillOrder, processPayment, sendConfirmation, validateOrder } from "./order-fulfillment";
|
|
540
|
+
|
|
541
|
+
describe("fulfillOrder", () => {
|
|
542
|
+
afterEach(() => vi.restoreAllMocks());
|
|
543
|
+
|
|
544
|
+
test("chains validate → pay → confirm", async () => {
|
|
545
|
+
vi.spyOn(validateOrder, "trigger").mockResolvedValue({ valid: true, orderId: "order-1" });
|
|
546
|
+
vi.spyOn(processPayment, "trigger").mockResolvedValue({
|
|
547
|
+
transactionId: "txn-order-1",
|
|
548
|
+
amount: 100,
|
|
549
|
+
status: "completed",
|
|
550
|
+
});
|
|
551
|
+
vi.spyOn(sendConfirmation, "trigger").mockResolvedValue({
|
|
552
|
+
orderId: "order-1",
|
|
553
|
+
transactionId: "txn-order-1",
|
|
554
|
+
confirmed: true,
|
|
555
|
+
});
|
|
277
556
|
|
|
278
|
-
|
|
279
|
-
const result = await workflow.mainJob.trigger({ a: 3, b: 4 });
|
|
557
|
+
const result = await fulfillOrder.body({ orderId: "order-1", amount: 100 }, { env: {} });
|
|
280
558
|
|
|
281
|
-
expect(
|
|
559
|
+
expect(validateOrder.trigger).toHaveBeenCalledWith({ orderId: "order-1", amount: 100 });
|
|
560
|
+
expect(result).toMatchObject({ confirmed: true, paymentStatus: "completed" });
|
|
282
561
|
});
|
|
283
562
|
});
|
|
284
563
|
```
|
|
285
564
|
|
|
286
|
-
**
|
|
565
|
+
**Use when:** you want to isolate the orchestrating job from its dependencies.
|
|
287
566
|
|
|
288
|
-
|
|
289
|
-
- Use `vi.spyOn(job, "trigger").mockResolvedValue(...)` to mock dependent jobs when they don't need `env`
|
|
290
|
-
- If dependent jobs require `env`, use `vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...)` and call `.trigger()` instead of mocking
|
|
291
|
-
- Use `workflow.mainJob.trigger()` to execute the full workflow chain and get the result
|
|
292
|
-
- **Best for:** Testing workflow orchestration and job dependencies
|
|
567
|
+
#### Jobs that wait on approval
|
|
293
568
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
Use `setupWaitPointMock` to mock `tailor.workflow.wait` when testing jobs that suspend on wait points:
|
|
569
|
+
`.wait()` calls delegate to `tailor.workflow.wait`. With the `tailor-runtime` environment active, use `workflowMock.setWaitHandler` to drive each branch and inspect `workflowMock.waitCalls`:
|
|
297
570
|
|
|
298
571
|
```typescript
|
|
299
|
-
import {
|
|
300
|
-
import {
|
|
301
|
-
import { processWithApproval } from "./
|
|
302
|
-
|
|
303
|
-
const TailorGlobal = globalThis as { tailor?: { workflow?: Record<string, unknown> } };
|
|
572
|
+
import { workflowMock } from "@tailor-platform/sdk/vitest";
|
|
573
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
574
|
+
import { processWithApproval } from "./approval";
|
|
304
575
|
|
|
305
|
-
describe("
|
|
306
|
-
|
|
307
|
-
|
|
576
|
+
describe("processWithApproval", () => {
|
|
577
|
+
beforeEach(() => {
|
|
578
|
+
workflowMock.reset();
|
|
308
579
|
});
|
|
309
580
|
|
|
310
|
-
test("approved
|
|
311
|
-
|
|
312
|
-
onWait: (_key, _payload) => ({ approved: true }),
|
|
313
|
-
});
|
|
581
|
+
test("returns approved status when .wait() resolves positively", async () => {
|
|
582
|
+
workflowMock.setWaitHandler({ approved: true });
|
|
314
583
|
|
|
315
584
|
const result = await processWithApproval.body({ orderId: "order-1" }, { env: {} });
|
|
316
585
|
|
|
317
586
|
expect(result).toEqual({ orderId: "order-1", status: "approved" });
|
|
318
|
-
expect(waitCalls).
|
|
319
|
-
expect(waitCalls[0]).toEqual({
|
|
587
|
+
expect(workflowMock.waitCalls[0]).toEqual({
|
|
320
588
|
key: "approval",
|
|
321
589
|
payload: { message: "Please approve order order-1", orderId: "order-1" },
|
|
322
590
|
});
|
|
323
591
|
});
|
|
324
592
|
|
|
325
|
-
test("rejected
|
|
326
|
-
|
|
327
|
-
onWait: () => ({ approved: false }),
|
|
328
|
-
});
|
|
593
|
+
test("returns rejected status when .wait() resolves negatively", async () => {
|
|
594
|
+
workflowMock.setWaitHandler({ approved: false });
|
|
329
595
|
|
|
330
596
|
const result = await processWithApproval.body({ orderId: "order-2" }, { env: {} });
|
|
331
597
|
|
|
332
|
-
expect(result).
|
|
598
|
+
expect(result.status).toBe("rejected");
|
|
333
599
|
});
|
|
334
600
|
});
|
|
335
601
|
```
|
|
336
602
|
|
|
337
|
-
|
|
603
|
+
`setWaitHandler` accepts a static value (returned from every `.wait()` call) or a function `(key, payload) => unknown` to compute one per call. `waitCalls` captures the `key` and `payload` passed in.
|
|
604
|
+
|
|
605
|
+
#### Running a full workflow locally
|
|
606
|
+
|
|
607
|
+
To exercise the full chain without any mocking, call `workflow.mainJob.trigger()`. Dependent jobs run their real `.body()` functions. Set `WORKFLOW_TEST_ENV_KEY` first so triggered jobs see the workflow env:
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
import { WORKFLOW_TEST_ENV_KEY } from "@tailor-platform/sdk/test";
|
|
611
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
612
|
+
import workflow from "./order-fulfillment";
|
|
613
|
+
|
|
614
|
+
describe("order-fulfillment workflow", () => {
|
|
615
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
616
|
+
|
|
617
|
+
test("mainJob.trigger() executes all jobs", async () => {
|
|
618
|
+
vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({}));
|
|
619
|
+
|
|
620
|
+
const result = await workflow.mainJob.trigger({ orderId: "order-3", amount: 300 });
|
|
621
|
+
|
|
622
|
+
expect(result).toMatchObject({ confirmed: true, paymentStatus: "completed" });
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
```
|
|
338
626
|
|
|
339
|
-
|
|
340
|
-
- Clean up mocks in `afterEach` by deleting `TailorGlobal.tailor`
|
|
341
|
-
- **Best for:** Jobs that suspend on wait points for human-in-the-loop approval
|
|
627
|
+
**Use when:** you want to verify orchestration end to end without the cost of a real deployment.
|
|
342
628
|
|
|
343
629
|
## End-to-End (E2E) Tests
|
|
344
630
|
|
|
345
|
-
E2E tests
|
|
631
|
+
E2E tests run against a deployed Tailor Platform application. They exercise the full stack — GraphQL, TailorDB, auth, workflows — end to end.
|
|
346
632
|
|
|
347
|
-
|
|
633
|
+
The `workflow` template ships a complete `e2e/` directory (`globalSetup.ts`, `workflow.test.ts`, `resolver.test.ts`) that you can copy.
|
|
348
634
|
|
|
349
|
-
|
|
635
|
+
### Install a GraphQL client
|
|
350
636
|
|
|
351
637
|
```bash
|
|
352
638
|
pnpm add -D graphql-request
|
|
353
639
|
```
|
|
354
640
|
|
|
355
|
-
|
|
641
|
+
### Global setup
|
|
356
642
|
|
|
357
|
-
|
|
643
|
+
Resolve the deployed URL and a machine-user token, and expose them to tests via `inject`:
|
|
358
644
|
|
|
359
645
|
```typescript
|
|
360
646
|
// e2e/globalSetup.ts
|
|
361
|
-
import {
|
|
647
|
+
import { getMachineUserToken, show } from "@tailor-platform/sdk/cli";
|
|
362
648
|
import type { TestProject } from "vitest/node";
|
|
363
649
|
|
|
364
650
|
declare module "vitest" {
|
|
@@ -370,17 +656,13 @@ declare module "vitest" {
|
|
|
370
656
|
|
|
371
657
|
export async function setup(project: TestProject) {
|
|
372
658
|
const app = await show();
|
|
373
|
-
const tokens = await
|
|
374
|
-
name: "admin",
|
|
375
|
-
});
|
|
659
|
+
const tokens = await getMachineUserToken({ name: "admin" });
|
|
376
660
|
project.provide("url", app.url);
|
|
377
661
|
project.provide("token", tokens.accessToken);
|
|
378
662
|
}
|
|
379
663
|
```
|
|
380
664
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
Create tests that use injected credentials to send real queries to your deployed application:
|
|
665
|
+
### Resolver E2E test
|
|
384
666
|
|
|
385
667
|
```typescript
|
|
386
668
|
// e2e/resolver.test.ts
|
|
@@ -389,75 +671,80 @@ import { gql, GraphQLClient } from "graphql-request";
|
|
|
389
671
|
import { describe, expect, inject, test } from "vitest";
|
|
390
672
|
|
|
391
673
|
function createGraphQLClient() {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
headers: {
|
|
395
|
-
Authorization: `Bearer ${inject("token")}`,
|
|
396
|
-
},
|
|
674
|
+
return new GraphQLClient(new URL("/query", inject("url")).href, {
|
|
675
|
+
headers: { Authorization: `Bearer ${inject("token")}` },
|
|
397
676
|
errorPolicy: "all",
|
|
398
677
|
});
|
|
399
678
|
}
|
|
400
679
|
|
|
401
|
-
describe("
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
describe("incrementUserAge", () => {
|
|
405
|
-
const uuid = randomUUID();
|
|
680
|
+
describe("incrementUserAge", () => {
|
|
681
|
+
const client = createGraphQLClient();
|
|
682
|
+
const email = `alice-${randomUUID()}@example.com`;
|
|
406
683
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
email: "alice-${uuid}@example.com"
|
|
413
|
-
age: 30
|
|
414
|
-
}) {
|
|
684
|
+
test("prepares the user", async () => {
|
|
685
|
+
const res = await client.rawRequest(
|
|
686
|
+
gql`
|
|
687
|
+
mutation ($input: UserCreateInput!) {
|
|
688
|
+
createUser(input: $input) {
|
|
415
689
|
id
|
|
416
690
|
}
|
|
417
691
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
692
|
+
`,
|
|
693
|
+
{ input: { name: "alice", email, age: 30 } },
|
|
694
|
+
);
|
|
695
|
+
expect(res.errors).toBeUndefined();
|
|
696
|
+
});
|
|
422
697
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
698
|
+
test("increments the user's age", async () => {
|
|
699
|
+
const res = await client.rawRequest(
|
|
700
|
+
gql`
|
|
701
|
+
mutation ($email: String!) {
|
|
702
|
+
incrementUserAge(email: $email) {
|
|
427
703
|
oldAge
|
|
428
704
|
newAge
|
|
429
705
|
}
|
|
430
706
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
});
|
|
437
|
-
});
|
|
707
|
+
`,
|
|
708
|
+
{ email },
|
|
709
|
+
);
|
|
710
|
+
expect(res.errors).toBeUndefined();
|
|
711
|
+
expect(res.data).toEqual({ incrementUserAge: { oldAge: 30, newAge: 31 } });
|
|
438
712
|
});
|
|
439
713
|
});
|
|
440
714
|
```
|
|
441
715
|
|
|
442
|
-
|
|
716
|
+
### Workflow E2E test
|
|
443
717
|
|
|
444
|
-
|
|
718
|
+
Use `startWorkflow` from the CLI helpers. It starts the workflow on the deployed platform and returns an `executionId` plus a `wait()` that blocks until the run completes:
|
|
445
719
|
|
|
446
720
|
```typescript
|
|
447
|
-
|
|
721
|
+
// e2e/workflow.test.ts
|
|
722
|
+
import { randomUUID } from "node:crypto";
|
|
723
|
+
import { startWorkflow } from "@tailor-platform/sdk/cli";
|
|
724
|
+
import { describe, expect, test } from "vitest";
|
|
725
|
+
import config from "../tailor.config";
|
|
726
|
+
import userProfileSync from "../src/workflow/sync-profile";
|
|
727
|
+
|
|
728
|
+
describe("user-profile-sync workflow", () => {
|
|
729
|
+
test("executes end to end", { timeout: 180_000 }, async () => {
|
|
730
|
+
const { executionId, wait } = await startWorkflow({
|
|
731
|
+
workflow: userProfileSync,
|
|
732
|
+
authInvoker: config.auth.invoker("admin"),
|
|
733
|
+
arg: {
|
|
734
|
+
name: "workflow-test",
|
|
735
|
+
email: `wf-${randomUUID()}@example.com`,
|
|
736
|
+
age: 25,
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
console.log(`execution id: ${executionId}`);
|
|
448
740
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
741
|
+
const result = await wait();
|
|
742
|
+
expect(result).toMatchObject({
|
|
743
|
+
workflowName: "user-profile-sync",
|
|
744
|
+
status: "SUCCESS",
|
|
745
|
+
});
|
|
746
|
+
});
|
|
454
747
|
});
|
|
455
748
|
```
|
|
456
749
|
|
|
457
|
-
**
|
|
458
|
-
|
|
459
|
-
- Tests run against actual deployed application
|
|
460
|
-
- `inject("url")` and `inject("token")` provide deployment credentials automatically
|
|
461
|
-
- Machine user authentication enables API access without manual token management
|
|
462
|
-
- Verify database persistence and API contracts
|
|
463
|
-
- **Best for:** Integration testing, end-to-end API validation
|
|
750
|
+
**Use when:** verifying actual deployments, auth flows, schema migrations, and anything that depends on runtime platform behavior you cannot mock.
|