feat(send-test): close the loop — bot reports done back to the form
The send-test form was stuck on "Sending to <Group>…" 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)
* <error message> (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) <noreply@anthropic.com>
This commit is contained in:
parent
c95b9658d1
commit
ec57a78853
@ -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<void> {
|
||||
const json = JSON.stringify(event);
|
||||
|
||||
@ -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<void> {
|
||||
const group = await db.query.whatsappGroups.findFirst({
|
||||
@ -10,11 +11,23 @@ export async function handleSendTest(groupId: string, text: string): Promise<voi
|
||||
});
|
||||
if (!group) {
|
||||
logger.warn({ groupId }, "send-test: group missing");
|
||||
await pgNotifyWeb({
|
||||
type: "send_test.done",
|
||||
groupId,
|
||||
ok: false,
|
||||
error: "Group not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const session = sessionManager.getSession(group.accountId);
|
||||
if (!session) {
|
||||
logger.warn({ groupId, accountId: group.accountId }, "send-test: account not connected");
|
||||
await pgNotifyWeb({
|
||||
type: "send_test.done",
|
||||
groupId,
|
||||
ok: false,
|
||||
error: "Account not connected — re-pair before sending",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -27,7 +40,14 @@ export async function handleSendTest(groupId: string, text: string): Promise<voi
|
||||
targetId: groupId,
|
||||
payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null },
|
||||
});
|
||||
await pgNotifyWeb({ type: "send_test.done", groupId, ok: true, error: null });
|
||||
} catch (err) {
|
||||
logger.error({ err, groupId }, "send-test: failed");
|
||||
await pgNotifyWeb({
|
||||
type: "send_test.done",
|
||||
groupId,
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Send failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
91
apps/web/src/components/send-test-form.test.tsx
Normal file
91
apps/web/src/components/send-test-form.test.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// SSE hook + the server action are deliberately stubbed. The action's
|
||||
// optimistic result is provided by feeding `useActionState` a manual
|
||||
// initial state via the mock's exported helper.
|
||||
const sendTestActionMock = vi.fn();
|
||||
vi.mock("@/actions/groups", () => ({
|
||||
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(<SendTestForm groupId="g-1" />);
|
||||
expect(html).toMatch(/<textarea[^>]*name="text"/);
|
||||
expect(html).toMatch(/<button[^>]*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(<SendTestForm groupId="abc-uuid" />);
|
||||
expect(html).toMatch(/<input[^>]+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(<SendTestForm groupId="g-1" />);
|
||||
|
||||
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(<SendTestForm groupId="g-1" />);
|
||||
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(<SendTestForm groupId="g-1" />);
|
||||
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(<SendTestForm groupId="g-1" />);
|
||||
expect(() =>
|
||||
eventHandlers["send_test.done"]?.({
|
||||
groupId: "g-1",
|
||||
ok: false,
|
||||
error: "Account not connected",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@ -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<Outcome>({ 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 (
|
||||
<form action={formAction} className="space-y-3">
|
||||
<input type="hidden" name="groupId" value={groupId} />
|
||||
@ -27,19 +69,40 @@ export function SendTestForm({ groupId }: { groupId: string }) {
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{state?.ok === false && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{state.error}
|
||||
|
||||
{outcome.kind === "in-flight" && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2Icon className="size-3.5 animate-spin" aria-hidden />
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
{state?.ok === true && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400" role="status">
|
||||
{state.message}
|
||||
{outcome.kind === "sent" && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-sm text-emerald-600 dark:text-emerald-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<CheckCircle2Icon className="size-3.5" aria-hidden />
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
{outcome.kind === "error" && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircleIcon className="size-3.5" aria-hidden />
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Sending…" : "Send Test"}
|
||||
<Button type="submit" disabled={sending}>
|
||||
{sending ? "Sending…" : "Send Test"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user