426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
953 lines
29 KiB
TypeScript
953 lines
29 KiB
TypeScript
/**
|
|
* BOOK_INFLUENCER action tests, including the two-phase money confirm before a paid booking. The @elizaos/cloud-sdk client is faked (helpers.ts, SDK boundary only); the action runs for real.
|
|
*/
|
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
import type {
|
|
CreateBookingInput,
|
|
InfluencerProfileDto,
|
|
} from "@elizaos/cloud-sdk";
|
|
import {
|
|
captureCallback,
|
|
FakeElizaCloudClient,
|
|
keyedRuntime,
|
|
makeMessage,
|
|
resetSdk,
|
|
setCreateBooking,
|
|
setListInfluencers,
|
|
unkeyedRuntime,
|
|
} from "./helpers";
|
|
|
|
mock.module("@elizaos/cloud-sdk", () => ({
|
|
ElizaCloudClient: FakeElizaCloudClient,
|
|
}));
|
|
|
|
const { bookInfluencerAction } = await import(
|
|
"../src/actions/book-influencer.ts"
|
|
);
|
|
const {
|
|
CONFIRM_TTL_MS,
|
|
findPendingCloudAppConfirmation,
|
|
persistCloudAppConfirmation,
|
|
} = await import("../src/safety.ts");
|
|
|
|
function profile(id: string, displayName: string): InfluencerProfileDto {
|
|
return {
|
|
id,
|
|
display_name: displayName,
|
|
niche: null,
|
|
bio: null,
|
|
platforms: [],
|
|
status: "active",
|
|
};
|
|
}
|
|
|
|
/** Install a booking tracker: counts calls and captures the last input. */
|
|
function trackBookings(): {
|
|
calls: CreateBookingInput[];
|
|
} {
|
|
const calls: CreateBookingInput[] = [];
|
|
setCreateBooking((i) => {
|
|
calls.push(i);
|
|
return Promise.resolve({
|
|
success: true,
|
|
booking: {
|
|
id: "bk",
|
|
advertiser_org_id: "o",
|
|
influencer_profile_id: i.profileId,
|
|
amount: String(i.amount),
|
|
status: "offered",
|
|
brief: i.brief,
|
|
},
|
|
});
|
|
});
|
|
return { calls };
|
|
}
|
|
|
|
describe("BOOK_INFLUENCER (two-phase money confirm)", () => {
|
|
beforeEach(() => resetSdk());
|
|
|
|
it("validate: true with key, false without", async () => {
|
|
expect(
|
|
await bookInfluencerAction.validate(keyedRuntime(), makeMessage("x")),
|
|
).toBe(true);
|
|
expect(
|
|
await bookInfluencerAction.validate(unkeyedRuntime(), makeMessage("x")),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("no key → no_key, no money call", async () => {
|
|
let called = false;
|
|
setCreateBooking((i) => {
|
|
called = true;
|
|
return Promise.resolve({
|
|
success: true,
|
|
booking: {
|
|
id: "b",
|
|
advertiser_org_id: "o",
|
|
influencer_profile_id: i.profileId,
|
|
amount: String(i.amount),
|
|
status: "offered",
|
|
brief: i.brief,
|
|
},
|
|
});
|
|
});
|
|
const cb = captureCallback();
|
|
const res = await bookInfluencerAction.handler(
|
|
unkeyedRuntime(),
|
|
makeMessage("hire Nova for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", amount: 200 },
|
|
cb.callback,
|
|
);
|
|
expect(res.success).toBe(false);
|
|
expect(res.data).toMatchObject({ reason: "no_key" });
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
it("first ask NEVER books; explicit confirm books exactly once", async () => {
|
|
const runtime = keyedRuntime();
|
|
let captured: CreateBookingInput | null = null;
|
|
let calls = 0;
|
|
setCreateBooking((i) => {
|
|
calls += 1;
|
|
captured = i;
|
|
return Promise.resolve({
|
|
success: true,
|
|
booking: {
|
|
id: "bk",
|
|
advertiser_org_id: "o",
|
|
influencer_profile_id: i.profileId,
|
|
amount: String(i.amount),
|
|
status: "offered",
|
|
brief: i.brief,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Phase 1 — first ask: confirmation required, NO booking.
|
|
const ask = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova to promote my app for $200"),
|
|
undefined,
|
|
{
|
|
profileId: "inf_1",
|
|
influencer: "Nova",
|
|
amount: 200,
|
|
brief: "post about us",
|
|
},
|
|
captureCallback().callback,
|
|
);
|
|
expect(
|
|
(ask?.data as { confirmationRequired?: boolean }).confirmationRequired,
|
|
).toBe(true);
|
|
expect(calls).toBe(0);
|
|
|
|
// Phase 2 — confirm on the SAME runtime: books once.
|
|
const confirmCb = captureCallback();
|
|
const done = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("yes confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
confirmCb.callback,
|
|
);
|
|
expect(done.success).toBe(true);
|
|
expect(calls).toBe(1);
|
|
expect(captured).toMatchObject({
|
|
profileId: "inf_1",
|
|
amount: 200,
|
|
brief: "post about us",
|
|
});
|
|
// Money-safety: the confirm carries a stable idempotency key so a
|
|
// transport-level retry cannot fund a second escrow.
|
|
expect(captured?.idempotencyKey).toMatch(/^influencer-confirm-.+/);
|
|
});
|
|
|
|
it("cancel: no booking", async () => {
|
|
const runtime = keyedRuntime();
|
|
let calls = 0;
|
|
setCreateBooking((i) => {
|
|
calls += 1;
|
|
return Promise.resolve({
|
|
success: true,
|
|
booking: {
|
|
id: "bk",
|
|
advertiser_org_id: "o",
|
|
influencer_profile_id: i.profileId,
|
|
amount: String(i.amount),
|
|
status: "offered",
|
|
brief: i.brief,
|
|
},
|
|
});
|
|
});
|
|
await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova for $50"),
|
|
undefined,
|
|
{ profileId: "inf_1", amount: 50 },
|
|
captureCallback().callback,
|
|
);
|
|
const res = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("no cancel"),
|
|
undefined,
|
|
{ confirm: false },
|
|
captureCallback().callback,
|
|
);
|
|
expect(res.data).toMatchObject({ canceled: true });
|
|
expect(calls).toBe(0);
|
|
});
|
|
|
|
it("confirm with no pending → no_pending_confirmation", async () => {
|
|
const cb = captureCallback();
|
|
const res = await bookInfluencerAction.handler(
|
|
keyedRuntime(),
|
|
makeMessage("yes"),
|
|
undefined,
|
|
{ confirm: true },
|
|
cb.callback,
|
|
);
|
|
expect(res.success).toBe(false);
|
|
expect(res.data).toMatchObject({ reason: "no_pending_confirmation" });
|
|
});
|
|
|
|
it("MONEY: confirm naming a DIFFERENT influencer refuses, funds nothing, clears the pending", async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova to promote my app for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", influencer: "Nova", amount: 200, brief: "post" },
|
|
captureCallback().fn,
|
|
);
|
|
|
|
const cb = captureCallback();
|
|
const result = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("yes — book Blaze instead"),
|
|
undefined,
|
|
{ parameters: { confirm: true, influencer: "Blaze" } },
|
|
cb.fn,
|
|
);
|
|
|
|
expect(calls).toHaveLength(0);
|
|
expect(result.success).toBe(false);
|
|
expect((result.data as { reason: string }).reason).toBe(
|
|
"confirm_target_mismatch",
|
|
);
|
|
const reply = cb.calls.at(-1)?.text ?? "";
|
|
expect(reply).toContain("Blaze");
|
|
expect(reply).toContain("Nova");
|
|
|
|
// Pending cleared: a later bare confirm cannot fund the stale booking.
|
|
const followUp = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
captureCallback().fn,
|
|
);
|
|
expect(calls).toHaveLength(0);
|
|
expect((followUp.data as { reason: string }).reason).toBe(
|
|
"no_pending_confirmation",
|
|
);
|
|
});
|
|
|
|
it("MONEY: confirm carrying a DIFFERENT structured budget refuses (frozen $200 vs turn $999)", async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", influencer: "Nova", amount: 200, brief: "post" },
|
|
captureCallback().fn,
|
|
);
|
|
const result = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm at $999"),
|
|
undefined,
|
|
{ parameters: { confirm: true, amount: 999 } },
|
|
captureCallback().fn,
|
|
);
|
|
expect(calls).toHaveLength(0);
|
|
expect((result.data as { reason: string }).reason).toBe(
|
|
"confirm_target_mismatch",
|
|
);
|
|
|
|
// Re-ask + a confirm that re-names the SAME influencer and budget books.
|
|
await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", influencer: "Nova", amount: 200, brief: "post" },
|
|
captureCallback().fn,
|
|
);
|
|
const ok = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("yes book Nova for $200"),
|
|
undefined,
|
|
{ parameters: { confirm: true, influencer: "Nova", amount: 200 } },
|
|
captureCallback().fn,
|
|
);
|
|
expect(ok.success).toBe(true);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.amount).toBe(200);
|
|
expect(calls[0]?.profileId).toBe("inf_1");
|
|
});
|
|
});
|
|
|
|
describe("BOOK_INFLUENCER pending-stacking + TTL guards", () => {
|
|
beforeEach(() => resetSdk());
|
|
|
|
it("a second ask while one is pending re-prompts; a later confirm funds only the FIRST ask", async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
|
|
await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", influencer: "Nova", amount: 200, brief: "post" },
|
|
undefined,
|
|
);
|
|
|
|
// Second first-phase ask (different influencer + amount) must NOT stack a
|
|
// second pending — it re-prompts for the one already waiting.
|
|
const second = captureCallback();
|
|
const res = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("actually hire Mallory for $500"),
|
|
undefined,
|
|
{ profileId: "inf_2", influencer: "Mallory", amount: 500, brief: "x" },
|
|
second.fn,
|
|
);
|
|
expect(res.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
profileId: "inf_1",
|
|
amount: 200,
|
|
});
|
|
expect(res.userFacingText).toContain("still waiting");
|
|
expect(calls.length).toBe(0);
|
|
|
|
// The bare confirm funds the booking the user was actually shown.
|
|
const done = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("yes confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
expect(done.success).toBe(true);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0]).toMatchObject({ profileId: "inf_1", amount: 200 });
|
|
});
|
|
|
|
it("a confirm against an aged pending refuses, funds nothing, and deletes the pending", async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
await persistCloudAppConfirmation(runtime, {
|
|
roomId: String(runtime.agentId),
|
|
action: "BOOK_INFLUENCER",
|
|
appId: "inf_1",
|
|
appName: "Nova",
|
|
amount: 200,
|
|
brief: "post about us",
|
|
intentCreatedAt: new Date(
|
|
Date.now() - CONFIRM_TTL_MS - 1000,
|
|
).toISOString(),
|
|
});
|
|
|
|
const res = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("yes confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
expect(res.success).toBe(false);
|
|
expect(res.data).toMatchObject({
|
|
reason: "confirmation_expired",
|
|
booked: false,
|
|
});
|
|
expect(calls.length).toBe(0);
|
|
|
|
// The stale pending is gone: a second bare confirm finds nothing to fund.
|
|
const again = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
expect(again.data).toMatchObject({ reason: "no_pending_confirmation" });
|
|
expect(calls.length).toBe(0);
|
|
});
|
|
|
|
it("a fresh ask replaces a stale pending (at most one pending per room)", async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
await persistCloudAppConfirmation(runtime, {
|
|
roomId: String(runtime.agentId),
|
|
action: "BOOK_INFLUENCER",
|
|
appId: "inf_old",
|
|
appName: "Old Guy",
|
|
amount: 999,
|
|
brief: "stale",
|
|
intentCreatedAt: new Date(
|
|
Date.now() - CONFIRM_TTL_MS - 1000,
|
|
).toISOString(),
|
|
});
|
|
|
|
const ask = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", influencer: "Nova", amount: 200, brief: "post" },
|
|
undefined,
|
|
);
|
|
expect(ask.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
profileId: "inf_1",
|
|
});
|
|
|
|
const done = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
expect(done.success).toBe(true);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0]).toMatchObject({ profileId: "inf_1", amount: 200 });
|
|
});
|
|
});
|
|
|
|
describe("BOOK_INFLUENCER budget parsing (currency cue required)", () => {
|
|
beforeEach(() => resetSdk());
|
|
|
|
it('a bare number in the message is NOT a budget ("book Nova, she has 80000 followers")', async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
setListInfluencers(() =>
|
|
Promise.resolve({ success: true, profiles: [profile("inf_1", "Nova")] }),
|
|
);
|
|
|
|
const ask = captureCallback();
|
|
const res = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("book Nova, she has 80000 followers"),
|
|
undefined,
|
|
{ influencer: "Nova" },
|
|
ask.fn,
|
|
);
|
|
expect(res.success).toBe(false);
|
|
expect(res.data).toMatchObject({ reason: "missing_input" });
|
|
expect(res.userFacingText).toContain("budget");
|
|
expect(calls.length).toBe(0);
|
|
|
|
// Nothing was persisted: a bare confirm has nothing to fund.
|
|
const confirm = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
expect(confirm.data).toMatchObject({ reason: "no_pending_confirmation" });
|
|
expect(calls.length).toBe(0);
|
|
});
|
|
|
|
it('cue\'d text amounts parse: "$50", "50 bucks", "50 usd"', async () => {
|
|
setListInfluencers(() =>
|
|
Promise.resolve({ success: true, profiles: [profile("inf_1", "Nova")] }),
|
|
);
|
|
for (const [text, expected] of [
|
|
["hire Nova for $50", 50],
|
|
["hire Nova for 50 bucks", 50],
|
|
["hire Nova for 50 usd", 50],
|
|
["hire Nova for 12.50 dollars", 12.5],
|
|
] as const) {
|
|
const res = await bookInfluencerAction.handler(
|
|
keyedRuntime(),
|
|
makeMessage(text),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(res.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
amount: expected,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("reads the nested options.parameters amount first (real planner path)", async () => {
|
|
const res = await bookInfluencerAction.handler(
|
|
keyedRuntime(),
|
|
makeMessage("book Nova"),
|
|
undefined,
|
|
{
|
|
parameters: {
|
|
profileId: "inf_1",
|
|
influencer: "Nova",
|
|
amount: 75,
|
|
brief: "post",
|
|
},
|
|
},
|
|
undefined,
|
|
);
|
|
expect(res.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
profileId: "inf_1",
|
|
amount: 75,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("BOOK_INFLUENCER influencer resolution (ambiguity-aware)", () => {
|
|
beforeEach(() => resetSdk());
|
|
|
|
it("an ambiguous reference asks to disambiguate instead of booking a lookalike", async () => {
|
|
const runtime = keyedRuntime();
|
|
const { calls } = trackBookings();
|
|
setListInfluencers(() =>
|
|
Promise.resolve({
|
|
success: true,
|
|
profiles: [profile("inf_a", "Nova Cat"), profile("inf_b", "Nova Dog")],
|
|
}),
|
|
);
|
|
|
|
const res = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("book Nova for $100"),
|
|
undefined,
|
|
{ influencer: "Nova", amount: 100 },
|
|
undefined,
|
|
);
|
|
expect(res.success).toBe(false);
|
|
expect(res.data).toMatchObject({
|
|
reason: "ambiguous",
|
|
candidates: ["Nova Cat", "Nova Dog"],
|
|
});
|
|
expect(res.userFacingText).toContain("Nova Cat");
|
|
expect(res.userFacingText).toContain("Nova Dog");
|
|
expect(calls.length).toBe(0);
|
|
|
|
// No pending was staged for either candidate.
|
|
const confirm = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
expect(confirm.data).toMatchObject({ reason: "no_pending_confirmation" });
|
|
expect(calls.length).toBe(0);
|
|
});
|
|
|
|
it("an adversarial substring profile cannot capture the booking (whole-word beats fragment)", async () => {
|
|
// Old first-match `ref.includes(name)` resolution: a profile named "Pro"
|
|
// captured "hire Nova to promote my app…" via the "pro" inside "promote".
|
|
setListInfluencers(() =>
|
|
Promise.resolve({
|
|
success: true,
|
|
profiles: [profile("inf_bad", "Pro"), profile("inf_good", "Nova")],
|
|
}),
|
|
);
|
|
const res = await bookInfluencerAction.handler(
|
|
keyedRuntime(),
|
|
makeMessage("hire Nova to promote my app for $200"),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(res.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
profileId: "inf_good",
|
|
amount: 200,
|
|
});
|
|
});
|
|
|
|
it("exact display-name match still resolves directly", async () => {
|
|
setListInfluencers(() =>
|
|
Promise.resolve({
|
|
success: true,
|
|
profiles: [profile("inf_1", "Nova"), profile("inf_2", "Mallory")],
|
|
}),
|
|
);
|
|
const res = await bookInfluencerAction.handler(
|
|
keyedRuntime(),
|
|
makeMessage("book her"),
|
|
undefined,
|
|
{ influencer: "nova", amount: 40 },
|
|
undefined,
|
|
);
|
|
expect(res.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
profileId: "inf_1",
|
|
amount: 40,
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Stateful fake escrow mirroring the SERVER's createBooking/fundBooking
|
|
* semantics (packages/cloud/shared/src/lib/services/influencer-marketplace.ts):
|
|
* a keyed debit ledger + a funding→offered finalize, where a same-key call
|
|
* resumes/replays the original booking without a second debit and a failed
|
|
* debit retires the funding row. Lets the tests count REAL credit holds across
|
|
* retries instead of just counting calls — a retry that mints a NEW key funds
|
|
* a second escrow here exactly like it would in production (#11844).
|
|
*/
|
|
class FakeEscrow {
|
|
/** Advertiser credit holds, one per funded booking — the money under guard. */
|
|
holds: Array<{ key: string; amount: number }> = [];
|
|
bookings = new Map<
|
|
string,
|
|
{ id: string; status: "funding" | "offered"; amount: number }
|
|
>();
|
|
calls: CreateBookingInput[] = [];
|
|
/** Failure injected into the NEXT fresh (non-resume) fund call. */
|
|
failNext:
|
|
| "none"
|
|
| "response_lost"
|
|
| "crash_in_funding"
|
|
| "insufficient_credits" = "none";
|
|
|
|
install(): void {
|
|
setCreateBooking((input) => {
|
|
this.calls.push(input);
|
|
const key = input.idempotencyKey ?? `anon-${this.calls.length}`;
|
|
const existing = this.bookings.get(key);
|
|
if (existing) {
|
|
// Same-key resume/replay: drive a crashed `funding` row to `offered`
|
|
// (or return the already-offered booking) with NO new debit.
|
|
existing.status = "offered";
|
|
return Promise.resolve(this.ok(existing, input));
|
|
}
|
|
const mode = this.failNext;
|
|
this.failNext = "none";
|
|
if (mode === "insufficient_credits") {
|
|
// Clean business rejection: debit failed, funding row retired —
|
|
// no hold, no resumable state. Duck-typed CloudApiError (402).
|
|
return Promise.reject(
|
|
Object.assign(new Error("Insufficient credits"), {
|
|
statusCode: 402,
|
|
errorBody: { success: false, error: "Insufficient credits" },
|
|
}),
|
|
);
|
|
}
|
|
// The keyed debit — THE hold this whole suite is guarding.
|
|
this.holds.push({ key, amount: input.amount });
|
|
const booking = {
|
|
id: `bk_${this.bookings.size + 1}`,
|
|
status:
|
|
mode === "crash_in_funding"
|
|
? ("funding" as const)
|
|
: ("offered" as const),
|
|
amount: input.amount,
|
|
};
|
|
this.bookings.set(key, booking);
|
|
if (mode === "response_lost" || mode === "crash_in_funding") {
|
|
// Transport-level failure AFTER the server committed money state:
|
|
// the client never sees a status code.
|
|
return Promise.reject(new TypeError("fetch failed: network dropped"));
|
|
}
|
|
return Promise.resolve(this.ok(booking, input));
|
|
});
|
|
}
|
|
|
|
private ok(
|
|
booking: { id: string; status: "funding" | "offered"; amount: number },
|
|
input: CreateBookingInput,
|
|
) {
|
|
return {
|
|
success: true as const,
|
|
booking: {
|
|
id: booking.id,
|
|
advertiser_org_id: "org",
|
|
influencer_profile_id: input.profileId,
|
|
amount: String(booking.amount),
|
|
status: booking.status,
|
|
brief: input.brief,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
describe("BOOK_INFLUENCER escrow idempotency-key survival (#11844)", () => {
|
|
beforeEach(() => resetSdk());
|
|
|
|
function roomOf(runtime: ReturnType<typeof keyedRuntime>): string {
|
|
return String(runtime.agentId);
|
|
}
|
|
|
|
async function stagePending(
|
|
runtime: ReturnType<typeof keyedRuntime>,
|
|
): Promise<void> {
|
|
await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Nova for $200"),
|
|
undefined,
|
|
{ profileId: "inf_1", influencer: "Nova", amount: 200, brief: "post" },
|
|
undefined,
|
|
);
|
|
}
|
|
|
|
async function confirm(runtime: ReturnType<typeof keyedRuntime>) {
|
|
return bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("confirm"),
|
|
undefined,
|
|
{ confirm: true },
|
|
undefined,
|
|
);
|
|
}
|
|
|
|
it("lane (a) fund committed + response lost: the pending/key survives and the re-confirm replays the SAME escrow — one hold, one key", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "response_lost";
|
|
const failed = await confirm(runtime);
|
|
expect(failed.success).toBe(false);
|
|
expect(failed.userFacingText).toContain("retry safely");
|
|
// The server DID commit before the response was lost.
|
|
expect(escrow.holds.length).toBe(1);
|
|
|
|
// The pending survived the transport failure: same task, marked recovery,
|
|
// still the sole holder of the key the first attempt used.
|
|
const pending = await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
);
|
|
expect(pending).not.toBeNull();
|
|
expect(pending?.metadata.recovery).toBe(true);
|
|
expect(escrow.calls[0]?.idempotencyKey).toBe(
|
|
`influencer-confirm-${pending?.taskId}`,
|
|
);
|
|
|
|
// Re-confirm: the SAME key replays the booking — NO second hold. (Before
|
|
// the fix the pending died with the first confirm, so this retry carried
|
|
// a NEW key and funded a SECOND escrow.)
|
|
const done = await confirm(runtime);
|
|
expect(done.success).toBe(true);
|
|
expect(done.data).toMatchObject({ booked: true, recovery: true });
|
|
expect(escrow.calls.length).toBe(2);
|
|
expect(escrow.calls[1]?.idempotencyKey).toBe(
|
|
escrow.calls[0]?.idempotencyKey,
|
|
);
|
|
expect(escrow.holds.length).toBe(1);
|
|
// Settled: the pending (and its key) is done with.
|
|
expect(
|
|
await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("lane (b) crash between keyed debit and finalize: the same-key re-confirm resumes 'funding' to terminal 'offered' with no second debit", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "crash_in_funding";
|
|
const failed = await confirm(runtime);
|
|
expect(failed.success).toBe(false);
|
|
const key = escrow.calls[0]?.idempotencyKey as string;
|
|
// Stranded server state: debited, stuck in `funding` — no API transition
|
|
// accepts it; only a same-key retry can repair it.
|
|
expect(escrow.bookings.get(key)?.status).toBe("funding");
|
|
expect(escrow.holds.length).toBe(1);
|
|
|
|
const done = await confirm(runtime);
|
|
expect(done.success).toBe(true);
|
|
expect(escrow.calls[1]?.idempotencyKey).toBe(key);
|
|
// Repaired to a terminal, user-actionable state — and still one debit.
|
|
expect(escrow.bookings.get(key)?.status).toBe("offered");
|
|
expect(escrow.holds.length).toBe(1);
|
|
});
|
|
|
|
it("the recovery retry is TTL-exempt: an aged recovery pending still repairs with the SAME key", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "crash_in_funding";
|
|
await confirm(runtime);
|
|
const pending = await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
);
|
|
expect(pending?.metadata.recovery).toBe(true);
|
|
// Age the recovery pending far past the confirm TTL.
|
|
await runtime.updateTask(pending?.taskId as never, {
|
|
metadata: {
|
|
...pending?.metadata,
|
|
intentCreatedAt: new Date(
|
|
Date.now() - CONFIRM_TTL_MS - 60_000,
|
|
).toISOString(),
|
|
},
|
|
});
|
|
|
|
// A plain pending would refuse the bare confirm here; the recovery one
|
|
// must still complete — expiring it would strand the `funding` debit.
|
|
const done = await confirm(runtime);
|
|
expect(done.success).toBe(true);
|
|
expect(escrow.calls[1]?.idempotencyKey).toBe(
|
|
escrow.calls[0]?.idempotencyKey,
|
|
);
|
|
expect(escrow.holds.length).toBe(1);
|
|
});
|
|
|
|
it("a definite 4xx rejection (insufficient credits) settles the confirm: nothing held, pending deleted, no recovery staged", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "insufficient_credits";
|
|
const res = await confirm(runtime);
|
|
expect(res.success).toBe(false);
|
|
expect(res.data).toMatchObject({
|
|
reason: "insufficient_credits",
|
|
booked: false,
|
|
});
|
|
expect(res.userFacingText).toContain("nothing was funded");
|
|
expect(escrow.holds.length).toBe(0);
|
|
expect(
|
|
await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
),
|
|
).toBeNull();
|
|
|
|
// No zombie retry: a later bare confirm has nothing to fund.
|
|
const again = await confirm(runtime);
|
|
expect(again.data).toMatchObject({ reason: "no_pending_confirmation" });
|
|
expect(escrow.calls.length).toBe(1);
|
|
});
|
|
|
|
it("canceling a recovery pending deletes it and is honest that the escrow may already be held", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "response_lost";
|
|
await confirm(runtime);
|
|
|
|
const res = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("no, cancel"),
|
|
undefined,
|
|
{ confirm: false },
|
|
undefined,
|
|
);
|
|
expect(res.data).toMatchObject({ canceled: true, recovery: true });
|
|
expect(res.userFacingText).toContain("may already exist");
|
|
expect(
|
|
await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
),
|
|
).toBeNull();
|
|
// Canceling never silently funded anything new.
|
|
expect(escrow.calls.length).toBe(1);
|
|
expect(escrow.holds.length).toBe(1);
|
|
});
|
|
|
|
it("a new ask while a recovery pending waits re-prompts the safe retry instead of minting a new key", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "response_lost";
|
|
await confirm(runtime);
|
|
|
|
// A fresh phase-1 ask (different influencer) must not stack a second
|
|
// pending — the recovery (and its key) stays the one thing to settle.
|
|
const ask = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("hire Mallory for $500"),
|
|
undefined,
|
|
{ profileId: "inf_2", influencer: "Mallory", amount: 500, brief: "x" },
|
|
undefined,
|
|
);
|
|
expect(ask.data).toMatchObject({
|
|
confirmationRequired: true,
|
|
profileId: "inf_1",
|
|
amount: 200,
|
|
});
|
|
expect(ask.userFacingText).toContain("retry safely");
|
|
|
|
const done = await confirm(runtime);
|
|
expect(done.success).toBe(true);
|
|
expect(escrow.calls.length).toBe(2);
|
|
expect(escrow.calls[1]?.idempotencyKey).toBe(
|
|
escrow.calls[0]?.idempotencyKey,
|
|
);
|
|
expect(escrow.holds.length).toBe(1);
|
|
});
|
|
|
|
it("a mismatched confirm against a recovery pending refuses but keeps the same-key repair pending alive", async () => {
|
|
const runtime = keyedRuntime();
|
|
const escrow = new FakeEscrow();
|
|
escrow.install();
|
|
await stagePending(runtime);
|
|
|
|
escrow.failNext = "response_lost";
|
|
await confirm(runtime);
|
|
const pending = await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
);
|
|
expect(pending?.metadata.recovery).toBe(true);
|
|
|
|
const mismatch = await bookInfluencerAction.handler(
|
|
runtime,
|
|
makeMessage("yes, book Mallory instead"),
|
|
undefined,
|
|
{ parameters: { confirm: true, influencer: "Mallory" } },
|
|
undefined,
|
|
);
|
|
expect(mismatch.success).toBe(false);
|
|
expect(mismatch.data).toMatchObject({
|
|
reason: "confirm_target_mismatch",
|
|
recovery: true,
|
|
});
|
|
expect(escrow.calls.length).toBe(1);
|
|
expect(escrow.holds.length).toBe(1);
|
|
|
|
const stillPending = await findPendingCloudAppConfirmation(
|
|
runtime,
|
|
roomOf(runtime),
|
|
"BOOK_INFLUENCER",
|
|
);
|
|
expect(stillPending?.taskId).toBe(pending?.taskId);
|
|
expect(stillPending?.metadata.recovery).toBe(true);
|
|
|
|
const repaired = await confirm(runtime);
|
|
expect(repaired.success).toBe(true);
|
|
expect(escrow.calls[1]?.idempotencyKey).toBe(
|
|
escrow.calls[0]?.idempotencyKey,
|
|
);
|
|
expect(escrow.holds.length).toBe(1);
|
|
});
|
|
});
|