cabloy 5.1.124 → 5.1.125

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/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.124
1
+ 5.1.125
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.125
4
+
5
+ ### Features
6
+
7
+ - Update payment functionality.
8
+
3
9
  ## 5.1.124
4
10
 
5
11
  ### Improvements
@@ -25,6 +25,7 @@ const aiItems = [
25
25
  { text: 'Playbook: Contract Regeneration', link: '/ai/playbook-contract-regeneration' },
26
26
  { text: 'Playbook: Module Removal', link: '/ai/playbook-module-removal' },
27
27
  { text: 'Playbook: Metadata Refresh', link: '/ai/playbook-metadata-refresh' },
28
+ { text: 'Playbook: Technical Blog Authoring', link: '/ai/playbook-technical-blog-authoring' },
28
29
  { text: 'CLI for Agents', link: '/ai/cli-for-agents' },
29
30
  { text: 'Rules and Config', link: '/ai/rules-and-config' },
30
31
  { text: 'Edition Detection', link: '/ai/edition-detection' },
@@ -0,0 +1,158 @@
1
+ # Playbook: Technical Blog Authoring
2
+
3
+ Use this playbook when writing a technical blog about Cabloy, Vona, Zova, or an adjacent engineering problem. Its purpose is to help authors and agents turn a real reader concern into an accurate, approachable, and useful article.
4
+
5
+ A blog post is not a replacement for reference documentation. Use the post to establish a mental model, explain why a design choice matters, and help readers choose a next step. Link to the relevant documentation for the complete contract and implementation detail.
6
+
7
+ ## Start with the reader's problem
8
+
9
+ Open with a problem that readers can recognize in their existing practice, not with framework terminology or a product claim.
10
+
11
+ For example, a Vue-oriented article can begin with a reader encountering growing composables, fragmented state ownership, or unclear cache invalidation. It should acknowledge that the familiar tools are useful before explaining the architectural tradeoff they do not settle by themselves.
12
+
13
+ Then state one narrow thesis. A useful thesis does all of the following:
14
+
15
+ - names the change in perspective;
16
+ - limits what the article claims;
17
+ - avoids declaring one ecosystem universally superior.
18
+
19
+ For example:
20
+
21
+ > Zova retains Vue 3 as its reactive foundation while giving complex application code explicit owners for state, behavior, dependencies, and lifecycle.
22
+
23
+ Do not begin with claims such as “Framework X is obsolete,” “this is the only correct architecture,” or broad adoption and performance claims that the article cannot prove.
24
+
25
+ ## Research before drafting
26
+
27
+ Use this authority order for technical claims:
28
+
29
+ 1. Current Cabloy documentation, source code, and tests for Cabloy, Vona, and Zova behavior.
30
+ 2. First-party upstream documentation for Vue, TypeScript, TanStack Query, React, Angular, or another external ecosystem.
31
+ 3. Carefully labelled experience, interpretation, and recommendations when a fact cannot be established from the sources above.
32
+
33
+ Treat legacy documentation as input material, not unquestioned truth. If it conflicts with current source, prefer the source.
34
+
35
+ Classify each meaningful statement while drafting:
36
+
37
+ | Statement type | How to write it |
38
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
39
+ | Verified framework fact | State it precisely and link to current public docs or an authoritative upstream source. |
40
+ | Comparison or interpretation | Explain the authoring-model difference with qualified wording such as “typically,” “can,” or “is designed to.” |
41
+ | Experience or recommendation | Make its situational nature visible: “consider this when…”, “can be a good fit for…”, or “in this kind of project…”. |
42
+
43
+ Do not turn a representative example, a personal observation, or a framework goal into a universal ecosystem claim.
44
+
45
+ ## Use a problem-to-practice narrative
46
+
47
+ For beginner-oriented technical posts, use this progression:
48
+
49
+ 1. **Reader problem** — establish empathy with a concrete, familiar situation.
50
+ 2. **Mental-model shift** — introduce one focused thesis that reframes the problem.
51
+ 3. **Progressive examples** — teach one concept and one ownership boundary at a time.
52
+ 4. **Decision aid** — use a role table or comparison table only when it helps readers choose where something belongs.
53
+ 5. **Tradeoffs** — state the learning cost, non-fit cases, and assumptions directly.
54
+ 6. **Next step** — give readers a small experiment to try and a short list of authoritative further reading.
55
+
56
+ The article should answer both “what changes?” and “why should I care?” before adding advanced implementation detail.
57
+
58
+ When explaining architecture, prefer ownership questions over tool-name questions:
59
+
60
+ - Who owns this state or behavior?
61
+ - Who may depend on it?
62
+ - How long should it live?
63
+ - Which boundary owns caching, invalidation, persistence, or rendering?
64
+
65
+ This lets the article explain an architecture without becoming an API inventory.
66
+
67
+ ## Explain Zova in its own model first
68
+
69
+ For Zova articles, begin with the controller / bean / IoC model before translating it into generic Vue terms. Vue comparisons are useful orientation aids, but they should not replace Zova's own concepts.
70
+
71
+ Keep these accuracy rules in view:
72
+
73
+ - Vue 3 provides Zova's reactive foundation.
74
+ - A controller field is not reactive merely because it is a TypeScript class field; it is reactive because Zova creates the controller as a framework-managed reactive bean.
75
+ - Controllers, beans, models, services, scopes, and TSX have distinct roles. Do not flatten them into synonyms for a composable or store.
76
+ - A Model is not simply “Zova's Pinia.” It can own query, mutation, cache, persistence, invalidation, and SSR-related state concerns.
77
+ - Discuss Cabloy Basic and Cabloy Start separately only when the UI layer, available suites, project assets, generated outputs, or edition-specific workflow affects the article.
78
+
79
+ For factual background, start from these guides:
80
+
81
+ - [Frontend Foundation](/frontend/foundation)
82
+ - [Reading Zova for Vue Developers](/frontend/reading-zova-for-vue-developers)
83
+ - [Zova vs Vue 3 Comparison](/frontend/zova-vs-vue3-comparison)
84
+ - [IoC and Beans](/frontend/ioc-and-beans)
85
+ - [Model Architecture](/frontend/model-architecture)
86
+
87
+ ## Make examples earn their place
88
+
89
+ Use short examples that demonstrate one idea each. A helpful progression is:
90
+
91
+ 1. page-local controller state and behavior;
92
+ 2. a controller collaborating with an injected model or service;
93
+ 3. an extraction that becomes justified as responsibility or reuse grows.
94
+
95
+ Every example should make the ownership boundary easier to see. If an example requires advanced lifecycle details, data contracts, or several framework concepts to understand, defer it or split it into smaller examples.
96
+
97
+ When a snippet has been simplified, say so. Do not present illustrative pseudocode as a copy-ready framework contract. Verify code, APIs, commands, and links against current sources before publishing.
98
+
99
+ Comparison tables should name the dimension being compared and describe a typical or default authoring model, not imply that every project in either ecosystem has identical structure.
100
+
101
+ ## Keep the article balanced
102
+
103
+ Strong technical writing makes the boundary of its own recommendation clear.
104
+
105
+ Include the cost of the proposed approach. For example, a more structured controller/bean/model architecture may require authors to learn ownership, scope, and lifecycle rules; it may not be the most economical choice for a throwaway page or very small application.
106
+
107
+ Avoid framing flexible tools as defective. Explain the problem that emerges when complexity grows and the conditions under which a more explicit structure becomes valuable.
108
+
109
+ A practical article earns trust by telling readers both when to use an approach and when not to.
110
+
111
+ ## Package articles consistently
112
+
113
+ Use a kebab-case topic directory under the local blog workspace:
114
+
115
+ ```text
116
+ .assets/blogs/<topic-slug>/
117
+ ```
118
+
119
+ The canonical article should begin with frontmatter that makes its purpose and distribution metadata explicit. Use the fields relevant to the publishing target:
120
+
121
+ ```yaml
122
+ title:
123
+ titleEn:
124
+ subtitle:
125
+ summary:
126
+ tags:
127
+ slug:
128
+ date:
129
+ ```
130
+
131
+ When a bilingual release is in scope, create semantic sibling articles such as `article-zh.md` and `article-en.md`. Translate the thesis, caveats, examples, and linked evidence faithfully, but adapt titles, idioms, reader framing, and platform copy to the language rather than translating word-for-word.
132
+
133
+ When distribution is requested, keep publishing material separate from the canonical article. A publishing kit can contain:
134
+
135
+ - recommended and alternate titles;
136
+ - subtitle, short summary, long summary, and platform abstract;
137
+ - tags and selected cover;
138
+ - social or platform-specific copy;
139
+ - editorial and accuracy notes.
140
+
141
+ Treat cover copy and images as publishing deliverables, not evidence for technical claims. Use a readable 1600 × 900 cover when a social-preview image is needed.
142
+
143
+ ## Pre-publication checklist
144
+
145
+ Before publishing, verify all of the following:
146
+
147
+ - [ ] The opening names a real reader problem and does not begin as framework promotion.
148
+ - [ ] The thesis is narrow, accurate, and non-adversarial.
149
+ - [ ] Each factual claim has current source or first-party documentation support.
150
+ - [ ] Interpretations and recommendations are written as interpretations and recommendations.
151
+ - [ ] Examples are concise, current, and explicit about simplification where applicable.
152
+ - [ ] Comparison tables describe dimensions and typical models, not universal truths.
153
+ - [ ] Tradeoffs and non-fit situations are stated plainly.
154
+ - [ ] Terminology, edition notes, commands, URLs, and documentation links are current.
155
+ - [ ] Bilingual siblings and publishing assets are aligned when they are part of the requested deliverable.
156
+ - [ ] Further reading directs readers to authoritative documentation, including [Verification](/ai/verification) when the article describes a development workflow.
157
+
158
+ The goal is not merely a polished argument. It is to leave the reader with a trustworthy mental model and a safe, concrete next step.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.124",
3
+ "version": "5.1.125",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -418,6 +418,9 @@ importers:
418
418
  vona-module-test-image:
419
419
  specifier: workspace:^
420
420
  version: link:src/suite-vendor/a-test/modules/test-image
421
+ vona-module-test-pay:
422
+ specifier: workspace:^
423
+ version: link:src/suite-vendor/a-test/modules/test-pay
421
424
  vona-module-test-rest:
422
425
  specifier: workspace:^
423
426
  version: link:src/suite-vendor/a-test/modules/test-rest
@@ -1915,6 +1918,9 @@ importers:
1915
1918
  vona-module-test-file:
1916
1919
  specifier: ^5.0.8
1917
1920
  version: link:modules/test-file
1921
+ vona-module-test-pay:
1922
+ specifier: ^5.0.0
1923
+ version: link:modules/test-pay
1918
1924
 
1919
1925
  src/suite-vendor/a-test/modules/test-auth:
1920
1926
  devDependencies:
@@ -1961,6 +1967,22 @@ importers:
1961
1967
  specifier: ^6.1.3
1962
1968
  version: 6.1.3
1963
1969
 
1970
+ src/suite-vendor/a-test/modules/test-pay:
1971
+ dependencies:
1972
+ vona-module-a-pay:
1973
+ specifier: workspace:*
1974
+ version: link:../../../a-pay/modules/a-pay
1975
+ vona-module-pay-mock:
1976
+ specifier: workspace:*
1977
+ version: link:../../../a-pay/modules/pay-mock
1978
+ devDependencies:
1979
+ clean-package:
1980
+ specifier: ^2.2.0
1981
+ version: 2.2.0
1982
+ rimraf:
1983
+ specifier: ^6.1.3
1984
+ version: 6.1.3
1985
+
1964
1986
  src/suite-vendor/a-test/modules/test-rest:
1965
1987
  dependencies:
1966
1988
  '@faker-js/faker':
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-pay",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-pay-mock",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-pay-paypal",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-pay-stripe",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "vona-suite-a-pay",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "",
5
5
  "author": "",
6
6
  "files": [],
7
7
  "type": "module",
8
8
  "dependencies": {
9
- "vona-module-a-pay": "^5.0.1",
10
- "vona-module-pay-mock": "^5.0.1",
11
- "vona-module-pay-paypal": "^5.0.1",
12
- "vona-module-pay-stripe": "^5.0.1"
9
+ "vona-module-a-pay": "^5.0.2",
10
+ "vona-module-pay-mock": "^5.0.2",
11
+ "vona-module-pay-paypal": "^5.0.2",
12
+ "vona-module-pay-stripe": "^5.0.2"
13
13
  },
14
14
  "title": "a-pay"
15
15
  }
@@ -1,209 +0,0 @@
1
- import assert from 'node:assert';
2
- import { randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
5
-
6
- import type { IPaymentOutcomeEvent } from '../src/types/payment.ts';
7
-
8
- interface IFixture {
9
- userId?: number;
10
- paymentSessionId?: number;
11
- outboxEventIds: number[];
12
- }
13
-
14
- async function createFixture(suffix: string): Promise<IFixture> {
15
- const scope = app.scope('a-pay');
16
- const user = await app.bean.user.register({ name: `pay-outbox-${suffix}` }, true);
17
- const session = await scope.service.paymentSession.create({
18
- userId: user.id,
19
- payScene: 'commerce-payment:commerceOrder',
20
- businessReference: `business-${suffix}`,
21
- amountMinor: 1299,
22
- currency: 'USD',
23
- correlationId: `payment-${suffix}`,
24
- });
25
- return { userId: user.id as number, paymentSessionId: session.id as number, outboxEventIds: [] };
26
- }
27
-
28
- function createOutcomePayload(fixture: IFixture): IPaymentOutcomeEvent {
29
- return {
30
- eventId: randomUUID(),
31
- paymentSessionId: fixture.paymentSessionId!,
32
- businessReference: 'outbox-test',
33
- providerName: 'test-provider',
34
- state: 'succeeded',
35
- amountMinor: 1299,
36
- currency: 'USD',
37
- };
38
- }
39
-
40
- async function insertOutbox(
41
- fixture: IFixture,
42
- overrides?: Partial<{
43
- eventType: 'payment.outcome.v1';
44
- state: 'pending' | 'claimed';
45
- attemptCount: number;
46
- nextAttemptAt: Date;
47
- claimToken: string;
48
- claimedAt: Date;
49
- claimExpiresAt: Date;
50
- }>,
51
- ) {
52
- const event = await app.scope('a-pay').model.outboxEvent.insert({
53
- eventType: overrides?.eventType ?? 'payment.outcome.v1',
54
- paymentSessionId: fixture.paymentSessionId!,
55
- payload: createOutcomePayload(fixture),
56
- state: overrides?.state ?? 'pending',
57
- attemptCount: overrides?.attemptCount ?? 0,
58
- nextAttemptAt: overrides?.nextAttemptAt ?? new Date(Date.now() - 1_000),
59
- claimToken: overrides?.claimToken,
60
- claimedAt: overrides?.claimedAt,
61
- claimExpiresAt: overrides?.claimExpiresAt,
62
- });
63
- fixture.outboxEventIds.push(event.id as number);
64
- return event;
65
- }
66
-
67
- async function cleanup(fixture: IFixture) {
68
- const scope = app.scope('a-pay');
69
- for (const id of fixture.outboxEventIds) await scope.model.outboxEvent.delete({ id });
70
- if (fixture.paymentSessionId !== undefined) {
71
- await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
72
- await scope.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
73
- await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
74
- }
75
- if (fixture.userId !== undefined) {
76
- await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
77
- await app.bean.user.removeById(fixture.userId);
78
- }
79
- }
80
-
81
- describe('outbox.test.ts', { concurrency: false }, () => {
82
- it('claims, backs off, and exhausts durable delivery attempts', async () => {
83
- await app.bean.executor.mockCtx(async () => {
84
- const fixture = await createFixture(randomUUID().slice(0, 12));
85
- try {
86
- const event = await insertOutbox(fixture);
87
- const scope = app.scope('a-pay');
88
- const first = await scope.service.outbox.claim(event.id);
89
- assert.equal(first?.attemptCount, 1);
90
- assert.ok(first?.claimToken);
91
- const releaseStart = Date.now();
92
- await scope.service.outbox.release(
93
- event.id,
94
- first!.claimToken!,
95
- new Error('first failure'),
96
- );
97
- let persisted = await scope.model.outboxEvent.getById(event.id);
98
- assert.equal(persisted?.state, 'pending');
99
- assert.equal(persisted?.errorSummary, 'first failure');
100
- assert.ok((persisted?.nextAttemptAt?.getTime() ?? 0) >= releaseStart + 900);
101
- assert.equal(await scope.service.outbox.claim(event.id), undefined);
102
- await scope.model.outboxEvent.updateById(event.id, {
103
- nextAttemptAt: new Date(Date.now() - 1),
104
- });
105
- const second = await scope.service.outbox.claim(event.id);
106
- assert.equal(second?.attemptCount, 2);
107
- const secondReleaseStart = Date.now();
108
- await scope.service.outbox.release(
109
- event.id,
110
- second!.claimToken!,
111
- new Error('second failure'),
112
- );
113
- persisted = await scope.model.outboxEvent.getById(event.id);
114
- assert.ok((persisted?.nextAttemptAt?.getTime() ?? 0) >= secondReleaseStart + 1_900);
115
-
116
- const exhausted = await insertOutbox(fixture, { attemptCount: 10 });
117
- assert.equal(await scope.service.outbox.claim(exhausted.id), undefined);
118
- assert.equal((await scope.model.outboxEvent.getById(exhausted.id))?.state, 'failed');
119
- } finally {
120
- await cleanup(fixture);
121
- }
122
- });
123
- });
124
-
125
- it('retries a dispatch when the persisted payment scene is unavailable', async () => {
126
- await app.bean.executor.mockCtx(async () => {
127
- const fixture = await createFixture(randomUUID().slice(0, 12));
128
- try {
129
- const scope = app.scope('a-pay');
130
- await scope.model.paymentSession.updateById(fixture.paymentSessionId!, {
131
- payScene: 'test-payment',
132
- });
133
- const event = await insertOutbox(fixture);
134
- await scope.queue.outboxDispatch.pushAsync({ outboxEventId: event.id });
135
- const persisted = await scope.model.outboxEvent.getById(event.id);
136
- assert.equal(persisted?.state, 'pending');
137
- assert.equal(persisted?.attemptCount, 1);
138
- assert.match(persisted?.errorSummary ?? '', /payment scene not found/);
139
- } finally {
140
- await cleanup(fixture);
141
- }
142
- });
143
- });
144
-
145
- it('reclaims expired leases and fences stale claim tokens', async () => {
146
- await app.bean.executor.mockCtx(async () => {
147
- const fixture = await createFixture(randomUUID().slice(0, 12));
148
- try {
149
- const event = await insertOutbox(fixture);
150
- const scope = app.scope('a-pay');
151
- const first = await scope.service.outbox.claim(event.id);
152
- await scope.model.outboxEvent.updateById(event.id, {
153
- claimExpiresAt: new Date(Date.now() - 1),
154
- });
155
- const second = await scope.service.outbox.claim(event.id);
156
- assert.ok(second?.claimToken);
157
- assert.notEqual(second?.claimToken, first?.claimToken);
158
- assert.equal(second?.attemptCount, 2);
159
- assert.equal(
160
- await scope.service.outbox.markDispatched(event.id, first!.claimToken!),
161
- undefined,
162
- );
163
- assert.equal(
164
- await scope.service.outbox.release(event.id, first!.claimToken!, new Error('stale')),
165
- undefined,
166
- );
167
- assert.equal(
168
- (await scope.model.outboxEvent.getById(event.id))?.claimToken,
169
- second?.claimToken,
170
- );
171
- await scope.service.outbox.markDispatched(event.id, second!.claimToken!);
172
- const persisted = await scope.model.outboxEvent.getById(event.id);
173
- assert.equal(persisted?.state, 'dispatched');
174
- assert.equal(persisted?.claimToken, undefined);
175
- } finally {
176
- await cleanup(fixture);
177
- }
178
- });
179
- });
180
-
181
- it('rolls back enqueued durable rows with the enclosing transaction', async () => {
182
- await app.bean.executor.mockCtx(async () => {
183
- const fixture = await createFixture(randomUUID().slice(0, 12));
184
- try {
185
- const scope = app.scope('a-pay');
186
- const eventId = await assert.rejects(
187
- app.ctx.db.transaction.begin(async () => {
188
- const event = await scope.service.outbox.enqueue(
189
- fixture.paymentSessionId!,
190
- 'payment.outcome.v1',
191
- createOutcomePayload(fixture),
192
- );
193
- fixture.outboxEventIds.push(event.id as number);
194
- throw new Error(String(event.id));
195
- }),
196
- /\d+/,
197
- );
198
- assert.equal(eventId, undefined);
199
- const events = await scope.model.outboxEvent.select({
200
- where: { paymentSessionId: fixture.paymentSessionId },
201
- });
202
- assert.deepEqual(events, []);
203
- fixture.outboxEventIds = [];
204
- } finally {
205
- await cleanup(fixture);
206
- }
207
- });
208
- });
209
- });
@@ -1,153 +0,0 @@
1
- import assert from 'node:assert';
2
- import { randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
5
-
6
- interface IFixture {
7
- userId?: number;
8
- paymentSessionId?: number;
9
- }
10
-
11
- async function createFixture(expiresAt: Date): Promise<IFixture> {
12
- const scope = app.scope('a-pay');
13
- const suffix = randomUUID().slice(0, 12);
14
- const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
15
- const session = await scope.service.paymentSession.create({
16
- userId: user.id,
17
- payScene: 'commerce-payment:commerceOrder',
18
- businessReference: `business-${suffix}`,
19
- amountMinor: 1299,
20
- currency: 'USD',
21
- correlationId: `payment-${suffix}`,
22
- });
23
- await scope.model.paymentSession.updateById(session.id, { expiresAt });
24
- return { userId: user.id as number, paymentSessionId: session.id as number };
25
- }
26
-
27
- async function cleanup(fixture: IFixture) {
28
- const scope = app.scope('a-pay');
29
- if (fixture.paymentSessionId !== undefined) {
30
- await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
31
- await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
32
- }
33
- if (fixture.userId !== undefined) {
34
- await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
35
- await app.bean.user.removeById(fixture.userId);
36
- }
37
- }
38
-
39
- describe('paymentSession.test.ts', { concurrency: false }, () => {
40
- it('derives provider facts and expiry from the payment scene', async () => {
41
- await app.bean.executor.mockCtx(async () => {
42
- const scope = app.scope('a-pay');
43
- const suffix = randomUUID().slice(0, 12);
44
- const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
45
- const fixture: IFixture = { userId: user.id as number };
46
- try {
47
- const createdAt = Date.now();
48
- const session = await scope.service.paymentSession.create({
49
- userId: user.id,
50
- payScene: 'commerce-payment:commerceOrder',
51
- businessReference: `business-${suffix}`,
52
- amountMinor: 1299,
53
- currency: 'USD',
54
- correlationId: `payment-${suffix}`,
55
- });
56
- fixture.paymentSessionId = session.id as number;
57
- assert.equal(session.providerName, 'pay-mock:mock');
58
- assert.equal(session.clientName, 'default');
59
- assert.equal(session.environment, 'sandbox');
60
- assert.ok(session.expiresAt.getTime() >= createdAt + 30 * 60 * 1000);
61
- assert.ok(session.expiresAt.getTime() <= Date.now() + 30 * 60 * 1000);
62
- } finally {
63
- await cleanup(fixture);
64
- }
65
- });
66
- });
67
-
68
- it('rejects a currency outside the payment scene policy', async () => {
69
- await app.bean.executor.mockCtx(async () => {
70
- const scope = app.scope('a-pay');
71
- const suffix = randomUUID().slice(0, 12);
72
- const user = await app.bean.user.register({ name: `payment-session-${suffix}` }, true);
73
- const fixture: IFixture = { userId: user.id as number };
74
- try {
75
- await assert.rejects(
76
- scope.service.paymentSession.create({
77
- userId: user.id,
78
- payScene: 'commerce-payment:commerceOrder',
79
- businessReference: `business-${suffix}`,
80
- amountMinor: 1299,
81
- currency: 'EUR',
82
- correlationId: `payment-${suffix}`,
83
- }),
84
- { status: 422 },
85
- );
86
- assert.deepEqual(
87
- await scope.model.paymentSession.select({
88
- where: { correlationId: `payment-${suffix}` },
89
- }),
90
- [],
91
- );
92
- } finally {
93
- await cleanup(fixture);
94
- }
95
- });
96
- });
97
-
98
- it('starts only an unexpired created payment session', async () => {
99
- await app.bean.executor.mockCtx(async () => {
100
- const fixture = await createFixture(new Date(Date.now() + 60_000));
101
- try {
102
- const scope = app.scope('a-pay');
103
- const session = await scope.service.paymentSession.start(fixture.paymentSessionId!);
104
- assert.equal(session.state, 'requires_action');
105
- await assert.rejects(scope.service.paymentSession.start(fixture.paymentSessionId!), {
106
- status: 409,
107
- });
108
- } finally {
109
- await cleanup(fixture);
110
- }
111
- });
112
- });
113
-
114
- it('does not start an expired payment session', async () => {
115
- await app.bean.executor.mockCtx(async () => {
116
- const fixture = await createFixture(new Date(Date.now() - 1));
117
- try {
118
- const scope = app.scope('a-pay');
119
- await assert.rejects(scope.service.paymentSession.start(fixture.paymentSessionId!), {
120
- status: 409,
121
- });
122
- assert.equal(
123
- (await scope.model.paymentSession.getById(fixture.paymentSessionId!))?.state,
124
- 'created',
125
- );
126
- } finally {
127
- await cleanup(fixture);
128
- }
129
- });
130
- });
131
-
132
- it('allows only one competing start to call the provider', async () => {
133
- const fixture = await app.bean.executor.mockCtx(async () => {
134
- return await createFixture(new Date(Date.now() + 60_000));
135
- });
136
- try {
137
- const results = await Promise.allSettled([
138
- app.bean.executor.mockCtx(async () => {
139
- return await app.scope('a-pay').service.paymentSession.start(fixture.paymentSessionId!);
140
- }),
141
- app.bean.executor.mockCtx(async () => {
142
- return await app.scope('a-pay').service.paymentSession.start(fixture.paymentSessionId!);
143
- }),
144
- ]);
145
- assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
146
- assert.equal(results.filter(result => result.status === 'rejected').length, 1);
147
- } finally {
148
- await app.bean.executor.mockCtx(async () => {
149
- await cleanup(fixture);
150
- });
151
- }
152
- });
153
- });
@@ -1,278 +0,0 @@
1
- import assert from 'node:assert';
2
- import { createHmac, randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
5
-
6
- interface IFixture {
7
- userId?: number;
8
- paymentSessionId?: number;
9
- }
10
-
11
- async function createFixture(suffix: string): Promise<IFixture> {
12
- const scope = app.scope('a-pay');
13
- const user = await app.bean.user.register({ name: `pay-webhook-${suffix}` }, true);
14
- const session = await scope.service.paymentSession.create({
15
- userId: user.id,
16
- payScene: 'commerce-payment:commerceOrder',
17
- businessReference: `business-${suffix}`,
18
- amountMinor: 1299,
19
- currency: 'USD',
20
- correlationId: `payment-${suffix}`,
21
- });
22
- return { userId: user.id as number, paymentSessionId: session.id as number };
23
- }
24
-
25
- async function cleanup(fixture: IFixture) {
26
- const scope = app.scope('a-pay');
27
- if (fixture.paymentSessionId !== undefined) {
28
- await scope.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
29
- await scope.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
30
- await scope.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
31
- await scope.model.paymentSession.delete({ id: fixture.paymentSessionId });
32
- }
33
- if (fixture.userId !== undefined) {
34
- await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
35
- await app.bean.user.removeById(fixture.userId);
36
- }
37
- }
38
-
39
- function createRawBody(
40
- fixture: IFixture,
41
- suffix: string,
42
- state: 'succeeded' | 'failed' | 'cancelled' = 'succeeded',
43
- options?: { eventId?: string; providerPaymentId?: string; providerCaptureId?: string },
44
- ) {
45
- return JSON.stringify({
46
- state,
47
- amountMinor: 1299,
48
- eventType: `payment.${state}`,
49
- paymentSessionId: String(fixture.paymentSessionId),
50
- eventId: options?.eventId ?? `mock-${suffix}`,
51
- providerPaymentId: options?.providerPaymentId ?? `payment-${suffix}`,
52
- providerCaptureId: options?.providerCaptureId ?? `capture-${suffix}`,
53
- currency: 'USD',
54
- });
55
- }
56
-
57
- function createCommand(rawBody: string) {
58
- const body = JSON.parse(rawBody);
59
- return {
60
- providerName: 'pay-mock:mock',
61
- clientName: 'default',
62
- environment: 'sandbox' as const,
63
- rawBody,
64
- verified: {
65
- eventId: body.eventId,
66
- eventType: body.eventType,
67
- paymentSessionId: body.paymentSessionId,
68
- payment: {
69
- state: body.state,
70
- providerPaymentId: body.providerPaymentId,
71
- providerCaptureId: body.providerCaptureId,
72
- },
73
- summary: {
74
- amountMinor: body.amountMinor,
75
- currency: body.currency,
76
- },
77
- },
78
- };
79
- }
80
-
81
- function sign(rawBody: string, clientName: 'default' | 'secondary' = 'default') {
82
- const options = app.bean.payProvider.getOptions('pay-mock:mock', clientName);
83
- assert.equal(typeof options.secretWebhook, 'string');
84
- return createHmac('sha256', options.secretWebhook).update(rawBody).digest('hex');
85
- }
86
-
87
- function webhookUrl(providerName = 'pay-mock:mock', clientName = 'default') {
88
- const url = app.util.getAbsoluteUrlByApiPath(`/pay/webhook/${providerName}/${clientName}`);
89
- return url.startsWith('http') ? url : `http://127.0.0.1:${app.config.server.listen.port}${url}`;
90
- }
91
-
92
- async function countFacts(paymentSessionId: number) {
93
- const scope = app.scope('a-pay');
94
- const [inboxes, audits, outbox] = await Promise.all([
95
- scope.model.webhookInbox.select({ where: { paymentSessionId } }),
96
- scope.model.paymentAudit.select({ where: { paymentSessionId } }),
97
- scope.model.outboxEvent.select({ where: { paymentSessionId } }),
98
- ]);
99
- return { inboxes, audits, outbox };
100
- }
101
-
102
- describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
103
- it('accepts a raw-body signed HTTP webhook and rejects an invalid signature without mutation', async () => {
104
- const fixture: IFixture = {};
105
- try {
106
- await app.bean.executor.mockCtx(async () => {
107
- Object.assign(fixture, await createFixture(randomUUID().slice(0, 12)));
108
- });
109
- const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
110
- const url = webhookUrl();
111
- const invalid = await fetch(url, {
112
- method: 'POST',
113
- headers: {
114
- 'Content-Type': 'application/json',
115
- 'x-pay-mock-signature': 'invalid',
116
- },
117
- body: rawBody,
118
- });
119
- assert.equal(invalid.status, 401, await invalid.text());
120
- await app.bean.executor.mockCtx(async () => {
121
- assert.deepEqual(await countFacts(fixture.paymentSessionId!), {
122
- inboxes: [],
123
- audits: [],
124
- outbox: [],
125
- });
126
- });
127
- const valid = await fetch(url, {
128
- method: 'POST',
129
- headers: {
130
- 'Content-Type': 'application/json',
131
- 'x-pay-mock-signature': sign(rawBody),
132
- },
133
- body: rawBody,
134
- });
135
- assert.equal(valid.status, 200);
136
- await app.bean.executor.mockCtx(async () => {
137
- const session = await app
138
- .scope('a-pay')
139
- .model.paymentSession.getById(fixture.paymentSessionId!);
140
- const facts = await countFacts(fixture.paymentSessionId!);
141
- assert.equal(session?.state, 'succeeded');
142
- assert.deepEqual(
143
- [facts.inboxes.length, facts.audits.length, facts.outbox.length],
144
- [1, 1, 1],
145
- );
146
- });
147
- } finally {
148
- await app.bean.executor.mockCtx(async () => await cleanup(fixture));
149
- }
150
- });
151
-
152
- it('uses the route client webhook secret and preserves session client binding', async () => {
153
- const fixture: IFixture = {};
154
- try {
155
- await app.bean.executor.mockCtx(async () => {
156
- Object.assign(fixture, await createFixture(randomUUID().slice(0, 12)));
157
- });
158
- const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
159
- const url = webhookUrl('pay-mock:mock', 'secondary');
160
- const wrongClient = await fetch(url, {
161
- method: 'POST',
162
- headers: {
163
- 'Content-Type': 'application/json',
164
- 'x-pay-mock-signature': sign(rawBody, 'default'),
165
- },
166
- body: rawBody,
167
- });
168
- assert.equal(wrongClient.status, 401, await wrongClient.text());
169
- const selectedClient = await fetch(url, {
170
- method: 'POST',
171
- headers: {
172
- 'Content-Type': 'application/json',
173
- 'x-pay-mock-signature': sign(rawBody, 'secondary'),
174
- },
175
- body: rawBody,
176
- });
177
- assert.equal(selectedClient.status, 409, await selectedClient.text());
178
- await app.bean.executor.mockCtx(async () => {
179
- assert.deepEqual(await countFacts(fixture.paymentSessionId!), {
180
- inboxes: [],
181
- audits: [],
182
- outbox: [],
183
- });
184
- });
185
- } finally {
186
- await app.bean.executor.mockCtx(async () => await cleanup(fixture));
187
- }
188
- });
189
-
190
- it('persists one verified payment fact and one logical outbox event across replay', async () => {
191
- await app.bean.executor.mockCtx(async () => {
192
- const fixture = await createFixture(randomUUID().slice(0, 12));
193
- try {
194
- const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
195
- const first = await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
196
- const replay = await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
197
- assert.equal(replay.id, first.id);
198
- assert.equal(replay.state, 'processed');
199
- const session = await app
200
- .scope('a-pay')
201
- .model.paymentSession.getById(fixture.paymentSessionId!);
202
- const facts = await countFacts(fixture.paymentSessionId!);
203
- assert.equal(session?.state, 'succeeded');
204
- assert.deepEqual(
205
- [facts.inboxes.length, facts.audits.length, facts.outbox.length],
206
- [1, 1, 1],
207
- );
208
- assert.equal(facts.outbox[0]?.eventType, 'payment.outcome.v1');
209
- } finally {
210
- await cleanup(fixture);
211
- }
212
- });
213
- });
214
-
215
- it('converges concurrent duplicate and out-of-order terminal webhooks on the first terminal state', async () => {
216
- const fixture: IFixture = {};
217
- try {
218
- await app.bean.executor.mockCtx(async () => {
219
- Object.assign(fixture, await createFixture(randomUUID().slice(0, 12)));
220
- });
221
- const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
222
- const receive = async () => {
223
- return await app.bean.executor.mockCtx(async () => {
224
- return await app.scope('a-pay').service.webhook.receive(createCommand(rawBody));
225
- });
226
- };
227
- const results = await Promise.allSettled([receive(), receive()]);
228
- assert.equal(
229
- results.filter(result => result.status === 'fulfilled').length,
230
- 2,
231
- JSON.stringify(results),
232
- );
233
- await app.bean.executor.mockCtx(async () => {
234
- const failedRawBody = createRawBody(fixture, randomUUID().slice(0, 12), 'failed');
235
- const ignored = await app
236
- .scope('a-pay')
237
- .service.webhook.receive(createCommand(failedRawBody));
238
- const session = await app
239
- .scope('a-pay')
240
- .model.paymentSession.getById(fixture.paymentSessionId!);
241
- const facts = await countFacts(fixture.paymentSessionId!);
242
- assert.equal(session?.state, 'succeeded');
243
- assert.equal(ignored.state, 'processed');
244
- assert.match(ignored.errorSummary ?? '', /ignored after succeeded/);
245
- assert.deepEqual(
246
- [facts.inboxes.length, facts.audits.length, facts.outbox.length],
247
- [2, 1, 1],
248
- );
249
- });
250
- } finally {
251
- await app.bean.executor.mockCtx(async () => await cleanup(fixture));
252
- }
253
- });
254
-
255
- it('rejects a same-state webhook with conflicting provider facts', async () => {
256
- await app.bean.executor.mockCtx(async () => {
257
- const fixture = await createFixture(randomUUID().slice(0, 12));
258
- try {
259
- const first = createRawBody(fixture, randomUUID().slice(0, 12));
260
- await app.scope('a-pay').service.webhook.receive(createCommand(first));
261
- const conflict = createRawBody(fixture, randomUUID().slice(0, 12), 'succeeded', {
262
- providerCaptureId: `other-capture-${randomUUID().slice(0, 12)}`,
263
- });
264
- await assert.rejects(
265
- app.scope('a-pay').service.webhook.receive(createCommand(conflict)),
266
- (error: any) => error.code === 409,
267
- );
268
- const facts = await countFacts(fixture.paymentSessionId!);
269
- assert.deepEqual(
270
- [facts.inboxes.length, facts.audits.length, facts.outbox.length],
271
- [1, 1, 1],
272
- );
273
- } finally {
274
- await cleanup(fixture);
275
- }
276
- });
277
- });
278
- });
@@ -1,141 +0,0 @@
1
- import assert from 'node:assert';
2
- import { randomUUID } from 'node:crypto';
3
- import { describe, it } from 'node:test';
4
- import { app } from 'vona-mock';
5
-
6
- interface IFixture {
7
- userId?: number;
8
- paymentSessionId?: number;
9
- }
10
-
11
- async function createFixture(suffix: string): Promise<IFixture> {
12
- const user = await app.bean.user.register({ name: `pay-mock-${suffix}` }, true);
13
- await app.bean.passport.signinMock(`pay-mock-${suffix}` as any);
14
- const session = await app.scope('a-pay').service.paymentSession.create({
15
- userId: user.id,
16
- payScene: 'commerce-payment:commerceOrder',
17
- businessReference: `business-${suffix}`,
18
- amountMinor: 1299,
19
- currency: 'USD',
20
- correlationId: `payment-${suffix}`,
21
- });
22
- await app.scope('a-pay').service.paymentSession.start(session.id);
23
- return { userId: user.id as number, paymentSessionId: session.id as number };
24
- }
25
-
26
- async function cleanup(fixture: IFixture) {
27
- const pay = app.scope('a-pay');
28
- if (fixture.paymentSessionId !== undefined) {
29
- await pay.model.outboxEvent.delete({ paymentSessionId: fixture.paymentSessionId });
30
- await pay.model.paymentAudit.delete({ paymentSessionId: fixture.paymentSessionId });
31
- await pay.model.webhookInbox.delete({ paymentSessionId: fixture.paymentSessionId });
32
- await pay.model.paymentSession.delete({ id: fixture.paymentSessionId });
33
- }
34
- if (fixture.userId !== undefined) {
35
- await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
36
- await app.bean.user.removeById(fixture.userId);
37
- }
38
- await app.bean.passport.signout();
39
- }
40
-
41
- describe('payMock.test.ts', { concurrency: false }, () => {
42
- it('captures Vona env secrets in the mock Provider options', () => {
43
- const options = app.bean.payProvider.getOptions('pay-mock:mock', 'default');
44
- assert.equal(options.secretCredential, app.meta.env.PAY_MOCK_DEFAULT_CREDENTIAL);
45
- assert.equal(options.secretWebhook, app.meta.env.PAY_MOCK_DEFAULT_WEBHOOK);
46
- assert.equal(String(options.secretCredential).startsWith('env:' + '//'), false);
47
- assert.equal(String(options.secretWebhook).startsWith('env:' + '//'), false);
48
- assert.deepEqual(options.capabilities, {
49
- redirectCheckout: false,
50
- embeddedCheckout: true,
51
- automaticCapture: true,
52
- manualCapture: false,
53
- refunds: true,
54
- partialRefunds: true,
55
- webhooks: true,
56
- });
57
- assert.deepEqual(
58
- app.bean.payProvider.resolveByName('pay-mock:mock', 'secondary').clientOptions.capabilities,
59
- options.capabilities,
60
- );
61
- });
62
-
63
- it('submits server-derived mock payment facts through the signed webhook', async () => {
64
- await app.bean.executor.mockCtx(async () => {
65
- const fixture: IFixture = {};
66
- try {
67
- Object.assign(fixture, await createFixture('success'));
68
- const receipt = await app
69
- .scope('pay-mock')
70
- .service.payMock.completePaymentSession(fixture.paymentSessionId!, 'succeeded');
71
- assert.deepEqual(receipt, { paymentSessionId: fixture.paymentSessionId, accepted: true });
72
- const pay = app.scope('a-pay');
73
- const [session, inboxes, audits, outbox] = await Promise.all([
74
- pay.model.paymentSession.getById(fixture.paymentSessionId!),
75
- pay.model.webhookInbox.select({ where: { paymentSessionId: fixture.paymentSessionId } }),
76
- pay.model.paymentAudit.select({ where: { paymentSessionId: fixture.paymentSessionId } }),
77
- pay.model.outboxEvent.select({ where: { paymentSessionId: fixture.paymentSessionId } }),
78
- ]);
79
- assert.equal(session?.state, 'succeeded');
80
- assert.equal(inboxes.length, 1);
81
- assert.equal(audits.filter(item => item.source === 'webhook').length, 1);
82
- assert.equal(outbox.length, 1);
83
- } finally {
84
- await cleanup(fixture);
85
- }
86
- });
87
- });
88
-
89
- it('completes a payment session in the active named instance', async () => {
90
- await app.bean.executor.mockCtx(
91
- async () => {
92
- const fixture: IFixture = {};
93
- try {
94
- Object.assign(fixture, await createFixture(`named-${randomUUID().slice(0, 12)}`));
95
- const receipt = await app
96
- .scope('pay-mock')
97
- .service.payMock.completePaymentSession(fixture.paymentSessionId!, 'succeeded');
98
- assert.deepEqual(receipt, { paymentSessionId: fixture.paymentSessionId, accepted: true });
99
- const pay = app.scope('a-pay');
100
- const [session, inboxes, audits, outbox] = await Promise.all([
101
- pay.model.paymentSession.getById(fixture.paymentSessionId!),
102
- pay.model.webhookInbox.select({
103
- where: { paymentSessionId: fixture.paymentSessionId },
104
- }),
105
- pay.model.paymentAudit.select({
106
- where: { paymentSessionId: fixture.paymentSessionId },
107
- }),
108
- pay.model.outboxEvent.select({ where: { paymentSessionId: fixture.paymentSessionId } }),
109
- ]);
110
- assert.equal(session?.state, 'succeeded');
111
- assert.equal(inboxes.length, 1);
112
- assert.equal(audits.filter(item => item.source === 'webhook').length, 1);
113
- assert.equal(outbox.length, 1);
114
- } finally {
115
- await cleanup(fixture);
116
- }
117
- },
118
- { instanceName: 'shareTest' as any },
119
- );
120
- });
121
-
122
- it('does not simulate a session that is no longer actionable', async () => {
123
- await app.bean.executor.mockCtx(async () => {
124
- const fixture: IFixture = {};
125
- try {
126
- Object.assign(fixture, await createFixture('terminal'));
127
- await app
128
- .scope('pay-mock')
129
- .service.payMock.completePaymentSession(fixture.paymentSessionId!, 'cancelled');
130
- await assert.rejects(
131
- app
132
- .scope('pay-mock')
133
- .service.payMock.completePaymentSession(fixture.paymentSessionId!, 'cancelled'),
134
- { status: 409 },
135
- );
136
- } finally {
137
- await cleanup(fixture);
138
- }
139
- });
140
- });
141
- });