From 008ed0a90059aba8ac3cc512a4ad64a57381bdac Mon Sep 17 00:00:00 2001 From: Megan Schott Date: Fri, 3 Jul 2026 11:56:43 -0500 Subject: [PATCH] feat(web): "Send test notification" button on /me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-serve "is my Pushover actually wired up?" check, right where the key is set. A type="button" (not a form submit) fires a new sendTestNotification server action that pushes a one-off test to the user's SAVED key via the existing sendPushoverMessage client. - No infra: the web already loads the Pushover app token from SSM (used today to validate keys) — this just also sends. - Identity is the session sub (same no-client-user-id contract as saveSettings); tests the saved key, so a freshly-typed-but-unsaved key prompts "Save first" (button disabled until a key exists). - Warm-Lambda in-memory debounce swallows rapid double-taps without a DDB write; the button also disables mid-flight. - Send failure is a soft error ("double-check your key"), not a throw. Tests: signed-out gate, needs-saved-key, sends-to-saved-key, soft-fail on Pushover error, double-tap debounce. Co-Authored-By: Claude Opus 4.8 --- web/src/app/me/actions.test.ts | 78 ++++++++++++++++++++++++++++++++ web/src/app/me/actions.ts | 56 +++++++++++++++++++++++ web/src/app/me/settings-form.tsx | 53 +++++++++++++++++++++- 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 web/src/app/me/actions.test.ts diff --git a/web/src/app/me/actions.test.ts b/web/src/app/me/actions.test.ts new file mode 100644 index 0000000..f42d26d --- /dev/null +++ b/web/src/app/me/actions.test.ts @@ -0,0 +1,78 @@ +// Tests for sendTestNotification: identity gate, needs-a-saved-key, +// send + soft-fail, and the double-tap debounce. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const auth = vi.fn(); +vi.mock("@/auth", () => ({ auth: () => auth() })); + +const getUserProfile = vi.fn(); +vi.mock("@/lib/dynamodb-writes", () => ({ + getUserProfile: (...a: unknown[]) => getUserProfile(...a), + // Unused by these tests but imported by the module under test. + getUserParkSubscriptions: vi.fn(), + putUserProfile: vi.fn(), + setParkSubscription: vi.fn(), +})); + +const sendPushoverMessage = vi.fn(); +vi.mock("@/lib/pushover", () => ({ + sendPushoverMessage: (...a: unknown[]) => sendPushoverMessage(...a), + validatePushoverUserKey: vi.fn(), +})); + +const KEY = "a".repeat(30); + +beforeEach(() => { + vi.clearAllMocks(); + auth.mockResolvedValue({ user: { id: "sub-1" } }); + getUserProfile.mockResolvedValue({ pushoverUserKey: KEY }); + sendPushoverMessage.mockResolvedValue(undefined); +}); + +describe("sendTestNotification", () => { + it("rejects when not signed in", async () => { + auth.mockResolvedValue(null); + const { sendTestNotification } = await import("./actions"); + expect(await sendTestNotification()).toEqual({ + ok: false, + error: "Not signed in.", + }); + expect(sendPushoverMessage).not.toHaveBeenCalled(); + }); + + it("requires a saved Pushover key", async () => { + auth.mockResolvedValue({ user: { id: "sub-nokey" } }); + getUserProfile.mockResolvedValue({}); + const { sendTestNotification } = await import("./actions"); + const res = await sendTestNotification(); + expect(res.ok).toBe(false); + expect(sendPushoverMessage).not.toHaveBeenCalled(); + }); + + it("sends to the SAVED key and returns ok", async () => { + auth.mockResolvedValue({ user: { id: "sub-ok" } }); + const { sendTestNotification } = await import("./actions"); + expect(await sendTestNotification()).toEqual({ ok: true }); + expect(sendPushoverMessage).toHaveBeenCalledOnce(); + expect(sendPushoverMessage.mock.calls[0][0]).toBe(KEY); + }); + + it("returns a soft error (not a throw) when Pushover send fails", async () => { + auth.mockResolvedValue({ user: { id: "sub-fail" } }); + sendPushoverMessage.mockRejectedValue(new Error("bad key")); + const { sendTestNotification } = await import("./actions"); + const res = await sendTestNotification(); + expect(res.ok).toBe(false); + }); + + it("debounces rapid double-taps for the same user", async () => { + auth.mockResolvedValue({ user: { id: "sub-debounce" } }); + const { sendTestNotification } = await import("./actions"); + expect(await sendTestNotification()).toEqual({ ok: true }); + const second = await sendTestNotification(); + expect(second.ok).toBe(false); + expect(sendPushoverMessage).toHaveBeenCalledOnce(); // second suppressed + }); +}); diff --git a/web/src/app/me/actions.ts b/web/src/app/me/actions.ts index 4fc1ee1..3362002 100644 --- a/web/src/app/me/actions.ts +++ b/web/src/app/me/actions.ts @@ -129,6 +129,62 @@ export async function saveSettings( return { ok: true, savedAt: new Date().toISOString() }; } +export type TestNotifResult = { ok: true } | { ok: false; error: string }; + +// Warm-Lambda-scoped debounce for the test button — swallows rapid +// double-taps without a DDB write. Best-effort (not durable across cold +// starts or instances), which is all a debounce needs to be; the client +// also disables the button mid-flight. +const testCooldown: Map = + ((globalThis as { __mmTestCooldown?: Map }).__mmTestCooldown ??= + new Map()); +const TEST_COOLDOWN_MS = 5000; + +/** + * Send a one-off Pushover "test alert" to the signed-in user's SAVED + * key — the self-serve "is my Pushover actually wired up?" check. + * + * Uses the key already on the profile (not the form field), so if the + * user just typed a new key they should Save first; the button hint says + * so. Identity is the session sub — no client-supplied user id, same + * contract as every other action here. + */ +export async function sendTestNotification(): Promise { + const session = await auth(); + const sub = session?.user?.id; + if (!sub) return { ok: false, error: "Not signed in." }; + + const profile = await getUserProfile(sub); + if (!profile?.pushoverUserKey) { + return { + ok: false, + error: "Save a Pushover user key first, then send a test.", + }; + } + + const now = Date.now(); + if (now - (testCooldown.get(sub) ?? 0) < TEST_COOLDOWN_MS) { + return { ok: false, error: "Just sent one — give it a few seconds." }; + } + testCooldown.set(sub, now); + + try { + await sendPushoverMessage( + profile.pushoverUserKey, + "🎢 Test alert from Magic Monitor — if you can see this, your alerts are set up correctly.", + { title: "Magic Monitor — test", url: settingsUrl(), urlTitle: "My alerts" }, + ); + } catch (err) { + // Surface as a soft error — most likely a stale/rotated key. + console.warn("[me/test] test push failed:", err); + return { + ok: false, + error: "Couldn't send — double-check your Pushover key and try again.", + }; + } + return { ok: true }; +} + function buildSubscriptionChangeBody(active: Set): string { if (active.size === 0) { return "You're no longer subscribed to alerts for any park. Visit Settings to opt back in."; diff --git a/web/src/app/me/settings-form.tsx b/web/src/app/me/settings-form.tsx index 70541dd..94d2a62 100644 --- a/web/src/app/me/settings-form.tsx +++ b/web/src/app/me/settings-form.tsx @@ -17,11 +17,16 @@ */ import Link from "next/link"; -import { useActionState } from "react"; +import { useActionState, useState, useTransition } from "react"; import { useFormStatus } from "react-dom"; import { PARKS, type ParkKey } from "@/lib/parks"; -import { saveSettings, type SaveSettingsResult } from "./actions"; +import { + saveSettings, + sendTestNotification, + type SaveSettingsResult, + type TestNotifResult, +} from "./actions"; interface Props { initialName: string; @@ -80,6 +85,8 @@ export function SettingsForm({ className="w-full rounded-md border border-line bg-bg-0 px-3 py-2 font-mono text-sm text-fg-0 focus:border-accent focus:outline-none" /> + +
@@ -130,6 +137,48 @@ export function SettingsForm({ ); } +/** + * "Send test notification" — a type="button" (NOT a submit) that fires + * the sendTestNotification action out-of-band, so it doesn't submit or + * validate the settings form. Tests the SAVED key; disabled until one + * exists (hint tells the user to Save a freshly-typed key first). + */ +function TestNotificationButton({ hasSavedKey }: { hasSavedKey: boolean }) { + const [pending, startTransition] = useTransition(); + const [result, setResult] = useState(null); + + return ( +
+ + {!hasSavedKey ? ( +

+ Save a Pushover key first, then send a test to confirm it works. +

+ ) : result ? ( + result.ok ? ( +

Sent — check your phone. 📲

+ ) : ( +

{result.error}

+ ) + ) : ( +

+ Sends a push to your saved key right now. +

+ )} +
+ ); +} + function Field({ label, htmlFor,