diff --git a/.github/scripts/functions-notification.test.mjs b/.github/scripts/functions-notification.test.mjs new file mode 100644 index 00000000..d54526c0 --- /dev/null +++ b/.github/scripts/functions-notification.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const source = await readFile(new URL("../../functions/index.js", import.meta.url), "utf8"); + +function fixture({ tokens = ["private-token"], sendError, readError, failed = 0 } = {}) { + const messages = []; + const logs = []; + const codes = []; + const exports = {}; + const admin = { + initializeApp() {}, + firestore: () => ({ collection: (name) => { + assert.equal(name, "shareCodeWatchers"); + return { doc: (code) => { + codes.push(code); + return { collection: (nested) => { + assert.equal(nested, "tokens"); + return { get: async () => { + if (readError) throw readError; + return { forEach: (visit) => tokens.forEach((fcmToken) => visit({ data: () => ({ fcmToken }) })) }; + } }; + } }; + } }; + } }), + messaging: () => ({ sendEachForMulticast: async (message) => { + messages.push(JSON.parse(JSON.stringify(message))); + if (sendError) throw sendError; + return { successCount: message.tokens.length - failed, failureCount: failed }; + } }), + }; + vm.runInNewContext(source, { + exports, + console: { log: (...values) => logs.push(values) }, + require: (name) => { + if (name === "firebase-admin") return admin; + assert.equal(name, "firebase-functions/v2/firestore"); + return { onDocumentWritten: (options, handler) => { + assert.equal(options.document, "schedules/{scheduleId}"); + return handler; + } }; + }, + }, { filename: "functions/index.js" }); + return { handle: exports.sendFcmToShareCodeWatchers, messages, logs, codes }; +} + +const schedule = { sharedCode: "ABC123", title: "private schedule", completed: false }; +function change(before, after) { + const snapshot = (data) => ({ exists: data !== undefined, data: () => data }); + return { params: { scheduleId: "schedule-id" }, data: { before: snapshot(before), after: snapshot(after) } }; +} + +for (const [name, before, after, title] of [ + ["creation", undefined, schedule, "일정이 추가되었습니다"], + ["deletion", schedule, undefined, "일정이 삭제되었습니다"], + ["completion", schedule, { ...schedule, completed: true }, "완료되었습니다"], + ["undo", { ...schedule, completed: true }, schedule, "상태 미완료로 바꿨습니다"], + ["edit", schedule, { ...schedule, title: "updated title" }, "일정이 변경되었습니다"], +]) { + test(`actual notification handler handles ${name}`, async () => { + const f = fixture(); + await f.handle(change(before, after)); + assert.deepEqual(f.codes, ["ABC123"]); + assert.deepEqual(f.messages, [{ notification: { title, body: (after || before).title }, tokens: ["private-token"] }]); + const logged = JSON.stringify(f.logs); + assert.doesNotMatch(logged, /private-token|private schedule|updated title|ABC123/); + }); +} + +test("missing event, unshared schedule and no recipients do not send", async () => { + for (const event of [{}, change(undefined, undefined), change(undefined, { title: "unshared" })]) { + const f = fixture(); + assert.equal(await f.handle(event), null); + assert.equal(f.messages.length, 0); + assert.equal(f.codes.length, 0); + } + const f = fixture({ tokens: [undefined, "", " ", 42] }); + assert.equal(await f.handle(change(undefined, schedule)), null); + assert.equal(f.messages.length, 0); +}); + +test("deduplicates registration tokens and splits at the real multicast limit", async () => { + const tokens = Array.from({ length: 1001 }, (_, index) => `token-${index}`); + const f = fixture({ tokens: [...tokens, tokens[0], tokens[1000]] }); + const result = await f.handle(change(undefined, schedule)); + assert.deepEqual(f.messages.map((message) => message.tokens.length), [500, 500, 1]); + assert.deepEqual(f.messages.flatMap((message) => message.tokens), tokens); + assert.equal(result.successCount, 1001); + assert.equal(result.failureCount, 0); +}); + +test("records partial delivery counts without exposing token or payload", async () => { + const f = fixture({ tokens: ["one", "two"], failed: 1 }); + const result = await f.handle(change(undefined, schedule)); + assert.equal(result.successCount, 1); + assert.equal(result.failureCount, 1); + assert.doesNotMatch(JSON.stringify(f.logs), /one|two|private schedule|ABC123/); +}); + +test("Firestore and total FCM failures reject instead of claiming success", async () => { + for (const options of [{ readError: new Error("read failed") }, { sendError: new Error("send failed") }]) { + const f = fixture(options); + await assert.rejects(f.handle(change(undefined, schedule)), /failed/); + assert.equal(f.logs.length, 0); + } +}); diff --git a/.github/workflows/functions-validation.yml b/.github/workflows/functions-validation.yml new file mode 100644 index 00000000..917c0bef --- /dev/null +++ b/.github/workflows/functions-validation.yml @@ -0,0 +1,48 @@ +name: Cloud Functions Validation + +on: + pull_request: + paths: + - 'functions/**' + - '.github/scripts/functions-notification.test.mjs' + - '.github/workflows/functions-validation.yml' + push: + branches: [main] + paths: + - 'functions/**' + - '.github/scripts/functions-notification.test.mjs' + - '.github/workflows/functions-validation.yml' + +permissions: + contents: read + +concurrency: + group: functions-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + functions: + name: Validate Functions runtime and notifications + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Clone repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: functions/package-lock.json + - name: Install and lint Functions + working-directory: functions + run: | + npm ci --ignore-scripts + npm run lint + npm test + - name: Load the trigger without contacting production + run: node -e 'require("./functions/index.js")' + - name: Verify notification handling at the Firestore and FCM boundaries + run: node --test .github/scripts/functions-notification.test.mjs diff --git a/functions/index.js b/functions/index.js index af8e1eca..488a10a3 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,80 +1,58 @@ -/** - * Import function triggers from their respective submodules: - * - * const {onCall} = require("firebase-functions/v2/https"); - * const {onDocumentWritten} = require("firebase-functions/v2/firestore"); - * - * See a full list of supported triggers at https://firebase.google.com/docs/functions - */ const admin = require("firebase-admin"); +const {onDocumentWritten} = require("firebase-functions/v2/firestore"); admin.initializeApp(); -const {onDocumentWritten} = require("firebase-functions/v2/firestore"); - exports.sendFcmToShareCodeWatchers = onDocumentWritten( {document: "schedules/{scheduleId}"}, async (event) => { - // Debug log to ensure function is triggered - console.log("FCM triggered for scheduleId:", - event.params.scheduleId); - - const schedule = event.data.after.data(); + if (!event.data) return null; + const before = event.data.before.data(); + const after = event.data.after.data(); + // 삭제 이벤트에는 after 데이터가 없다. 공유 대상과 제목은 삭제 전 값에서 읽는다. + const schedule = after || before; if (!schedule || !schedule.sharedCode) return null; - const shareCode = schedule.sharedCode; const tokensSnapshot = await admin .firestore() .collection("shareCodeWatchers") - .doc(shareCode) + .doc(schedule.sharedCode) .collection("tokens") .get(); - - const tokens = []; + const uniqueTokens = new Set(); tokensSnapshot.forEach((doc) => { - const data = doc.data(); - if (data.fcmToken) tokens.push(data.fcmToken); + const token = doc.data().fcmToken; + if (typeof token === "string" && token.trim()) uniqueTokens.add(token); }); - + const tokens = [...uniqueTokens]; if (tokens.length === 0) return null; - // Detect completion state change let notificationTitle = "일정이 변경되었습니다"; if (!event.data.before.exists) { notificationTitle = "일정이 추가되었습니다"; } else if (!event.data.after.exists) { notificationTitle = "일정이 삭제되었습니다"; - } else { - const before = event.data.before.data(); - const after = event.data.after.data(); - if (before && after && before.completed !== after.completed) { - if (after.completed === true) { - notificationTitle = "완료되었습니다"; - } else if (after.completed === false) { - notificationTitle = "상태 미완료로 바꿨습니다"; - } - } + } else if (before.completed !== after.completed) { + notificationTitle = after.completed === true ? + "완료되었습니다" : "상태 미완료로 바꿨습니다"; } - const payload = { - notification: { - title: notificationTitle, - body: `${schedule.title}`, - }, - }; - - // Log tokens and payload for debugging - console.log("Sending FCM to tokens:", tokens, payload); - - // Use sendEachForMulticast for recent firebase-admin SDKs - const multicastMessage = { - notification: { - title: notificationTitle, - body: `${schedule.title}`, - }, - tokens: tokens, - }; - console.log("Sending FCM to tokens:", tokens, multicastMessage); - return admin.messaging().sendEachForMulticast(multicastMessage); + // multicast는 호출마다 최대 500개다. 토큰이나 일정 내용을 로그에 남기지 않는다. + // https://firebase.google.com/docs/cloud-messaging/send/admin-sdk + let successCount = 0; + let failureCount = 0; + for (let offset = 0; offset < tokens.length; offset += 500) { + const result = await admin.messaging().sendEachForMulticast({ + notification: { + title: notificationTitle, + body: typeof schedule.title === "string" ? schedule.title : "", + }, + tokens: tokens.slice(offset, offset + 500), + }); + successCount += result.successCount; + failureCount += result.failureCount; + } + console.log("공유 일정 알림 전송 결과", {successCount, failureCount}); + return {successCount, failureCount}; }, ); diff --git a/functions/package-lock.json b/functions/package-lock.json index b6cef957..8403db68 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6968,9 +6968,9 @@ "peer": true }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/functions/package.json b/functions/package.json index b8816bdc..17bc134c 100644 --- a/functions/package.json +++ b/functions/package.json @@ -7,7 +7,8 @@ "shell": "firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", - "logs": "firebase functions:log" + "logs": "firebase functions:log", + "test": "node --test test/*.test.cjs" }, "engines": { "node": "22" @@ -22,5 +23,8 @@ "eslint-config-google": "^0.14.0", "firebase-functions-test": "^3.1.0" }, - "private": true + "private": true, + "overrides": { + "qs": "6.16.0" + } } diff --git a/functions/test/runtime.test.cjs b/functions/test/runtime.test.cjs new file mode 100644 index 00000000..e8ff1f6d --- /dev/null +++ b/functions/test/runtime.test.cjs @@ -0,0 +1,33 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const express = require("express"); +const qs = require("qs"); + +test("qs enforces bracket-key comma array limits", () => { + assert.throws(() => qs.parse("a[]=1,2,3,4", { + comma: true, + arrayLimit: 3, + throwOnLimitExceeded: true, + }), RangeError); +}); + +test("Functions Express parent still parses nested form data", async (t) => { + const app = express(); + app.use(express.urlencoded({extended: true})); + app.post("/form", (request, response) => response.json(request.body)); + const server = await new Promise((resolve) => { + const listening = app.listen(0, "127.0.0.1", () => resolve(listening)); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const address = server.address(); + const response = await fetch(`http://127.0.0.1:${address.port}/form`, { + method: "POST", + headers: {"content-type": "application/x-www-form-urlencoded"}, + body: "person[name]=tester&items[]=one&items[]=two", + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + person: {name: "tester"}, + items: ["one", "two"], + }); +});