diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index aaead070..48562b90 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,6 +6,31 @@ on: branches: - master jobs: + unit: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TWILIO_ACCOUNT_SID: AC_dummy_value_so_tests_dont_error_out + TWILIO_AUTH_TOKEN: 123 + elasticURL: "http://localhost:9200" + NODE_COVERALLS_DEBUG: 1 + steps: + - uses: actions/checkout@v2 + - name: install node + uses: actions/setup-node@v2 + with: + node-version: "16" + - uses: bahmutov/npm-install@v1 + + - run: yarn unittest --coverage + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: Unit tests + parallel: true + tests: name: Tests runs-on: ubuntu-latest @@ -37,6 +62,10 @@ jobs: node-version: "16" - uses: bahmutov/npm-install@v1 + - run: yarn db:migrate + + - run: yarn db:refresh + - run: yarn test --coverage --detectOpenHandles - name: Coveralls uses: coverallsapp/github-action@master @@ -149,7 +178,7 @@ jobs: # - run: 'curl ''http://localhost:4000'' -X POST -H ''content-type: application/json'' --data ''{ "query": "query { search(termId: \"202250\", query: \"fundies\") { nodes { ... on ClassOccurrence { name subject classId } } } }" }''' coverage: - needs: [end_to_end, tests] + needs: [end_to_end, tests, unit] name: Sends Coveralls coverage runs-on: ubuntu-latest env: diff --git a/package.json b/package.json index fc8e4051..a10e957a 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,11 @@ "about:tsc": "//// Runs TSC (because of our config, this will NOT compile the files, just typecheck them)", "tsc": "tsc", "about:test": "//// Runs a basic suite of unit and integration tests", - "test": "yarn jest --verbose --testPathIgnorePatterns='(seq|reg|git).[jt]s'", + "test": "yarn jest --verbose --testPathIgnorePatterns='(seq|reg|git|unit).[jt]s'", "about:dbtest": "//// Runs tests interacting with our database. Must have the Docker containers running. This won't touch live data - it will create and teardown a temporary database.", "dbtest": "jest -i --projects tests/database --verbose", + "about:unittest": "//// Runs unit tests. Does not need db, elasticsearch, spun up. Does not need the Docker containers to be running.", + "unittest": "jest -i --projects tests/unit --verbose", "about:build_backend": "//// Compiles this project", "build_backend": "rm -rf dist && mkdir -p dist && babel --extensions '.js,.ts' . -d dist/ --copy-files --ignore node_modules --ignore .git --include-dotfiles && rm -rf dist/.git", "about:build": "//// Compiles this project, surpressing output", diff --git a/prisma/migrations/20240814155920_add_notif_counts/migration.sql b/prisma/migrations/20240814155920_add_notif_counts/migration.sql new file mode 100644 index 00000000..ace5398b --- /dev/null +++ b/prisma/migrations/20240814155920_add_notif_counts/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "followed_courses" ADD COLUMN "notif_count" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "followed_sections" ADD COLUMN "notif_count" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 288bbf79..5ddd4bd4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,6 +91,7 @@ model Subject { model FollowedCourse { courseHash String @map("course_hash") userId Int @map("user_id") + notifCount Int @default(0) @map("notif_count") course Course @relation(fields: [courseHash], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -101,6 +102,7 @@ model FollowedCourse { model FollowedSection { sectionHash String @map("section_hash") userId Int @map("user_id") + notifCount Int @default(0) @map("notif_count") section Section @relation(fields: [sectionHash], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/services/notifyer.ts b/services/notifyer.ts index b66ebaba..8784bde2 100644 --- a/services/notifyer.ts +++ b/services/notifyer.ts @@ -3,7 +3,8 @@ * See the license file in the root folder for details. */ -import { User } from "@prisma/client"; +import { User, FollowedSection } from "@prisma/client"; +import prisma from "./prisma"; import twilioNotifyer from "../twilio/notifs"; import macros from "../utils/macros"; import { @@ -45,9 +46,20 @@ export async function sendNotifications( return; } else { const courseNotifPromises: Promise[] = notificationInfo.updatedCourses - .map((course) => { - const courseMessage = generateCourseMessage(course); + .map(async (course) => { const users = courseHashToUsers[course.courseHash] ?? []; + + await prisma.followedCourse.updateMany({ + where: { + courseHash: course.courseHash, + userId: { in: users.map((u) => u.id) }, + }, + data: { + notifCount: { increment: 1 }, + }, + }); + + const courseMessage = generateCourseMessage(course); return users.map((user) => { return twilioNotifyer.sendNotificationText( user.phoneNumber, @@ -59,9 +71,21 @@ export async function sendNotifications( const sectionNotifPromises: Promise[] = notificationInfo.updatedSections - .map((section) => { - const sectionMessage = generateSectionMessage(section); + .map(async (section) => { const users = sectionHashToUsers[section.sectionHash] ?? []; + + //increment notifCount of this section's entries in followedSection + await prisma.followedSection.updateMany({ + where: { + sectionHash: section.sectionHash, + userId: { in: users.map((u) => u.id) }, + }, + data: { + notifCount: { increment: 1 }, + }, + }); + + const sectionMessage = generateSectionMessage(section); return users.map((user) => { return twilioNotifyer.sendNotificationText( user.phoneNumber, @@ -71,6 +95,20 @@ export async function sendNotifications( }) .reduce((acc, val) => acc.concat(val), []); + //delete any entries in followedCourse w/ notifCount >= 3 + await prisma.followedCourse.deleteMany({ + where: { + notifCount: { gt: 2 }, + }, + }); + + //delete any entries in followedSection w/ notifCount >= 3 + await prisma.followedSection.deleteMany({ + where: { + notifCount: { gt: 2 }, + }, + }); + await Promise.all([...courseNotifPromises, ...sectionNotifPromises]).then( () => { macros.log("Notifications sent from notifyer!"); diff --git a/services/updater.ts b/services/updater.ts index 4352c98f..a994953d 100644 --- a/services/updater.ts +++ b/services/updater.ts @@ -161,6 +161,8 @@ class Updater { this.SECTION_MODEL ); + //Filter out courseHash & sectionHash if they have too high notifsSent + await sendNotifications( notificationInfo, courseHashToUsers, @@ -518,7 +520,8 @@ class Updater { const columnName = `${modelName}_hash`; const pluralName = `${modelName}s`; const dbResults = (await prisma.$queryRawUnsafe( - `SELECT ${columnName}, JSON_AGG(JSON_BUILD_OBJECT('id', id, 'phoneNumber', phone_number)) FROM followed_${pluralName} JOIN users on users.id = followed_${pluralName}.user_id GROUP BY ${columnName}` + //test edit: edited this select cmd to filter out any followed_modelName w/ notifsSent greater than 2 + `SELECT ${columnName}, JSON_AGG(JSON_BUILD_OBJECT('id', id, 'phoneNumber', phone_number)) FROM followed_${pluralName} JOIN users on users.id = followed_${pluralName}.user_id WHERE notif_count < 3 GROUP BY ${columnName}` )) as Record[]; return Object.assign( diff --git a/tests/database/search.test.seq.ts b/tests/database/search.test.seq.ts index c3a3d3f7..d30aa25d 100644 --- a/tests/database/search.test.seq.ts +++ b/tests/database/search.test.seq.ts @@ -1,6 +1,5 @@ import searcher from "../../services/searcher"; import prisma from "../../services/prisma"; -import { LeafQuery, ParsedQuery } from "../../types/searchTypes"; beforeAll(async () => { searcher.subjects = {}; @@ -23,67 +22,6 @@ describe("searcher", () => { }); }); - //Unit tests for the parseQuery function - describe("parseQuery", () => { - it("query with no phrases", () => { - const retQueries: ParsedQuery = searcher.parseQuery( - "this is a query with no phrases" - ); - expect(retQueries.phraseQ.length).toEqual(0); //no phrase queries - expect(retQueries.fieldQ).not.toEqual(null); - const fieldQuery: LeafQuery = retQueries.fieldQ; - - expect(fieldQuery).toEqual({ - multi_match: { - query: "this is a query with no phrases", - type: "most_fields", - fields: searcher.getFields(), - }, - }); - }); - - it("query with just a phrase", () => { - const retQueries: ParsedQuery = searcher.parseQuery('"this is a phrase"'); - - expect(retQueries.phraseQ.length).toEqual(1); - expect(retQueries.fieldQ).toEqual(null); - - const phraseQuery: LeafQuery = retQueries.phraseQ[0]; - - expect(phraseQuery).toEqual({ - multi_match: { - query: "this is a phrase", - type: "phrase", - fields: searcher.getFields(), - }, - }); - }); - - it("query with a phrase and other text", () => { - const retQueries: ParsedQuery = searcher.parseQuery('text "phrase" text'); - expect(retQueries.phraseQ.length).toEqual(1); - expect(retQueries.fieldQ).not.toEqual(null); - - const phraseQuery: LeafQuery = retQueries.phraseQ[0]; - const fieldQuery: LeafQuery = retQueries.fieldQ; - - expect(phraseQuery).toEqual({ - multi_match: { - query: "phrase", - type: "phrase", - fields: searcher.getFields(), - }, - }); - - expect(fieldQuery).toEqual({ - multi_match: { - query: "text text", - type: "most_fields", - fields: searcher.getFields(), - }, - }); - }); - }); // TODO: create an association between cols in elasticCourseSerializer and here describe("generateQuery", () => { it("generates match_all when no query", () => { diff --git a/tests/general/notifyer.test.ts b/tests/general/notifyer.test.ts index f85e6e4b..1fa8a93d 100644 --- a/tests/general/notifyer.test.ts +++ b/tests/general/notifyer.test.ts @@ -1,28 +1,175 @@ import { NotificationInfo } from "../../types/notifTypes"; import { sendNotifications } from "../../services/notifyer"; import twilioNotifyer from "../../twilio/notifs"; -import { User } from "@prisma/client"; +import { Prisma, User, Course as PrismaCourse } from "@prisma/client"; +import dumpProcessor from "../../services/dumpProcessor"; +import prisma from "../../services/prisma"; +import { + Course, + Section, + Requisite, + convertBackendMeetingsToPrismaType, +} from "../../types/types"; +import Keys from "../../utils/keys"; + +function processCourse(classInfo: Course): Prisma.CourseCreateInput { + const additionalProps = { + id: `${Keys.getClassHash(classInfo)}`, + description: classInfo.desc, + minCredits: Math.floor(classInfo.minCredits), + maxCredits: Math.floor(classInfo.maxCredits), + lastUpdateTime: new Date(classInfo.lastUpdateTime), + }; + + const correctedQuery = { + ...classInfo, + ...additionalProps, + classAttributes: { set: classInfo.classAttributes || [] }, + nupath: { set: [] }, + }; + + const { desc: _d, sections: _s, ...finalCourse } = correctedQuery; + + return finalCourse; +} + +async function createSection( + sec: Section, + seatsRemaining: number, + waitRemaining: number +): Promise { + await prisma.section.create({ + data: { + classType: sec.classType, + seatsCapacity: sec.seatsCapacity, + waitCapacity: sec.waitCapacity, + campus: sec.campus, + honors: sec.honors, + url: sec.url, + id: Keys.getSectionHash(sec), + crn: sec.crn, + seatsRemaining, + waitRemaining, + info: "", + meetings: convertBackendMeetingsToPrismaType(sec.meetings), + profs: { set: sec.profs }, + course: { connect: { id: Keys.getClassHash(sec) } }, + }, + }); +} const mockSendNotificationText = jest.fn(() => { + //console.log("I SHOULD BE CALLED"); return Promise.resolve(); }); -beforeEach(() => { +const SEMS_TO_UPDATE = ["202210"]; + +const EMPTY_REQ: Requisite = { + type: "or", + values: [], +}; + +const defaultClassProps = { + host: "neu.edu", + classAttributes: [], + prettyUrl: "pretty", + desc: "a class", + url: "url", + lastUpdateTime: 20, + maxCredits: 4, + minCredits: 0, + coreqs: EMPTY_REQ, + prereqs: EMPTY_REQ, + feeAmount: 0, + feeDescription: "", +}; + +const defaultSectionProps = { + campus: "Boston", + honors: false, + url: "url", + profs: [], + meetings: [], +}; + +const USER_ONE = { id: 1, phoneNumber: "+11231231234" }; +const USER_TWO = { id: 2, phoneNumber: "+19879879876" }; + +//courseHash: "neu.edu/202210/CS/2500", +//campus: "NEU", +const FUNDIES_ONE: Course = { + classId: "2500", + name: "Fundamentals of Computer Science 2", + termId: SEMS_TO_UPDATE[0], + subject: "CS", + ...defaultClassProps, +}; + +//courseHash: "neu.edu/202210/ARTF/1122", +//campus: "NEU", +const ART: Course = { + classId: "1122", + name: "Principles of Programming Languages", + termId: SEMS_TO_UPDATE[0], + subject: "ARTF", + ...defaultClassProps, +}; + +//sectionHash: "neu.edu/202210/CS/2500/11920", +//campus: "NEU", +const FUNDIES_ONE_S1: Section = { + crn: "11920", + classId: "2500", + classType: "lecture", + termId: SEMS_TO_UPDATE[0], + subject: "CS", + seatsCapacity: 1, + seatsRemaining: 114, + waitCapacity: 0, + waitRemaining: 0, + lastUpdateTime: defaultClassProps.lastUpdateTime, + host: defaultClassProps.host, + ...defaultSectionProps, +}; + +beforeEach(async () => { jest.clearAllMocks(); jest.restoreAllMocks(); + jest.useFakeTimers(); + jest.spyOn(dumpProcessor, "main").mockImplementation(() => { + return Promise.resolve(); + }); twilioNotifyer.sendNotificationText = mockSendNotificationText; }); +afterEach(async () => { + await prisma.termInfo.deleteMany({}); + await prisma.followedCourse.deleteMany({}); + await prisma.followedSection.deleteMany({}); + await prisma.user.deleteMany({}); + await prisma.section.deleteMany({}); + await prisma.course.deleteMany({}); + + jest.clearAllTimers(); +}); + +afterAll(async () => { + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + describe("Notifyer", () => { describe("sendNotifications()", () => { let notificationInfo: NotificationInfo; let courseHashToUsers: Record; let sectionHashToUsers: Record; - it("does not send anything where there are no updated courses and sections", () => { + it("does not send anything where there are no updated courses and sections", async () => { notificationInfo = { updatedCourses: [], updatedSections: [] }; courseHashToUsers = {}; sectionHashToUsers = {}; - sendNotifications( + + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -30,7 +177,7 @@ describe("Notifyer", () => { expect(mockSendNotificationText).toBeCalledTimes(0); }); - it("sends a notification for each course and section and for each user subscribed", () => { + it("sends a notification for each course and section and for each user subscribed", async () => { notificationInfo = { updatedCourses: [ { @@ -75,7 +222,58 @@ describe("Notifyer", () => { { id: 2, phoneNumber: "+19879879876" }, ], }; - sendNotifications( + + await prisma.user.create({ data: USER_ONE }); + await prisma.user.create({ data: USER_TWO }); + await prisma.termInfo.create({ + data: { + termId: "202210", + subCollege: "NEU", + text: "description", + }, + }); + await prisma.course.create({ + data: processCourse(FUNDIES_ONE), + }); + await prisma.course.create({ + data: processCourse(ART), + }); + await createSection(FUNDIES_ONE_S1, 0, FUNDIES_ONE_S1.waitRemaining); + await prisma.followedCourse.create({ + data: { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 1, + }, + }); + await prisma.followedCourse.create({ + data: { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 2, + }, + }); + await prisma.followedCourse.create({ + data: { + courseHash: "neu.edu/202210/CS/2500", + userId: 1, + notifCount: 0, + }, + }); + await prisma.followedSection.create({ + data: { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 1, + notifCount: 0, + }, + }); + await prisma.followedSection.create({ + data: { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 2, + notifCount: 0, + }, + }); + + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -83,7 +281,7 @@ describe("Notifyer", () => { expect(mockSendNotificationText).toBeCalledTimes(5); }); - it("does not send a notification if no users are subscribed to the updated course/section", () => { + it("does not send a notification if no users are subscribed to the updated course/section", async () => { notificationInfo = { updatedCourses: [ { @@ -120,7 +318,7 @@ describe("Notifyer", () => { { id: 2, phoneNumber: "+19879879876" }, ], }; - sendNotifications( + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -128,7 +326,8 @@ describe("Notifyer", () => { expect(mockSendNotificationText).toBeCalledTimes(0); }); - it("sends a properly formatted message when a new section is added to a course", () => { + it("sends a properly formatted message when a new section is added to a course", async () => { + //console.log("INSIDE TEST 2"); notificationInfo = { updatedCourses: [ { @@ -146,7 +345,7 @@ describe("Notifyer", () => { "neu.edu/202210/ARTF/1122": [{ id: 1, phoneNumber: "+11231231234" }], }; sectionHashToUsers = {}; - sendNotifications( + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -159,7 +358,8 @@ describe("Notifyer", () => { ); }); - it("sends a properly formatted message when multiple sections are added to a course", () => { + it("sends a properly formatted message when multiple sections are added to a course", async () => { + //console.log("INSIDE TEST 3"); notificationInfo = { updatedCourses: [ { @@ -177,7 +377,7 @@ describe("Notifyer", () => { "neu.edu/202210/ARTF/1122": [{ id: 1, phoneNumber: "+11231231234" }], }; sectionHashToUsers = {}; - sendNotifications( + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -190,7 +390,8 @@ describe("Notifyer", () => { ); }); - it("sends a properly formatted message when seats open up in a section", () => { + it("sends a properly formatted message when seats open up in a section", async () => { + //console.log("INSIDE TEST 4"); notificationInfo = { updatedCourses: [], updatedSections: [ @@ -211,7 +412,7 @@ describe("Notifyer", () => { { id: 1, phoneNumber: "+11231231234" }, ], }; - sendNotifications( + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -224,7 +425,8 @@ describe("Notifyer", () => { ); }); - it("sends a properly formatted message when waitlist seats open up in a section", () => { + it("sends a properly formatted message when waitlist seats open up in a section", async () => { + //console.log("INSIDE TEST 5"); notificationInfo = { updatedCourses: [], updatedSections: [ @@ -245,7 +447,7 @@ describe("Notifyer", () => { { id: 1, phoneNumber: "+11231231234" }, ], }; - sendNotifications( + await sendNotifications( notificationInfo, courseHashToUsers, sectionHashToUsers @@ -257,5 +459,505 @@ describe("Notifyer", () => { expectedSectionMessage ); }); + + it("does not send any notifications for each course and section when each subscribed section and class has notifCount>=3", async () => { + //console.log("TEST 6"); + + notificationInfo = { + updatedCourses: [ + { + termId: "202210", + subject: "ARTF", + courseId: "1122", + courseHash: "neu.edu/202210/ARTF/1122", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + { + termId: "202210", + subject: "CS", + courseId: "2500", + courseHash: "neu.edu/202210/CS/2500", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + ], + updatedSections: [ + { + termId: "202210", + subject: "CS", + courseId: "2500", + crn: "11920", + sectionHash: "neu.edu/202210/CS/2500/11920", + campus: "NEU", + seatsRemaining: 114, + }, + ], + }; + await prisma.user.createMany({ data: [USER_ONE, USER_TWO] }); + await prisma.termInfo.create({ + data: { + termId: "202210", + subCollege: "NEU", + text: "description", + }, + }); + await prisma.course.createMany({ + data: [processCourse(FUNDIES_ONE), processCourse(ART)], + }); + await createSection(FUNDIES_ONE_S1, 0, FUNDIES_ONE_S1.waitRemaining); + await prisma.followedCourse.createMany({ + data: [ + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 1, + notifCount: 3, + }, + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 2, + notifCount: 3, + }, + { + courseHash: "neu.edu/202210/CS/2500", + userId: 1, + notifCount: 3, + }, + ], + }); + await prisma.followedSection.createMany({ + data: [ + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 1, + notifCount: 3, + }, + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 2, + notifCount: 3, + }, + ], + }); + + courseHashToUsers = {}; + sectionHashToUsers = {}; + + await sendNotifications( + notificationInfo, + courseHashToUsers, + sectionHashToUsers + ); + expect(mockSendNotificationText).toBeCalledTimes(0); + }); + + it("deletes subscriptions for each course and section when their notifCount>=3", async () => { + //console.log("TEST 7"); + + notificationInfo = { + updatedCourses: [ + { + termId: "202210", + subject: "ARTF", + courseId: "1122", + courseHash: "neu.edu/202210/ARTF/1122", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + { + termId: "202210", + subject: "CS", + courseId: "2500", + courseHash: "neu.edu/202210/CS/2500", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + ], + updatedSections: [ + { + termId: "202210", + subject: "CS", + courseId: "2500", + crn: "11920", + sectionHash: "neu.edu/202210/CS/2500/11920", + campus: "NEU", + seatsRemaining: 114, + }, + ], + }; + await prisma.user.createMany({ data: [USER_ONE, USER_TWO] }); + await prisma.termInfo.create({ + data: { + termId: "202210", + subCollege: "NEU", + text: "description", + }, + }); + await prisma.course.createMany({ + data: [processCourse(FUNDIES_ONE), processCourse(ART)], + }); + + await createSection(FUNDIES_ONE_S1, 0, FUNDIES_ONE_S1.waitRemaining); + await prisma.followedCourse.createMany({ + data: [ + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 1, + notifCount: 3, + }, + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 2, + notifCount: 3, + }, + { + courseHash: "neu.edu/202210/CS/2500", + userId: 1, + notifCount: 3, + }, + ], + }); + await prisma.followedSection.createMany({ + data: [ + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 1, + notifCount: 3, + }, + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 2, + notifCount: 3, + }, + ], + }); + + courseHashToUsers = {}; + sectionHashToUsers = {}; + + const initialCourseNotifs = await prisma.followedCourse.count(); + expect(initialCourseNotifs).toEqual(3); + const initialSectionNotifs = await prisma.followedSection.count(); + expect(initialSectionNotifs).toEqual(2); + + await sendNotifications( + notificationInfo, + courseHashToUsers, + sectionHashToUsers + ); + + const remainingCourseNotifs = await prisma.followedCourse.count(); + expect(remainingCourseNotifs).toEqual(0); + const remainingSectionNotifs = await prisma.followedSection.count(); + expect(remainingSectionNotifs).toEqual(0); + }); + + it("sends notifications for each course and section when each subscribed section and class has notifCount<3", async () => { + //console.log("TEST 8"); + + notificationInfo = { + updatedCourses: [ + { + termId: "202210", + subject: "ARTF", + courseId: "1122", + courseHash: "neu.edu/202210/ARTF/1122", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + { + termId: "202210", + subject: "CS", + courseId: "2500", + courseHash: "neu.edu/202210/CS/2500", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + ], + updatedSections: [ + { + termId: "202210", + subject: "CS", + courseId: "2500", + crn: "11920", + sectionHash: "neu.edu/202210/CS/2500/11920", + campus: "NEU", + seatsRemaining: 114, + }, + ], + }; + await prisma.user.createMany({ data: [USER_ONE, USER_TWO] }); + await prisma.termInfo.create({ + data: { + termId: "202210", + subCollege: "NEU", + text: "description", + }, + }); + await prisma.course.createMany({ + data: [processCourse(FUNDIES_ONE), processCourse(ART)], + }); + + await createSection(FUNDIES_ONE_S1, 0, FUNDIES_ONE_S1.waitRemaining); + await prisma.followedCourse.createMany({ + data: [ + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 1, + notifCount: 1, + }, + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 2, + notifCount: 0, + }, + { + courseHash: "neu.edu/202210/CS/2500", + userId: 1, + notifCount: 2, + }, + ], + }); + await prisma.followedSection.createMany({ + data: [ + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 1, + notifCount: 1, + }, + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 2, + notifCount: 0, + }, + ], + }); + + courseHashToUsers = { + "neu.edu/202210/ARTF/1122": [ + { id: 1, phoneNumber: "+11231231234" }, + { id: 2, phoneNumber: "+19879879876" }, + ], + "neu.edu/202210/CS/2500": [{ id: 1, phoneNumber: "+11231231234" }], + }; + sectionHashToUsers = { + "neu.edu/202210/CS/2500/11920": [ + { id: 1, phoneNumber: "+11231231234" }, + { id: 2, phoneNumber: "+19879879876" }, + ], + }; + + await sendNotifications( + notificationInfo, + courseHashToUsers, + sectionHashToUsers + ); + expect(mockSendNotificationText).toBeCalledTimes(5); + }); + + it("maintains subscriptions for each course and section when their notifCount<3", async () => { + //console.log("TEST 9"); + + notificationInfo = { + updatedCourses: [ + { + termId: "202210", + subject: "ARTF", + courseId: "1122", + courseHash: "neu.edu/202210/ARTF/1122", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + { + termId: "202210", + subject: "CS", + courseId: "2500", + courseHash: "neu.edu/202210/CS/2500", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + ], + updatedSections: [ + { + termId: "202210", + subject: "CS", + courseId: "2500", + crn: "11920", + sectionHash: "neu.edu/202210/CS/2500/11920", + campus: "NEU", + seatsRemaining: 114, + }, + ], + }; + await prisma.user.createMany({ data: [USER_ONE, USER_TWO] }); + await prisma.termInfo.create({ + data: { + termId: "202210", + subCollege: "NEU", + text: "description", + }, + }); + await prisma.course.createMany({ + data: [processCourse(FUNDIES_ONE), processCourse(ART)], + }); + + await createSection(FUNDIES_ONE_S1, 0, FUNDIES_ONE_S1.waitRemaining); + await prisma.followedCourse.createMany({ + data: [ + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 1, + notifCount: 1, + }, + { + courseHash: "neu.edu/202210/ARTF/1122", + userId: 2, + notifCount: 0, + }, + { + courseHash: "neu.edu/202210/CS/2500", + userId: 1, + notifCount: 1, + }, + ], + }); + await prisma.followedSection.createMany({ + data: [ + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 1, + notifCount: 1, + }, + { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 2, + notifCount: 0, + }, + ], + }); + + courseHashToUsers = { + "neu.edu/202210/ARTF/1122": [ + { id: 1, phoneNumber: "+11231231234" }, + { id: 2, phoneNumber: "+19879879876" }, + ], + "neu.edu/202210/CS/2500": [{ id: 1, phoneNumber: "+11231231234" }], + }; + sectionHashToUsers = { + "neu.edu/202210/CS/2500/11920": [ + { id: 1, phoneNumber: "+11231231234" }, + { id: 2, phoneNumber: "+19879879876" }, + ], + }; + + const initialCourseNotifs = await prisma.followedCourse.count(); + expect(initialCourseNotifs).toEqual(3); + const initialSectionNotifs = await prisma.followedSection.count(); + expect(initialSectionNotifs).toEqual(2); + + await sendNotifications( + notificationInfo, + courseHashToUsers, + sectionHashToUsers + ); + + const remainingCourseNotifs = await prisma.followedCourse.count(); + expect(remainingCourseNotifs).toEqual(3); + const remainingSectionNotifs = await prisma.followedSection.count(); + expect(remainingSectionNotifs).toEqual(2); + }); + + it("increases notifCount for each course and section after notif is sent", async () => { + //console.log("TEST 7"); + + notificationInfo = { + updatedCourses: [ + { + termId: "202210", + subject: "CS", + courseId: "2500", + courseHash: "neu.edu/202210/CS/2500", + campus: "NEU", + numberOfSectionsAdded: 1, + }, + ], + updatedSections: [ + { + termId: "202210", + subject: "CS", + courseId: "2500", + crn: "11920", + sectionHash: "neu.edu/202210/CS/2500/11920", + campus: "NEU", + seatsRemaining: 114, + }, + ], + }; + await prisma.user.create({ data: USER_ONE }); + await prisma.termInfo.create({ + data: { + termId: "202210", + subCollege: "NEU", + text: "description", + }, + }); + await prisma.course.create({ + data: processCourse(FUNDIES_ONE), + }); + await createSection(FUNDIES_ONE_S1, 0, FUNDIES_ONE_S1.waitRemaining); + await prisma.followedCourse.create({ + data: { + courseHash: "neu.edu/202210/CS/2500", + userId: 1, + notifCount: 0, + }, + }); + await prisma.followedSection.create({ + data: { + sectionHash: "neu.edu/202210/CS/2500/11920", + userId: 1, + notifCount: 1, + }, + }); + + courseHashToUsers = { + "neu.edu/202210/CS/2500": [{ id: 1, phoneNumber: "+11231231234" }], + }; + sectionHashToUsers = { + "neu.edu/202210/CS/2500/11920": [ + { id: 1, phoneNumber: "+11231231234" }, + ], + }; + + const initialCourseNotifCount: { notifCount: number }[] = + await prisma.followedCourse.findMany({ + where: { userId: 1 }, + select: { notifCount: true }, + }); + expect(initialCourseNotifCount).toEqual([{ notifCount: 0 }]); + const initialSectionNotifCount = await prisma.followedSection.findMany({ + where: { userId: 1 }, + select: { notifCount: true }, + }); + expect(initialSectionNotifCount).toEqual([{ notifCount: 1 }]); + + await sendNotifications( + notificationInfo, + courseHashToUsers, + sectionHashToUsers + ); + + const finalCourseNotifCount: { notifCount: number }[] = + await prisma.followedCourse.findMany({ + where: { userId: 1 }, + select: { notifCount: true }, + }); + expect(finalCourseNotifCount).toEqual([{ notifCount: 1 }]); + const finalSectionNotifCount = await prisma.followedSection.findMany({ + where: { userId: 1 }, + select: { notifCount: true }, + }); + expect(finalSectionNotifCount).toEqual([{ notifCount: 2 }]); + }); }); }); diff --git a/tests/unit/jest.config.js b/tests/unit/jest.config.js new file mode 100644 index 00000000..4d52b9ad --- /dev/null +++ b/tests/unit/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + name: "unittest", + displayName: "Unit Tests", + rootDir: "../../", + moduleFileExtensions: ["js", "json", "node", "ts"], + testMatch: ["**/*.(spec|test).unit.[jt]s?(x)"], +}; diff --git a/scrapers/tests/cache.test.ts b/tests/unit/scrapers/cache.test.unit.ts similarity index 97% rename from scrapers/tests/cache.test.ts rename to tests/unit/scrapers/cache.test.unit.ts index 3ca615cc..fc90f0c6 100644 --- a/scrapers/tests/cache.test.ts +++ b/tests/unit/scrapers/cache.test.unit.ts @@ -1,5 +1,5 @@ -import cache from "../cache"; -import macros from "../../utils/macros"; +import cache from "../../../scrapers/cache"; +import macros from "../../../utils/macros"; import path from "path"; import fs from "fs-extra"; diff --git a/scrapers/tests/request.test.ts b/tests/unit/scrapers/request.test.unit.ts similarity index 93% rename from scrapers/tests/request.test.ts rename to tests/unit/scrapers/request.test.unit.ts index 86cf9b63..395add0c 100644 --- a/scrapers/tests/request.test.ts +++ b/tests/unit/scrapers/request.test.unit.ts @@ -3,7 +3,7 @@ * See the license file in the root folder for details. */ -import Request from "../request"; +import Request from "../../../scrapers/request"; // Give extra time to ensure that the initial DNS lookup works jest.setTimeout(10_000); diff --git a/tests/general/services/searcher.test.ts b/tests/unit/services/searcher.test.unit.ts similarity index 52% rename from tests/general/services/searcher.test.ts rename to tests/unit/services/searcher.test.unit.ts index b21df80c..bfcdcefa 100644 --- a/tests/general/services/searcher.test.ts +++ b/tests/unit/services/searcher.test.unit.ts @@ -1,4 +1,5 @@ import searcher from "../../../services/searcher"; +import { LeafQuery, ParsedQuery } from "../../../types/searchTypes"; function validateValues( filterKey: string, @@ -70,3 +71,71 @@ describe("filters", () => { validateValues("honors", validValues, invalidValues); }); }); + +describe("searcher unit tests", () => { + beforeAll(async () => { + searcher.subjects = {}; + }); + + //Unit tests for the parseQuery function + describe("parseQuery", () => { + it("query with no phrases", () => { + const retQueries: ParsedQuery = searcher.parseQuery( + "this is a query with no phrases" + ); + expect(retQueries.phraseQ.length).toEqual(0); //no phrase queries + expect(retQueries.fieldQ).not.toEqual(null); + const fieldQuery: LeafQuery = retQueries.fieldQ; + + expect(fieldQuery).toEqual({ + multi_match: { + query: "this is a query with no phrases", + type: "most_fields", + fields: searcher.getFields(), + }, + }); + }); + + it("query with just a phrase", () => { + const retQueries: ParsedQuery = searcher.parseQuery('"this is a phrase"'); + + expect(retQueries.phraseQ.length).toEqual(1); + expect(retQueries.fieldQ).toEqual(null); + + const phraseQuery: LeafQuery = retQueries.phraseQ[0]; + + expect(phraseQuery).toEqual({ + multi_match: { + query: "this is a phrase", + type: "phrase", + fields: searcher.getFields(), + }, + }); + }); + + it("query with a phrase and other text", () => { + const retQueries: ParsedQuery = searcher.parseQuery('text "phrase" text'); + expect(retQueries.phraseQ.length).toEqual(1); + expect(retQueries.fieldQ).not.toEqual(null); + + const phraseQuery: LeafQuery = retQueries.phraseQ[0]; + const fieldQuery: LeafQuery = retQueries.fieldQ; + + expect(phraseQuery).toEqual({ + multi_match: { + query: "phrase", + type: "phrase", + fields: searcher.getFields(), + }, + }); + + expect(fieldQuery).toEqual({ + multi_match: { + query: "text text", + type: "most_fields", + fields: searcher.getFields(), + }, + }); + }); + }); +});