From ec57a78853d34e005b592995e71a45fd8bbe9e95 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 10:04:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(send-test):=20close=20the=20loop=20?= =?UTF-8?q?=E2=80=94=20bot=20reports=20done=20back=20to=20the=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send-test form was stuck on "Sending to …" because the server action returns the moment it publishes the IPC NOTIFY; the bot processed the actual WhatsApp send out-of-band and the form had no way to learn whether it succeeded. Round-trip now wired end-to-end: - New WebEvent variant `send_test.done` { groupId, ok, error }. - bot/src/ipc/send-test-handler emits it on every exit path: - missing group → ok=false, "Group not found" - account offline → ok=false, "Account not connected — re-pair first" - send threw → ok=false, error message - send succeeded → ok=true, null - web/src/hooks/use-events declares the new event in its type map. - web SendTestForm subscribes via useEvents, filters by its own groupId so a parallel send-test on another group can't move our state, and renders one of three pills: * Sending… (in-flight — Loader2 spinner) * Sent ✓ (success — emerald CheckCircle2) * (failure — destructive AlertCircle) The "Send Test" button stays disabled while in-flight. Tests (+5; 110 web tests total): send-test-form.test.tsx - SSR markup: textarea, submit button, hidden groupId, no premature pill on first render. - useEvents wiring: form registers a `send_test.done` handler. - Handler safely accepts: * matching success event * matching failure event * mismatched groupId (must not throw) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/bot/src/ipc/notify.ts | 10 +- apps/bot/src/ipc/send-test-handler.ts | 20 ++++ .../src/components/send-test-form.test.tsx | 91 +++++++++++++++++++ apps/web/src/components/send-test-form.tsx | 79 ++++++++++++++-- apps/web/src/hooks/use-events.ts | 1 + 5 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/send-test-form.test.tsx diff --git a/apps/bot/src/ipc/notify.ts b/apps/bot/src/ipc/notify.ts index d2c6089..adc869b 100644 --- a/apps/bot/src/ipc/notify.ts +++ b/apps/bot/src/ipc/notify.ts @@ -12,7 +12,15 @@ export type WebEvent = | { type: "session.timeout"; accountId: string } | { type: "groups.synced"; accountId: string; count: number } | { type: "reminder.fired"; reminderId: string; runId: string; status: string } - | { type: "reminder.failed"; reminderId: string; error: string }; + | { type: "reminder.failed"; reminderId: string; error: string } + // The web action enqueues a send_test via pg_notify and shows + // "Sending…" optimistically. This event closes the loop. + | { + type: "send_test.done"; + groupId: string; + ok: boolean; + error: string | null; + }; export async function pgNotifyWeb(event: WebEvent): Promise { const json = JSON.stringify(event); diff --git a/apps/bot/src/ipc/send-test-handler.ts b/apps/bot/src/ipc/send-test-handler.ts index b21965b..bf0cc4d 100644 --- a/apps/bot/src/ipc/send-test-handler.ts +++ b/apps/bot/src/ipc/send-test-handler.ts @@ -3,6 +3,7 @@ import { sendTextToGroup } from "../whatsapp/sender.js"; import { writeAuditLog } from "../audit.js"; import { db } from "../db.js"; import { logger } from "../logger.js"; +import { pgNotifyWeb } from "./notify.js"; export async function handleSendTest(groupId: string, text: string): Promise { const group = await db.query.whatsappGroups.findFirst({ @@ -10,11 +11,23 @@ export async function handleSendTest(groupId: string, text: string): Promise ({ + sendTestAction: (...args: unknown[]) => sendTestActionMock(...args), +})); + +// Capture the handlers the form passes to useEvents so we can fire +// `send_test.done` ourselves and observe the state transition. +const eventHandlers: { + "send_test.done"?: (e: { groupId: string; ok: boolean; error: string | null }) => void; +} = {}; +vi.mock("@/hooks/use-events", () => ({ + useEvents: (handlers: typeof eventHandlers) => { + Object.assign(eventHandlers, handlers); + }, +})); + +import { SendTestForm } from "./send-test-form"; + +describe("SendTestForm — initial render", () => { + it("renders the textarea and submit button, no status pill yet", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/]*name="text"/); + expect(html).toMatch(/]*type="submit"/); + expect(html).toContain("Send Test"); + // No success / error / in-flight pill on first render. + expect(html).not.toContain("Sent ✓"); + expect(html).not.toContain("Sending"); + expect(html).not.toContain("role=\"alert\""); + }); + + it("includes the groupId as a hidden input the action will read", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/]+type="hidden"[^>]+name="groupId"[^>]+value="abc-uuid"/); + }); +}); + +describe("SendTestForm — wires `send_test.done` events", () => { + it("registers a `send_test.done` handler with useEvents on render", () => { + // Reset the captured handlers between tests. + delete eventHandlers["send_test.done"]; + + renderToStaticMarkup(); + + expect(typeof eventHandlers["send_test.done"]).toBe("function"); + }); + + it("the handler ignores events for OTHER groups (won't move our state)", () => { + // We can't easily inspect React state from SSR markup, but we can + // at least verify the handler accepts and discards an event with a + // mismatched groupId without throwing. + delete eventHandlers["send_test.done"]; + renderToStaticMarkup(); + expect(() => + eventHandlers["send_test.done"]?.({ + groupId: "g-2", + ok: true, + error: null, + }), + ).not.toThrow(); + }); + + it("the handler accepts a matching success event without throwing", () => { + delete eventHandlers["send_test.done"]; + renderToStaticMarkup(); + expect(() => + eventHandlers["send_test.done"]?.({ + groupId: "g-1", + ok: true, + error: null, + }), + ).not.toThrow(); + }); + + it("the handler accepts a matching failure event without throwing", () => { + delete eventHandlers["send_test.done"]; + renderToStaticMarkup(); + expect(() => + eventHandlers["send_test.done"]?.({ + groupId: "g-1", + ok: false, + error: "Account not connected", + }), + ).not.toThrow(); + }); +}); diff --git a/apps/web/src/components/send-test-form.tsx b/apps/web/src/components/send-test-form.tsx index c83a0db..9659a31 100644 --- a/apps/web/src/components/send-test-form.tsx +++ b/apps/web/src/components/send-test-form.tsx @@ -1,15 +1,57 @@ "use client"; +import { useEffect, useState } from "react"; import { useActionState } from "react"; +import { CheckCircle2Icon, AlertCircleIcon, Loader2Icon } from "lucide-react"; import { sendTestAction, type SendTestResult } from "@/actions/groups"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { useEvents } from "@/hooks/use-events"; const initial: SendTestResult | null = null; +type Outcome = + | { kind: "idle" } + | { kind: "in-flight"; message: string } + | { kind: "sent"; message: string } + | { kind: "error"; message: string }; + export function SendTestForm({ groupId }: { groupId: string }) { const [state, formAction, isPending] = useActionState(sendTestAction, initial); + const [outcome, setOutcome] = useState({ kind: "idle" }); + + // Bridge the optimistic action result into our richer Outcome state. + // The action's `ok` only confirms the IPC NOTIFY was published — we + // wait for the bot's `send_test.done` event below to know whether + // WhatsApp actually accepted the message. + useEffect(() => { + if (!state) return; + if (state.ok) { + setOutcome({ kind: "in-flight", message: state.message }); + } else { + setOutcome({ kind: "error", message: state.error }); + } + }, [state]); + + // Subscribe to the bot's reply. Only act on events for OUR groupId so + // a parallel send-test on another group doesn't move our state. + useEvents({ + "send_test.done": (data) => { + if (data.groupId !== groupId) return; + if (data.ok) { + setOutcome({ kind: "sent", message: "Sent ✓ — check the WhatsApp group." }); + } else { + setOutcome({ + kind: "error", + message: data.error ?? "Send failed — see bot logs for details.", + }); + } + }, + }); + + const sending = isPending || outcome.kind === "in-flight"; + return (
@@ -27,19 +69,40 @@ export function SendTestForm({ groupId }: { groupId: string }) { className="mt-1" /> - {state?.ok === false && ( -

- {state.error} + + {outcome.kind === "in-flight" && ( +

+ + {outcome.message}

)} - {state?.ok === true && ( -

- {state.message} + {outcome.kind === "sent" && ( +

+ + {outcome.message}

)} + {outcome.kind === "error" && ( +

+ + {outcome.message} +

+ )} +
-
diff --git a/apps/web/src/hooks/use-events.ts b/apps/web/src/hooks/use-events.ts index c863dff..b295970 100644 --- a/apps/web/src/hooks/use-events.ts +++ b/apps/web/src/hooks/use-events.ts @@ -12,6 +12,7 @@ export type WebEventMap = { "groups.synced": { accountId: string; count: number }; "reminder.fired": { reminderId: string; runId: string; status: string }; "reminder.failed": { reminderId: string; error: string }; + "send_test.done": { groupId: string; ok: boolean; error: string | null }; }; type Handlers = { [K in keyof WebEventMap]?: (data: WebEventMap[K]) => void };