@substrat-run/contract-tests 0.127.0 → 0.129.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.
@@ -1516,6 +1516,35 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1516
1516
  // `poison-a` failed and is backed off: an error on the row, but a retry still due.
1517
1517
  expect(page.entries.some((d) => d.consumer === 'executor:flaky-effector')).toBe(false);
1518
1518
  });
1519
+ it('replays a declared lifecycle over the outbox through the platform verb (#1744)', async () => {
1520
+ // `readLifecycleFlow`'s own suite pins the replay over a hand-built outbox; this
1521
+ // proves the wiring — real emits, on both adapters, read back through `lifecycleFlow`.
1522
+ const stub = await host.getScope(alice, t1, s1);
1523
+ const since = new Date(Date.now() - 60_000).toISOString();
1524
+ await stub.invoke('test/move', { entityId: 'l1', state: 'draft' });
1525
+ await stub.invoke('test/move', { entityId: 'l1', state: 'live' });
1526
+ // No state in the payload: the declared edge says where it went.
1527
+ await stub.invoke('test/move', { entityId: 'l1' });
1528
+ const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
1529
+ entityType: 'test-lifecycle',
1530
+ lifecycle: {
1531
+ field: 'state',
1532
+ initial: 'draft',
1533
+ states: {
1534
+ draft: { on: { 'test/move': 'live' } },
1535
+ live: { on: { 'test/move': 'done' } },
1536
+ done: { terminal: true },
1537
+ },
1538
+ },
1539
+ since,
1540
+ until: new Date(Date.now() + 60_000).toISOString(),
1541
+ });
1542
+ expect(flow.edges.find((e) => e.from === 'draft' && e.to === 'live').count).toBe(1);
1543
+ expect(flow.edges.find((e) => e.from === 'live' && e.to === 'done').count).toBe(1);
1544
+ expect(flow.states.find((s) => s.state === 'done').current).toBe(1);
1545
+ expect(flow.observation).toMatchObject({ entities: 1, events: 3, inferred: 1, complete: true });
1546
+ expect(flow.totals.finished).toBe(1);
1547
+ });
1519
1548
  // -- scope data introspection: the §5.4 admin-query RPC --------------------
1520
1549
  //
1521
1550
  // A read-only window into a scope's OWN database (the console/dashboard Data
@@ -2166,10 +2195,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2166
2195
  const dump = await host.admin.exportScope(staff, t1, s1);
2167
2196
  // Doctor the dump into one captured before the `operation` column existed:
2168
2197
  // the DDL loses the column and every row loses that cell — exactly what a
2169
- // dump exported by an older platform carries. The replay's DDL is
2170
- // authoritative, and KERNEL_DDL's IF NOT EXISTS cannot widen a table the
2171
- // dump brought, so without the post-replay re-run of the additive
2172
- // migrations the emit below fails with `no such column: operation`.
2198
+ // dump exported by an older platform carries. The restore builds the outbox
2199
+ // from the kernel's DDL, not the dump's (#1883), and inserts the rows by
2200
+ // column name, so the missing cell reads NULL and the emit below still has
2201
+ // its column.
2173
2202
  const doctored = dump.tables.map((t) => {
2174
2203
  if (t.name !== '_substrat_outbox')
2175
2204
  return t;
@@ -2289,6 +2318,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2289
2318
  { name: 'MARKER', ddl: 'CREATE TABLE MARKER (id TEXT)', columns: ['id'], rows: [] },
2290
2319
  ])).rejects.toThrow(/listed twice/);
2291
2320
  });
2321
+ // A column listed twice is the same shape one level down: the row insert names it twice,
2322
+ // and which of the two cells lands is SQLite's choice, not the dump's. Case-folded, as
2323
+ // SQLite resolves column names that way too (#1883 review).
2324
+ it('refuses a column listed twice in one table, including differing only in case', async () => {
2325
+ await expect(importForged([
2326
+ { name: 'marker', ddl: 'CREATE TABLE marker (id TEXT, note TEXT)', columns: ['id', 'note', 'ID'], rows: [] },
2327
+ ])).rejects.toThrow(/column "ID" in table "marker" is listed twice/);
2328
+ });
2292
2329
  // Non-vacuous: proves the checks do not simply reject everything, and that a
2293
2330
  // `;` inside a string literal is not read as a statement boundary — a DEFAULT
2294
2331
  // of `'a;b'` is legal SQL, and a `split(';')` would refuse it.
@@ -2350,6 +2387,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2350
2387
  expect(ids).toContain(t2);
2351
2388
  });
2352
2389
  it('restores: a directory diverged past the copy is rewound, and still serves', async () => {
2390
+ const ownRestores = { actor: staff, action: 'restoreDirectory' };
2391
+ const restoresInCopy = (await host.admin.auditLog(staff, ownRestores)).length;
2353
2392
  const backup = await host.admin.exportDirectory(staff);
2354
2393
  // Diverge past the copy — a tenant that did not exist when it was taken.
2355
2394
  const ghost = tenantId.parse(ulid());
@@ -2375,13 +2414,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2375
2414
  // The restore is audited, in the log it just replaced: the entry after a
2376
2415
  // restored history is the restore itself, so the seam is legible.
2377
2416
  //
2378
- // A wide window on purpose: this asks whether the entry EXISTS, and the 50 it used
2379
- // to read was incidental. `redrainEvents` now records an intent row per call (#1334
2380
- // review — the receipt must survive a crash between the reopen and its row), so the
2381
- // suite writes more admin rows than it did and a narrow window stopped reaching back
2382
- // this far. Nothing about what is asserted changes.
2383
- const log = await host.admin.auditLog(staff, { limit: 500 });
2384
- expect(log.some((e) => e.action === 'restoreDirectory')).toBe(true);
2417
+ // Filtered to THIS suite's restores and read as a delta, never as a window over the
2418
+ // whole log: the log reads oldest-first and is the whole directory's, so a window
2419
+ // stops reaching the entry once enough other rows precede it. A 50-row window did
2420
+ // when the suite's own rows grew (#1334), and a 500-row one did when other test
2421
+ // files sharing the directory ran first (#1899). The copy carries the restores it
2422
+ // had seen, so one more than it held is exactly this one.
2423
+ expect((await host.admin.auditLog(staff, ownRestores)).length).toBe(restoresInCopy + 1);
2385
2424
  });
2386
2425
  // A directory dump is untrusted input exactly as a scope dump is (#1143), and
2387
2426
  // both adapters refuse it with the same words. The DO path always judged it;