Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/services/.server/pluginHookPublisher.test.ts
T

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"
);
});
});