trustgraph/ts/packages/flow/src/__tests__/flow-manager-service.test.ts

304 lines
10 KiB
TypeScript
Raw Normal View History

2026-06-04 06:53:21 -05:00
import {Effect, HashMap, Option, SynchronizedRef} from "effect";
import {describe, expect, it} from "vitest";
import type {
BackendConsumer,
BackendProducer,
ConfigRequest,
ConfigResponse,
CreateConsumerOptions,
CreateProducerOptions,
FlowRequest,
FlowResponse,
Message,
PubSubBackend,
RequestResponse,
} from "@trustgraph/base";
import {
topics,
} from "@trustgraph/base";
import {FlowManagerError, makeFlowManagerService} from "../flow-manager/service.js";
class NoopPubSub implements PubSubBackend {
readonly sentByTopic = new Map<string, Array<unknown>>();
2026-06-06 10:33:10 -05:00
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.succeed({
send: (message) => Effect.sync(() => {
const sent = this.sentByTopic.get(options.topic) ?? [];
sent.push(message);
this.sentByTopic.set(options.topic, sent);
2026-06-06 10:33:10 -05:00
}),
flush: Effect.void,
close: Effect.void,
});
}
2026-06-06 10:33:10 -05:00
createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.succeed({
receive: () => Effect.succeed(null),
acknowledge: () => Effect.void,
negativeAcknowledge: () => Effect.void,
unsubscribe: Effect.void,
close: Effect.void,
});
}
2026-06-06 10:33:10 -05:00
readonly close: Effect.Effect<void> = Effect.void;
}
class RecordingConfigClient implements RequestResponse<ConfigRequest, ConfigResponse> {
readonly requests: Array<ConfigRequest> = [];
constructor(
private readonly blueprints: Array<{readonly key: string; readonly value: unknown}> = [],
private readonly flows: Array<{readonly key: string; readonly value: unknown}> = [],
private readonly legacyFlows: Array<{readonly key: string; readonly value: unknown}> = [],
) {}
2026-06-06 10:33:10 -05:00
readonly start: Effect.Effect<void> = Effect.void;
2026-06-06 10:33:10 -05:00
readonly stop: Effect.Effect<void> = Effect.void;
2026-06-06 10:33:10 -05:00
request(request: ConfigRequest): Effect.Effect<ConfigResponse> {
return Effect.sync(() => {
this.requests.push(request);
if (request.operation !== "getvalues") return {};
2026-06-06 10:33:10 -05:00
if (request.type === "flow-blueprint") {
return {values: this.blueprints};
}
if (request.type === "flow") {
return {values: this.flows};
}
if (request.type === "flows") {
return {values: this.legacyFlows};
}
2026-06-06 10:33:10 -05:00
return {values: []};
});
}
}
const makeService = (backend: PubSubBackend = new NoopPubSub()) =>
makeFlowManagerService({
id: "flow-manager-test",
manageProcessSignals: false,
pubsub: backend,
});
const seedConfigClient = async (
service: ReturnType<typeof makeFlowManagerService>,
configClient: RecordingConfigClient,
) =>
Effect.runPromise(
SynchronizedRef.update(service.state, (state) => ({
...state,
configClient,
})),
);
const seedResponseProducer = async (
backend: NoopPubSub,
service: ReturnType<typeof makeFlowManagerService>,
) => {
2026-06-06 10:33:10 -05:00
const responseProducer = await Effect.runPromise(backend.createProducer<FlowResponse>({
topic: topics.flowResponse,
2026-06-06 10:33:10 -05:00
}));
await Effect.runPromise(
SynchronizedRef.update(service.state, (state) => ({
...state,
responseProducer,
})),
);
};
describe("FlowManagerService operations", () => {
2026-06-04 05:20:03 -05:00
it("dispatches all flow operations through the Match-backed handler", async () => {
const configClient = new RecordingConfigClient(
[
{
key: "custom",
value: "{\"description\":\"Custom\",\"topics\":{\"input\":\"topic.in\"}}",
},
],
[
{
key: "flow-a",
value: "{\"blueprint-name\":\"custom\",\"description\":\"Alpha\",\"parameters\":{\"limit\":3}}",
},
],
);
const service = makeService();
await seedConfigClient(service, configClient);
2026-06-06 10:33:10 -05:00
await expect(Effect.runPromise(service.handleOperationEffect({operation: "list-blueprints"}))).resolves.toEqual({
2026-06-04 05:20:03 -05:00
"blueprint-names": ["custom", "default"],
});
2026-06-06 10:33:10 -05:00
await expect(Effect.runPromise(service.handleOperationEffect({
2026-06-04 05:20:03 -05:00
operation: "get-blueprint",
"blueprint-name": "custom",
2026-06-06 10:33:10 -05:00
}))).resolves.toMatchObject({
2026-06-04 05:20:03 -05:00
"blueprint-definition": "{\"description\":\"Custom\",\"topics\":{\"input\":\"topic.in\"}}",
});
2026-06-06 10:33:10 -05:00
await expect(Effect.runPromise(service.handleOperationEffect({
2026-06-04 05:20:03 -05:00
operation: "put-blueprint",
"blueprint-name": "added",
"blueprint-definition": {description: "Added", topics: {input: "topic.added"}},
2026-06-06 10:33:10 -05:00
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({
2026-06-04 05:20:03 -05:00
operation: "delete-blueprint",
"blueprint-name": "custom",
2026-06-06 10:33:10 -05:00
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({operation: "list-flows"}))).resolves.toEqual({
2026-06-04 05:20:03 -05:00
"flow-ids": ["flow-a"],
});
2026-06-06 10:33:10 -05:00
await expect(Effect.runPromise(service.handleOperationEffect({
2026-06-04 05:20:03 -05:00
operation: "get-flow",
"flow-id": "flow-a",
2026-06-06 10:33:10 -05:00
}))).resolves.toEqual({
2026-06-04 05:20:03 -05:00
flow: "{\"blueprint-name\":\"custom\",\"description\":\"Alpha\",\"parameters\":{\"limit\":3}}",
});
2026-06-06 10:33:10 -05:00
await expect(Effect.runPromise(service.handleOperationEffect({
2026-06-04 05:20:03 -05:00
operation: "start-flow",
"flow-id": "flow-b",
"blueprint-name": "custom",
2026-06-06 10:33:10 -05:00
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({
2026-06-04 05:20:03 -05:00
operation: "stop-flow",
"flow-id": "flow-a",
2026-06-06 10:33:10 -05:00
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({operation: "unknown-flow"}))).rejects.toMatchObject({
2026-06-04 05:20:03 -05:00
_tag: "FlowManagerError",
operation: "operation",
message: "Unknown flow operation: unknown-flow",
});
expect(configClient.requests.some((request) =>
request.operation === "put" && request.keys?.[0] === "flow-blueprint"
)).toBe(true);
expect(configClient.requests.some((request) =>
request.operation === "delete" && request.keys?.[0] === "flow-blueprint"
)).toBe(true);
});
it("uses tagged errors for invalid flow mutations", async () => {
const service = makeService();
2026-06-06 10:33:10 -05:00
const startError = await Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow"}))
.catch((caught: unknown) => caught);
2026-06-06 10:33:10 -05:00
const stopError = await Effect.runPromise(service.handleStopFlowEffect({operation: "stop-flow"}))
.catch((caught: unknown) => caught);
expect(startError).toBeInstanceOf(FlowManagerError);
expect(startError).toMatchObject({_tag: "FlowManagerError", operation: "start-flow"});
expect(stopError).toBeInstanceOf(FlowManagerError);
expect(stopError).toMatchObject({_tag: "FlowManagerError", operation: "stop-flow"});
});
it("mutates flow state through the ref and pushes config updates", async () => {
const configClient = new RecordingConfigClient();
const service = makeService();
await seedConfigClient(service, configClient);
2026-06-06 10:33:10 -05:00
await Effect.runPromise(service.handleStartFlowEffect({
operation: "start-flow",
"flow-id": "flow-a",
description: "alpha",
parameters: {limit: 3},
2026-06-06 10:33:10 -05:00
}));
let state = await Effect.runPromise(SynchronizedRef.get(service.state));
2026-06-04 06:53:21 -05:00
expect(Option.getOrUndefined(HashMap.get(state.flows, "flow-a"))).toMatchObject({
id: "flow-a",
blueprintName: "default",
description: "alpha",
parameters: {limit: 3},
status: "running",
});
2026-06-06 10:33:10 -05:00
await Effect.runPromise(service.handleStopFlowEffect({
operation: "stop-flow",
"flow-id": "flow-a",
2026-06-06 10:33:10 -05:00
}));
state = await Effect.runPromise(SynchronizedRef.get(service.state));
2026-06-04 06:53:21 -05:00
expect(HashMap.has(state.flows, "flow-a")).toBe(false);
expect(configClient.requests.map((request) => ({
operation: request.operation,
keys: request.keys,
}))).toEqual([
{operation: "put", keys: ["flows"]},
{operation: "put", keys: ["flow"]},
{operation: "delete", keys: ["flows", "flow-a"]},
{operation: "delete", keys: ["flow", "flow-a"]},
{operation: "put", keys: ["flows"]},
{operation: "put", keys: ["flow"]},
]);
});
it("decodes valid blueprint config and skips invalid blueprint records", async () => {
const configClient = new RecordingConfigClient([
{
key: "custom",
value: "{\"description\":\"Custom\",\"topics\":{\"input\":\"topic.in\"},\"extra\":true}",
},
{
key: "broken",
value: "{\"description\":\"Missing topics\"}",
},
]);
const service = makeService();
await seedConfigClient(service, configClient);
2026-06-06 10:33:10 -05:00
await Effect.runPromise(service.refreshBlueprintsFromConfigEffect);
const state = await Effect.runPromise(SynchronizedRef.get(service.state));
2026-06-04 06:53:21 -05:00
expect(Option.getOrUndefined(HashMap.get(state.blueprints, "custom"))).toMatchObject({
description: "Custom",
topics: {input: "topic.in"},
extra: true,
});
2026-06-04 06:53:21 -05:00
expect(HashMap.has(state.blueprints, "broken")).toBe(false);
expect(HashMap.has(state.blueprints, "default")).toBe(true);
});
it("serializes duplicate starts through the ref-backed map", async () => {
const configClient = new RecordingConfigClient();
const service = makeService();
await seedConfigClient(service, configClient);
const results = await Promise.allSettled([
2026-06-06 10:33:10 -05:00
Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow", "flow-id": "flow-a"})),
Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow", "flow-id": "flow-a"})),
]);
const state = await Effect.runPromise(SynchronizedRef.get(service.state));
2026-06-04 06:53:21 -05:00
expect(Option.getOrUndefined(HashMap.get(state.flows, "flow-a"))).toMatchObject({id: "flow-a"});
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
});
it("sends flow-error responses from handleMessageEffect", async () => {
const backend = new NoopPubSub();
const configClient = new RecordingConfigClient();
const service = makeService(backend);
await seedConfigClient(service, configClient);
await seedResponseProducer(backend, service);
const message: Message<FlowRequest> = {
value: () => ({operation: "start-flow"}),
properties: () => ({id: "request-1"}),
};
await Effect.runPromise(service.handleMessageEffect(message));
expect(backend.sentByTopic.get(topics.flowResponse)).toEqual([
{
error: {
type: "flow-error",
message: "Missing flow-id",
},
},
]);
});
});