diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index c9ed74196..fb7ee5b04 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -60,3 +60,4 @@ jobs: tests/integration/auth.test.ts tests/integration/moderation.test.ts tests/integration/profile.test.ts + tests/integration/notifications.test.ts diff --git a/components/db/bills.ts b/components/db/bills.ts index d6c5569eb..aaf0b832a 100644 --- a/components/db/bills.ts +++ b/components/db/bills.ts @@ -1,11 +1,4 @@ -import { - collection, - getDocs, - limit, - orderBy, - Timestamp, - where -} from "firebase/firestore" +import { collection, getDocs, limit, orderBy, where } from "firebase/firestore" import { useAsync } from "react-async-hook" import type { BillHistory, @@ -14,6 +7,7 @@ import type { import { firestore } from "../firebase" import { loadDoc, midnight, nullableQuery } from "./common" import { currentGeneralCourt } from "functions/src/shared" +import { Timestamp } from "functions/src/firebase" export type { BillHistory } from "../../functions/src/bills/types" export type MemberReference = { @@ -26,14 +20,14 @@ export type MemberReference = { export type BillContent = { Title: string + Pinslip: string | null BillNumber: string DocketNumber: string GeneralCourtNumber: number - PrimarySponsor?: MemberReference + PrimarySponsor: MemberReference | null Cosponsors: MemberReference[] LegislationTypeName: string - Pinslip: string - DocumentText: string + DocumentText?: string | null } export type BillTopic = { @@ -51,10 +45,12 @@ export type Bill = { opposeCount: number neutralCount: number nextHearingAt?: Timestamp + nextHearingId?: string latestTestimonyAt?: Timestamp latestTestimonyId?: string fetchedAt: Timestamp history: BillHistory + similar: string[] currentCommittee?: CurrentCommittee city?: string topics?: BillTopic[] diff --git a/components/shared/FollowingQueries.tsx b/components/shared/FollowingQueries.tsx index 288da17e2..65fce4cbd 100644 --- a/components/shared/FollowingQueries.tsx +++ b/components/shared/FollowingQueries.tsx @@ -5,6 +5,7 @@ import { getDocs, query, setDoc, + Timestamp, where } from "firebase/firestore" import { Bill } from "../db" @@ -89,7 +90,8 @@ export async function setFollow( billId: billId, court: courtId }, - type: "bill" + type: "bill", + nextDigestAt: Timestamp.fromDate(new Date()) }) : await setDoc(doc(subscriptionRef, topicName), { topicName: topicName, @@ -97,7 +99,8 @@ export async function setFollow( userLookup: { profileId: profileId }, - type: "testimony" + type: "testimony", + nextDigestAt: Timestamp.fromDate(new Date()) }) } diff --git a/functions/src/bills/types.ts b/functions/src/bills/types.ts index 66686c3ba..033774407 100644 --- a/functions/src/bills/types.ts +++ b/functions/src/bills/types.ts @@ -40,13 +40,24 @@ export const CommitteeMember = Record({ senateChair: Maybe(CommitteeMember) }) +export type MemberReference = Static +export const MemberReference = Record({ + Id: String, + Name: String, + Type: Number +}) + export type BillContent = Static export const BillContent = Record({ - Pinslip: Nullable(String), Title: String, - PrimarySponsor: Nullable(Record({ Name: String })), - DocumentText: Maybe(String), - Cosponsors: Array(Record({ Name: Maybe(String) })) + Pinslip: Nullable(String), + BillNumber: String, + DocketNumber: String, + GeneralCourtNumber: Number, + PrimarySponsor: Nullable(MemberReference), + Cosponsors: Array(MemberReference), + LegislationTypeName: String, + DocumentText: Maybe(String) }) export type BillTopic = Static diff --git a/scripts/firebase-admin/generateBill.ts b/scripts/firebase-admin/generateBill.ts index eb15b71e3..90c5664ed 100644 --- a/scripts/firebase-admin/generateBill.ts +++ b/scripts/firebase-admin/generateBill.ts @@ -22,7 +22,11 @@ export const script: Script = async ({ db, args }) => { Title: "", PrimarySponsor: null, DocumentText: "", - Cosponsors: [] + Cosponsors: [], + BillNumber: "", + DocketNumber: "", + GeneralCourtNumber: 0, + LegislationTypeName: "" } const newBill: Bill = { diff --git a/stories/organisms/SubmitTestimonyForm.stories.tsx b/stories/organisms/SubmitTestimonyForm.stories.tsx index 446fc2fff..9574c0d1e 100644 --- a/stories/organisms/SubmitTestimonyForm.stories.tsx +++ b/stories/organisms/SubmitTestimonyForm.stories.tsx @@ -67,7 +67,7 @@ export const FormStory = { BillNumber: "", DocketNumber: "", GeneralCourtNumber: 0, - PrimarySponsor: undefined, + PrimarySponsor: null, Cosponsors: [], LegislationTypeName: "", Pinslip: "", @@ -84,7 +84,8 @@ export const FormStory = { fetchedAt: Timestamp.fromDate(new Date()), history: [], currentCommittee: undefined, - city: undefined + city: undefined, + similar: [] }} synced={false} /> diff --git a/stories/organisms/billDetail/BillSponsorCard.stories.tsx b/stories/organisms/billDetail/BillSponsorCard.stories.tsx index fcb13cc05..d49de045e 100644 --- a/stories/organisms/billDetail/BillSponsorCard.stories.tsx +++ b/stories/organisms/billDetail/BillSponsorCard.stories.tsx @@ -98,7 +98,8 @@ const bill: Bill = { email: "a@b.com" } }, - city: "Boston" + city: "Boston", + similar: [] } Primary.args = { diff --git a/stories/organisms/billDetail/BillTestimonyListCard.stories.tsx b/stories/organisms/billDetail/BillTestimonyListCard.stories.tsx index 1f2dc8010..01f36b8de 100644 --- a/stories/organisms/billDetail/BillTestimonyListCard.stories.tsx +++ b/stories/organisms/billDetail/BillTestimonyListCard.stories.tsx @@ -47,7 +47,8 @@ export const Primary: Story = { Branch: "1", Action: "1" } - ] + ], + similar: [] } }, name: "BillTestimonyListCard", diff --git a/stories/organisms/billDetail/MockBillData.tsx b/stories/organisms/billDetail/MockBillData.tsx index 366ccf1a0..67d0e8e73 100644 --- a/stories/organisms/billDetail/MockBillData.tsx +++ b/stories/organisms/billDetail/MockBillData.tsx @@ -88,5 +88,6 @@ export const bill: Bill = { }, city: "Boston", topics: newBillTopics, - summary: "This is the summary" + summary: "This is the summary", + similar: [] } diff --git a/tests/integration/common.ts b/tests/integration/common.ts index daf5955be..e8617e55f 100644 --- a/tests/integration/common.ts +++ b/tests/integration/common.ts @@ -35,7 +35,11 @@ export async function createNewBill(props?: Partial) { Pinslip: null, Title: "fake", PrimarySponsor: null, - Cosponsors: [] + Cosponsors: [], + BillNumber: "", + DocketNumber: "", + GeneralCourtNumber: 0, + LegislationTypeName: "" } const bill: Bill = { id: billId, @@ -160,7 +164,11 @@ export const setNewProfile = (user: { * Returns functions to get, remove, and check where the testimony is. */ -export const createNewTestimony = async (uid: string, billId: string) => { +export const createNewTestimony = async ( + uid: string, + billId: string, + court: number = 193 +) => { const tid = nanoid(6) const currentUserEmail = auth.currentUser?.email @@ -178,7 +186,7 @@ export const createNewTestimony = async (uid: string, billId: string) => { billId, publishedAt: testTimestamp.now(), updatedAt: testTimestamp.now(), - court: 192, + court: court, position: "oppose", content: "testimony content", public: true @@ -332,8 +340,11 @@ export const testCreatePendingOrgWithEmailAndPassword = async ( return userCreds } -export const deleteUser = async (user: { uid: string }) => { - await testAuth.deleteUser(user.uid) +export const deleteUser = async (uid: string) => { + const userDoc = await testDb.doc(`/users/${uid}`).get() + const userData = userDoc.data() + await testAuth.deleteUser(uid) + await testDb.doc(`/users/${uid}`).delete() } export const expectUser = async ( diff --git a/tests/integration/notifications.test.ts b/tests/integration/notifications.test.ts new file mode 100644 index 000000000..de2b4ad30 --- /dev/null +++ b/tests/integration/notifications.test.ts @@ -0,0 +1,528 @@ +import { httpsCallable } from "firebase/functions" +import { nanoid } from "nanoid" +import { + createNewBill, + createNewTestimony, + createUser, + deleteUser, + getBill, + signInTestAdmin, + signInUser +} from "tests/integration/common" +import { terminateFirebase, testDb } from "tests/testUtils" +import { functions } from "../../components/firebase" +import { UserRecord } from "firebase-admin/auth" +import { setFollow, setUnfollow } from "components/shared/FollowingQueries" +import { Timestamp } from "firebase/firestore" + +let billId: string + +type Request = { uid: string; fullName: string; email: string } + +export const createFakeOrg = httpsCallable( + functions, + "createFakeOrg" +) + +let authorUid: string +let email: string +let author: UserRecord +let orgId: string + +jest.setTimeout(10000) + +beforeAll(async () => { + billId = await createNewBill() + author = await createUser("user") + await signInUser(author.email!) + authorUid = author.uid + email = author.email! + orgId = nanoid(8) + const fullName = "fakeOrg" + const orgEmail = `${orgId}@example.com` + await signInTestAdmin() + await createFakeOrg({ uid: orgId, fullName, email: orgEmail }) +}) +let unsubscribeFunctions: (() => void)[] = [] +afterEach(async () => { + // Unsubscribe all snapshot listeners + unsubscribeFunctions.forEach(unsubscribe => unsubscribe()) + unsubscribeFunctions = [] + + // Clean up notificationEvents collection + const notificationEventsSnapshot = await testDb + .collection("/notificationEvents") + .get() + const deletePromises = notificationEventsSnapshot.docs.map(doc => + doc.ref.delete() + ) + await Promise.all(deletePromises) + + // Clean up userNotificationFeed collection + const notificationsSnapshot = await testDb + .collection(`/users/${authorUid}/userNotificationFeed`) + .get() + const deleteNotificationPromises = notificationsSnapshot.docs.map(doc => + doc.ref.delete() + ) + await Promise.all(deleteNotificationPromises) +}) + +afterAll(async () => { + // Clean up notificationEvents collection + const notificationEventsSnapshot = await testDb + .collection("/notificationEvents") + .get() + + const deletePromises = notificationEventsSnapshot.docs.map(doc => + doc.ref.delete() + ) + await Promise.all(deletePromises) + + // Clean up userNotificationFeed collection + const notificationsSnapshot = await testDb + .collection(`/users/${authorUid}/userNotificationFeed`) + .get() + + const deleteNotificationPromises = notificationsSnapshot.docs.map(doc => + doc.ref.delete() + ) + await Promise.all(deleteNotificationPromises) + await deleteUser(authorUid) + await deleteUser(orgId) + await terminateFirebase() +}) + +describe("Following/Unfollowing user/bill", () => { + it("Follow/Unfollow an Organization", async () => { + const topicName = `testimony-${orgId}` + + await signInUser(author.email!) + + // Follow + await setFollow( + authorUid, + topicName, + undefined, + undefined, + undefined, + orgId + ) + + let subscriptions = await testDb + .collection(`/users/${authorUid}/activeTopicSubscriptions`) + .where("userLookup.profileId", "==", orgId) + .get() + + expect(subscriptions.size).toBe(1) + let subscription = subscriptions.docs[0].data() + expect(subscription.topicName).toBe(`testimony-${orgId}`) + expect(subscription.uid).toBe(authorUid) + expect(subscription.type).toBe("testimony") + expect(subscription.userLookup).toEqual({ + profileId: orgId + }) + + //Unfollow + await setUnfollow(authorUid, topicName) + + subscriptions = await testDb + .collection(`/users/${authorUid}/activeTopicSubscriptions`) + .where("userLookup.profileId", "==", orgId) + .get() + + expect(subscriptions.size).toBe(0) + }) + it("Follow a User", async () => { + const testUser = await createUser("user") + const testUserId = testUser.uid + const topicName = `testimony-${testUser.uid}` + + await setFollow( + authorUid, + topicName, + undefined, + undefined, + undefined, + testUser.uid + ) + + let subscriptions = await testDb + .collection(`/users/${authorUid}/activeTopicSubscriptions`) + .where("userLookup.profileId", "==", testUserId) + .get() + + expect(subscriptions.size).toBe(1) + const subscription = subscriptions.docs[0].data() + expect(subscription.topicName).toBe(`testimony-${testUserId}`) + expect(subscription.uid).toBe(authorUid) + expect(subscription.type).toBe("testimony") + expect(subscription.userLookup).toEqual({ + profileId: testUserId + }) + + //Unfollow + await setUnfollow(authorUid, topicName) + + subscriptions = await testDb + .collection(`/users/${authorUid}/activeTopicSubscriptions`) + .where("userLookup.profileId", "==", testUser.uid) + .get() + + expect(subscriptions.size).toBe(0) + }) + it("Follow/Unfollow a Bill", async () => { + const bill = await getBill(billId) + const topicName = `bill-${bill.court.toString()}-${billId}` + const { court: courtId } = bill + await setFollow(authorUid, topicName, bill, billId, courtId, undefined) + + let subscriptions = await testDb + .collection(`/users/${authorUid}/activeTopicSubscriptions`) + .where("billLookup.billId", "==", billId) + .get() + + expect(subscriptions.size).toBe(1) + const subscription = subscriptions.docs[0].data() + expect(subscription.topicName).toBe( + `bill-${bill.court.toString()}-${billId}` + ) + expect(subscription.type).toBe("bill") + expect(subscription.billLookup).toEqual({ + billId: billId, + court: bill.court + }) + + //Unfollow + await setUnfollow(authorUid, topicName) + + subscriptions = await testDb + .collection(`/users/${authorUid}/activeTopicSubscriptions`) + .where("billLookup.billId", "==", billId) + .get() + expect(subscriptions.size).toBe(0) + }) +}) + +describe("Receiving notifications", () => { + const waitForNotificationEvent = async ( + initialCount: number, + type: string + ) => { + return new Promise((resolve, reject) => { + const unsubscribe = testDb + .collection("/notificationEvents") + .where("type", "==", type) + .onSnapshot(snapshot => { + if (snapshot.size === initialCount + 1) { + unsubscribe() + resolve(snapshot) + } + }, reject) + unsubscribeFunctions.push(unsubscribe) + }) + } + + const waitForUserNotificationFeed = async (testimonyId: string) => { + return new Promise((resolve, reject) => { + const unsubscribe = testDb + .collection(`/users/${authorUid}/userNotificationFeed`) + .where("notification.testimonyId", "==", testimonyId) + .onSnapshot(snapshot => { + if (snapshot.size === 1) { + unsubscribe() + resolve(snapshot) + } + }, reject) + unsubscribeFunctions.push(unsubscribe) + }) + } + it("Receiving TestimonySubmissionNotification from Organization", async () => { + const unsubscribeFunctions: (() => void)[] = [] + const topicName = `testimony-${orgId}` + await signInUser(author.email!) + + // Follow + await setFollow( + authorUid, + topicName, + undefined, + undefined, + undefined, + orgId + ) + + const initialNotificationCount = ( + await testDb + .collection("/notificationEvents") + .where("type", "==", "testimony") + .get() + ).size + + const { tid } = await createNewTestimony(orgId, billId) + + const notificationEventsPromise = waitForNotificationEvent( + initialNotificationCount, + "testimony" + ) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + unsubscribeFunctions.forEach(unsubscribe => unsubscribe()) + reject(new Error("Test timed out")) + }, 10000) + }) + + try { + const notificationEvents = (await Promise.race([ + notificationEventsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notificationEvents.size).toBe(initialNotificationCount + 1) + + const notificationsPromise = waitForUserNotificationFeed(tid) + const notifications = (await Promise.race([ + notificationsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notifications.size).toBeGreaterThan(0) + } catch (error) { + console.error("Error:", error) + throw error + } + }) + + it("Receiving TestimonySubmissionNotification from User", async () => { + const testUser = await createUser("user") + const testUserId = testUser.uid + const topicName = `testimony-${testUserId}` + await signInUser(author.email!) + + await setFollow( + authorUid, + topicName, + undefined, + undefined, + undefined, + testUserId + ) + + const initialNotificationCount = ( + await testDb + .collection("/notificationEvents") + .where("type", "==", "testimony") + .get() + ).size + + const { tid } = await createNewTestimony(testUserId, billId) + + const notificationEventsPromise = waitForNotificationEvent( + initialNotificationCount, + "testimony" + ) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + unsubscribeFunctions.forEach(unsubscribe => unsubscribe()) + reject(new Error("Test timed out")) + }, 10000) + }) + + try { + const notificationEvents = (await Promise.race([ + notificationEventsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notificationEvents.size).toBe(initialNotificationCount + 1) + + const notificationsPromise = waitForUserNotificationFeed(tid) + const notifications = (await Promise.race([ + notificationsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notifications.size).toBe(1) + const notification = notifications.docs[0].data().notification + expect(notification.isUserMatch).toBe(true) + expect(notification.isBillMatch).toBe(false) + } catch (error) { + console.error(error) + throw error + } + }) + it("Receiving TestimonySubmissionNotification from Bill", async () => { + const testUser = await createUser("user") + const testUserId = testUser.uid + const bill = await getBill(billId) + const topicName = `bill-${bill.court.toString()}-${billId}` + const { court: courtId } = bill + await signInUser(author.email!) + await setFollow(authorUid, topicName, bill, billId, courtId, undefined) + + const initialNotificationCount = ( + await testDb + .collection("/notificationEvents") + .where("type", "==", "testimony") + .get() + ).size + const { tid } = await createNewTestimony(testUserId, billId, courtId) + + const notificationEventsPromise = waitForNotificationEvent( + initialNotificationCount, + "testimony" + ) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + unsubscribeFunctions.forEach(unsubscribe => unsubscribe()) + reject(new Error("Test timed out")) + }, 10000) + }) + + try { + const notificationEvents = (await Promise.race([ + notificationEventsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notificationEvents.size).toBe(initialNotificationCount + 1) + + const notificationsPromise = waitForUserNotificationFeed(tid) + const notifications = (await Promise.race([ + notificationsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notifications.size).toBe(1) + const notification = notifications.docs[0].data().notification + expect(notification.isUserMatch).toBe(false) + expect(notification.isBillMatch).toBe(true) + } catch (error) { + console.error(error) + throw error + } + }) + + it("Receiving BillHistoryUpdateNotificationEvent", async () => { + const bill = await getBill(billId) + const topicName = `bill-${bill.court.toString()}-${billId}` + const { court: courtId } = bill + await signInUser(author.email!) + await setFollow(authorUid, topicName, bill, billId, courtId, undefined) + + const history1 = { + Date: Timestamp.now().toDate().toISOString(), + Branch: Math.random() < 0.5 ? "Senate" : "House", + Action: (Math.random() + 1).toString(36).substring(2) + } + + await testDb.doc(`/generalCourts/${courtId}/bills/${billId}`).update({ + history: [history1] + }) + + const notificationEventsPromise = new Promise((resolve, reject) => { + const unsubscribe = testDb + .collection("/notificationEvents") + .where("billId", "==", billId) + .where("billHistory", "array-contains", history1) + .onSnapshot(snapshot => { + if (snapshot.size === 1) { + expect(snapshot.docs[0].data().billHistory[0]).toEqual(history1) + unsubscribe() + resolve(snapshot) + } + }, reject) + }) + + const notificationsEvents = + (await notificationEventsPromise) as FirebaseFirestore.QuerySnapshot + expect(notificationsEvents.size).toBe(1) + + const initialNotificationCount = ( + await testDb + .collection(`/users/${authorUid}/userNotificationFeed`) + .where("notification.type", "==", "bill") + .get() + ).size + + // Use onSnapshot to listen for changes in userNotificationFeed + const notificationsPromise = new Promise((resolve, reject) => { + const unsubscribe = testDb + .collection(`/users/${authorUid}/userNotificationFeed`) + .where("notification.type", "==", "bill") + .onSnapshot(snapshot => { + if (snapshot.size === initialNotificationCount + 1) { + unsubscribe() + resolve(snapshot) + } + }, reject) + }) + + const notifications = + (await notificationsPromise) as FirebaseFirestore.QuerySnapshot + expect(notifications.size).toBe(1) + const notification = notifications.docs[0].data().notification + expect(notification.isBillMatch).toBe(true) + }) + + it("Receiving TestimonySubmissionNotification from Bill and User", async () => { + const testUser = await createUser("user") + const testUserId = testUser.uid + const bill = await getBill(billId) + const { court: courtId } = bill + await signInUser(author.email!) + + await setFollow( + authorUid, + `bill-${bill.court.toString()}-${billId}`, + bill, + billId, + courtId, + undefined + ) + await setFollow( + authorUid, + `testimony-${testUserId}`, + undefined, + undefined, + undefined, + testUserId + ) + + const initialNotificationCount = ( + await testDb + .collection("/notificationEvents") + .where("type", "==", "testimony") + .get() + ).size + + const { tid } = await createNewTestimony(testUserId, billId, courtId) + + const notificationEventsPromise = waitForNotificationEvent( + initialNotificationCount, + "testimony" + ) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + unsubscribeFunctions.forEach(unsubscribe => unsubscribe()) + reject(new Error("Test timed out")) + }, 10000) + }) + + try { + const notificationEvents = (await Promise.race([ + notificationEventsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notificationEvents.size).toBe(initialNotificationCount + 1) + + const notificationsPromise = waitForUserNotificationFeed(tid) + const notifications = (await Promise.race([ + notificationsPromise, + timeoutPromise + ])) as FirebaseFirestore.QuerySnapshot + expect(notifications.size).toBe(1) + const notification = notifications.docs[0].data().notification + expect(notification.isUserMatch).toBe(true) + expect(notification.isBillMatch).toBe(true) + } catch (error) { + console.error(error) + throw error + } + }) +}) diff --git a/tests/unit/billdetail.test.tsx b/tests/unit/billdetail.test.tsx index f349f9572..7513d1341 100644 --- a/tests/unit/billdetail.test.tsx +++ b/tests/unit/billdetail.test.tsx @@ -109,7 +109,8 @@ const mockBill: Bill = { Branch: "Senate" } ], - city: "Sample City" + city: "Sample City", + similar: [] } // set up Redux mock store with thunk middleware bc resolveBill is thunk @@ -162,7 +163,9 @@ describe("BillDetails", () => { const readMoreButton = screen.getByRole("button", { name: "Read more.." }) expect(readMoreButton).toBeInTheDocument fireEvent.click(readMoreButton) - expect(screen.getByText(DocumentText)).toBeInTheDocument + if (DocumentText) { + expect(screen.getByText(DocumentText)).toBeInTheDocument() + } }) // below test assumes mockBill contains a primary sponsor