199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type PluginHookPublisherMocks = {
|
|
transaction: ReturnType<typeof vi.fn>;
|
|
publishOutboxEvent: ReturnType<typeof vi.fn>;
|
|
emitPluginHook: ReturnType<typeof vi.fn>;
|
|
loggerWarn: ReturnType<typeof vi.fn>;
|
|
loggerError: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
const { TestPluginHookValidationError } = vi.hoisted(() => {
|
|
class TestPluginHookValidationError extends Error {
|
|
public override readonly name = "PluginHookValidationError";
|
|
public readonly hookName: string;
|
|
public readonly issues: unknown[];
|
|
|
|
constructor(hookName: string, issues: unknown[]) {
|
|
super(`Invalid payload for plugin hook '${hookName}'`);
|
|
this.hookName = hookName;
|
|
this.issues = issues;
|
|
}
|
|
}
|
|
|
|
return { TestPluginHookValidationError };
|
|
});
|
|
|
|
function getMocks(): PluginHookPublisherMocks {
|
|
const store = globalThis as typeof globalThis & { __pluginHookPublisherMocks__?: PluginHookPublisherMocks };
|
|
store.__pluginHookPublisherMocks__ ??= {
|
|
transaction: vi.fn(async (callback: (tx: unknown) => Promise<unknown>) => callback({ tx: true })),
|
|
publishOutboxEvent: vi.fn(async () => ({ id: "outbox_1" })),
|
|
emitPluginHook: vi.fn(async (tx, def, payload, publish, options) =>
|
|
publish(tx, {
|
|
tenantId: options?.tenantId ?? null,
|
|
type: def.outboxEventType,
|
|
payload,
|
|
dispatchKey: options?.dispatchKey ?? null,
|
|
})
|
|
),
|
|
loggerWarn: vi.fn(),
|
|
loggerError: vi.fn(),
|
|
};
|
|
return store.__pluginHookPublisherMocks__;
|
|
}
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {
|
|
$transaction: getMocks().transaction,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@toolctrl/db/outbox", () => ({
|
|
publishOutboxEvent: getMocks().publishOutboxEvent,
|
|
}));
|
|
|
|
vi.mock("@toolctrl/plugin-runtime", () => ({
|
|
emitPluginHook: getMocks().emitPluginHook,
|
|
PluginHookValidationError: TestPluginHookValidationError,
|
|
}));
|
|
|
|
vi.mock("~/utils/logger.server", () => ({
|
|
createScopedLogger: () => ({
|
|
warn: getMocks().loggerWarn,
|
|
error: getMocks().loggerError,
|
|
}),
|
|
}));
|
|
|
|
import { emitPluginHookFireAndForget } from "./pluginHookPublisher";
|
|
|
|
const mocks = getMocks();
|
|
|
|
const hookDefinition = {
|
|
name: "workflow.failed",
|
|
outboxEventType: "plugin.hook.workflow.failed",
|
|
};
|
|
|
|
describe("emitPluginHookFireAndForget", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => callback({ tx: true }));
|
|
mocks.publishOutboxEvent.mockResolvedValue({ id: "outbox_1" });
|
|
mocks.emitPluginHook.mockImplementation(async (tx, def, payload, publish, options) =>
|
|
publish(tx, {
|
|
tenantId: options?.tenantId ?? null,
|
|
type: def.outboxEventType,
|
|
payload,
|
|
dispatchKey: options?.dispatchKey ?? null,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("records hook publish failures in logs without failing the caller", async () => {
|
|
mocks.publishOutboxEvent.mockRejectedValueOnce(new Error("outbox unavailable"));
|
|
|
|
await expect(
|
|
emitPluginHookFireAndForget(
|
|
hookDefinition,
|
|
{ tenantId: "tenant_1", workflowId: "workflow_1" },
|
|
{ tenantId: "tenant_1", dispatchKey: "workflow-execution:execution_1:failed" }
|
|
)
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
|
expect(mocks.publishOutboxEvent).toHaveBeenCalledTimes(1);
|
|
expect(mocks.loggerError).toHaveBeenCalledWith(
|
|
{
|
|
hookName: "workflow.failed",
|
|
tenantId: "tenant_1",
|
|
error: "outbox unavailable",
|
|
},
|
|
"plugin.hook.emit_failed"
|
|
);
|
|
});
|
|
|
|
it("publishes a plugin hook outbox event inside its one-shot transaction", async () => {
|
|
await emitPluginHookFireAndForget(
|
|
{
|
|
name: "tenant.created",
|
|
outboxEventType: "plugin.hook.tenant.created",
|
|
},
|
|
{
|
|
tenantId: "tenant_1",
|
|
slug: "acme",
|
|
ownerEmail: "owner@example.com",
|
|
plan: "free",
|
|
createdAt: "2026-05-17T10:00:00.000Z",
|
|
},
|
|
{ tenantId: "tenant_1", dispatchKey: "tenant:tenant_1" }
|
|
);
|
|
|
|
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
|
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
|
{ tx: true },
|
|
{
|
|
tenantId: "tenant_1",
|
|
type: "plugin.hook.tenant.created",
|
|
payload: {
|
|
tenantId: "tenant_1",
|
|
slug: "acme",
|
|
ownerEmail: "owner@example.com",
|
|
plan: "free",
|
|
createdAt: "2026-05-17T10:00:00.000Z",
|
|
},
|
|
dispatchKey: "tenant:tenant_1",
|
|
}
|
|
);
|
|
});
|
|
|
|
it("records hook validation failures in logs without failing the caller", async () => {
|
|
const issues = [{ path: ["workflowId"], message: "Required" }];
|
|
mocks.emitPluginHook.mockRejectedValueOnce(new TestPluginHookValidationError("workflow.failed", issues));
|
|
|
|
await expect(
|
|
emitPluginHookFireAndForget(hookDefinition, { tenantId: "tenant_1" }, { tenantId: "tenant_1", dispatchKey: "workflow:bad" })
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
|
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
|
{
|
|
hookName: "workflow.failed",
|
|
tenantId: "tenant_1",
|
|
issues,
|
|
},
|
|
"plugin.hook.payload_invalid"
|
|
);
|
|
});
|
|
|
|
it("records transaction failures in logs without failing the caller", async () => {
|
|
mocks.transaction.mockRejectedValueOnce(new Error("db down"));
|
|
|
|
await expect(
|
|
emitPluginHookFireAndForget(
|
|
{
|
|
name: "user.created",
|
|
outboxEventType: "plugin.hook.user.created",
|
|
},
|
|
{
|
|
userId: "user_1",
|
|
tenantId: "tenant_1",
|
|
email: "u@example.com",
|
|
locale: "en",
|
|
createdAt: "2026-05-17T10:00:00.000Z",
|
|
},
|
|
{ tenantId: "tenant_1", dispatchKey: "user:user_1" }
|
|
)
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
|
expect(mocks.loggerError).toHaveBeenCalledWith(
|
|
{
|
|
hookName: "user.created",
|
|
tenantId: "tenant_1",
|
|
error: "db down",
|
|
},
|
|
"plugin.hook.emit_failed"
|
|
);
|
|
});
|
|
});
|