Skip to content

Commit cd7a507

Browse files
committed
feat: add functions to dispatch notifications
1 parent 85a4a64 commit cd7a507

File tree

9 files changed

+495
-22
lines changed

9 files changed

+495
-22
lines changed

firestore.rules

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ rules_version = '2';
22
service cloud.firestore {
33
match /databases/{database}/documents {
44
match /places/{placeID} {
5-
allow list: if request.auth != null && resource.data.users[request.auth.uid] >= 0;
5+
allow list: if request.auth != null && resource.data.accounts[request.auth.uid] != null;
66
// anyone can get to allow search from ShareLink
77
allow get: if request.auth != null;
88
// anyone can write to allow update from the invitation - TODO: candidate for a cloud function
99
allow write: if request.auth != null;
10+
// Deny all access to the /private subcollection
1011
match /{subcollection=**} {
1112
allow read, write: if request.auth != null;
1213
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { jest } from "@jest/globals";
2+
3+
const sendEachForMulticast = jest.fn();
4+
5+
export const getMessaging = () => ({
6+
sendEachForMulticast,
7+
});

functions/src/__tests__/online.test.ts

+229-4
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,30 @@ import {
44
getFirestore,
55
Timestamp,
66
CollectionReference,
7+
DocumentReference,
78
} from "firebase-admin/firestore";
9+
import { getMessaging } from "firebase-admin/messaging";
810
import functions from "firebase-functions-test";
911

1012
import * as myFunctions from "../index";
1113
import { UserRole } from "../../../src/backend/UserRoles";
12-
import { place } from "../../../src/backend/FirestoreModels.gen";
13-
import { NotificationEvent } from "../../../src/backend/NotificationEvents";
14+
import {
15+
keg,
16+
personsIndex,
17+
place,
18+
} from "../../../src/backend/FirestoreModels.gen";
19+
import {
20+
FreeTableMessage,
21+
FreshKegMessage,
22+
NotificationEvent,
23+
UpdateDeviceTokenMessage,
24+
} from "../../../src/backend/NotificationEvents";
25+
import {
26+
getNotificationTokensDoc,
27+
getPersonsIndexDoc,
28+
getPlacesCollection,
29+
NotificationTokensDocument,
30+
} from "../helpers";
1431

1532
const testEnv = functions(
1633
{
@@ -27,12 +44,12 @@ afterAll(() => {
2744
describe(`deletePlaceSubcollection`, () => {
2845
const addPlace = async (opts: { placeId: string; withKegs: boolean }) => {
2946
const db = getFirestore();
30-
const placeCollection = db.collection("places");
47+
const placeCollection = getPlacesCollection(db);
3148
const placeDoc = placeCollection.doc(opts.placeId);
3249
await placeDoc.set({
3350
createdAt: Timestamp.now(),
3451
name: "Test Place",
35-
});
52+
} as any);
3653
const result = {
3754
kegsCollection: undefined as CollectionReference<any> | undefined,
3855
personsCollection: placeDoc.collection("persons"),
@@ -220,4 +237,212 @@ describe(`truncateUserInDb`, () => {
220237
undefined
221238
);
222239
});
240+
241+
it(`should also delete the user from the document with notification tokens`, async () => {
242+
const userUid = `beatle`;
243+
const db = getFirestore();
244+
const notificationTokensDoc = getNotificationTokensDoc(db);
245+
await notificationTokensDoc.set({
246+
tokens: { [userUid]: `registrationToken` },
247+
});
248+
const notificationTokensBefore = (
249+
await notificationTokensDoc.get()
250+
).data()!;
251+
expect(notificationTokensBefore.tokens[userUid]).toBe(`registrationToken`);
252+
const wrapped = testEnv.wrap(myFunctions.truncateUserInDb);
253+
await wrapped({ uid: userUid });
254+
const notificationTokensAfter = (await notificationTokensDoc.get()).data()!;
255+
expect(notificationTokensAfter.tokens[userUid]).toBeUndefined();
256+
});
257+
});
258+
259+
describe(`updateNotificationToken`, () => {
260+
const db = getFirestore();
261+
const notificationTokensDoc = getNotificationTokensDoc(db);
262+
it(`should add a user to notification collection if not there`, async () => {
263+
const userUid = `eleanor`;
264+
const deviceToken = `testingDeviceToken`;
265+
await notificationTokensDoc.set({ tokens: {} });
266+
const wrapped = testEnv.wrap(myFunctions.updateNotificationToken);
267+
await wrapped({
268+
auth: { uid: userUid },
269+
data: {
270+
deviceToken,
271+
} satisfies UpdateDeviceTokenMessage,
272+
});
273+
const notificationTokens = (await notificationTokensDoc.get()).data()!;
274+
expect(notificationTokens.tokens[userUid]).toBe(deviceToken);
275+
});
276+
it(`should update the user token if already there`, async () => {
277+
const uid = `condor`;
278+
const oldDeviceToken = `oldDeviceToken`;
279+
const newDeviceToken = `newDeviceToken`;
280+
await notificationTokensDoc.set({ tokens: { [uid]: oldDeviceToken } });
281+
const wrapped = testEnv.wrap(myFunctions.updateNotificationToken);
282+
await wrapped({
283+
auth: { uid },
284+
data: { deviceToken: newDeviceToken } satisfies UpdateDeviceTokenMessage,
285+
});
286+
const notificationTokens = (await notificationTokensDoc.get()).data()!;
287+
expect(notificationTokens.tokens[uid]).toBe(newDeviceToken);
288+
});
289+
});
290+
291+
describe(`dispatchNotification`, () => {
292+
beforeEach(() => {
293+
jest.clearAllMocks();
294+
});
295+
it(`should use jest automock for messaging`, () => {
296+
const messaging = getMessaging();
297+
expect(messaging.sendEachForMulticast).toHaveBeenCalledTimes(0);
298+
});
299+
300+
const createPlace = async (opts: {
301+
placeId: string;
302+
keg?: {
303+
beer: string;
304+
serial: number;
305+
};
306+
users: Array<{
307+
accountTuple: [UserRole, NotificationEvent];
308+
name: string;
309+
registrationToken: string;
310+
uid: string;
311+
}>;
312+
}) => {
313+
const db = getFirestore();
314+
const placeCollection = getPlacesCollection(db);
315+
const placeDoc = placeCollection.doc(opts.placeId);
316+
const accounts: place["accounts"] = opts.users.reduce((acc, u) => {
317+
acc[u.uid] = u.accountTuple;
318+
return acc;
319+
}, {} as place["accounts"]);
320+
await placeDoc.set({
321+
accounts: accounts,
322+
createdAt: Timestamp.now(),
323+
name: "Test Place",
324+
} as any);
325+
const personsIndexDoc = getPersonsIndexDoc(placeDoc);
326+
const personsIndexAll: personsIndex["all"] = opts.users.reduce((acc, u) => {
327+
acc[u.uid] = [u.name, Timestamp.now(), 0, u.uid] as any;
328+
return acc;
329+
}, {} as personsIndex["all"]);
330+
await personsIndexDoc.set({
331+
all: personsIndexAll,
332+
});
333+
const notificationTokensDoc = getNotificationTokensDoc(db);
334+
const tokens: NotificationTokensDocument["tokens"] = opts.users.reduce(
335+
(acc, u) => {
336+
acc[u.uid] = u.registrationToken;
337+
return acc;
338+
},
339+
{} as NotificationTokensDocument["tokens"]
340+
);
341+
await notificationTokensDoc.set({ tokens });
342+
let kegDoc: DocumentReference<keg> | undefined;
343+
if (opts.keg) {
344+
const kegsCollection = placeDoc.collection(
345+
"kegs"
346+
) as CollectionReference<keg>;
347+
kegDoc = await kegsCollection.add({
348+
beer: opts.keg.beer,
349+
createdAt: Timestamp.now(),
350+
serial: opts.keg.serial,
351+
} as any);
352+
}
353+
return { kegDoc, placeDoc, personsIndexDoc, notificationTokensDoc };
354+
};
355+
356+
it(`should dispatch a notification for freeTable message to subscribed users`, async () => {
357+
const { placeDoc } = await createPlace({
358+
placeId: `testDispatchNotification_freeTable`,
359+
users: [
360+
{
361+
accountTuple: [UserRole.owner, NotificationEvent.freeTable],
362+
name: `Alice`,
363+
registrationToken: `registrationToken1`,
364+
uid: `user1`,
365+
},
366+
{
367+
accountTuple: [
368+
UserRole.staff,
369+
NotificationEvent.freeTable | NotificationEvent.freshKeg,
370+
],
371+
name: `Bob`,
372+
registrationToken: `registrationToken2`,
373+
uid: `user2`,
374+
},
375+
{
376+
accountTuple: [UserRole.admin, NotificationEvent.unsubscribed],
377+
name: `Dan`,
378+
registrationToken: `registrationToken3`,
379+
uid: `user3`,
380+
},
381+
],
382+
});
383+
const wrapped = testEnv.wrap(myFunctions.dispatchNotification);
384+
await wrapped({
385+
auth: { uid: `user1` },
386+
data: {
387+
place: placeDoc.path,
388+
tag: NotificationEvent.freeTable,
389+
} satisfies FreeTableMessage,
390+
});
391+
const messaging = getMessaging();
392+
expect(messaging.sendEachForMulticast).toHaveBeenCalledTimes(1);
393+
const callArg = (messaging.sendEachForMulticast as any).mock.calls[0][0];
394+
expect(callArg.tokens).toEqual([
395+
`registrationToken1`,
396+
`registrationToken2`,
397+
]);
398+
expect(callArg.notification.body.startsWith(`Alice`)).toBe(true);
399+
});
400+
it(`should dispatch notification for freshKeg message to subscribed users`, async () => {
401+
const { kegDoc } = await createPlace({
402+
keg: {
403+
beer: `Test Beer`,
404+
serial: 1,
405+
},
406+
placeId: `testDispatchNotification_freshKeg`,
407+
users: [
408+
{
409+
accountTuple: [UserRole.owner, NotificationEvent.freshKeg],
410+
name: `Alice`,
411+
registrationToken: `registrationToken1`,
412+
uid: `user1`,
413+
},
414+
{
415+
accountTuple: [
416+
UserRole.staff,
417+
NotificationEvent.freeTable | NotificationEvent.freshKeg,
418+
],
419+
name: `Bob`,
420+
registrationToken: `registrationToken2`,
421+
uid: `user2`,
422+
},
423+
{
424+
accountTuple: [UserRole.admin, NotificationEvent.unsubscribed],
425+
name: `Dan`,
426+
registrationToken: `registrationToken3`,
427+
uid: `user3`,
428+
},
429+
],
430+
});
431+
const wrapped = testEnv.wrap(myFunctions.dispatchNotification);
432+
await wrapped({
433+
auth: { uid: `user1` },
434+
data: {
435+
keg: kegDoc!.path,
436+
tag: NotificationEvent.freshKeg,
437+
} satisfies FreshKegMessage,
438+
});
439+
const messaging = getMessaging();
440+
expect(messaging.sendEachForMulticast).toHaveBeenCalledTimes(1);
441+
const callArg = (messaging.sendEachForMulticast as any).mock.calls[0][0];
442+
expect(callArg.tokens).toEqual([
443+
`registrationToken1`,
444+
`registrationToken2`,
445+
]);
446+
expect(callArg.notification.body.includes(`Test Beer`)).toBe(true);
447+
});
223448
});

functions/src/helpers.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
type CollectionReference,
3+
type DocumentReference,
4+
} from "firebase-admin/firestore";
5+
6+
import type {
7+
personsIndex,
8+
place,
9+
} from "../../src/backend/FirestoreModels.gen";
10+
11+
export type NotificationTokensDocument = {
12+
// maps user uid to their notification token
13+
tokens: Record<string, string>;
14+
};
15+
16+
export const getPlacesCollection = (db: FirebaseFirestore.Firestore) =>
17+
db.collection("places") as CollectionReference<place>;
18+
19+
// Only cloud functions have access to this collection.
20+
export const getPrivateCollection = (db: FirebaseFirestore.Firestore) =>
21+
db.collection("private");
22+
23+
export const getNotificationTokensDoc = (db: FirebaseFirestore.Firestore) =>
24+
getPrivateCollection(db).doc(
25+
`notificationTokens`
26+
) as DocumentReference<NotificationTokensDocument>;
27+
28+
export const getPersonsIndexDoc = (placeDoc: DocumentReference<place>) =>
29+
placeDoc
30+
.collection(`personsIndex`)
31+
.doc(`1`) as DocumentReference<personsIndex>;

0 commit comments

Comments
 (0)