@arcote.tech/arc-testing 0.7.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/dist/index.js +121 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @arcote.tech/arc-testing
|
|
2
|
+
|
|
3
|
+
In-process test harness for Arc. Write **use-case-shaped** tests on `bun:test`
|
|
4
|
+
using the **same scope API the client uses** — no port, no mocks of the framework,
|
|
5
|
+
full type inference from your context.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createHarness } from "@arcote.tech/arc-testing";
|
|
9
|
+
|
|
10
|
+
const h = await createHarness(myContext);
|
|
11
|
+
|
|
12
|
+
// declare current state
|
|
13
|
+
await h.seed.rows("plan", [{ _id: "plan_pro", price: 4900 }]);
|
|
14
|
+
|
|
15
|
+
// an authenticated actor — the SAME scope API as the app
|
|
16
|
+
const creator = h.scope(userToken, { projectId });
|
|
17
|
+
|
|
18
|
+
await creator.mutation().startCheckout({ projectId, planId: "plan_pro" });
|
|
19
|
+
const order = await creator.query().paymentOrder.findOne({ _id: orderId });
|
|
20
|
+
|
|
21
|
+
// a webhook / route, in-process (real route boundary, no port)
|
|
22
|
+
await h.route("payuWebhook", { orderId, status: "COMPLETED" });
|
|
23
|
+
|
|
24
|
+
// drain the async listener cascade deterministically (no setTimeout)
|
|
25
|
+
await h.settle();
|
|
26
|
+
|
|
27
|
+
// assert events + state with native bun:test expect
|
|
28
|
+
expect(h.events.ofType("subscriptionActivated")).toHaveLength(1);
|
|
29
|
+
expect(await creator.query().subscription.findOne({ _id: projectId }))
|
|
30
|
+
.toMatchObject({ status: "active" });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
| | |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `createHarness(context)` | builds the harness (in-memory SQLite, declarative seeds run, listeners active) |
|
|
38
|
+
| `h.scope(token, params)` | an authenticated scope — `.mutation()` (= client `useMutation`), `.query()` (= client `useQuery`, awaited rows) |
|
|
39
|
+
| `h.anonymous()` | a scope with no identity (public caller) |
|
|
40
|
+
| `h.seed.rows(store, rows)` | raw insert into a view/aggregate store (like `withSeed`); rows need `_id` |
|
|
41
|
+
| `h.seed.event(type, payload, { as })` | publish through real projection handlers (and listeners) |
|
|
42
|
+
| `h.route(name, body, { headers, token })` | invoke a route/webhook in-process (real verification + protection gate) |
|
|
43
|
+
| `h.settle()` | resolve once the async listener cascade has fully drained |
|
|
44
|
+
| `h.events` | typed log: `.ofType(name)`, `.last(name)`, `.count(name)`, `.all()` |
|
|
45
|
+
|
|
46
|
+
`mutation()` / `query()` are the exact client `ScopeAPI` accessors (core's
|
|
47
|
+
`MutationExecutor` / `QueryAccessor`) — element names, params, payloads and rows
|
|
48
|
+
are all inferred from your context. No new naming layer, no `any`.
|
|
49
|
+
|
|
50
|
+
## Recipe: testing the real billing `buy-subscription-card` (platform)
|
|
51
|
+
|
|
52
|
+
Prerequisites (one-time, user-gated):
|
|
53
|
+
1. Publish `@arcote.tech/arc`, `@arcote.tech/arc-host`, `@arcote.tech/arc-testing` to npm.
|
|
54
|
+
2. Add `@arcote.tech/arc-testing` to `packages/billing` devDependencies (npm, **not** `link:` — `link:` breaks the Docker build).
|
|
55
|
+
|
|
56
|
+
PayU-free reaction test (avoids the external gateway by injecting the domain
|
|
57
|
+
event, then asserting the **real** billing listener cascade — grant → activation):
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
61
|
+
import { createHarness, type Harness } from "@arcote.tech/arc-testing";
|
|
62
|
+
import { getBillingContext } from "@ndt/billing";
|
|
63
|
+
import { userToken } from "@ndt/auth";
|
|
64
|
+
|
|
65
|
+
describe("buy-subscription-card (billing reaction)", () => {
|
|
66
|
+
let h: Harness<ReturnType<typeof getBillingContext>>;
|
|
67
|
+
const accountId = "acc_test";
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => { h = await createHarness(getBillingContext()); });
|
|
70
|
+
|
|
71
|
+
it("paid order → grant → subscription activated", async () => {
|
|
72
|
+
const user = h.scope(userToken, { accountId, role: "user" });
|
|
73
|
+
|
|
74
|
+
// inject the 'payment completed' domain event with the caller's identity,
|
|
75
|
+
// then let the real billingPaymentGrant → billingSubscriptionActivation chain run
|
|
76
|
+
await h.seed.event(
|
|
77
|
+
"paymentOrderCompleted",
|
|
78
|
+
{ _id: "order_1", userId: accountId, amountPLNGrosz: 4900, kind: "subscription" },
|
|
79
|
+
{ as: { tokenName: "user", params: { accountId, role: "user" } } },
|
|
80
|
+
);
|
|
81
|
+
await h.settle();
|
|
82
|
+
|
|
83
|
+
expect(h.events.ofType("subscriptionActivated")).toHaveLength(1);
|
|
84
|
+
const sub = await user.query().subscription.findOne({ _id: accountId });
|
|
85
|
+
expect(sub).toMatchObject({ /* active subscription shape */ });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For the **full** checkout-through-webhook flow (`startCheckoutCardAsUser` →
|
|
91
|
+
`payuWebhook`), stub the PayU client the checkout/route modules call, then drive
|
|
92
|
+
it with `creator.mutation().startCheckoutCardAsUser(...)` and
|
|
93
|
+
`h.route("payuWebhook", payuBody)` exactly as the self-proof does
|
|
94
|
+
(`tests/buy-subscription-like.test.ts`).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// src/harness.ts
|
|
2
|
+
import {
|
|
3
|
+
BunSQLiteDatabase,
|
|
4
|
+
createSQLiteAdapterFactory
|
|
5
|
+
} from "@arcote.tech/arc-adapter-db-sqlite";
|
|
6
|
+
import { ContextHandler } from "@arcote.tech/arc-host";
|
|
7
|
+
|
|
8
|
+
// src/events.ts
|
|
9
|
+
function createEventLog(publisher) {
|
|
10
|
+
const records = [];
|
|
11
|
+
publisher.subscribe("*", (ev) => {
|
|
12
|
+
records.push({
|
|
13
|
+
id: ev.id,
|
|
14
|
+
type: ev.type,
|
|
15
|
+
payload: ev.payload,
|
|
16
|
+
createdAt: ev.createdAt ?? new Date,
|
|
17
|
+
authContext: ev.authContext ?? null
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
const matches = (recordType, query) => recordType === query || recordType.endsWith(`.${query}`);
|
|
21
|
+
return {
|
|
22
|
+
all: () => [...records],
|
|
23
|
+
ofType: (type) => records.filter((r) => matches(r.type, type)),
|
|
24
|
+
last: (type) => {
|
|
25
|
+
for (let i = records.length - 1;i >= 0; i--) {
|
|
26
|
+
if (matches(records[i].type, type))
|
|
27
|
+
return records[i];
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
},
|
|
31
|
+
count: (type) => records.filter((r) => matches(r.type, type)).length
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/scope.ts
|
|
36
|
+
import {
|
|
37
|
+
buildContextAccessor,
|
|
38
|
+
ScopedModel
|
|
39
|
+
} from "@arcote.tech/arc";
|
|
40
|
+
function createScope(model, name) {
|
|
41
|
+
const scoped = new ScopedModel(model, name);
|
|
42
|
+
const mutation = () => buildContextAccessor(scoped.context, scoped.getAdapters(), "mutateContext", (_descriptor, execute) => execute());
|
|
43
|
+
const query = () => buildContextAccessor(scoped.context, scoped.getAdapters(), "queryContext", (_descriptor, execute) => execute());
|
|
44
|
+
return {
|
|
45
|
+
mutation,
|
|
46
|
+
query,
|
|
47
|
+
setToken: (token) => scoped.setToken(token),
|
|
48
|
+
setIdentity: (tokenName, params) => {
|
|
49
|
+
scoped.getAdapters().authAdapter?.setDecoded({ tokenName, params });
|
|
50
|
+
},
|
|
51
|
+
scoped
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/seed.ts
|
|
56
|
+
function createSeed(dataStorage, publisher) {
|
|
57
|
+
let counter = 0;
|
|
58
|
+
return {
|
|
59
|
+
rows: async (store, rows) => {
|
|
60
|
+
const target = dataStorage.getStore(store);
|
|
61
|
+
await target.applyChanges(rows.map((data) => ({ type: "set", data })));
|
|
62
|
+
},
|
|
63
|
+
event: async (type, payload, opts) => {
|
|
64
|
+
await publisher.publish({
|
|
65
|
+
id: `seed_${type}_${++counter}`,
|
|
66
|
+
type,
|
|
67
|
+
payload,
|
|
68
|
+
createdAt: new Date,
|
|
69
|
+
authContext: opts?.as ?? null
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/harness.ts
|
|
76
|
+
var scopeCounter = 0;
|
|
77
|
+
async function createHarness(context) {
|
|
78
|
+
const factory = createSQLiteAdapterFactory(new BunSQLiteDatabase(":memory:"));
|
|
79
|
+
const contextHandler = new ContextHandler(context, factory(context));
|
|
80
|
+
await contextHandler.init();
|
|
81
|
+
const model = contextHandler.getModel();
|
|
82
|
+
const dataStorage = contextHandler.getDataStorage();
|
|
83
|
+
const publisher = contextHandler.getEventPublisher();
|
|
84
|
+
const events = createEventLog(publisher);
|
|
85
|
+
const seed = createSeed(dataStorage, publisher);
|
|
86
|
+
function scope(tokenOrName, params) {
|
|
87
|
+
if (typeof tokenOrName === "string") {
|
|
88
|
+
return createScope(model, tokenOrName);
|
|
89
|
+
}
|
|
90
|
+
const s = createScope(model, `${tokenOrName.name}_${++scopeCounter}`);
|
|
91
|
+
s.setIdentity(tokenOrName.name, params ?? {});
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
scope,
|
|
96
|
+
anonymous: () => createScope(model, `anon_${++scopeCounter}`),
|
|
97
|
+
seed,
|
|
98
|
+
events,
|
|
99
|
+
route: async (name, body, opts) => {
|
|
100
|
+
const response = await contextHandler.executeRoute(name, {
|
|
101
|
+
method: opts?.method,
|
|
102
|
+
body,
|
|
103
|
+
headers: opts?.headers,
|
|
104
|
+
query: opts?.query
|
|
105
|
+
}, opts?.token ?? null);
|
|
106
|
+
let json = null;
|
|
107
|
+
try {
|
|
108
|
+
json = await response.clone().json();
|
|
109
|
+
} catch {
|
|
110
|
+
json = null;
|
|
111
|
+
}
|
|
112
|
+
return { status: response.status, json, response };
|
|
113
|
+
},
|
|
114
|
+
settle: () => publisher.whenIdle?.() ?? Promise.resolve(),
|
|
115
|
+
destroy: () => dataStorage.destroy(),
|
|
116
|
+
contextHandler
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
createHarness
|
|
121
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arcote.tech/arc-testing",
|
|
3
|
+
"version": "0.7.27",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"author": "Przemysław Krasiński [arcote.tech]",
|
|
7
|
+
"description": "In-process test harness for Arc — write use-case-shaped tests on bun:test with the same scope API the client uses",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node --format esm --external @arcote.tech/arc --external @arcote.tech/arc-host --external @arcote.tech/arc-adapter-db-sqlite && bun run build:types",
|
|
23
|
+
"build:types": "tsc --emitDeclarationOnly --declaration --declarationMap --outDir ./dist",
|
|
24
|
+
"postbuild": "rm -f tsconfig.tsbuildinfo",
|
|
25
|
+
"type-check": "tsc --noEmit",
|
|
26
|
+
"test": "bun test"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@arcote.tech/arc": "^0.7.27",
|
|
30
|
+
"@arcote.tech/arc-host": "^0.7.27",
|
|
31
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.27"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "latest",
|
|
35
|
+
"typescript": "^5.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|