diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml index ad21911c..3b155672 100755 --- a/.github/workflows/jest.yml +++ b/.github/workflows/jest.yml @@ -1,4 +1,4 @@ -Name: 'Jest' +name: 'Jest' on: push: branches: diff --git a/__tests__/utilities.test.ts b/__tests__/utilities.test.ts new file mode 100644 index 00000000..f3e147c3 --- /dev/null +++ b/__tests__/utilities.test.ts @@ -0,0 +1,75 @@ +import { DESC } from '@globals/constants'; +import { TCourseId } from '@globals/types'; +import { + // mapRatingToColor, + // mapRatingToColorInverted, + mapPayloadToArray, +} from '@src/utilities'; + +// types +type TInputPayload = { + // eslint-disable-next-line no-unused-vars + [T in TCourseId]: any; +}; + +describe('frontend utilities tests', () => { + describe('mapRatingToColor()', () => { + it('maps ratings to colors', () => { + // TODO + }); + }); + + describe('mapRatingToColorInverted()', () => { + it('maps ratings to inverted colors', () => { + // TODO + }); + }); + + describe('mapPayloadToArray()', () => { + let inputPayload: Partial = {}; + + beforeEach(() => { + inputPayload = { + 'CS-6035': { sortKeyField: 3, courseId: 'CS-6035' }, + 'CS-6150': { sortKeyField: 1, courseId: 'CS-6150' }, + 'CS-6200': { sortKeyField: 2, courseId: 'CS-6200' }, + }; + }); + + it('returns empty array for empty payload', () => { + const inputPayload = {}; + const mappedArray = mapPayloadToArray(inputPayload); + expect(mappedArray).toMatchObject([]); + }); + + it('maps payload to array, ascending by sortKey', () => { + const mappedArray = mapPayloadToArray(inputPayload, 'sortKeyField'); + const expectedOutput = [ + { sortKeyField: 1, courseId: 'CS-6150' }, + { sortKeyField: 2, courseId: 'CS-6200' }, + { sortKeyField: 3, courseId: 'CS-6035' }, + ]; + expect(mappedArray).toMatchObject(expectedOutput); + }); + + it('maps payload to array, descending by sortKey', () => { + const mappedArray = mapPayloadToArray(inputPayload, 'courseID', DESC); + const expectedOutput = [ + { sortKeyField: 2, courseId: 'CS-6200' }, + { sortKeyField: 1, courseId: 'CS-6150' }, + { sortKeyField: 3, courseId: 'CS-6035' }, + ]; + expect(mappedArray).toMatchObject(expectedOutput); + }); + + it('falls through in original order if no sortKey specified', () => { + const mappedArray = mapPayloadToArray(inputPayload); + const expectedOutput = [ + { sortKeyField: 3, courseId: 'CS-6035' }, + { sortKeyField: 1, courseId: 'CS-6150' }, + { sortKeyField: 2, courseId: 'CS-6200' }, + ]; + expect(mappedArray).toMatchObject(expectedOutput); + }); + }); +}); diff --git a/firebase/FirebaseConfig.ts b/firebase/FirebaseConfig.ts index b34167e8..0646e243 100755 --- a/firebase/FirebaseConfig.ts +++ b/firebase/FirebaseConfig.ts @@ -31,8 +31,7 @@ export const storage = getStorage(firebaseApp); // be running via `yarn fb:emu` first before starting the app via `yarn dev`) const isEmulatorMode = process.env.NEXT_PUBLIC_IS_EMULATOR_MODE?.toLowerCase() === 'true'; -const isEmulatorEnvironment = - process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'; +const isEmulatorEnvironment = process.env.NODE_ENV === 'development'; if (isEmulatorMode && isEmulatorEnvironment) { // prevent multiple Firestore emulator connections on re-render -- reference: https://stackoverflow.com/a/74727587 const host = diff --git a/firebase/__mocks__/fbApp.ts b/firebase/__mocks__/fbApp.ts new file mode 100644 index 00000000..a238ae38 --- /dev/null +++ b/firebase/__mocks__/fbApp.ts @@ -0,0 +1,3 @@ +jest.mock('firebase/app', () => ({ + initializeApp: jest.fn(() => ({})), +})); diff --git a/firebase/__mocks__/fbAuth.ts b/firebase/__mocks__/fbAuth.ts new file mode 100644 index 00000000..3889b592 --- /dev/null +++ b/firebase/__mocks__/fbAuth.ts @@ -0,0 +1,4 @@ +jest.mock('firebase/auth', () => ({ + connectAuthEmulator: jest.fn(), + getAuth: jest.fn(), +})); diff --git a/firebase/__mocks__/fbFirestore.ts b/firebase/__mocks__/fbFirestore.ts new file mode 100644 index 00000000..640fc6f1 --- /dev/null +++ b/firebase/__mocks__/fbFirestore.ts @@ -0,0 +1,12 @@ +jest.mock('firebase/firestore', () => ({ + getFirestore: jest.fn(() => ({ + toJSON: jest.fn(() => ({ + settings: { host: '' }, + })), + })), + connectFirestoreEmulator: jest.fn(), + doc: jest.fn(), + getDoc: jest.fn(), + setDoc: jest.fn(), + deleteDoc: jest.fn(), +})); diff --git a/firebase/__mocks__/fbStorage.ts b/firebase/__mocks__/fbStorage.ts new file mode 100644 index 00000000..371447f8 --- /dev/null +++ b/firebase/__mocks__/fbStorage.ts @@ -0,0 +1,4 @@ +jest.mock('firebase/storage', () => ({ + connectStorageEmulator: jest.fn(), + getStorage: jest.fn(), +})); diff --git a/firebase/__tests__/dbOperations.test.ts b/firebase/__tests__/dbOperations.test.ts new file mode 100644 index 00000000..c883b6b3 --- /dev/null +++ b/firebase/__tests__/dbOperations.test.ts @@ -0,0 +1,75 @@ +// mock imports (cf. `/firebase/__mocks__`) +import 'firebase/app'; +import 'firebase/firestore'; +import 'firebase/auth'; +import 'firebase/storage'; + +describe('backend dbOperations tests', () => { + describe('courses CRUD operations', () => { + describe('getCourses', () => { + it('gets all courses from Firebase Firestore DB', () => { + // TODO + }); + }); + + describe('getCourse()', () => { + // TODO + }); + + describe('addCourse()', () => { + // TODO + }); + + describe('updateCourse()', () => { + // TODO + }); + + describe('deleteCourse()', () => { + // TODO + }); + }); + + describe('reviews CRUD operations', () => { + describe('getReviews()', () => { + // TODO + }); + + describe('getReviewsRecent()', () => { + // TODO + }); + + describe('getReview()', () => { + // TODO + }); + + describe('addReview()', () => { + // TODO + }); + + describe('updateReview()', () => { + // TODO + }); + + describe('deleteReview()', () => { + // TODO + }); + }); + + describe('users CRUD operations', () => { + describe('getUser()', () => { + // TODO + }); + + describe('addUser()', () => { + // TODO + }); + + describe('editUser()', () => { + // TODO + }); + + describe('deleteUser()', () => { + // TODO + }); + }); +}); diff --git a/firebase/__tests__/utilities.test.ts b/firebase/__tests__/utilities.test.ts new file mode 100644 index 00000000..8a32a49d --- /dev/null +++ b/firebase/__tests__/utilities.test.ts @@ -0,0 +1,635 @@ +// mock imports (cf. `/firebase/__mocks__`) +import 'firebase/app'; +import 'firebase/firestore'; +import 'firebase/auth'; +import 'firebase/storage'; + +// non-mock imports +import { cloneDeep } from 'lodash'; +import { + updateReviewsRecent, + ON_ADD_REVIEW, + ON_EDIT_REVIEW, +} from '@backend/utilities'; +import { + Review, + TCourseId, + TPayloadCoursesDataDynamic, + TPayloadReviews, +} from '@globals/types'; +import { + getCourses, + getCourse, + getReviewsRecent, + getReviews, + getReview, +} from '@backend/dbOperations'; + +const _aggregateData = '_aggregateData'; + +// type definitions for mock responses data +type TMockReviewsRecentData = { + // eslint-disable-next-line no-unused-vars + [courseId in TCourseId | '_aggregateData']: Review[]; +}; + +type TMockReviewsData = { + // eslint-disable-next-line no-unused-vars + [courseId in TCourseId]: { + [yearSemesterTerm: string]: TPayloadReviews; + }; +}; + +// mock the dbOperations functions +jest.mock('@backend/dbOperations', () => ({ + getCourses: jest.fn(), + getCourse: jest.fn(), + updateCourse: jest.fn(), + getReviewsRecent: jest.fn(), + getReviews: jest.fn(), + getReview: jest.fn(), + updateReview: jest.fn(), +})); + +describe('backend utilities tests', () => { + // mock the Firebase Firestore responses + let mockCoursesData: Partial = {}; + let mockReviewsRecentData: Partial = {}; + let mockReviewsData: Partial = {}; + let mockReviewsDataFlat: TPayloadReviews = {}; + + beforeEach(() => { + mockCoursesData = { + 'CS-6035': { + courseId: 'CS-6035', + numReviews: 1, + avgWorkload: 4, + avgDifficulty: 1, + avgOverall: 4, + avgStaffSupport: null, + reviewsCountsByYearSem: { 2022: { 1: 1 } }, + }, + 'CS-6150': { + courseId: 'CS-6150', + numReviews: 1, + avgWorkload: 9, + avgDifficulty: 3, + avgOverall: 3, + avgStaffSupport: null, + reviewsCountsByYearSem: { 2022: { 1: 1 } }, + }, + 'CS-6200': { + courseId: 'CS-6200', + numReviews: 2, + avgWorkload: 11.5, + avgDifficulty: 3.5, + avgOverall: 2, + avgStaffSupport: null, + reviewsCountsByYearSem: { 2022: { 1: 2 } }, + }, + }; + + mockReviewsRecentData = { + _aggregateData: [ + { + reviewId: 'CS-6150-2022-1-1650861991417', + courseId: 'CS-6150', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1650861991417, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 9, + difficulty: 3, + overall: 3, + }, + { + reviewId: 'CS-6035-2022-1-1651453027591', + courseId: 'CS-6035', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1651453027591, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 4, + difficulty: 1, + overall: 4, + }, + { + reviewId: 'CS-6200-2022-1-1651810061319', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'def', + isGTVerifiedReviewer: false, + created: 1651810061319, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 15, + difficulty: 4, + overall: 3, + }, + { + reviewId: 'CS-6200-2022-1-1652068644484', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'abc', + isGTVerifiedReviewer: false, + created: 1652068644484, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 8, + difficulty: 3, + overall: 1, + }, + ], + 'CS-6035': [ + { + reviewId: 'CS-6035-2022-1-1651453027591', + courseId: 'CS-6035', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1651453027591, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 4, + difficulty: 1, + overall: 4, + }, + ], + 'CS-6150': [ + { + reviewId: 'CS-6150-2022-1-1650861991417', + courseId: 'CS-6150', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1650861991417, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 9, + difficulty: 3, + overall: 3, + }, + ], + 'CS-6200': [ + { + reviewId: 'CS-6200-2022-1-1652068644484', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'abc', + isGTVerifiedReviewer: false, + created: 1652068644484, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 8, + difficulty: 3, + overall: 1, + }, + { + reviewId: 'CS-6200-2022-1-1651810061319', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'def', + isGTVerifiedReviewer: false, + created: 1651810061319, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 15, + difficulty: 4, + overall: 3, + }, + ], + }; + + mockReviewsData = { + 'CS-6035': { + '2022-1': { + 'CS-6035-2022-1-1651453027591': { + reviewId: 'CS-6035-2022-1-1651453027591', + courseId: 'CS-6035', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1651453027591, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 4, + difficulty: 1, + overall: 4, + }, + }, + }, + 'CS-6150': { + '2022-1': { + 'CS-6150-2022-1-1650861991417': { + reviewId: 'CS-6150-2022-1-1650861991417', + courseId: 'CS-6150', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1650861991417, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 9, + difficulty: 3, + overall: 3, + }, + }, + }, + 'CS-6200': { + '2022-1': { + 'CS-6200-2022-1-1652068644484': { + reviewId: 'CS-6200-2022-1-1652068644484', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'abc', + isGTVerifiedReviewer: false, + created: 1652068644484, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 8, + difficulty: 3, + overall: 1, + }, + 'CS-6200-2022-1-1651810061319': { + reviewId: 'CS-6200-2022-1-1651810061319', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'def', + isGTVerifiedReviewer: false, + created: 1651810061319, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 15, + difficulty: 4, + overall: 3, + }, + }, + }, + }; + + mockReviewsDataFlat = { + 'CS-6150-2022-1-1650861991417': { + reviewId: 'CS-6150-2022-1-1650861991417', + courseId: 'CS-6150', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1650861991417, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 9, + difficulty: 3, + overall: 3, + }, + 'CS-6035-2022-1-1651453027591': { + reviewId: 'CS-6035-2022-1-1651453027591', + courseId: 'CS-6035', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1651453027591, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 4, + difficulty: 1, + overall: 4, + }, + 'CS-6200-2022-1-1651810061319': { + reviewId: 'CS-6200-2022-1-1651810061319', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'def', + isGTVerifiedReviewer: false, + created: 1651810061319, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 15, + difficulty: 4, + overall: 3, + }, + 'CS-6200-2022-1-1652068644484': { + reviewId: 'CS-6200-2022-1-1652068644484', + courseId: 'CS-6200', + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'abc', + isGTVerifiedReviewer: false, + created: 1652068644484, + modified: null, + body: 'existing review', + upvotes: 0, + downvotes: 0, + workload: 8, + difficulty: 3, + overall: 1, + }, + }; + + // provide the mocked dBOperations functions implementations + (getCourses as jest.Mock).mockResolvedValue(mockCoursesData); + (getCourse as jest.Mock).mockImplementation((courseId: TCourseId) => + Promise.resolve(mockCoursesData[courseId]), + ); + (getReviewsRecent as jest.Mock).mockImplementation((courseId?: TCourseId) => + Promise.resolve( + courseId + ? mockReviewsRecentData[courseId] + : mockReviewsRecentData[_aggregateData], + ), + ); + (getReviews as jest.Mock).mockImplementation( + (courseId: TCourseId, year: string, semesterTerm: string) => + Promise.resolve(mockReviewsData[courseId]![`${year}-${semesterTerm}`]), + ); + (getReview as jest.Mock).mockImplementation((reviewId: string) => + Promise.resolve(mockReviewsDataFlat[reviewId]), + ); + }); + + describe('reviews data CRUD sub-operations', () => { + describe('base updates', () => { + describe('updateReviewsRecent()', () => { + it('adds a review to recents data array for course', async () => { + // arrange + const reviewId = 'CS-6035-2023-2-1691132155000'; + const courseId: TCourseId = 'CS-6035'; + const reviewData: Review = { + reviewId, + courseId, + year: 2023, + semesterId: 'sm', + isLegacy: false, + reviewerId: 'tuv', + isGTVerifiedReviewer: false, + created: 1691132155000, + modified: null, + body: 'new review', + upvotes: 0, + downvotes: 0, + workload: 10, + difficulty: 1, + overall: 5, + }; + const oldRecentsArray = cloneDeep(mockReviewsRecentData[courseId])!; + + // act + await updateReviewsRecent({ + operation: ON_ADD_REVIEW, + reviewData, + reviewId, + courseId, + }); + + // assert + const expectedOutput = [...oldRecentsArray, reviewData]; + expect(mockReviewsRecentData[courseId]).toMatchObject(expectedOutput); + }); + + it('adds a review to recents data array for aggregate', async () => { + // arrange + const reviewId = 'CS-6035-2023-2-1691132155000'; + const courseId: TCourseId = 'CS-6035'; + const reviewData: Review = { + reviewId, + courseId, + year: 2023, + semesterId: 'sm', + isLegacy: false, + reviewerId: 'tuv', + isGTVerifiedReviewer: false, + created: 1691132155000, + modified: null, + body: 'new review', + upvotes: 0, + downvotes: 0, + workload: 10, + difficulty: 1, + overall: 5, + }; + const oldRecentsArray = cloneDeep( + mockReviewsRecentData[_aggregateData], + )!; + + // act + await updateReviewsRecent({ + operation: ON_ADD_REVIEW, + reviewData, + reviewId, + }); + + // assert + const expectedOutput = [...oldRecentsArray, reviewData]; + expect(mockReviewsRecentData[_aggregateData]).toMatchObject( + expectedOutput, + ); + }); + + it('updates a review in recents data array for course', async () => { + // arrange + const reviewId = 'CS-6035-2022-1-1651453027591'; + const courseId: TCourseId = 'CS-6035'; + const reviewData: Review = { + reviewId, + courseId, + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1651453027591, + modified: Date.now(), + body: 'updated review', + upvotes: 0, + downvotes: 0, + workload: 4, + difficulty: 1, + overall: 4, + }; + const oldRecentsArray = cloneDeep(mockReviewsRecentData[courseId])!; + const indexOfReview = oldRecentsArray.findIndex( + (review) => review.reviewId === reviewId, + ); + + // act + await updateReviewsRecent({ + operation: ON_EDIT_REVIEW, + reviewData, + reviewId, + courseId, + }); + + // assert + oldRecentsArray.splice(indexOfReview, 1, reviewData); + expect(mockReviewsRecentData[courseId]).toMatchObject( + oldRecentsArray, + ); + }); + + it('updates a review in recents data array for aggregate', async () => { + // arrange + const reviewId = 'CS-6035-2022-1-1651453027591'; + const courseId: TCourseId = 'CS-6035'; + const reviewData: Review = { + reviewId, + courseId, + year: 2022, + semesterId: 'sp', + isLegacy: true, + reviewerId: 'xyz', + isGTVerifiedReviewer: false, + created: 1651453027591, + modified: Date.now(), + body: 'updated review', + upvotes: 0, + downvotes: 0, + workload: 4, + difficulty: 1, + overall: 4, + }; + const oldRecentsArray = cloneDeep( + mockReviewsRecentData[_aggregateData], + )!; + const indexOfReview = oldRecentsArray.findIndex( + (review) => review.reviewId === reviewId, + ); + + // act + await updateReviewsRecent({ + operation: ON_EDIT_REVIEW, + reviewData, + reviewId, + }); + + // assert + oldRecentsArray.splice(indexOfReview, 1, reviewData); + expect(mockReviewsRecentData[_aggregateData]).toMatchObject( + oldRecentsArray, + ); + }); + + it('deletes a review in recents data array for course', async () => { + // TODO + }); + + it('deletes a review in recents data array for aggregate', async () => { + // TODO + }); + }); + + describe('updateUser()', () => { + // TODO + }); + + describe('addOrUpdateReview()', () => { + // TODO + }); + }); + }); + + describe('updates on add review', () => { + describe('updateCourseDataOnAddReview()', () => { + // TODO + }); + + describe('updateReviewsRecentOnAddReview()', () => { + // TODO + }); + + describe('updateUserDataOnAddReview()', () => { + // TODO + }); + }); + + describe('updates on update review', () => { + describe('updateCourseDataOnUpdateReview()', () => { + // TODO + }); + + describe('updateReviewsRecentOnUpdateReview()', () => { + // TODO + }); + + describe('updateUserDataOnUpdateReview()', () => { + // TODO + }); + }); + + describe('updates on delete review', () => { + describe('updateCourseDataOnDeleteReview()', () => { + // TODO + }); + + describe('updateReviewsRecentOnDeleteReview()', () => { + // TODO + }); + + describe('updateUserDataOnDeleteReview()', () => { + // TODO + }); + }); +}); diff --git a/firebase/__tests__/utilityFunctions.test.ts b/firebase/__tests__/utilityFunctions.test.ts index b58981d1..2bc0eee2 100755 --- a/firebase/__tests__/utilityFunctions.test.ts +++ b/firebase/__tests__/utilityFunctions.test.ts @@ -10,7 +10,7 @@ import { TAveragesData, updateAverage, updateAverages, -} from '../utilityFunctions'; +} from '@backend/utilityFunctions'; const computeAverage = (dataArray: TNullableNumber[]) => dataArray @@ -30,7 +30,7 @@ const mapReviewsDataToAverages = (reviewsData: Review[]) => ({ // ), }); -describe('firebase utility functions tests', () => { +describe('backend utility functions tests', () => { describe('parseReviewId() tests', () => { it('parses simple courseId', () => { const reviewId = 'CS-1234-2100-3-1234567890'; diff --git a/firebase/dbOperations.ts b/firebase/dbOperations.ts index 2f709290..fb26e945 100755 --- a/firebase/dbOperations.ts +++ b/firebase/dbOperations.ts @@ -37,6 +37,11 @@ import { const { COURSES } = coreDataDocuments; /* --- COURSES --- */ + +/** + * Get all courses from Firebase Firestore DB document `/coreData/courses` + * @returns `TPayloadCoursesDataDynamic` containing collection of courses' dynamic data + */ export const getCourses = async () => { try { const snapshot = await getOneDoc(baseCollectionCoreData, COURSES); @@ -49,6 +54,11 @@ export const getCourses = async () => { } }; +/** + * Get single course from Firebase Firestore DB document `/coreData/courses` + * @param courseId OMS course ID + * @returns `CourseDataDynamic` containing single course's dynamic data + */ export const getCourse = async (courseId: TCourseId) => { try { const coursesDataDoc = await getCourses(); @@ -59,16 +69,33 @@ export const getCourse = async (courseId: TCourseId) => { } }; +/** + * Add a single course to Firebase Firestore DB document `/coreData/courses` + * @param courseId OMS course ID + * @param courseData OMSHub course data + * @returns A `Promise` which is resolved on successful addition of `CourseDataDynamic` data field to the Firebase Firestore DB document + */ export const addCourse = async ( courseId: TCourseId, courseData: CourseDataDynamic, ) => addOrUpdateCourse(courseId, courseData); +/** + * Update a single course in Firebase Firestore DB document `/coreData/courses` + * @param courseId OMS course ID + * @param courseData OMSHub course data + * @returns A `Promise` which is resolved on successful update of `CourseDataDynamic` data field in the Firebase Firestore DB document + */ export const updateCourse = async ( courseId: TCourseId, courseData: CourseDataDynamic, ) => addOrUpdateCourse(courseId, courseData); +/** + * Delete a single course from Firebase Firestore DB document `/coreData/courses` + * @param courseId OMS course ID + * @returns A `Promise` which is resolved on successful delete of `CourseDataDynamic` data field from the Firebase Firestore DB document + */ export const deleteCourse = async (courseId: TCourseId) => { try { const coursesDataDoc = await getCourses(); @@ -86,6 +113,14 @@ export const deleteCourse = async (courseId: TCourseId) => { }; /* --- REVIEWS (keyed by courseId-year-semesterId) --- */ + +/** + * Get all course's reviews for specified year-semester from Firebase Firestore DB document `/reviewsData/{courseId}/{year}-{semesterTerm}/data` + * @param courseId OMS course ID + * @param year Year of course taken + * @param semesterTerm Semester of course taken (`1`/Spring, `2`/Summer, `3`/Fall) + * @returns `TPayloadReviews` containing collection of reviews + */ export const getReviews = async ( courseId: TCourseId, year: string, @@ -104,14 +139,20 @@ export const getReviews = async ( } }; -// N.B. Start of array has additional "buffer reviews" (initialized to 20) to -// guard against net deletion below 50. Return value should be sliced by -// caller in order to limit to only 50 accordingly, i.e.,: -// let reviews = await getReviewsRecent() -// reviews = reviews?.reverse().slice(0, 50) +/** + * Get all course's recent reviews from Firebase Firestore DB document `/recentsData/{courseId}` via data field `data`. + * Start of array has additional "buffer reviews" (initialized to 20) to guard against net deletion below 50. + * @param courseId OMS course ID (omitted to get recent reviews across all courses) + * @returns `Review[]` containing array of reviews + * @example + * // Return value should be sliced by caller in order to + * // limit to only 50 accordingly, i.e.,: + * let reviewsAggregateData = await getReviewsRecent(); + * reviewsAggregateData = reviewsAggregateData?.reverse().slice(0, 50); + */ export const getReviewsRecent = async (courseId?: TCourseId) => { try { - // N.B. use undefined `courseId` arg form for non-course-specific/aggregated array + // N.B. use omitted/undefined `courseId` arg form for non-course-specific/aggregated array const dataId = courseId ?? `_aggregateData`; const snapshot = await getOneDoc(baseCollectionRecentsData, dataId); @@ -127,11 +168,15 @@ export const getReviewsRecent = async (courseId?: TCourseId) => { } }; +/** + * Get a single review from Firebase Firestore DB document `_reviewsDataFlat/{reviewId}`. + * @param reviewId OMSHub review ID + * @returns review `Review` + */ export const getReview = async (reviewId: string) => { try { const snapshot = await getOneDoc(baseCollectionReviewsDataFlat, reviewId); - // @ts-ignore -- coerce to `Review` entity based on known form of snapshot.data() per Firestore db data - const reviewData: Review = snapshot.data() ?? {}; + const reviewData = (snapshot.data() ?? {}) as Review; return reviewData; } catch (e: any) { console.log(e); @@ -139,6 +184,13 @@ export const getReview = async (reviewId: string) => { } }; +/** + * Add a new review to Firestore Firebase DB. + * @param userId OMSHub user ID + * @param reviewId OMSHub review ID + * @param reviewData OMSHub review data + * @returns `Promise`s which resolve on successful update of the Firestore Firebase DB. + */ export const addReview = async ( userId: string, reviewId: string, @@ -156,6 +208,13 @@ export const addReview = async ( } }; +/** + * Edit an existing review in Firestore Firebase DB. + * @param userId OMSHub user ID + * @param reviewId OMSHub review ID + * @param reviewData OMSHub review data + * @returns `Promise`s which resolve on successful update of the Firestore Firebase DB. + */ export const updateReview = async ( userId: string, reviewId: string, @@ -173,6 +232,12 @@ export const updateReview = async ( } }; +/** + * Delete an existing review from Firestore Firebase DB. + * @param userId OMSHub user ID + * @param reviewId OMSHub review ID + * @returns `Promise`s which resolve on successful update of the Firestore Firebase DB. + */ export const deleteReview = async (userId: string, reviewId: string) => { try { const { courseId, year, semesterTerm } = parseReviewId(reviewId); @@ -204,12 +269,17 @@ export const deleteReview = async (userId: string, reviewId: string) => { }; /* --- USERS --- */ + +/** + * Get an existing user from Firebase Firestore DB. + * @param userId OMSHub user ID + * @returns the updated user `User` + */ export const getUser = async (userId: string) => { try { const snapshot = await getOneDoc(baseCollectionUsersData, userId); const nullUser: User = { userId: null, hasGTEmail: false, reviews: {} }; - // @ts-ignore -- coerce to `User` entity based on known form of snapshot.data() per Firestore db data - const userData: User = snapshot.data() ?? nullUser; + const userData = (snapshot.data() ?? nullUser) as User; return userData; } catch (e: any) { console.log(e); @@ -217,6 +287,12 @@ export const getUser = async (userId: string) => { } }; +/** + * Add a new user to Firebase Firestore DB. + * @param userId OMSHub user ID + * @param hasGTEmail Boolean flag indicating whether login email belongs to domain `gatech.edu` + * @returns A `Promise` which resolves on successful update of the Firestore Firebase DB. + */ export const addUser = async (userId: string, hasGTEmail: boolean = false) => { try { const newUserData: User = { userId, hasGTEmail, reviews: {} }; @@ -227,6 +303,12 @@ export const addUser = async (userId: string, hasGTEmail: boolean = false) => { } }; +/** + * Edit an existing user in Firebase Firestore DB. + * @param userId OMSHub user ID + * @param userData OMSHub user data + * @returns A `Promise` which resolves on successful update of the Firestore Firebase DB. + */ export const editUser = async (userId: string, userData: User) => { try { const oldUserData = await getUser(userId); @@ -244,6 +326,11 @@ export const editUser = async (userId: string, userData: User) => { } }; +/** + * Delete an existing user from Firebase Firestore DB. + * @param userId OMSHub user ID + * @returns A `Promise` which resolves on successful update of the Firestore Firebase DB. + */ export const deleteUser = async (userId: string) => { try { await deleteDoc(doc(db, `${baseCollectionUsersData}/${userId}`)); diff --git a/firebase/utilities.ts b/firebase/utilities.ts index 624f8e79..0e84c2a5 100755 --- a/firebase/utilities.ts +++ b/firebase/utilities.ts @@ -37,17 +37,36 @@ const { COURSES } = coreDataDocuments; /* --- GENERIC CRUD SUB-OPERATIONS (FOR FLAT COLLECTIONS) --- */ +/** + * Get one document from Firestore DB + * @param collectionPathString Firebase Firestore collection path to document + * @param documentId Firebase Firestore document ID + * @returns Firebase Firestore document reference as a `Promise` + */ export const getOneDoc = async ( collectionPathString: string, documentId: TDocumentDataId, ) => getDoc(doc(db, collectionPathString, documentId)); +/** + * Add or update one document in Firestore DB + * @param collectionPathString Firebase Firestore collection path to document + * @param documentId Firebase Firestore document ID + * @param documentData Firebase Firestore document data + * @returns A `Promise` which is resolved on successful writing of the document to the Firebase Firestore DB + */ export const addOrEditDoc = async ( collectionPathString: string, documentId: TDocumentDataId, documentData: TDocumentData, ) => setDoc(doc(db, collectionPathString, documentId), documentData); +/** + * Delete one document from Firestore DB + * @param collectionPathString Firebase Firestore collection path to document + * @param documentId Firebase Firestore document ID + * @returns A `Promise` which is resolved on successful deletion of document from the Firebase Firestore DB + */ export const delDoc = async ( collectionPathString: string, documentId: TDocumentDataId, @@ -55,14 +74,19 @@ export const delDoc = async ( /* --- COURSE DATA CRUD SUB-OPERATIONS --- */ +/** + * Add or update a single course `CourseDataDynamic` in Firestore Firestore DB data field `/coreData/courses/{courseId}` + * @param courseId OMS course ID + * @param courseData OMS course data + * @returns A `Promise` which is resolved on successful writing of the `CourseDataDynamic` data field to the Firebase Firestore DB + */ export const addOrUpdateCourse = async ( courseId: TCourseId, courseData: CourseDataDynamic, ) => { try { const coursesDataDoc = await getCourses(); - // @ts-ignore -- newCoursesDataDoc is populated in subsequent logic - let newCoursesDataDoc: TPayloadCoursesDataDynamic = {}; + let newCoursesDataDoc = {} as TPayloadCoursesDataDynamic; if (coursesDataDoc) { if (Object.keys(coursesDataDoc).length) { newCoursesDataDoc = { ...coursesDataDoc }; @@ -81,11 +105,11 @@ export const addOrUpdateCourse = async ( /* --- REVIEWS DATA CRUD SUB-OPERATIONS --- */ -// utilities +// base updates -const ON_ADD_REVIEW = 'ON_ADD_REVIEW'; -const ON_EDIT_REVIEW = 'ON_EDIT_REVIEW'; -const ON_DELETE_REVIEW = 'ON_DELETE_REVIEW'; +export const ON_ADD_REVIEW = 'ON_ADD_REVIEW'; +export const ON_EDIT_REVIEW = 'ON_EDIT_REVIEW'; +export const ON_DELETE_REVIEW = 'ON_DELETE_REVIEW'; type TOperationUpdateOnReviewEvent = | 'ON_ADD_REVIEW' | 'ON_EDIT_REVIEW' @@ -98,7 +122,15 @@ interface ArgsUpdateReviewsRecent { courseId?: TCourseId; } -const updateReviewsRecent = async ({ +/** + * Update recent reviews array `Review[]` in Firestore Firestore DB document `/recentsData/{courseId}/data` or `/recentsData/_aggregateData/data` + * @param operation add (`ON_ADD_REVIEW`), edit (`ON_EDIT_REVIEW`), or delete (`ON_DELETE_REVIEW`) + * @param reviewData OMSHub review data (omitted if operation is delete) + * @param reviewId OMSHub review ID + * @param courseId OMS course ID (omitted if recent reviews array in question is `_aggregateData`) + * @returns A `Promise` which is resolved on successful add, edit, or delete of the `Review[]` document in the Firebase Firestore DB + */ +export const updateReviewsRecent = async ({ operation, reviewData = undefined, // delete only reviewId, @@ -169,7 +201,15 @@ interface ArgsUpdateUser { userId: string; } -const updateUser = async ({ +/** + * Update user `User` in Firestore Firestore DB document `/usersData/{userId}` on review add, edit, or delete + * @param operation add (`ON_ADD_REVIEW`), edit (`ON_EDIT_REVIEW`), or delete (`ON_DELETE_REVIEW`) + * @param reviewData OMSHub review data (omitted if operation is delete) + * @param reviewId OMSHub review ID + * @param userId Firebase Auth user ID + * @returns A `Promise` which is resolved on successful update of `User` document in the Firebase Firestore DB + */ +export const updateUser = async ({ operation, reviewData = undefined, // delete only reviewId, @@ -220,6 +260,12 @@ const updateUser = async ({ } }; +/** + * Add or update review `Review` in Firestore Firestore DB document `/reviewsData/{courseId}/{year}-{semesterTerm}/data` on review add or edit + * @param reviewId OMSHub review ID + * @param reviewData OMSHub review data + * @returns A `Promise` which is resolved on successful add or update of `Review` document in the Firebase Firestore DB + */ export const addOrUpdateReview = async ( reviewId: string, reviewData: TDocumentData, @@ -249,6 +295,12 @@ export const addOrUpdateReview = async ( // updates on add review +/** + * On add review `Review`, update Firebase Firestore DB document `/coreData/courses/{courseId}` + * @param reviewId OMSHub review ID + * @param reviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `CourseDataDynamic` data field in the Firebase Firestore DB + */ export const updateCourseDataOnAddReview = async ( reviewId: string, reviewData: Review, @@ -330,6 +382,11 @@ export const updateCourseDataOnAddReview = async ( } }; +/** + * On add review `Review`, update Firebase Firestore DB documents `/recentsData/{courseId}` and `/recentsData/_aggregateData` + * @param newReviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `data` data field in the Firebase Firestore DB + */ export const updateReviewsRecentOnAddReview = async (newReviewData: Review) => { try { const { reviewId } = newReviewData; @@ -355,6 +412,12 @@ export const updateReviewsRecentOnAddReview = async (newReviewData: Review) => { } }; +/** + * On add review `Review`, update Firebase Firestore DB document `/usersData/{userId}` + * @param userId OMSHub user ID + * @param newReviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `userId` document in the Firebase Firestore DB + */ export const updateUserDataOnAddReview = async ( userId: string, newReviewData: Review, @@ -376,6 +439,12 @@ export const updateUserDataOnAddReview = async ( // updates on update review +/** + * On update review `Review`, update Firebase Firestore DB document `/coreData/courses/{courseId}` + * @param reviewId OMSHub review ID + * @param reviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `CourseDataDynamic` data field in the Firebase Firestore DB + */ export const updateCourseDataOnUpdateReview = async ( reviewId: string, reviewData: Review, @@ -450,6 +519,11 @@ export const updateCourseDataOnUpdateReview = async ( } }; +/** + * On update review `Review`, update Firebase Firestore DB documents `/recentsData/{courseId}` and `/recentsData/_aggregateData` + * @param newReviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `data` data field in the Firebase Firestore DB + */ export const updateReviewsRecentOnUpdateReview = async ( newReviewData: Review, ) => { @@ -477,6 +551,12 @@ export const updateReviewsRecentOnUpdateReview = async ( } }; +/** + * On update review `Review`, update Firebase Firestore DB document `/usersData/{userId}` + * @param userId OMSHub user ID + * @param newReviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `userId` document in the Firebase Firestore DB + */ export const updateUserDataOnUpdateReview = async ( userId: string, newReviewData: Review, @@ -498,6 +578,12 @@ export const updateUserDataOnUpdateReview = async ( // updates on delete review +/** + * On delete review `Review`, update Firebase Firestore DB document `/coreData/courses/{courseId}` + * @param reviewId OMSHub review ID + * @param reviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `CourseDataDynamic` data field in the Firebase Firestore DB + */ export const updateCourseDataOnDeleteReview = async (reviewId: string) => { try { // @ts-ignore -- intended semantics in this context is `Number` @@ -579,6 +665,11 @@ export const updateCourseDataOnDeleteReview = async (reviewId: string) => { } }; +/** + * On delete review `Review`, update Firebase Firestore DB documents `/recentsData/{courseId}` and `/recentsData/_aggregateData` + * @param newReviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `data` data field in the Firebase Firestore DB + */ export const updateReviewsRecentOnDeleteReview = async (reviewId: string) => { try { const { courseId } = parseReviewId(reviewId); @@ -601,6 +692,12 @@ export const updateReviewsRecentOnDeleteReview = async (reviewId: string) => { } }; +/** + * On delete review `Review`, update Firebase Firestore DB document `/usersData/{userId}` + * @param userId OMSHub user ID + * @param newReviewData OMSHub review data + * @returns A `Promise` which is resolved on successful update of `userId` document in the Firebase Firestore DB + */ export const updateUserDataOnDeleteReview = async ( userId: string, reviewId: string, diff --git a/firebase/utilityFunctions.ts b/firebase/utilityFunctions.ts index ab7fa9e7..075a2c1e 100755 --- a/firebase/utilityFunctions.ts +++ b/firebase/utilityFunctions.ts @@ -3,6 +3,11 @@ const LEN_SIMPLE_COURSE_NUMBER = 5; // DD-CCCC-... (e.g., CS-6200-...) const LEN_COMPOUND_COURSE_NUMBER = 6; // DD-CCCC-CCC-... (e.g., CS-8803-O08-...) [total 6 * `-`] const SEPARATOR_TOKEN = '-'; +/** + * Parses `reviewId` into its constituent ID fields + * @param reviewId OMSHub review ID + * @returns OMSHub course ID, and year and semester of course taken + */ export const parseReviewId = (reviewId: string) => { let courseId: TCourseId; let departmentId = ''; @@ -46,6 +51,15 @@ export type TAveragesData = { newValue?: number; }; +/** + * Update aggregate average value on add, update, or delete of single data value + * @param oldAverage Old average value + * @param oldCount Old count of group data + * @param newCount New count of group data + * @param oldValue Old data value + * @param newValue New data value + * @returns Updated average value per new count and new value + */ export const updateAverage = ({ oldAverage, oldCount = 0, // 0 for `oldAverage === null` @@ -91,8 +105,10 @@ type TAveragesInputData = { newValue?: number; }; -// This function converts input averages to output averages based on -// review add, edit, or delete +/** + * Recalculate averages for workload, difficult, and overall + * on review add, edit, or delete. + */ export const updateAverages = ({ courseId, oldCount, diff --git a/globals/__tests__/utilities.test.ts b/globals/__tests__/utilities.test.ts new file mode 100644 index 00000000..80737bb0 --- /dev/null +++ b/globals/__tests__/utilities.test.ts @@ -0,0 +1,61 @@ +import { + // mapDynamicCoursesDataToCourses, + extractEmailDomain, + isGTEmail, + isOutlookEmail, +} from '@globals/utilities'; +import { DOMAIN_GATECH } from '@globals/constants'; + +describe('global utilities tests', () => { + describe('mappers', () => { + describe('mapDynamicCoursesDataToCourses()', () => { + it('maps dynamic course data from Firebase Firestore DB response to full course data model', () => { + // TODO + }); + }); + }); + + describe('utility functions', () => { + describe('extractEmailDomain()', () => { + it('extracts email domain', () => { + const userEmail = 'gpb@gatech.edu'; + const domain = extractEmailDomain(userEmail, DOMAIN_GATECH); + expect(domain).toEqual(DOMAIN_GATECH); + }); + + it('does not extract invalid domain', () => { + const userEmail = 'gpb@gatech.eduu'; + const domain = extractEmailDomain(userEmail, DOMAIN_GATECH); + expect(domain).not.toEqual(DOMAIN_GATECH); + }); + }); + + describe('isGTEmail()', () => { + it('detects email domain @gatech.edu', () => { + const userEmail = 'gpb@gatech.edu'; + const hasGTDomain = isGTEmail(userEmail); + expect(hasGTDomain).toBeTruthy(); + }); + + it('rejects non-@gatech.edu email domain', () => { + const userEmail = 'gpb@outlook.com'; + const hasGTDomain = isGTEmail(userEmail); + expect(hasGTDomain).toBeFalsy(); + }); + }); + + describe('isOutlookEmail()', () => { + it('detects email domain @outlook.com', () => { + const userEmail = 'gpb@outlook.com'; + const hasOutlookEmail = isOutlookEmail(userEmail); + expect(hasOutlookEmail).toBeTruthy(); + }); + + it('rejects non-@outlook.edu email domain', () => { + const userEmail = 'gpb@gatech.edu'; + const hasOutlookEmail = isOutlookEmail(userEmail); + expect(hasOutlookEmail).toBeFalsy(); + }); + }); + }); +}); diff --git a/globals/utilities.ts b/globals/utilities.ts index 034ce88c..af204ab8 100644 --- a/globals/utilities.ts +++ b/globals/utilities.ts @@ -55,12 +55,16 @@ export const getGrade = (gradeId: TGradeId) => grades[gradeId]; /* --- MAPPERS --- */ +/** + * Converts Firebase Firestore DB payload to full `Course` data model + * @param coursesDataDynamic Response payload from Firebase Firestore DB + * @returns Complete `Course` data model as `TPayloadCourses` + */ export const mapDynamicCoursesDataToCourses = ( coursesDataDynamic: TPayloadCoursesDataDynamic, ) => { const coursesDataStatic = getCoursesDataStatic(); - // @ts-ignore -- courses is populated in subsequent `forEach` - const courses: TPayloadCourses = {}; + const courses = {} as TPayloadCourses; // @ts-ignore -- `TCourseId` is guaranteed by `coursesDataStatic` Object.keys(coursesDataStatic).forEach((courseId: TCourseId) => { courses[courseId] = { @@ -73,14 +77,30 @@ export const mapDynamicCoursesDataToCourses = ( /* --- UTILITY FUNCTIONS --- */ +/** + * Extract email domain from email input + * @param userEmail Input user email + * @param domain Input email domain (including prefix `@`) + * @returns Email domain + * @example + * // `gatechDomain` has value `@gatech.edu` + * const gatechDomain = extractEmailDomain('gpb@gatech.edu', '@gatech.edu'); + */ +export const extractEmailDomain = (userEmail: string, domain: string) => + userEmail.toLowerCase().slice(userEmail.length - domain.length); + +/** + * Determine if `userEmail` has domain `@gatech.edu` + * @param userEmail Input user email + * @returns Boolean `true` (domain is `@gatech.edu`) or `false` (otherwise) + */ export const isGTEmail = (userEmail: string) => - userEmail - .toLowerCase() - .slice(userEmail.length - DOMAIN_GATECH.length) - .includes(DOMAIN_GATECH); + extractEmailDomain(userEmail, DOMAIN_GATECH).includes(DOMAIN_GATECH); +/** + * Determine if `userEmail` has domain `@outlook.com` + * @param userEmail Input user email + * @returns Boolean `true` (domain is `@outlook.com`) or `false` (otherwise) + */ export const isOutlookEmail = (userEmail: string) => - userEmail - .toLowerCase() - .slice(userEmail.length - DOMAIN_OUTLOOK.length) - .includes(DOMAIN_OUTLOOK); + extractEmailDomain(userEmail, DOMAIN_OUTLOOK).includes(DOMAIN_OUTLOOK); diff --git a/jest.config.js b/jest.config.js index ada1a6f8..549840cf 100755 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,13 @@ const customJestConfig = { moduleNameMapper: { // Handle module aliases (this will be automatically configured for you soon) '^@/pages/(.*)$': '/pages/$1', + '@backend/(.*)$': '/firebase/$1', + '@src/(.*)$': '/src/$1', + // Firebase modules mocks + 'firebase/app': '/firebase/__mocks__/fbApp.ts', + 'firebase/auth': '/firebase/__mocks__/fbAuth.ts', + 'firebase/firestore': '/firebase/__mocks__/fbFirestore.ts', + 'firebase/storage': '/firebase/__mocks__/fbStorage.ts', }, moduleDirectories: ['node_modules', '/'], testEnvironment: 'jest-environment-jsdom', diff --git a/src/utilities.ts b/src/utilities.ts index 3b59f765..0b7e3404 100755 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -100,6 +100,13 @@ type TSortKey = | 'userId'; type TSortDirection = 'ASC' | 'DESC'; +/** + * Convert payload object to array form. By default, sorting falls through to existing ordering. + * @param map The payload object (an object of objects) + * @param sortKey The ID field used for sort-ordering in array output + * @param sortDirection Ascending (default) or descending + * @returns array form of payload object + */ export const mapPayloadToArray = ( map: TKeyMap | undefined, sortKey?: TSortKey | string,