Files
agents-marketplace/tests/unit/post-advert-flow-live-time.test.ts
david 2aa1688a17 fix(ci): green the test suite — pnpm 10 build scripts, libsodium ESM, stale time fixtures
CI has been red since the pnpm 10 migration. Four independent causes:

- better-sqlite3: pnpm 10 blocks dependency build scripts by default, so the
  native binding was never compiled on CI. Allowlist it via
  onlyBuiltDependencies in pnpm-workspace.yaml.
- libsodium-wrappers-sumo 0.7.16: the published ESM wrapper does
  `import "./libsodium-sumo.mjs"` but the tarball does not ship that file
  (it lives in the libsodium-sumo package). Patch the import to the bare
  specifier; local runs only worked because of a hand-copied file in
  node_modules.
- tx-accept/claim [live] tests: the live path checks deadlines against
  Date.now(), and the fixed fixture timestamps expired in June. Freeze the
  clock inside the fixture windows with vi.useFakeTimers.
- CML set_exunits panic ("RuntimeError: unreachable"): with
  localUPLCEval: false the provider asks Ogmios for ex-units; the test mocks
  answered `{}`, so the provider fell back to a spend redeemer at index 0
  while the escrow input sorts to index 1 — CML panics on the unknown
  redeemer key. Mock evaluateTransaction with the true index like real
  Ogmios.

Also regenerate the advert-datum golden: the fixture builders passed plain JS
numbers to cbor-x (minimal ints, float64 for the timestamp) while the
production codec — whose output is live on mainnet — emits full-width uint64.
Builders now encode integer fields as BigInt so builders, codec, and golden
agree byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:16:19 +02:00

271 lines
11 KiB
TypeScript

/**
* post-advert-flow-live-time.test.ts — RED phase tests for M1-F-time-cleanup
*
* Proves that runPostAdvert() computes advertised_at:
* - via mockSlotToWallclockMs(tipSlot) when chain is a MockChainProvider
* (existing convention — must continue to pass after the live branch is added)
* - via Date.now() when chain is a LiveOgmiosProvider
* (new behavior — RED until Catherine implements the branch in postAdvertFlow.ts)
*
* Observation strategy for advertised_at on live chain:
* - Spy on chain.submitTx to capture the txCborHex submitted by buildPostAdvertTx.
* - The test tx format (encodeTxBody) is length-prefixed JSON over hex.
* - Parse the JSON to find the datumHex output field.
* - Decode the datumHex with decodeAdvertDatum to read advertised_at.
*
* Uses vi.useFakeTimers() + vi.setSystemTime() for deterministic Date.now().
*
* M1-F-time-cleanup RED — live backend tests fail until Catherine adds the
* live-path branch in supplier/src/cli/postAdvertFlow.ts.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { runPostAdvert } from "../../supplier/src/cli/postAdvertFlow.js";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { decodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
import {
buildCliAdvertDatum,
VALID_TIP_SLOT,
} from "../fixtures/supplier-side/sample-advert-datum.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
// ─── Fixed fake time ──────────────────────────────────────────────────────────
const FAKE_NOW_DATE = new Date("2026-04-28T12:00:00Z");
const FAKE_NOW_MS = FAKE_NOW_DATE.getTime(); // 1777377600000
// ─── Discriminating tip slot ──────────────────────────────────────────────────
/**
* TIP_SLOT_LIVE is chosen so that:
* mockSlotToWallclockMs(TIP_SLOT_LIVE) = TIP_SLOT_LIVE * 1000 = 50_000_000
* which is far below FAKE_NOW_MS (1_745_841_600_000).
*
* buildPostAdvertTx has a ±5min validity check (±300_000ms). Neither
* |FAKE_NOW_MS - 50_000_000| nor |50_000_000 - 50_000_000|
* can satisfy the validity window simultaneously — so the test implicitly
* validates that Catherine's implementation branches correctly AND also
* updates the validity check in buildPostAdvertTx to use Date.now() for live chains.
*
* For RED phase: when postAdvertFlow uses mockSlotToWallclockMs on a live chain,
* it sets advertised_at = 50_000_000. buildPostAdvertTx also uses mockSlotToWallclockMs,
* so the ±5min check passes (|50_000_000 - 50_000_000| = 0). The datum gets written.
* We then read it back and verify it is NOT FAKE_NOW_MS — proving the test is RED.
*/
const TIP_SLOT_LIVE = 50_000;
const TIP_SLOT_LIVE_MS = TIP_SLOT_LIVE * 1_000; // = 50_000_000 (mock convention, NOT real POSIX)
// ─── Helpers ──────────────────────────────────────────────────────────────────
function rpcOk<T>(result: T) {
return { ok: true, json: async () => ({ jsonrpc: "2.0", result }) };
}
function protocolParamsResponse() {
return rpcOk({
minFeeCoefficient: 44,
minFeeConstant: { ada: { lovelace: 155381 } },
maxTransactionSize: { bytes: 16384 },
maxValueSize: { bytes: 5000 },
stakeCredentialDeposit: { ada: { lovelace: 2000000 } },
stakePoolDeposit: { ada: { lovelace: 500000000 } },
prices: { memory: "0.0577", steps: "0.0000721" },
maxExecutionUnitsPerTransaction: { memory: 14000000, cpu: 10000000000 },
coinsPerUtxoByte: { ada: { lovelace: 4310 } },
collateralPercentage: 150,
maxCollateralInputs: 3,
plutusCostModels: { "plutus:v1": {}, "plutus:v2": {}, "plutus:v3": {} },
monetaryExpansion: "0.003",
treasuryExpansion: "0.2",
minStakePoolCost: { ada: { lovelace: 340000000 } },
minFeeReferenceScripts: { base: 15 },
governanceActionDeposit: { ada: { lovelace: 100000000000 } },
delegateRepresentativeDeposit: { ada: { lovelace: 500000000 } },
});
}
/**
* Attempt to extract and decode the AdvertDatum from a submitted tx CBOR hex.
*
* The live path emits real Cardano (Conway) CBOR. The advert datum rides in
* the script output as an inline datum: datum_option = [1, #6.24(bytes)], so
* in hex it appears as "d818" (tag 24) followed by a definite-length byte
* string (0x58 <len> or 0x59 <len16>) holding the raw AdvertDatum CBOR.
*
* Returns null if no tag-24 wrapped datum is found or decoding fails.
*/
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
try {
const m = /d818(?:58([0-9a-f]{2})|59([0-9a-f]{4}))/i.exec(txCborHex);
if (!m) return null;
const byteLen = parseInt(m[1] ?? m[2]!, 16);
const start = m.index + m[0].length;
const datumHex = txCborHex.slice(start, start + byteLen * 2);
if (datumHex.length !== byteLen * 2) return null;
const datum = decodeAdvertDatum(datumHex);
return datum.advertised_at;
} catch {
return null;
}
}
/**
* Build a LiveOgmiosProvider with a mocked fetch returning TIP_SLOT_LIVE.
* The submitTx and awaitTx methods are spied on so we can capture submitted CBOR.
*/
function buildLiveChain(): {
chain: LiveOgmiosProvider;
capturedTxCborHex: { value: string | undefined };
} {
const supplier = buildSupplierWalletKey();
const captured: { value: string | undefined } = { value: undefined };
const mockFetch = vi.fn().mockImplementation(
async (_url: string, init?: RequestInit) => {
const body = JSON.parse((init?.body as string) ?? "{}");
const method: string = body.method ?? "";
if (method === "queryNetwork/tip") {
return rpcOk({ slot: TIP_SLOT_LIVE, id: "a".repeat(64) });
}
if (method === "queryLedgerState/protocolParameters") {
return protocolParamsResponse();
}
if (method === "queryLedgerState/utxo") {
return rpcOk([
{
transaction: { id: "c".repeat(64) },
index: 0,
address: supplier.address,
value: { ada: { lovelace: 10_000_000 } },
datum: null,
datumHash: null,
script: null,
},
]);
}
if (method === "submitTransaction") {
return rpcOk({ transaction: { id: "e".repeat(64) } });
}
return rpcOk({});
},
);
const chain = new LiveOgmiosProvider({
ogmiosUrl: "http://ogmios:1337",
fetch: mockFetch,
});
// Capture the tx CBOR from buildPostAdvertTx's chain.submitTx call
vi.spyOn(chain, "submitTx").mockImplementation(async (txCborHex: string) => {
captured.value = txCborHex;
return "e".repeat(64);
});
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
return { chain, capturedTxCborHex: captured };
}
/**
* buildMockChain — matches the convention from cli-post-advert-flow.test.ts.
* VALID_TIP_SLOT = 0 → mockSlotToWallclockMs(0) = 0.
*/
function buildMockChain(): MockChainProvider {
const chain = new MockChainProvider();
chain.advanceSlot(VALID_TIP_SLOT);
return chain;
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_DATE);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
// ─── Mock backend — existing convention preserved ─────────────────────────────
describe("runPostAdvert() [mock backend] — advertised_at = mockSlotToWallclockMs(tipSlot)", () => {
it("advert.advertised_at === mockSlotToWallclockMs(VALID_TIP_SLOT)", async () => {
// VALID_TIP_SLOT = 0 → mockSlotToWallclockMs(0) = 0
const EXPECTED_ADVERTISED_AT = VALID_TIP_SLOT * 1_000; // = 0
const chain = buildMockChain();
const result = await runPostAdvert({
chain,
walletKey: buildSupplierWalletKey(),
advertDatum: buildCliAdvertDatum(),
});
// With MockChainProvider the seeded UTxO is queryable directly
const utxo = await chain.queryUtxo(result.advertRef);
expect(utxo).not.toBeNull();
const storedDatum = decodeAdvertDatum(utxo!.datumHex!);
expect(storedDatum.advertised_at).toBe(EXPECTED_ADVERTISED_AT);
});
});
// ─── Live backend — uses Date.now() ──────────────────────────────────────────
describe("runPostAdvert() [live backend] — advertised_at = Date.now()", () => {
it("advert.advertised_at === Date.now() (FAKE_NOW_MS) when chain is LiveOgmiosProvider", async () => {
// RED phase: the current implementation uses mockSlotToWallclockMs(TIP_SLOT_LIVE)
// = 50_000_000. The test asserts advertised_at === FAKE_NOW_MS (1_745_841_600_000).
// This FAILS until Catherine branches postAdvertFlow.ts on LiveOgmiosProvider.
//
// Note: for this test to pass GREEN, Catherine must also update buildPostAdvertTx
// to use Date.now() for the ±5min validity check on live chains, since
// |FAKE_NOW_MS - 50_000_000| >> 5min and would otherwise throw
// "advertised_at out of validity range".
const { chain, capturedTxCborHex } = buildLiveChain();
await runPostAdvert({
chain,
walletKey: buildSupplierWalletKey(),
advertDatum: buildCliAdvertDatum(),
});
// Extract advertised_at from the datum embedded in the submitted tx CBOR
expect(capturedTxCborHex.value).not.toBeUndefined();
const advertisedAt = extractAdvertisedAtFromTxCbor(capturedTxCborHex.value!);
expect(advertisedAt).not.toBeNull();
// Must be FAKE_NOW_MS, not TIP_SLOT_LIVE * 1000
expect(advertisedAt).toBe(FAKE_NOW_MS);
});
it("callee uses Date.now() not tipSlot*1000 — advertised_at is in real-POSIX-ms range (>1.7e12)", async () => {
// Complementary discriminating assertion:
// - TIP_SLOT_LIVE_MS = 50_000_000 (NOT in real POSIX range)
// - FAKE_NOW_MS = 1_745_841_600_000 (in real POSIX range > 1.7e12)
// RED: current code sets advertised_at = 50_000_000 (< 1.7e12 threshold).
// GREEN: Catherine's code must set advertised_at = Date.now() = FAKE_NOW_MS (> 1.7e12).
const { chain, capturedTxCborHex } = buildLiveChain();
await runPostAdvert({
chain,
walletKey: buildSupplierWalletKey(),
advertDatum: buildCliAdvertDatum(),
});
expect(capturedTxCborHex.value).not.toBeUndefined();
const advertisedAt = extractAdvertisedAtFromTxCbor(capturedTxCborHex.value!);
expect(advertisedAt).not.toBeNull();
// Sanity checks on constants
expect(FAKE_NOW_MS).toBeGreaterThan(1.7e12);
expect(TIP_SLOT_LIVE_MS).toBeLessThan(1e9); // far below real-clock range
// The actual assertion: advertised_at must be in the real-POSIX-ms range
expect(advertisedAt!).toBeGreaterThan(1.7e12);
// And must NOT be the mock-convention value
expect(advertisedAt!).not.toBe(TIP_SLOT_LIVE_MS);
});
});