/** * DEV-only notification testing utilities. * * IMPORTANT: * This service intentionally routes through the same production notification * orchestration paths used by refresh flows, wakeup pushes, and replacement. * Avoid adding duplicate scheduling logic here. */ import { Capacitor } from "@capacitor/core"; import type { PushNotificationSchema } from "@capacitor/push-notifications"; import { logger } from "@/utils/logger"; import { getOrCreateDeviceId } from "./deviceId"; import { clearNotificationDebugLogs, logNotification, } from "./NotificationDebugEvents"; import { logNotificationClearing } from "./notificationLog"; import { getBackendBaseUrl, getBypassAuth, getNotificationApiBaseUrl, getTestMode, setBackendBaseUrl, setBypassAuth, setTestMode, } from "./NotificationDebugConfig"; import { getLastKnownFcmToken, reregisterFcmTokenNow, } from "./firebaseMessagingClient"; import { getNotificationApiHeaders, httpAuthErrorMessage, } from "./notificationApiAuth"; import { applyNotificationRefreshPayload, handleCapacitorPushNotificationReceived, refreshNotificationsWithDiagnostics, type NotificationRefreshPayload, } from "./NativeNotificationService"; import { truncateFcmTokenForLog } from "./notificationLog"; import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin"; type PendingNotificationInfo = { identifier: string; nextTriggerDate?: number | null; triggerType?: string | null; wallClockMillis?: number | null; wallClockSource?: string | null; }; export type PendingNotificationsResult = { pending: PendingNotificationInfo[]; /** Native layer does not implement inspection on this platform (e.g. Android). */ inspectorUnavailableMessage?: string; }; export type SendRealWakeupPingResult = | { ok: true; responseBody?: unknown } | { ok: false; errorMessage: string; status?: number; responseBody?: unknown; }; function wakeupPingResponseDetail(body: unknown): Record { if (typeof body !== "object" || body === null) { return {}; } const record = body as Record; const detail: Record = {}; for (const key of [ "success", "message", "reason", "error", "tokenSuffix", "deviceId", ] as const) { if (record[key] !== undefined) { detail[key] = record[key]; } } return detail; } function wakeupPingFailureMessage(status: number, body: unknown): string { if (typeof body === "object" && body !== null) { const record = body as Record; for (const key of ["message", "reason", "error"] as const) { const value = record[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } } if (status === 401 || status === 403) { return httpAuthErrorMessage(status); } return `HTTP ${status}`; } function isUnimplementedError(e: unknown): boolean { return ( typeof e === "object" && e !== null && "code" in e && (e as { code?: string }).code === "UNIMPLEMENTED" ); } const LOG = "[NotificationDebugService]"; export const NotificationDebugService = { clearDebugLogs(): void { clearNotificationDebugLogs(); }, getActiveBackendUrl(): string { return getNotificationApiBaseUrl(); }, getBackendUrlOverride(): string | null { return getBackendBaseUrl(); }, saveBackendBaseUrl(url: string): void { setBackendBaseUrl(url); logNotification( url.trim() ? `Backend URL saved (${getNotificationApiBaseUrl()})` : "Backend URL cleared (using default)", ); }, setTestModeEnabled(enabled: boolean): void { setTestMode(enabled); logNotification(`Test mode ${enabled ? "enabled" : "disabled"}`); }, isTestModeEnabled(): boolean { return getTestMode(); }, setBypassAuthEnabled(enabled: boolean): void { setBypassAuth(enabled); logNotification( `Auth bypass ${enabled ? "enabled" : "disabled"} (local dev only)`, ); }, isBypassAuthEnabled(): boolean { return getBypassAuth(); }, getFcmToken(): string | null { return getLastKnownFcmToken(); }, async registerTokenNow(): Promise { logNotification("Register token now (debug panel)"); await reregisterFcmTokenNow(); }, async triggerBackendRefresh(): Promise { await refreshNotificationsWithDiagnostics({ source: "debug panel" }); }, /** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */ async simulateWakeupViaRefresh(): Promise { logNotification("WAKEUP_PING simulation (local refresh API only)"); await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING simulation", }); }, /** Full pipeline: backend `/debug/send-wakeup` → FCM → native WAKEUP_PING handler. */ async sendRealWakeupPing(): Promise { logNotification("Real WAKEUP_PING requested"); const fcmToken = getLastKnownFcmToken()?.trim() ?? ""; if (!fcmToken) { const errorMessage = "no FCM token (register first)"; logNotification(`Real WAKEUP_PING failed: ${errorMessage}`); return { ok: false, errorMessage }; } try { const auth = await getNotificationApiHeaders(); if (!auth.ok) { logNotification(`Real WAKEUP_PING failed: ${auth.message}`); return { ok: false, errorMessage: auth.message }; } const deviceId = await getOrCreateDeviceId(); const baseUrl = getNotificationApiBaseUrl(); const res = await fetch(`${baseUrl}/debug/send-wakeup`, { method: "POST", headers: auth.headers, body: JSON.stringify({ deviceId, fcmToken, platform: Capacitor.getPlatform(), testMode: getTestMode(), }), }); let responseBody: unknown; try { responseBody = await res.json(); } catch { responseBody = undefined; } if (!res.ok) { const errorMessage = wakeupPingFailureMessage(res.status, responseBody); logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, { status: res.status, token: truncateFcmTokenForLog(fcmToken), ...wakeupPingResponseDetail(responseBody), }); return { ok: false, errorMessage, status: res.status, responseBody, }; } logNotification("Real WAKEUP_PING success", { token: truncateFcmTokenForLog(fcmToken), deviceId, ...wakeupPingResponseDetail(responseBody), }); return { ok: true, responseBody }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, { token: truncateFcmTokenForLog(fcmToken), }); logger.warn(`${LOG} sendRealWakeupPing failed`, err); return { ok: false, errorMessage }; } }, generateMockNotifications( intervalMs: number = 60_000, ): NotificationRefreshPayload { const now = Date.now(); const future1 = now + intervalMs; const future2 = now + intervalMs * 2; return { shouldNotify: true, nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }], }; }, async triggerMockRefresh(intervalMs?: number): Promise { logNotification("Mock refresh requested"); const payload = this.generateMockNotifications(intervalMs); const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? []; logNotification(`Mock payload generated (${timestamps.length} timestamps)`); if (!Capacitor.isNativePlatform()) { logNotification("Mock refresh skipped: not running on native platform"); return; } await applyNotificationRefreshPayload(payload); logNotification("Mock refresh applied"); }, async simulateWakeupPing(): Promise { logNotification("Simulating WAKEUP_PING (production push handler)"); if (!Capacitor.isNativePlatform()) { logNotification("WAKEUP_PING simulation skipped: not native platform"); return; } const notification = { title: "WAKEUP_PING", body: "", id: "dev_wakeup_ping", data: { type: "WAKEUP_PING" }, } as unknown as PushNotificationSchema; await handleCapacitorPushNotificationReceived(notification); }, async runFloodTest(intervalMs?: number): Promise { logNotification("Flood test started (20 sequential refreshes)"); for (let i = 0; i < 20; i++) { logNotification(`Flood iteration ${i + 1}/20`); await this.triggerMockRefresh(intervalMs); } logNotification("Flood test completed"); }, async clearNotifications(): Promise { logNotification("Clear notifications (debug panel)"); if (!Capacitor.isNativePlatform()) { logNotification("Clear skipped: not running on native platform"); return; } const plugin = DailyNotification as unknown as { clearAllNotifications?: () => Promise; cancelAllNotifications?: () => Promise; }; if (typeof plugin.clearAllNotifications === "function") { logNotificationClearing("clearAllNotifications"); await plugin.clearAllNotifications(); } else if (typeof plugin.cancelAllNotifications === "function") { logNotificationClearing("cancelAllNotifications"); await plugin.cancelAllNotifications(); } else { logNotification("Clear not available (plugin method missing)"); return; } logNotification("Notifications cleared"); }, async getPendingNotifications(): Promise { logNotification("Fetching pending notifications"); if (!Capacitor.isNativePlatform()) { logNotification("Pending fetch skipped: not running on native platform"); return { pending: [] }; } try { const res = await NotificationInspector.getPendingNotifications(); const items = (res?.pending ?? []) as PendingNotificationInfo[]; logNotification(`Pending fetched (${items.length})`); return { pending: items }; } catch (e: unknown) { if (isUnimplementedError(e)) { return { pending: [], inspectorUnavailableMessage: "Pending notification inspection is currently supported on iOS only.", }; } logNotification("Pending fetch failed"); logger.warn(`${LOG} getPendingNotifications failed`, e); return { pending: [] }; } }, };