Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions web/src/app/me/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
56 changes: 56 additions & 0 deletions web/src/app/me/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> =
((globalThis as { __mmTestCooldown?: Map<string, number> }).__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<TestNotifResult> {
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<ParkKey>): string {
if (active.size === 0) {
return "You're no longer subscribed to alerts for any park. Visit Settings to opt back in.";
Expand Down
53 changes: 51 additions & 2 deletions web/src/app/me/settings-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
/>
</Field>

<TestNotificationButton hasSavedKey={Boolean(initialPushoverUserKey)} />
</fieldset>

<fieldset className="space-y-3">
Expand Down Expand Up @@ -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<TestNotifResult | null>(null);

return (
<div>
<button
type="button"
disabled={pending || !hasSavedKey}
onClick={() => {
setResult(null);
startTransition(async () => setResult(await sendTestNotification()));
}}
className="inline-flex items-center gap-2 rounded-md border border-line bg-bg-1 px-3 py-1.5 text-sm text-fg-1 hover:bg-bg-2 disabled:opacity-50 transition-colors"
>
{pending ? "Sending…" : "Send test notification"}
</button>
{!hasSavedKey ? (
<p className="mt-1 text-xs text-fg-3">
Save a Pushover key first, then send a test to confirm it works.
</p>
) : result ? (
result.ok ? (
<p className="mt-1 text-xs text-ok">Sent — check your phone. 📲</p>
) : (
<p className="mt-1 text-xs text-bad">{result.error}</p>
)
) : (
<p className="mt-1 text-xs text-fg-3">
Sends a push to your saved key right now.
</p>
)}
</div>
);
}

function Field({
label,
htmlFor,
Expand Down
Loading