wacrm/src/lib/flows/engine.test.ts
Arnas Donauskas ed15493f95
feat(flows): templates + v1.5 nodes (collect_input/condition/set_tag) + run-history viewer (PR 4/4)
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.

What landed:

  1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
     sendTextMessage with the phone-variant retry + DB persistence
     pattern. Unblocks send_message + collect_input nodes that
     previously auto-advanced without sending.

  2. Three new node types in the engine:
     - collect_input: sends a prompt, suspends, captures the next
       text reply into flow_runs.vars[var_key], advances. Supports
       {{vars.X}} interpolation in downstream prompts + handoff
       notes.
     - condition: branches on subject (var / tag / contact_field)
       with operators (equals / contains / present / absent).
       Auto-advancing, no Meta call. Pure predicate logic exported
       as evaluateConditionPredicate with 7 new unit tests.
     - set_tag: upsert/delete on contact_tags then advance. Non-
       fatal on write failure — logged + advances anyway so a tag
       glitch doesn't strand the customer.

  3. Real send_message — calls engineSendText, logs the result,
     handles errors by ending the run with status='failed'. Same
     {{vars.X}} interpolation.

  4. Three starter templates (src/lib/flows/templates.ts):
     - Welcome menu (the owner's brief example)
     - FAQ bot (keyword → list → answers → end)
     - Lead capture (first_inbound → collect_input chain → handoff
       with vars in the note)
     POST /api/flows now accepts {template_slug} to clone a
     template into a fresh draft. GET /api/flows/templates exposes
     the gallery shape (no nodes payload — fetched on actual clone).

  5. Builder UI extensions:
     - NODE_META + config sub-forms for collect_input, condition,
       set_tag.
     - Template gallery on the /flows New-flow dialog (loads from
       /api/flows/templates, falls back gracefully if absent).
     - "Runs" button in the editor header → /flows/[id]/runs.

  6. Run-history viewer at /flows/[id]/runs — newest-first list of
     the 50 most recent runs, with expandable per-run event
     timeline (started, node_entered, message_sent,
     reply_received, fallback_fired, handoff, timeout, error,
     completed). Useful for debugging "why didn't my flow advance?"
     from the brief's use case.

  7. Validator updates: new error rules for each new node type
     (prompt_text required, var_key alphanumeric, operator valid,
     true_next/false_next exist, etc.). outgoingEdges helper now
     covers collect_input/condition/set_tag for reachability.

All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.

Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:27:01 +03:00

298 lines
8.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
matchReplyId,
matchesKeywordTrigger,
isAutoAdvancing,
isSuspending,
isTerminal,
evaluateConditionPredicate,
} from "./engine";
describe("matchReplyId", () => {
it("returns null for nodes without options", () => {
expect(
matchReplyId({ node_type: "start", config: { next_node_key: "x" } }, "y"),
).toBeNull();
expect(
matchReplyId({ node_type: "send_message", config: {} }, "y"),
).toBeNull();
expect(matchReplyId({ node_type: "end", config: {} }, "y")).toBeNull();
});
it("matches the buttons array on a send_buttons node", () => {
const node = {
node_type: "send_buttons",
config: {
text: "Pick one",
buttons: [
{ reply_id: "yes", title: "Yes", next_node_key: "confirmed" },
{ reply_id: "no", title: "No", next_node_key: "declined" },
],
},
};
expect(matchReplyId(node, "yes")).toBe("confirmed");
expect(matchReplyId(node, "no")).toBe("declined");
});
it("returns null when no button reply_id matches", () => {
const node = {
node_type: "send_buttons",
config: {
text: "Pick",
buttons: [
{ reply_id: "a", title: "A", next_node_key: "to_a" },
{ reply_id: "b", title: "B", next_node_key: "to_b" },
],
},
};
expect(matchReplyId(node, "c")).toBeNull();
expect(matchReplyId(node, "")).toBeNull();
});
it("searches across all sections in a send_list node", () => {
const node = {
node_type: "send_list",
config: {
text: "Pick an order",
button_label: "View",
sections: [
{
title: "Recent",
rows: [
{ reply_id: "o1", title: "Order 1", next_node_key: "ord_1" },
],
},
{
title: "Older",
rows: [
{ reply_id: "o2", title: "Order 2", next_node_key: "ord_2" },
{ reply_id: "o3", title: "Order 3", next_node_key: "ord_3" },
],
},
],
},
};
expect(matchReplyId(node, "o1")).toBe("ord_1");
expect(matchReplyId(node, "o2")).toBe("ord_2");
expect(matchReplyId(node, "o3")).toBe("ord_3");
expect(matchReplyId(node, "o99")).toBeNull();
});
it("returns null when send_list has no sections / empty sections", () => {
expect(
matchReplyId(
{ node_type: "send_list", config: { text: "x", sections: [] } },
"x",
),
).toBeNull();
expect(
matchReplyId(
{
node_type: "send_list",
config: { text: "x", sections: [{ rows: [] }] },
},
"x",
),
).toBeNull();
});
});
describe("matchesKeywordTrigger", () => {
it("returns false for empty text", () => {
expect(matchesKeywordTrigger("", { keywords: ["hi"] })).toBe(false);
});
it("returns false when keywords array is empty", () => {
expect(matchesKeywordTrigger("anything", { keywords: [] })).toBe(false);
});
it("default match_type='contains' does case-insensitive substring", () => {
const cfg = { keywords: ["support"] };
expect(matchesKeywordTrigger("I need SUPPORT please", cfg)).toBe(true);
expect(matchesKeywordTrigger("Support is great", cfg)).toBe(true);
expect(matchesKeywordTrigger("Help me", cfg)).toBe(false);
});
it("match_type='exact' compares the whole string case-insensitively", () => {
const cfg = { keywords: ["help"], match_type: "exact" as const };
expect(matchesKeywordTrigger("help", cfg)).toBe(true);
expect(matchesKeywordTrigger("HELP", cfg)).toBe(true);
expect(matchesKeywordTrigger("help me", cfg)).toBe(false);
});
it("case_sensitive=true preserves case", () => {
const cfg = {
keywords: ["Support"],
case_sensitive: true,
};
expect(matchesKeywordTrigger("I need Support", cfg)).toBe(true);
expect(matchesKeywordTrigger("I need support", cfg)).toBe(false);
});
it("matches any one of multiple keywords", () => {
const cfg = { keywords: ["help", "support", "issue"] };
expect(matchesKeywordTrigger("I have an issue", cfg)).toBe(true);
expect(matchesKeywordTrigger("I need Help!", cfg)).toBe(true);
expect(matchesKeywordTrigger("nothing to see here", cfg)).toBe(false);
});
it("skips empty strings in the keywords array", () => {
const cfg = { keywords: ["", "support", ""] };
expect(matchesKeywordTrigger("support center", cfg)).toBe(true);
expect(matchesKeywordTrigger("nope", cfg)).toBe(false);
});
});
describe("node classification helpers", () => {
it("isAutoAdvancing covers start + send_message + condition + set_tag", () => {
expect(isAutoAdvancing("start")).toBe(true);
expect(isAutoAdvancing("send_message")).toBe(true);
expect(isAutoAdvancing("condition")).toBe(true);
expect(isAutoAdvancing("set_tag")).toBe(true);
expect(isAutoAdvancing("send_buttons")).toBe(false);
expect(isAutoAdvancing("send_list")).toBe(false);
expect(isAutoAdvancing("collect_input")).toBe(false);
expect(isAutoAdvancing("handoff")).toBe(false);
expect(isAutoAdvancing("end")).toBe(false);
});
it("isSuspending covers the input-requiring nodes", () => {
expect(isSuspending("send_buttons")).toBe(true);
expect(isSuspending("send_list")).toBe(true);
expect(isSuspending("collect_input")).toBe(true);
expect(isSuspending("start")).toBe(false);
expect(isSuspending("send_message")).toBe(false);
expect(isSuspending("condition")).toBe(false);
expect(isSuspending("set_tag")).toBe(false);
expect(isSuspending("handoff")).toBe(false);
expect(isSuspending("end")).toBe(false);
});
it("isTerminal covers handoff + end", () => {
expect(isTerminal("handoff")).toBe(true);
expect(isTerminal("end")).toBe(true);
expect(isTerminal("start")).toBe(false);
expect(isTerminal("send_buttons")).toBe(false);
expect(isTerminal("condition")).toBe(false);
});
it("the three classifications are mutually exclusive for known node types", () => {
const types = [
"start",
"send_message",
"send_buttons",
"send_list",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
];
for (const t of types) {
const flags = [isAutoAdvancing(t), isSuspending(t), isTerminal(t)];
// Exactly one of the three should be true for every known node.
expect(flags.filter(Boolean).length).toBe(1);
}
});
});
describe("evaluateConditionPredicate", () => {
it("present: true when subject has a value", () => {
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: "alice@example.com",
configValue: undefined,
}),
).toBe(true);
});
it("present: false when subject is undefined or empty", () => {
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: undefined,
configValue: undefined,
}),
).toBe(false);
expect(
evaluateConditionPredicate({
operator: "present",
subjectValue: "",
configValue: undefined,
}),
).toBe(false);
});
it("absent: inverse of present", () => {
expect(
evaluateConditionPredicate({
operator: "absent",
subjectValue: undefined,
configValue: undefined,
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "absent",
subjectValue: "x",
configValue: undefined,
}),
).toBe(false);
});
it("equals: exact string comparison; case-sensitive", () => {
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: "VIP",
configValue: "VIP",
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: "vip",
configValue: "VIP",
}),
).toBe(false);
});
it("equals: undefined subject never matches (even against empty)", () => {
expect(
evaluateConditionPredicate({
operator: "equals",
subjectValue: undefined,
configValue: "",
}),
).toBe(false);
});
it("contains: substring match", () => {
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: "support@example.com",
configValue: "@example.com",
}),
).toBe(true);
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: "support@other.com",
configValue: "@example.com",
}),
).toBe(false);
});
it("contains: undefined subject never matches", () => {
expect(
evaluateConditionPredicate({
operator: "contains",
subjectValue: undefined,
configValue: "anything",
}),
).toBe(false);
});
});