feat(api/admin): wire APNS branch in notifications test endpoint

v1.4.46 — the admin "Send test notification" switch handled TELEGRAM,
NTFY, and WEB_PUSH but had no APNS arm. An iOS-paired admin's APNS
channel fell through to the "Unknown channel type" default branch, so
the Settings → Notifications "Test" button reported the channel as
broken even though the dispatcher routed real notifications through
APNs without issue.

Mirror the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its
own per-device fan-out (`prisma.device.findMany` for the user's iOS
devices) and returns the same `SendOutcome` envelope the other senders
do. On failure the route surfaces the sender's `reason` in the
per-channel error (e.g. `apns_no_devices`, `apns_not_configured`) so
the admin UI shows the actionable cause rather than a generic "Send
failed".

Six regression cases pin the contract:
  * APNS channel triggers sendViaApns with the canonical payload,
  * success → `{ channel: "APNS", success: true }`,
  * failure with reason → reason surfaces in the per-channel error,
  * failure without reason → falls back to `apns_send_failed`,
  * APNS + WEB_PUSH fan-out in the same response works,
  * APNS no longer hits the "Unknown channel type" default arm.
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 00:56:39 +02:00
1 parent 7cf760325f
commit 12753d4531
2 files changed
+328

No files matched your search

@@ -0,0 +1,302 @@
/**
* v1.4.46 — coverage for the admin notification-test route, with the
* focus on the APNS branch that the prior switch was missing entirely
* (`channel.type === "APNS"` fell through to the "Unknown channel
* type" default, even though APNs is a first-class channel in the
* dispatcher and the device-pairing flow). The regression cases pin:
*
* * APNS channel triggers the APNs sender,
* * the sender's `ok: true` returns `success: true` in the results
* envelope,
* * the sender's `ok: false` surfaces its `reason` in the per-channel
* error,
* * the route co-handles APNS + WEB_PUSH in the same response when
* both are enabled,
* * un-stubbed channels (NTFY for symmetry) still flow through.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `enc(${value})`),
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
}));
vi.mock("@/lib/notifications/senders/telegram", () => ({
sendViaTelegram: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/web-push", () => ({
sendViaWebPush: vi.fn(),
}));
vi.mock("@/lib/notifications/senders/apns", () => ({
sendViaApns: vi.fn(),
}));
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: vi.fn(() => ({
t: (key: string) => key,
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
import { sendViaApns } from "@/lib/notifications/senders/apns";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: ADMIN_USER_ID,
username: "admin",
role: "ADMIN",
},
} as never;
interface FakeChannelRow {
id: string;
userId: string;
type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
enabled: boolean;
config: string;
preferences: Array<{ eventType: string; enabled: boolean }>;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
id: "ch-tg",
userId: ADMIN_USER_ID,
type: "TELEGRAM",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
locale: "en",
} as never);
});
interface ResultEnvelope {
data: {
sent: boolean;
message: string;
results: Array<{
channel: string;
success: boolean;
error?: string;
}>;
};
}
describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => {
it("routes APNS channel through sendViaApns with title/message/eventType", async () => {
const apnsChannel: FakeChannelRow = {
id: "ch-apns",
userId: ADMIN_USER_ID,
type: "APNS",
enabled: true,
config: "enc({})",
preferences: [],
};
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
apnsChannel,
] as never);
vi.mocked(sendViaApns).mockResolvedValue({ ok: true });
const res = await POST();
const body = (await res.json()) as ResultEnvelope;
expect(sendViaApns).toHaveBeenCalledTimes(1);
expect(sendViaApns).toHaveBeenCalledWith(
ADMIN_USER_ID,
expect.objectContaining({
title: expect.any(String),
message: expect.any(String),
eventType: "SYSTEM_ALERT",
}),
);
expect(body.data.results).toEqual([
{ channel: "APNS", success: true },
]);
expect(body.data.sent).toBe(true);
});
it("surfaces the APNs sender's reason on failure (per-channel error)", async () => {
const apnsChannel: FakeChannelRow = {
id: "ch-apns",
userId: ADMIN_USER_ID,
type: "APNS",
enabled: true,
config: "enc({})",
preferences: [],
};
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
apnsChannel,
] as never);
vi.mocked(sendViaApns).mockResolvedValue({
ok: false,
hardReject: false,
reason: "apns_no_devices",
});
const res = await POST();
const body = (await res.json()) as ResultEnvelope;
expect(body.data.results).toEqual([
{
channel: "APNS",
success: false,
error: "apns_no_devices",
},
]);
expect(body.data.sent).toBe(false);
});
it("falls back to a generic error when the sender omits a reason", async () => {
const apnsChannel: FakeChannelRow = {
id: "ch-apns",
userId: ADMIN_USER_ID,
type: "APNS",
enabled: true,
config: "enc({})",
preferences: [],
};
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
apnsChannel,
] as never);
vi.mocked(sendViaApns).mockResolvedValue({
ok: false,
hardReject: false,
});
const res = await POST();
const body = (await res.json()) as ResultEnvelope;
expect(body.data.results[0]).toEqual({
channel: "APNS",
success: false,
error: "apns_send_failed",
});
});
it("fans out across APNS + WEB_PUSH in the same response when both enabled", async () => {
const channels: FakeChannelRow[] = [
{
id: "ch-apns",
userId: ADMIN_USER_ID,
type: "APNS",
enabled: true,
config: "enc({})",
preferences: [],
},
{
id: "ch-web",
userId: ADMIN_USER_ID,
type: "WEB_PUSH",
enabled: true,
config: "enc({})",
preferences: [],
},
];
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
channels as never,
);
vi.mocked(sendViaApns).mockResolvedValue({ ok: true });
vi.mocked(sendViaWebPush).mockResolvedValue({ ok: true });
const res = await POST();
const body = (await res.json()) as ResultEnvelope;
expect(sendViaApns).toHaveBeenCalledTimes(1);
expect(sendViaWebPush).toHaveBeenCalledTimes(1);
expect(body.data.results).toEqual([
{ channel: "APNS", success: true },
{ channel: "WEB_PUSH", success: true },
]);
expect(body.data.sent).toBe(true);
});
it("does not fall through to the 'Unknown channel type' default for APNS", async () => {
// Regression for the v1.4.46 bug: pre-fix the switch had no APNS
// arm and APNs channels reported "Unknown channel type" in the
// admin UI. The new arm must short-circuit that path.
const apnsChannel: FakeChannelRow = {
id: "ch-apns",
userId: ADMIN_USER_ID,
type: "APNS",
enabled: true,
config: "enc({})",
preferences: [],
};
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
apnsChannel,
] as never);
vi.mocked(sendViaApns).mockResolvedValue({ ok: true });
const res = await POST();
const body = (await res.json()) as ResultEnvelope;
expect(body.data.results[0].error).not.toBe("Unknown channel type");
expect(sendViaApns).toHaveBeenCalled();
});
});
describe("POST /api/admin/notifications/test — preserved branches", () => {
it("still routes NTFY channels through sendViaNtfy", async () => {
const ntfyChannel: FakeChannelRow = {
id: "ch-ntfy",
userId: ADMIN_USER_ID,
type: "NTFY",
enabled: true,
config: 'enc({"topic":"hl-test"})',
preferences: [],
};
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
ntfyChannel,
] as never);
vi.mocked(sendViaNtfy).mockResolvedValue({ ok: true } as never);
const res = await POST();
const body = (await res.json()) as ResultEnvelope;
expect(sendViaNtfy).toHaveBeenCalledTimes(1);
expect(body.data.results[0]).toMatchObject({
channel: "NTFY",
success: true,
});
});
});
@@ -6,6 +6,7 @@ import { decrypt, encrypt } from "@/lib/crypto";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaApns } from "@/lib/notifications/senders/apns";
import type {
TelegramChannelConfig,
NtfyChannelConfig,
@@ -172,6 +173,31 @@ export const POST = apiHandler(async () => {
success = webPushResult.ok;
break;
}
// v1.4.46 — APNs was missing from the admin-test switch even
// though the dispatcher already routes the same payload to the
// sender in production. Mirrors the WEB_PUSH branch: the sender
// owns its own DB lookup (`prisma.device.findMany` for the
// user's iOS devices) and its own per-device fan-out, so the
// admin route just calls the per-channel entry point and reads
// the boolean outcome.
case "APNS": {
const apnsResult = await sendViaApns(user.id, {
title: payload.title,
message: payload.message,
eventType: payload.eventType,
metadata: payload.metadata,
});
success = apnsResult.ok;
if (!success) {
results.push({
channel: "APNS",
success: false,
error: apnsResult.reason ?? "apns_send_failed",
});
continue;
}
break;
}
default:
results.push({
channel: type,