Skip to content
Merged
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
108 changes: 108 additions & 0 deletions .github/scripts/functions-notification.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
48 changes: 48 additions & 0 deletions .github/workflows/functions-validation.yml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 31 additions & 53 deletions functions/index.js
Original file line number Diff line number Diff line change
@@ -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};
},
);
6 changes: 3 additions & 3 deletions functions/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
}
33 changes: 33 additions & 0 deletions functions/test/runtime.test.cjs
Original file line number Diff line number Diff line change
@@ -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"],
});
});
Loading