Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"bullmq": "^5.81.2",
"drizzle-orm": "^0.45.2",
"fast-xml-parser": "^5.9.3",
"firebase-admin": "^13.7.0",
"firebase-admin": "^14.4.0",
"google-auth-library": "^10.9.1",
"hono": "^4.13.5",
"i18next": "^26.3.6",
Expand Down
56 changes: 45 additions & 11 deletions apps/api/src/services/fcm.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FirebaseMessagingError } from 'firebase-admin/messaging';

const { messagingSend, initializeApp, cert, appsList } = vi.hoisted(() => ({
const { messagingSend, initializeApp, getApp, cert, appsList } = vi.hoisted(() => ({
messagingSend: vi.fn(),
initializeApp: vi.fn(() => ({})),
getApp: vi.fn(() => ({})),
cert: vi.fn((sa: unknown) => sa),
appsList: [] as unknown[],
}));

vi.mock('firebase-admin', () => ({
default: {
get apps() {
return appsList;
},
app: vi.fn(() => ({})),
initializeApp,
credential: { cert },
messaging: () => ({ send: messagingSend }),
},
vi.mock('firebase-admin/app', () => ({
getApps: vi.fn(() => appsList),
getApp,
initializeApp,
cert,
}));

vi.mock('firebase-admin/messaging', async (importOriginal) => {
const actual = await importOriginal<typeof import('firebase-admin/messaging')>();
return {
...actual,
getMessaging: vi.fn(() => ({ send: messagingSend })),
};
});

import {
isFcmConfigured,
sendFcmNotification,
Expand Down Expand Up @@ -98,6 +103,35 @@ describe('sendFcmNotification', () => {
});
});

it('reports unregistered:true for a real v14-shaped FirebaseMessagingError (registration-token-not-registered)', async () => {
process.env.FIREBASE_SERVICE_ACCOUNT = JSON.stringify({ private_key: 'x', client_email: 'y' });
// Construct via the SDK's own error class (not a plain {code} object) so a
// firebase-admin major bump that reshapes error internals fails this test
// first, before it silently breaks sendFcmNotification's code matching.
// The public .d.ts hides the constructor (marked @internal), so we go
// through the runtime shape directly — this is what admin.messaging().send()
// actually throws.
const Ctor = FirebaseMessagingError as unknown as new (info: {
code: string;
message: string;
}) => FirebaseMessagingError;
const err = new Ctor({
code: 'registration-token-not-registered',
message: 'Requested entity was not found.',
});
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('messaging/registration-token-not-registered');
messagingSend.mockRejectedValueOnce(err);

const res = await sendFcmNotification('dead-tok', { title: 't', body: 'b' });

expect(res).toEqual({
ok: false,
reason: 'messaging/registration-token-not-registered',
unregistered: true,
});
});

it('reports a live failure without unregistered on any other error', async () => {
process.env.FIREBASE_SERVICE_ACCOUNT = JSON.stringify({ private_key: 'x', client_email: 'y' });
messagingSend.mockRejectedValueOnce({ code: 'messaging/internal-error' });
Expand Down
25 changes: 15 additions & 10 deletions apps/api/src/services/fcm.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import admin from 'firebase-admin';
import { initializeApp, getApp, getApps, cert, type App, type ServiceAccount } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';

/**
* Native Firebase Cloud Messaging (FCM) sender for Android. Mirrors apns.ts's
* never-throws, structured-result contract so expoPush.ts's dispatcher can
* treat both native providers identically (#3639).
*
* Uses the modular `firebase-admin/app` + `firebase-admin/messaging` API —
* firebase-admin 14 removed the legacy `admin.app`/`admin.messaging()`
* compat namespace from the package's default export.
*/

let firebaseApp: admin.app.App | null = null;
let firebaseApp: App | null = null;

function parseServiceAccount(raw: string): admin.ServiceAccount {
function parseServiceAccount(raw: string): ServiceAccount {
let parsed: { privateKey?: string; private_key?: string };
try {
parsed = JSON.parse(raw);
Expand All @@ -21,22 +26,22 @@ function parseServiceAccount(raw: string): admin.ServiceAccount {
if (typeof parsed.privateKey === 'string') {
parsed.privateKey = parsed.privateKey.replace(/\\n/g, '\n');
}
return parsed as admin.ServiceAccount;
return parsed as ServiceAccount;
}

/** True iff FIREBASE_SERVICE_ACCOUNT is present. Mirrors isApnsConfigured(). */
export function isFcmConfigured(): boolean {
return !!process.env.FIREBASE_SERVICE_ACCOUNT;
}

function initFirebase(): admin.app.App {
function initFirebase(): App {
if (firebaseApp) return firebaseApp;
const raw = process.env.FIREBASE_SERVICE_ACCOUNT;
if (!raw) throw new Error('FIREBASE_SERVICE_ACCOUNT is not set');
const serviceAccount = parseServiceAccount(raw);
firebaseApp = admin.apps.length
? admin.app()
: admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
firebaseApp = getApps().length
? getApp()
: initializeApp({ credential: cert(serviceAccount) });
return firebaseApp;
}

Expand Down Expand Up @@ -91,8 +96,8 @@ export async function sendFcmNotification(token: string, payload: FcmPayload): P
return { ok: false, reason: 'not_configured' };
}
try {
initFirebase();
const messageId = await admin.messaging().send({
const app = initFirebase();
const messageId = await getMessaging(app).send({
token,
notification: { title: payload.title, body: payload.body },
data: stringifyData(payload.data),
Expand Down
Loading
Loading