@substrat-run/contract-tests 0.114.0 → 0.116.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/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/job-run-suite.d.ts +24 -0
- package/dist/job-run-suite.d.ts.map +1 -0
- package/dist/job-run-suite.js +582 -0
- package/dist/job-run-suite.js.map +1 -0
- package/dist/modules.d.ts +343 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +176 -4
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +117 -0
- package/dist/permission-suite.js.map +1 -1
- package/dist/schedule-suite.d.ts.map +1 -1
- package/dist/schedule-suite.js +55 -6
- package/dist/schedule-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +251 -14
- package/dist/scope-host-suite.js.map +1 -1
- package/package.json +3 -3
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
2
|
import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
|
|
3
|
-
import { connectionId, dataSubjectId, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
|
|
3
|
+
import { connectionId, dataSubjectId, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
|
|
4
4
|
import { isSearchIndexTable, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
|
|
6
|
+
/**
|
|
7
|
+
* Assert a refusal by the CODE it declared, not by the sentence it happens to carry
|
|
8
|
+
* (#113 phase 5).
|
|
9
|
+
*
|
|
10
|
+
* `errorCodeOf` is the same reading the control plane's `mapError` does, so what this
|
|
11
|
+
* pins and the problem document a transport renders agree by construction. A `/message/`
|
|
12
|
+
* match agreed with it only by coincidence — and that coincidence was load-bearing:
|
|
13
|
+
* `CODE_PATTERNS` in `packages/control-plane-api/src/errors.ts` guessed the code by
|
|
14
|
+
* regex, so rewording a throw silently changed a 404 into a 500, and the suite's own
|
|
15
|
+
* message assertion was the only thing standing in the way. Moving the assertion here
|
|
16
|
+
* makes the message free to change and the TYPE the thing that cannot.
|
|
17
|
+
*
|
|
18
|
+
* A promise that RESOLVES fails this too: `errorCodeOf(undefined)` is `undefined`, which
|
|
19
|
+
* is never a code — so it cannot pass by not throwing at all.
|
|
20
|
+
*
|
|
21
|
+
* `message` is for the minority of refusals where the sentence carries something the code
|
|
22
|
+
* cannot: WHICH branch fired, when two branches share one code. `deleteVertical` refuses a
|
|
23
|
+
* live scope with "delete or rebind" and an archived one with "reap or restore" — both
|
|
24
|
+
* `conflict`, and an adapter that answered the wrong one would send an operator to a button
|
|
25
|
+
* that is not there. Pass it only for that; a code is the contract everywhere else.
|
|
26
|
+
*/
|
|
27
|
+
const expectRefusal = async (p, code, message) => {
|
|
28
|
+
const err = await p.then(() => undefined, (e) => e);
|
|
29
|
+
expect(errorCodeOf(err)).toBe(code);
|
|
30
|
+
if (message !== undefined)
|
|
31
|
+
expect(err?.message).toMatch(message);
|
|
32
|
+
};
|
|
6
33
|
/**
|
|
7
34
|
* The scope-host contract suite (design doc §11). Every adapter — pure SQLite,
|
|
8
35
|
* Cloudflare, and any future one — must pass this unchanged (D-14). If an
|
|
@@ -451,6 +478,44 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
451
478
|
// rebuild instant went to the new table, and reopening it would write it there twice.
|
|
452
479
|
await expect(host.admin.redrainEvents(staff, t1, sDrain, { drainedBefore: stampedAt })).resolves.toBe(0);
|
|
453
480
|
const justAfter = new Date(Date.parse(stampedAt) + 1).toISOString();
|
|
481
|
+
// COUNT ONLY (#1545) — the same window asked read-only, which is what a dry run needs
|
|
482
|
+
// and what nothing could answer before: the verb only ever reopened, so a caller could
|
|
483
|
+
// learn the number by changing the rows or not at all.
|
|
484
|
+
const redrainRows = async () => (await host.admin.auditLog(staff, { tenantId: t1 })).filter((r) => r.action === 'redrainEvents' && r.scopeId === sDrain).length;
|
|
485
|
+
const receiptsBefore = await redrainRows();
|
|
486
|
+
const undrainedBefore = await host.admin.readUndrainedEvents(staff, t1, sDrain, 200);
|
|
487
|
+
// Strictly before, on the same boundary the reopen holds: a count that included the
|
|
488
|
+
// instant would rehearse a different window from the one the real run touches.
|
|
489
|
+
await expect(host.admin.redrainEvents(staff, t1, sDrain, { drainedBefore: stampedAt, countOnly: true })).resolves.toBe(0);
|
|
490
|
+
// Asked TWICE on purpose: a "count" that reopened its own window would answer
|
|
491
|
+
// differently the second time, which is the one failure a rehearsal must not have.
|
|
492
|
+
await expect(host.admin.redrainEvents(staff, t1, sDrain, { drainedBefore: justAfter, countOnly: true })).resolves.toBe(first.length);
|
|
493
|
+
await expect(host.admin.redrainEvents(staff, t1, sDrain, { drainedBefore: justAfter, countOnly: true })).resolves.toBe(first.length);
|
|
494
|
+
// …and it moved nothing: the stamped rows are still stamped, so the drain has exactly
|
|
495
|
+
// the same work in front of it as before the count.
|
|
496
|
+
expect((await host.admin.readUndrainedEvents(staff, t1, sDrain, 200)).map((e) => e.id)).toEqual(undrainedBefore.map((e) => e.id));
|
|
497
|
+
// …and wrote no receipt. The intent row exists because a reopen that crashed before
|
|
498
|
+
// its outcome row would leave a second egress with no trace; a count egresses nothing,
|
|
499
|
+
// and a row claiming a redrain on a scope that was only counted is a false statement
|
|
500
|
+
// in the one log that is evidence.
|
|
501
|
+
expect(await redrainRows()).toBe(receiptsBefore);
|
|
502
|
+
// It is still a READ, and K-24 takes all reads rather than a chosen subset — so the
|
|
503
|
+
// access log carries a row per count, naming the window and the number it found.
|
|
504
|
+
// "Nobody can tell who counted every tenant's outbox" is the hole that would be.
|
|
505
|
+
const counts = (await host.admin.accessLog(staff, { tenantId: t1, method: 'redrainEvents' })).filter((r) => r.scopeId === sDrain);
|
|
506
|
+
expect(counts).toHaveLength(3);
|
|
507
|
+
expect(counts.map((r) => r.resultCount)).toEqual(expect.arrayContaining([0, first.length]));
|
|
508
|
+
// The future refusal is the verb's, not the reopen's alone — the window rule is
|
|
509
|
+
// checked before the two branches part.
|
|
510
|
+
await expect(host.admin.redrainEvents(staff, t1, sDrain, {
|
|
511
|
+
drainedBefore: new Date(Date.now() + 60_000).toISOString(),
|
|
512
|
+
countOnly: true,
|
|
513
|
+
})).rejects.toThrow(/future/);
|
|
514
|
+
// K-3 holds for the count as for the reopen: a foreign pair is refused, never answered
|
|
515
|
+
// 0 — "nothing to reopen" would read as a finished rehearsal of a scope never touched.
|
|
516
|
+
await expect(host.admin.redrainEvents(staff, t2, sDrain, { drainedBefore: justAfter, countOnly: true })).rejects.toThrow(/unknown scope/);
|
|
517
|
+
// The number the count gave IS the number the reopen changes — the property that makes
|
|
518
|
+
// a dry run a rehearsal rather than a second, differently-shaped question.
|
|
454
519
|
await expect(host.admin.redrainEvents(staff, t1, sDrain, { drainedBefore: justAfter })).resolves.toBe(first.length);
|
|
455
520
|
// The reopened rows come back through the ordinary read, unchanged — nothing about the
|
|
456
521
|
// event moves except that it may leave again.
|
|
@@ -2950,8 +3015,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2950
3015
|
expect((await at('listtest'))?.publishRequestedAt).toBeTruthy();
|
|
2951
3016
|
await host.admin.setVerticalListed(staff, 'listtest', true);
|
|
2952
3017
|
expect((await at('listtest'))?.publishRequestedAt).toBeUndefined(); // cleared on review
|
|
2953
|
-
await
|
|
2954
|
-
await
|
|
3018
|
+
await expectRefusal(host.admin.setVerticalListed(staff, 'no-such-vertical', true), 'not_found');
|
|
3019
|
+
await expectRefusal(host.admin.requestPublish(staff, 'no-such-vertical'), 'not_found');
|
|
2955
3020
|
});
|
|
2956
3021
|
it('blocks new installs (setVerticalInstallsBlocked) — a provisioning gate, not a delete', async () => {
|
|
2957
3022
|
const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
|
|
@@ -2964,7 +3029,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2964
3029
|
expect((await at('blocktest'))?.listed).toBe(false);
|
|
2965
3030
|
await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', false);
|
|
2966
3031
|
expect((await at('blocktest'))?.installsBlocked).toBe(false);
|
|
2967
|
-
await
|
|
3032
|
+
await expectRefusal(host.admin.setVerticalInstallsBlocked(staff, 'no-such-vertical', true), 'not_found');
|
|
2968
3033
|
});
|
|
2969
3034
|
it('grants the tenant-provisioner capability (setVerticalTenantProvisioner) — a staff grant a re-push cannot touch', async () => {
|
|
2970
3035
|
const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
|
|
@@ -2979,7 +3044,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2979
3044
|
expect((await at('managertest'))?.tenantProvisioner).toBe(true);
|
|
2980
3045
|
await host.admin.setVerticalTenantProvisioner(staff, 'managertest', false);
|
|
2981
3046
|
expect((await at('managertest'))?.tenantProvisioner).toBe(false);
|
|
2982
|
-
await
|
|
3047
|
+
await expectRefusal(host.admin.setVerticalTenantProvisioner(staff, 'no-such-vertical', true), 'not_found');
|
|
2983
3048
|
});
|
|
2984
3049
|
it('carries the declared provisioner intent (#455) — a refreshable request, orthogonal to the grant', async () => {
|
|
2985
3050
|
const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
|
|
@@ -3030,12 +3095,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3030
3095
|
expect((await at('mailer'))?.emailSender).toBe(true);
|
|
3031
3096
|
await host.admin.setVerticalEmailSender(staff, 'mailer', false);
|
|
3032
3097
|
expect((await at('mailer'))?.emailSender).toBe(false);
|
|
3033
|
-
await
|
|
3098
|
+
await expectRefusal(host.admin.setVerticalEmailSender(staff, 'no-such-vertical', true), 'not_found');
|
|
3034
3099
|
});
|
|
3035
3100
|
it('deletes a vertical — refused while a scope is bound, total once nothing is', async () => {
|
|
3036
3101
|
// 'callout' still backs s1 (bound above): the refusal that stops a delete from
|
|
3037
|
-
// stranding a live scope's version pin and routing.
|
|
3038
|
-
|
|
3102
|
+
// stranding a live scope's version pin and routing. Pinned by the CODE (#113
|
|
3103
|
+
// phase 5) — the sentence is free to change, the 409 is not.
|
|
3104
|
+
await expectRefusal(host.admin.deleteVertical(staff, 'callout'), 'conflict');
|
|
3039
3105
|
expect((await host.admin.listVerticals(staff)).some((v) => v.slug === 'callout')).toBe(true);
|
|
3040
3106
|
// A vertical nothing is bound to deletes totally: row, versions, channels.
|
|
3041
3107
|
const versionId = ulid();
|
|
@@ -3059,7 +3125,55 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3059
3125
|
await host.admin.registerVertical(staff, { slug: 'deletable', name: 'Deletable', source: 'cli', ownerTenant: t2 });
|
|
3060
3126
|
expect(await host.admin.listVersions(staff, 'deletable')).toEqual([]); // no resurrected versions
|
|
3061
3127
|
await host.admin.deleteVertical(staff, 'deletable');
|
|
3062
|
-
await
|
|
3128
|
+
await expectRefusal(host.admin.deleteVertical(staff, 'no-such-vertical'), 'not_found');
|
|
3129
|
+
});
|
|
3130
|
+
it('refuses the serving-state read and write for a vertical that does not exist', async () => {
|
|
3131
|
+
await expectRefusal(host.admin.verticalServing(staff, 'no-such-vertical'), 'not_found');
|
|
3132
|
+
await expectRefusal(host.admin.setVerticalServing(staff, 'no-such-vertical', {
|
|
3133
|
+
ref: 'serving-script', versionId: ulid(), doClasses: [], migrationTag: 'g1',
|
|
3134
|
+
}), 'not_found');
|
|
3135
|
+
});
|
|
3136
|
+
it('refuses every registry verb that addresses a version which does not exist (#113 phase 5)', async () => {
|
|
3137
|
+
// The five `unknown version …` throw sites, one per verb, on both adapters. Nothing
|
|
3138
|
+
// asserted them before this case: each was a bare `Error` whose 404 came from a
|
|
3139
|
+
// `/unknown version /` regex in the control plane, so a reword — or a regression to
|
|
3140
|
+
// `new Error` — changed the status with no test to notice. The code is the contract
|
|
3141
|
+
// now, and it is pinned here rather than in the transport.
|
|
3142
|
+
//
|
|
3143
|
+
// The id is well-formed and simply names nothing, which is what separates this from
|
|
3144
|
+
// a parse failure: `not_found`, never `validation_failed`.
|
|
3145
|
+
const ghost = ulid();
|
|
3146
|
+
await expectRefusal(host.admin.admitVersion(staff, ghost), 'not_found');
|
|
3147
|
+
await expectRefusal(host.admin.rejectVersion(staff, ghost, 'no such version'), 'not_found');
|
|
3148
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', ghost), 'not_found');
|
|
3149
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, ghost), 'not_found');
|
|
3150
|
+
await expectRefusal(host.admin.versionManifest(staff, 'callout', ghost), 'not_found');
|
|
3151
|
+
// versionManifest refuses on the PAIR — `!v || v.vertical_slug !== verticalSlug` —
|
|
3152
|
+
// so the absent id above only exercises HALF its guard. A version that really
|
|
3153
|
+
// exists, under a DIFFERENT vertical, must read as absent too: asking for it under
|
|
3154
|
+
// someone else's slug is not a way in. Without this second call an implementation
|
|
3155
|
+
// that dropped the ownership check would still pass the case, which is the same
|
|
3156
|
+
// shape of hole this whole change exists to close.
|
|
3157
|
+
await host.admin.registerVertical(staff, {
|
|
3158
|
+
slug: 'otherowner',
|
|
3159
|
+
name: 'OtherOwner',
|
|
3160
|
+
source: 'cli',
|
|
3161
|
+
ownerTenant: t1,
|
|
3162
|
+
});
|
|
3163
|
+
const elsewhere = ulid();
|
|
3164
|
+
await host.admin.publishVersion(staff, {
|
|
3165
|
+
id: elsewhere,
|
|
3166
|
+
verticalSlug: 'otherowner',
|
|
3167
|
+
version: '1.0.0',
|
|
3168
|
+
manifestDigest: 'm-other',
|
|
3169
|
+
permissionDigest: 'p-other',
|
|
3170
|
+
migrationDigest: 'g-other',
|
|
3171
|
+
deploymentRef: null,
|
|
3172
|
+
});
|
|
3173
|
+
// It resolves under its OWN vertical — so the refusal below is the pair check
|
|
3174
|
+
// firing, not the version failing to exist.
|
|
3175
|
+
await host.admin.versionManifest(staff, 'otherowner', elsewhere);
|
|
3176
|
+
await expectRefusal(host.admin.versionManifest(staff, 'callout', elsewhere), 'not_found');
|
|
3063
3177
|
});
|
|
3064
3178
|
it('an archived scope blocks the delete naming the reap step; a reaped tombstone never blocks', async () => {
|
|
3065
3179
|
// A deleted app leaves an `archived` row (restorable via unarchive), then a
|
|
@@ -3070,9 +3184,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3070
3184
|
const sRet = scopeId.parse(ulid());
|
|
3071
3185
|
await host.admin.registerVertical(staff, { slug: 'retirable', name: 'Retirable', source: 'cli', ownerTenant: t2 });
|
|
3072
3186
|
await host.provisionScope(staff, { tenantId: t2, scopeId: sRet, jurisdiction: 'eu', vertical: 'retirable' });
|
|
3073
|
-
|
|
3187
|
+
// Both halves are `conflict` and the sentence is what tells them apart, so both are
|
|
3188
|
+
// asserted (#113 phase 5). The CODE is what the transport renders the 409 from —
|
|
3189
|
+
// the archived half reached the control plane as a generic 500 until these throws
|
|
3190
|
+
// were typed, because the pattern row that carried the live one read
|
|
3191
|
+
// `/still backs \d+ scope\(s\)/` and the word `archived` sits in the middle of it.
|
|
3192
|
+
await expectRefusal(host.admin.deleteVertical(staff, 'retirable'), 'conflict', /still backs 1 scope\(s\) — delete or rebind/);
|
|
3074
3193
|
await host.admin.archiveScope(staff, t2, sRet);
|
|
3075
|
-
await
|
|
3194
|
+
await expectRefusal(host.admin.deleteVertical(staff, 'retirable'), 'conflict', /1 archived scope\(s\) — reap or restore/);
|
|
3076
3195
|
await host.admin.reapScope(staff, t2, sRet);
|
|
3077
3196
|
await host.admin.deleteVertical(staff, 'retirable');
|
|
3078
3197
|
expect((await host.admin.listVerticals(staff)).some((v) => v.slug === 'retirable')).toBe(false);
|
|
@@ -3104,7 +3223,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3104
3223
|
await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
|
|
3105
3224
|
await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/rejected, not admitted/);
|
|
3106
3225
|
// Terminal: a rejected version is not resurrected, a new one is published.
|
|
3107
|
-
await
|
|
3226
|
+
await expectRefusal(host.admin.admitVersion(staff, versionId), 'conflict');
|
|
3108
3227
|
const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
3109
3228
|
expect(rejected?.admissionNote).toContain('widened a role');
|
|
3110
3229
|
});
|
|
@@ -3120,7 +3239,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3120
3239
|
}
|
|
3121
3240
|
});
|
|
3122
3241
|
it('refuses a version for a vertical nobody registered', async () => {
|
|
3123
|
-
await
|
|
3242
|
+
await expectRefusal(host.admin.publishVersion(staff, {
|
|
3124
3243
|
id: ulid(),
|
|
3125
3244
|
verticalSlug: 'ghost',
|
|
3126
3245
|
version: '1.0.0',
|
|
@@ -3128,7 +3247,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3128
3247
|
permissionDigest: 'p',
|
|
3129
3248
|
migrationDigest: 'g',
|
|
3130
3249
|
deploymentRef: null,
|
|
3131
|
-
})
|
|
3250
|
+
}), 'not_found');
|
|
3132
3251
|
});
|
|
3133
3252
|
// -- channels and promotion-time checkpoints (#31 step 2) ----------------
|
|
3134
3253
|
// Promotion is the moment a change reaches anyone, so it is where §4's two
|
|
@@ -3871,6 +3990,124 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3871
3990
|
expect(reactions).toHaveLength(2);
|
|
3872
3991
|
expect(new Set(reactions.map((r) => r.caused_by)).size).toBe(2);
|
|
3873
3992
|
});
|
|
3993
|
+
// -- the invocation a DELIVERY belongs to (#1525) -------------------------
|
|
3994
|
+
//
|
|
3995
|
+
// #1237 put `invocation_id` on the outbox, which answers "which call emitted this
|
|
3996
|
+
// event". A delivery joins to that through `event_id`, so the tempting reading is
|
|
3997
|
+
// that deliveries needed nothing. What the event's id cannot say is which call
|
|
3998
|
+
// attempted the DELIVERY — and for an executor those come apart by design: attempt
|
|
3999
|
+
// one runs in the emitting call's post-commit tail, every retry after it in a drain
|
|
4000
|
+
// that is a different call or none at all.
|
|
4001
|
+
//
|
|
4002
|
+
// Each test takes its own scope: they assert over the whole delivery log they read.
|
|
4003
|
+
describe('the invocation a delivery belongs to (#1525)', () => {
|
|
4004
|
+
const flowScope = async () => {
|
|
4005
|
+
const s = scopeId.parse(ulid());
|
|
4006
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: 'flow-vertical' });
|
|
4007
|
+
await host.admin.activateScope(staff, t1, s);
|
|
4008
|
+
return s;
|
|
4009
|
+
};
|
|
4010
|
+
it('stamps a consumer delivery with the invocation the call carried', async () => {
|
|
4011
|
+
const s = await flowScope();
|
|
4012
|
+
const stub = await host.getScope(alice, t1, s);
|
|
4013
|
+
const call = ulid();
|
|
4014
|
+
await stub.invoke('flow/produce', undefined, { invocationId: call });
|
|
4015
|
+
// The VALUE, not merely the column: a delivery that recorded null here would
|
|
4016
|
+
// satisfy every "the column exists" assertion and join to nothing.
|
|
4017
|
+
const rows = (await stub.invoke('flow/deliveries'));
|
|
4018
|
+
expect(rows).toHaveLength(2);
|
|
4019
|
+
expect(rows.map((r) => r.invocation_id)).toEqual([call, call]);
|
|
4020
|
+
// A SECOND call is a second id. The field is cleared after each invocation
|
|
4021
|
+
// (#1237), so a leak would file this call's deliveries under the previous one
|
|
4022
|
+
// — which a single-call assertion cannot see.
|
|
4023
|
+
const next = ulid();
|
|
4024
|
+
await stub.invoke('flow/produce', undefined, { invocationId: next });
|
|
4025
|
+
const after = (await stub.invoke('flow/deliveries'));
|
|
4026
|
+
expect(after).toHaveLength(4);
|
|
4027
|
+
expect(after.filter((r) => r.invocation_id === call)).toHaveLength(2);
|
|
4028
|
+
expect(after.filter((r) => r.invocation_id === next)).toHaveLength(2);
|
|
4029
|
+
});
|
|
4030
|
+
it('records null when the caller carried no invocation, and still delivers', async () => {
|
|
4031
|
+
// A seed, a test, an internal call. Null is the honest answer — and the
|
|
4032
|
+
// delivery itself must still happen and still be journalled, since a drain that
|
|
4033
|
+
// failed because no id was carried is a far worse outcome than an unjoined row.
|
|
4034
|
+
// The positive twin above shares this mechanism, so breaking the write reddens
|
|
4035
|
+
// that one rather than leaving both green on a column nobody fills.
|
|
4036
|
+
const s = await flowScope();
|
|
4037
|
+
const stub = await host.getScope(alice, t1, s);
|
|
4038
|
+
await stub.invoke('flow/produce');
|
|
4039
|
+
const rows = (await stub.invoke('flow/deliveries'));
|
|
4040
|
+
expect(rows).toHaveLength(2);
|
|
4041
|
+
expect(rows.every((r) => r.invocation_id === null)).toBe(true);
|
|
4042
|
+
expect(rows.every((r) => r.error === null)).toBe(true);
|
|
4043
|
+
});
|
|
4044
|
+
it('carries the delivery\'s invocation out through the effects walk', async () => {
|
|
4045
|
+
// The column is written; this is what makes it reachable. `walkEventEffects`
|
|
4046
|
+
// is the read that renders a delivery, so a column added to the store and
|
|
4047
|
+
// missed there would leave the join invisible to every surface that shows one.
|
|
4048
|
+
const s = await flowScope();
|
|
4049
|
+
const stub = await host.getScope(alice, t1, s);
|
|
4050
|
+
const call = ulid();
|
|
4051
|
+
await stub.invoke('flow/produce', undefined, { invocationId: call });
|
|
4052
|
+
const rows = (await stub.invoke('flow/causes'));
|
|
4053
|
+
const step1 = rows.find((r) => r.type === 'flow.step1');
|
|
4054
|
+
const tree = await host.admin.eventEffects(staff, t1, s, {
|
|
4055
|
+
eventId: eventId.parse(step1.id),
|
|
4056
|
+
});
|
|
4057
|
+
expect(tree.root.deliveries).not.toHaveLength(0);
|
|
4058
|
+
expect(tree.root.deliveries.every((d) => d.invocationId === call)).toBe(true);
|
|
4059
|
+
});
|
|
4060
|
+
it('stamps an executor delivery with the call whose tail ran it', async () => {
|
|
4061
|
+
// Executors are the half the event's own id could never have covered. On the
|
|
4062
|
+
// hosted adapter this journal is written from the COORDINATOR over an RPC, so
|
|
4063
|
+
// there is no ambient field to read and the id has to survive the hop.
|
|
4064
|
+
const s = scopeId.parse(ulid());
|
|
4065
|
+
await host.provisionScope(staff, {
|
|
4066
|
+
tenantId: t1,
|
|
4067
|
+
scopeId: s,
|
|
4068
|
+
vertical: 'connector-vertical',
|
|
4069
|
+
});
|
|
4070
|
+
await host.admin.activateScope(staff, t1, s);
|
|
4071
|
+
const stub = await host.getScope(alice, t1, s);
|
|
4072
|
+
const call = ulid();
|
|
4073
|
+
await stub.invoke('connector/request-effect', { tag: 'inv-ok' }, { invocationId: call });
|
|
4074
|
+
const emitted = await host.admin.invocationEvents(staff, t1, s, { invocationId: call });
|
|
4075
|
+
const requested = emitted.events.find((e) => e.type === 'effect.requested');
|
|
4076
|
+
const tree = await host.admin.eventEffects(staff, t1, s, { eventId: requested.id });
|
|
4077
|
+
const delivery = tree.root.deliveries.find((d) => d.consumer === 'executor:flaky-effector');
|
|
4078
|
+
expect(delivery).toBeDefined();
|
|
4079
|
+
expect(delivery.state).toBe('delivered');
|
|
4080
|
+
expect(delivery.invocationId).toBe(call);
|
|
4081
|
+
});
|
|
4082
|
+
it('files a RETRY under the call that re-attempted it, not the one that emitted', async () => {
|
|
4083
|
+
// The test that proves this column is not a copy of the outbox's. A doomed
|
|
4084
|
+
// executor is attempted once in the emitting call's tail and once more on a
|
|
4085
|
+
// drain — and the drain is not a call. So the dead row's event still names the
|
|
4086
|
+
// request that produced it while the attempt that gave up names none, which is
|
|
4087
|
+
// exactly the pair `DeadLetter` could not previously tell apart.
|
|
4088
|
+
const s = scopeId.parse(ulid());
|
|
4089
|
+
await host.provisionScope(staff, {
|
|
4090
|
+
tenantId: t1,
|
|
4091
|
+
scopeId: s,
|
|
4092
|
+
vertical: 'connector-vertical',
|
|
4093
|
+
});
|
|
4094
|
+
await host.admin.activateScope(staff, t1, s);
|
|
4095
|
+
const stub = await host.getScope(alice, t1, s);
|
|
4096
|
+
const call = ulid();
|
|
4097
|
+
await stub.invoke('connector/request-doomed', { tag: 'inv-doomed' }, { invocationId: call });
|
|
4098
|
+
// maxAttempts 2: attempt one ran inline under `call`, attempt two runs here.
|
|
4099
|
+
expect((await host.drainDue(t1, s)).deadLettered).toBe(1);
|
|
4100
|
+
const page = await host.admin.deadLetters(staff, t1, s, {});
|
|
4101
|
+
const dead = page.entries.find((d) => d.consumer === 'executor:doomed-effector');
|
|
4102
|
+
expect(dead).toBeDefined();
|
|
4103
|
+
// The EVENT still joins to the request that emitted it…
|
|
4104
|
+
expect(dead.invocationId).toBe(call);
|
|
4105
|
+
// …and the ATTEMPT that gave up names no call, because a drain is not one. An
|
|
4106
|
+
// implementation that copied the event's id here would read `call` and claim a
|
|
4107
|
+
// request had done something it did not do.
|
|
4108
|
+
expect(dead.attemptInvocationId).toBeNull();
|
|
4109
|
+
});
|
|
4110
|
+
});
|
|
3874
4111
|
it('creates a tenant record, idempotently; only real creates are audited', async () => {
|
|
3875
4112
|
await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' });
|
|
3876
4113
|
await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' }); // no-op
|