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>
92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|