diff --git a/src/app/api/webhooks/activity-alerts/route.ts b/src/app/api/webhooks/activity-alerts/route.ts new file mode 100644 index 000000000..9901d0a8c --- /dev/null +++ b/src/app/api/webhooks/activity-alerts/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { + dispatchActivityAlert, + isActivityAlertEvent, + ACTIVITY_ALERT_EVENTS, +} from "@/lib/webhooks"; + +export const dynamic = "force-dynamic"; + +interface ActivityAlertBody { + event: string; + data?: Record; +} + +export async function POST(req: NextRequest): Promise { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + let body: ActivityAlertBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { event, data = {} } = body; + + if (!event || typeof event !== "string") { + return NextResponse.json( + { + error: "Missing required field: event", + validEvents: ACTIVITY_ALERT_EVENTS, + }, + { status: 400 } + ); + } + + if (!isActivityAlertEvent(event)) { + return NextResponse.json( + { + error: `Invalid event '${event}'. Must be one of: ${ACTIVITY_ALERT_EVENTS.join(", ")}`, + validEvents: ACTIVITY_ALERT_EVENTS, + }, + { status: 400 } + ); + } + + dispatchActivityAlert(user.id, event, { + ...data, + github_login: session.githubLogin, + triggered_at: new Date().toISOString(), + }); + + return NextResponse.json({ + success: true, + event, + message: `Activity alert '${event}' queued for dispatch`, + }); +} + +export async function GET(): Promise { + return NextResponse.json({ + events: ACTIVITY_ALERT_EVENTS, + description: + "Activity alert events trigger outbound webhooks when subscribed events occur in DevTrack.", + }); +} diff --git a/src/app/api/webhooks/custom/[id]/route.ts b/src/app/api/webhooks/custom/[id]/route.ts index 83b04d54e..5bfaba3a4 100644 --- a/src/app/api/webhooks/custom/[id]/route.ts +++ b/src/app/api/webhooks/custom/[id]/route.ts @@ -131,7 +131,9 @@ export async function PATCH( ); } const validEvents = [ + "streak.milestone_reached", "goal.completed", + "weekly_summary.ready", "goal.created", "streak.milestone", "daily.summary", diff --git a/src/app/api/webhooks/custom/route.ts b/src/app/api/webhooks/custom/route.ts index 92c4ee14a..b626af9a3 100644 --- a/src/app/api/webhooks/custom/route.ts +++ b/src/app/api/webhooks/custom/route.ts @@ -15,6 +15,8 @@ interface WebhookInput { name: string; url: string; events: string[]; + content_type?: string; + secret_token?: string; } async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { @@ -65,7 +67,7 @@ export async function POST(req: NextRequest) { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } - const { name, url, events } = body; + const { name, url, events, content_type, secret_token } = body; const validatedName = validateTextInput(name, "Webhook name", 100); @@ -99,7 +101,9 @@ export async function POST(req: NextRequest) { } const validEvents = [ + "streak.milestone_reached", "goal.completed", + "weekly_summary.ready", "goal.created", "streak.milestone", "daily.summary", @@ -127,7 +131,11 @@ export async function POST(req: NextRequest) { ); } - const secretKey = generateSecretKey(); + const rawToken = + typeof secret_token === "string" && secret_token.trim() + ? secret_token.trim() + : generateSecretKey(); + const secretKey = rawToken; const { encrypted, iv } = encryptSecretKey(secretKey); const { data: webhook, error } = await supabaseAdmin @@ -139,6 +147,10 @@ export async function POST(req: NextRequest) { events, secret_key: encrypted, secret_iv: iv, + content_type: + typeof content_type === "string" && content_type.trim() + ? content_type.trim() + : "application/json", is_enabled: true, }) .select("id, name, url, events, is_enabled, created_at") diff --git a/src/components/webhook/WebhookManager.tsx b/src/components/webhook/WebhookManager.tsx index 1e5bf7278..746f31357 100644 --- a/src/components/webhook/WebhookManager.tsx +++ b/src/components/webhook/WebhookManager.tsx @@ -21,13 +21,66 @@ interface WebhookDelivery { delivered_at: string; } +/** + * All available webhook events. + */ const AVAILABLE_EVENTS = [ - { value: "goal.completed", label: "Goal Completed" }, - { value: "goal.created", label: "Goal Created" }, - { value: "streak.milestone", label: "Streak Milestone" }, - { value: "daily.summary", label: "Daily Summary" }, - { value: "weekly.summary", label: "Weekly Summary" }, - { value: "metrics.updated", label: "Metrics Updated" }, + { + value: "streak.milestone_reached", + label: "Streak Milestone Reached", + group: "Activity Alerts", + description: "Fired when a user hits a streak milestone (7, 30, 100 days, etc.)", + }, + { + value: "goal.completed", + label: "Goal Completed", + group: "Activity Alerts", + description: "Fired when a weekly coding goal is marked complete", + }, + { + value: "weekly_summary.ready", + label: "Weekly Summary Ready", + group: "Activity Alerts", + description: "Fired when the weekly coding summary is generated", + }, + { + value: "goal.created", + label: "Goal Created", + group: "Goals", + description: "Fired when a new goal is created", + }, + { + value: "streak.milestone", + label: "Streak Milestone (legacy)", + group: "Streaks", + description: "Legacy streak milestone event", + }, + { + value: "daily.summary", + label: "Daily Summary", + group: "Summaries", + description: "Daily activity summary dispatch", + }, + { + value: "weekly.summary", + label: "Weekly Summary (legacy)", + group: "Summaries", + description: "Legacy weekly summary event", + }, + { + value: "metrics.updated", + label: "Metrics Updated", + group: "Metrics", + description: "Fired when metrics are refreshed", + }, +]; + +const CONTENT_TYPES = [ + { value: "application/json", label: "application/json (default)" }, + { + value: "application/x-www-form-urlencoded", + label: "application/x-www-form-urlencoded", + }, ]; export default function WebhookManager() { @@ -41,6 +94,9 @@ export default function WebhookManager() { const [formName, setFormName] = useState(""); const [formUrl, setFormUrl] = useState(""); const [formEvents, setFormEvents] = useState([]); + const [formContentType, setFormContentType] = useState("application/json"); + const [formSecretToken, setFormSecretToken] = useState(""); + const [secretCopied, setSecretCopied] = useState(false); const [selectedWebhook, setSelectedWebhook] = useState(null); const [webhookDetails, setWebhookDetails] = useState<{ @@ -85,6 +141,8 @@ export default function WebhookManager() { name: formName, url: formUrl, events: formEvents, + content_type: formContentType, + ...(formSecretToken.trim() ? { secret_token: formSecretToken.trim() } : {}), }), }); @@ -98,9 +156,14 @@ export default function WebhookManager() { setFormName(""); setFormUrl(""); setFormEvents([]); + setFormContentType("application/json"); + setFormSecretToken(""); + setShowCreateForm(false); await loadWebhooks(); } catch (err) { - setCreatingError(err instanceof Error ? err.message : "Failed to create webhook"); + setCreatingError( + err instanceof Error ? err.message : "Failed to create webhook" + ); } finally { setCreating(false); } @@ -195,7 +258,9 @@ export default function WebhookManager() { } async function handleRotateSecret(id: string) { - if (!confirm("Rotate secret key? Your endpoint will need to use the new key.")) { + if ( + !confirm("Rotate secret key? Your endpoint will need to use the new key.") + ) { return; } @@ -224,6 +289,16 @@ export default function WebhookManager() { ); } + async function copySecret(secret: string) { + try { + await navigator.clipboard.writeText(secret); + setSecretCopied(true); + setTimeout(() => setSecretCopied(false), 2000); + } catch { + // Clipboard API not available – user can select manually + } + } + function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { month: "short", @@ -235,7 +310,9 @@ export default function WebhookManager() { } function getEventLabel(eventValue: string): string { - return AVAILABLE_EVENTS.find((e) => e.value === eventValue)?.label || eventValue; + return ( + AVAILABLE_EVENTS.find((e) => e.value === eventValue)?.label || eventValue + ); } if (loading) { @@ -244,22 +321,32 @@ export default function WebhookManager() {
{[1, 2].map((i) => ( -
+
))}
); } + const activityAlertEvents = AVAILABLE_EVENTS.filter( + (e) => e.group === "Activity Alerts" + ); + const otherEvents = AVAILABLE_EVENTS.filter( + (e) => e.group !== "Activity Alerts" + ); + return (

- Custom Webhooks + Outbound Webhooks

- Trigger external events from your DevTrack metrics + Receive HTTP POST requests when DevTrack activity events occur

@@ -277,13 +364,27 @@ export default function WebhookManager() { {newSecret && (
-

Webhook Created!

+

+ Webhook Created! +

- Save this secret key - it will not be shown again: + Save this secret token — it will not be shown again. Use it to + verify the{" "} + X-Webhook-Signature{" "} + header (HMAC-SHA256) on your endpoint.

- - {newSecret} - +
+ + {newSecret} + + +
)} @@ -309,7 +410,7 @@ export default function WebhookManager() {
+ +
+ +
+ -
- {AVAILABLE_EVENTS.map((event) => ( -
+ +
+ + +
+

+ 🔔 + Activity Alerts +

+
+ {activityAlertEvents.map((event) => ( + - ))} + toggleEvent(event.value)} + disabled={creating} + className="sr-only" + /> + + {formEvents.includes(event.value) && ( + + + + )} + + + {event.label} + + {event.description} + + + + ))} +
+
+ +
+

+ Other Events +

+
+ {otherEvents.map((event) => ( + + ))} +
@@ -418,17 +632,43 @@ export default function WebhookManager() {
@@ -437,9 +677,24 @@ export default function WebhookManager() { className="p-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" title="View details" > - - - + + +
@@ -519,7 +784,9 @@ export default function WebhookManager() { > diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts index 214d80b2f..b64dfcc69 100644 --- a/src/lib/webhooks.ts +++ b/src/lib/webhooks.ts @@ -15,7 +15,9 @@ export interface WebhookDeliveryResult { } const WEBHOOK_EVENTS = [ + "streak.milestone_reached", "goal.completed", + "weekly_summary.ready", "goal.created", "streak.milestone", "daily.summary", @@ -23,16 +25,32 @@ const WEBHOOK_EVENTS = [ "metrics.updated", ] as const; -export type WebhookEvent = typeof WEBHOOK_EVENTS[number]; +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; + +export const ACTIVITY_ALERT_EVENTS = [ + "streak.milestone_reached", + "goal.completed", + "weekly_summary.ready", +] as const satisfies readonly WebhookEvent[]; + +export type ActivityAlertEvent = (typeof ACTIVITY_ALERT_EVENTS)[number]; export function isValidWebhookEvent(event: string): event is WebhookEvent { - return WEBHOOK_EVENTS.includes(event as WebhookEvent); + return (WEBHOOK_EVENTS as readonly string[]).includes(event); +} + +export function isActivityAlertEvent(event: string): event is ActivityAlertEvent { + return (ACTIVITY_ALERT_EVENTS as readonly string[]).includes(event); } export function getAvailableEvents(): readonly string[] { return WEBHOOK_EVENTS; } +export function getActivityAlertEvents(): readonly string[] { + return ACTIVITY_ALERT_EVENTS; +} + export function generateSecretKey(): string { return randomBytes(32).toString("hex"); } @@ -97,34 +115,38 @@ export async function dispatchWebhook( let errorMessage: string | undefined; try { + const contentType: string = + (webhook as { content_type?: string }).content_type ?? "application/json"; + const response = await fetch(webhook.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Webhook-Signature": `sha256=${signature}`, - "X-Webhook-Event": event, - "X-Webhook-Delivery-Id": webhookId, - }, - body: payloadString, - signal: AbortSignal.timeout(10000), - redirect: "manual", -}); - -if ([301, 302, 303, 307, 308].includes(response.status)) { - const location = response.headers.get("location"); - - if (!location) { - throw new Error("Redirect response missing location header"); - } + method: "POST", + headers: { + "Content-Type": contentType, + "X-Webhook-Signature": `sha256=${signature}`, + "X-Webhook-Event": event, + "X-Webhook-Delivery-Id": webhookId, + "X-DevTrack-Version": "1", + }, + body: payloadString, + signal: AbortSignal.timeout(10000), + redirect: "manual", + }); - const redirectSafe = await isSafeUrl(location); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get("location"); - if (!redirectSafe) { - throw new Error( - "SSRF protection: blocked redirect to private/internal address" - ); - } -} + if (!location) { + throw new Error("Redirect response missing location header"); + } + + const redirectSafe = await isSafeUrl(location); + + if (!redirectSafe) { + throw new Error( + "SSRF protection: blocked redirect to private/internal address" + ); + } + } statusCode = response.status; const success = response.ok; @@ -175,3 +197,18 @@ export async function dispatchToAllWebhooks( webhooks.map((webhook) => dispatchWebhook(webhook.id, event, data)) ); } + +export async function dispatchActivityAlert( + userId: string, + event: ActivityAlertEvent, + data: Record +): Promise { + try { + await dispatchToAllWebhooks(userId, event, { + ...data, + devtrack_event: event, + }); + } catch (err) { + console.error(`[webhook-dispatcher] Failed to dispatch ${event}:`, err); + } +} diff --git a/test/webhook-activity-alerts.test.ts b/test/webhook-activity-alerts.test.ts new file mode 100644 index 000000000..011433b3e --- /dev/null +++ b/test/webhook-activity-alerts.test.ts @@ -0,0 +1,320 @@ +import "./setup"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isActivityAlertEvent, + isValidWebhookEvent, + getActivityAlertEvents, + getAvailableEvents, + ACTIVITY_ALERT_EVENTS, + dispatchActivityAlert, + signPayload, +} from "../src/lib/webhooks"; + +vi.mock("../src/lib/supabase"); +vi.mock("../src/lib/crypto"); +vi.mock("../src/lib/ssrf-protection"); + +import { supabaseAdmin } from "../src/lib/supabase"; +import * as cryptoModule from "../src/lib/crypto"; +import * as ssrfModule from "../src/lib/ssrf-protection"; + +global.fetch = vi.fn(); + +// Helpers +function makeSupabaseMock(webhookRows: { id: string }[] = []) { + const singleResult = { data: null, error: { code: "PGRST116" } }; + const selectChain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + contains: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue({ data: webhookRows, error: null }), + single: vi.fn().mockResolvedValue(singleResult), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + order: vi.fn().mockReturnThis(), + }; + vi.mocked(supabaseAdmin.from).mockReturnValue(selectChain as any); + return selectChain; +} + +// ACTIVITY_ALERT_EVENTS constant +describe("ACTIVITY_ALERT_EVENTS constant", () => { + it("contains exactly three events", () => { + expect(ACTIVITY_ALERT_EVENTS).toHaveLength(3); + }); + + it("contains streak.milestone_reached", () => { + expect(ACTIVITY_ALERT_EVENTS).toContain("streak.milestone_reached"); + }); + + it("contains goal.completed", () => { + expect(ACTIVITY_ALERT_EVENTS).toContain("goal.completed"); + }); + + it("contains weekly_summary.ready", () => { + expect(ACTIVITY_ALERT_EVENTS).toContain("weekly_summary.ready"); + }); + + it("has no duplicates", () => { + const set = new Set(ACTIVITY_ALERT_EVENTS); + expect(set.size).toBe(ACTIVITY_ALERT_EVENTS.length); + }); +}); + +// isActivityAlertEvent() +describe("isActivityAlertEvent", () => { + it("returns true for streak.milestone_reached", () => { + expect(isActivityAlertEvent("streak.milestone_reached")).toBe(true); + }); + + it("returns true for goal.completed", () => { + expect(isActivityAlertEvent("goal.completed")).toBe(true); + }); + + it("returns true for weekly_summary.ready", () => { + expect(isActivityAlertEvent("weekly_summary.ready")).toBe(true); + }); + + it("returns false for legacy streak.milestone", () => { + expect(isActivityAlertEvent("streak.milestone")).toBe(false); + }); + + it("returns false for weekly.summary (legacy)", () => { + expect(isActivityAlertEvent("weekly.summary")).toBe(false); + }); + + it("returns false for an unknown event", () => { + expect(isActivityAlertEvent("unknown.event")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isActivityAlertEvent("")).toBe(false); + }); + + it("returns false for null", () => { + expect(isActivityAlertEvent(null as unknown as string)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isActivityAlertEvent(undefined as unknown as string)).toBe(false); + }); + + it("is case-sensitive", () => { + expect(isActivityAlertEvent("Goal.Completed")).toBe(false); + expect(isActivityAlertEvent("STREAK.MILESTONE_REACHED")).toBe(false); + }); +}); + +// isValidWebhookEvent() — must recognise the new events +describe("isValidWebhookEvent — new events", () => { + it("accepts streak.milestone_reached", () => { + expect(isValidWebhookEvent("streak.milestone_reached")).toBe(true); + }); + + it("accepts weekly_summary.ready", () => { + expect(isValidWebhookEvent("weekly_summary.ready")).toBe(true); + }); + + it("still accepts goal.completed", () => { + expect(isValidWebhookEvent("goal.completed")).toBe(true); + }); + + it("still rejects an arbitrary string", () => { + expect(isValidWebhookEvent("not.a.real.event")).toBe(false); + }); +}); + +// getActivityAlertEvents() +describe("getActivityAlertEvents", () => { + it("returns a readonly list of the three activity-alert events", () => { + const events = getActivityAlertEvents(); + expect(events).toContain("streak.milestone_reached"); + expect(events).toContain("goal.completed"); + expect(events).toContain("weekly_summary.ready"); + expect(events).toHaveLength(3); + }); + + it("is consistent across calls", () => { + expect(getActivityAlertEvents()).toEqual(getActivityAlertEvents()); + }); +}); + +// getAvailableEvents() — backwards-compat checks +describe("getAvailableEvents — includes new events", () => { + it("includes streak.milestone_reached", () => { + expect(getAvailableEvents()).toContain("streak.milestone_reached"); + }); + + it("includes weekly_summary.ready", () => { + expect(getAvailableEvents()).toContain("weekly_summary.ready"); + }); + + it("still includes the legacy events", () => { + const events = getAvailableEvents(); + expect(events).toContain("goal.created"); + expect(events).toContain("streak.milestone"); + expect(events).toContain("daily.summary"); + expect(events).toContain("weekly.summary"); + expect(events).toContain("metrics.updated"); + }); + + it("total event count is 8 (5 legacy-only + 2 new + goal.completed shared)", () => { + // streak.milestone_reached, goal.completed, weekly_summary.ready (activity alerts) + // goal.created, streak.milestone, daily.summary, weekly.summary, metrics.updated (legacy-only) + // goal.completed is in both sets but listed only once = 8 total + expect(getAvailableEvents()).toHaveLength(8); + }); + + it("has no duplicate entries", () => { + const events = getAvailableEvents(); + expect(new Set(events).size).toBe(events.length); + }); +}); + +// signPayload() +describe("signPayload", () => { + it("produces a 64-char hex string for HMAC-SHA256", () => { + const sig = signPayload('{"event":"goal.completed"}', "test-secret"); + expect(sig).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces different signatures for different payloads", () => { + const s1 = signPayload('{"event":"goal.completed"}', "secret"); + const s2 = signPayload('{"event":"streak.milestone_reached"}', "secret"); + expect(s1).not.toBe(s2); + }); + + it("produces different signatures for different secrets", () => { + const payload = '{"event":"goal.completed"}'; + const s1 = signPayload(payload, "secret-a"); + const s2 = signPayload(payload, "secret-b"); + expect(s1).not.toBe(s2); + }); + + it("is deterministic for the same inputs", () => { + const payload = '{"event":"weekly_summary.ready"}'; + expect(signPayload(payload, "mysecret")).toBe( + signPayload(payload, "mysecret") + ); + }); +}); + +// dispatchActivityAlert() +describe("dispatchActivityAlert", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ssrfModule.isSafeUrl).mockResolvedValue(true); + vi.mocked(cryptoModule.encryptToken).mockReturnValue({ + encrypted: "enc", + iv: "iv", + }); + vi.mocked(cryptoModule.decryptToken).mockReturnValue("decrypted_secret"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("resolves without throwing when no webhooks are configured", async () => { + makeSupabaseMock([]); // no webhooks + await expect( + dispatchActivityAlert("user-1", "goal.completed", { goalId: "g-1" }) + ).resolves.toBeUndefined(); + }); + + it("calls dispatchToAllWebhooks (via supabase lookup) for streak.milestone_reached", async () => { + makeSupabaseMock([]); // no matching webhooks → no fetch calls + await dispatchActivityAlert("user-1", "streak.milestone_reached", { + milestone: 30, + }); + // supabaseAdmin.from should have been called to look up webhooks + expect(supabaseAdmin.from).toHaveBeenCalledWith("webhook_configs"); + }); + + it("calls dispatchToAllWebhooks for weekly_summary.ready", async () => { + makeSupabaseMock([]); + await dispatchActivityAlert("user-1", "weekly_summary.ready", { + week: "2026-W01", + }); + expect(supabaseAdmin.from).toHaveBeenCalledWith("webhook_configs"); + }); + + it("does not throw when supabase query fails (error isolation)", async () => { + vi.mocked(supabaseAdmin.from).mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + contains: vi.fn().mockReturnThis(), + limit: vi.fn().mockRejectedValue(new Error("DB down")), + single: vi.fn(), + insert: vi.fn(), + order: vi.fn().mockReturnThis(), + } as any); + + await expect( + dispatchActivityAlert("user-1", "goal.completed", {}) + ).resolves.toBeUndefined(); + }); + + it("includes devtrack_event in the payload data", async () => { + // Arrange: one matching webhook + const webhookId = "wh-abc"; + const webhookRow = { + id: webhookId, + url: "https://example.com/hook", + secret_key: "enc", + secret_iv: "iv", + is_enabled: true, + }; + + vi.mocked(supabaseAdmin.from).mockImplementation((table: string) => { + if (table === "webhook_configs") { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + contains: vi.fn().mockReturnThis(), + // First call (dispatchToAllWebhooks) returns the list of webhook IDs + limit: vi.fn().mockResolvedValue({ + data: [{ id: webhookId }], + error: null, + }), + // Second call (dispatchWebhook) returns the full row + single: vi.fn().mockResolvedValue({ data: webhookRow, error: null }), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + order: vi.fn().mockReturnThis(), + } as any; + } + // webhook_deliveries insert + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + order: vi.fn().mockReturnThis(), + } as any; + }); + + vi.mocked(global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + await dispatchActivityAlert("user-1", "goal.completed", { goalId: "g-99" }); + + const fetchCalls = vi.mocked(global.fetch as ReturnType).mock.calls; + if (fetchCalls.length > 0) { + const body = JSON.parse(fetchCalls[0][1]?.body as string); + expect(body.data).toMatchObject({ devtrack_event: "goal.completed" }); + } + }); +}); + +describe("/api/webhooks/activity-alerts route", () => { + it("exports a GET handler that returns the event list", async () => { + const { GET } = await import( + "../src/app/api/webhooks/activity-alerts/route" + ); + const res = await GET(); + const json = await res.json(); + expect(json.events).toContain("streak.milestone_reached"); + expect(json.events).toContain("goal.completed"); + expect(json.events).toContain("weekly_summary.ready"); + }); +}); diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index ec8e1053b..821c332fa 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -100,9 +100,9 @@ describe("Webhooks Module", () => { expect(events).toContain("metrics.updated"); }); - it("should return exactly 6 events", () => { + it("should return exactly 8 events (5 legacy-only + 2 new activity-alert + goal.completed shared)", () => { const events = getAvailableEvents(); - expect(events.length).toBe(6); + expect(events.length).toBe(8); }); it("should return immutable list", () => { @@ -114,7 +114,7 @@ describe("Webhooks Module", () => { it("should not contain duplicates", () => { const events = getAvailableEvents(); const uniqueEvents = new Set(events); - expect(uniqueEvents.size).toBe(6); + expect(uniqueEvents.size).toBe(8); }); });