Add an explicit notificationDebug.bypassAuth setting (default off) so custom backend URLs and test mode no longer skip authentication. Expose the toggle in the Notification Debug Panel for local ngrok workflows; hosted test servers receive JWT-authenticated requests by default.
359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
/**
|
|
* 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<string, unknown> {
|
|
if (typeof body !== "object" || body === null) {
|
|
return {};
|
|
}
|
|
const record = body as Record<string, unknown>;
|
|
const detail: Record<string, unknown> = {};
|
|
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<string, unknown>;
|
|
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<void> {
|
|
logNotification("Register token now (debug panel)");
|
|
await reregisterFcmTokenNow();
|
|
},
|
|
|
|
async triggerBackendRefresh(): Promise<void> {
|
|
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
|
|
},
|
|
|
|
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
|
|
async simulateWakeupViaRefresh(): Promise<void> {
|
|
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<SendRealWakeupPingResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void>;
|
|
cancelAllNotifications?: () => Promise<void>;
|
|
};
|
|
|
|
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<PendingNotificationsResult> {
|
|
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: [] };
|
|
}
|
|
},
|
|
};
|