diff --git a/.github/workflows/on_push_and_pull_request.yml b/.github/workflows/on_push_and_pull_request.yml index f9860c7..fc6a337 100644 --- a/.github/workflows/on_push_and_pull_request.yml +++ b/.github/workflows/on_push_and_pull_request.yml @@ -13,4 +13,9 @@ jobs: - run: npm ci - run: npm test - run: npm run lint - - run: npm run checkStyle \ No newline at end of file + - run: npm run checkStyle + - run: npm run build + - run: | + if ! [ -f build/start.js ]; then + exit 1; + fi \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c134bc8..099809a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ WORKDIR /home/node/app COPY . . # Install dependencies -RUN npm ci +RUN npm ci --omit=dev RUN npm run build diff --git a/package-lock.json b/package-lock.json index 1e28885..f86ed50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "envalid": "^8.0.0", "express": "^4.18.2", "fp-ts": "^2.16.7", - "mcv-discord-bot": "file:", "node-cache": "^5.1.2", "zod": "^3.23.8" }, @@ -34,6 +33,7 @@ "eslint-plugin-unused-imports": "^3.2.0", "globals": "^15.8.0", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", "nodemon": "^3.1.4", "prettier": "^3.3.2", "prisma": "^5.16.1", @@ -371,6 +371,36 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", + "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-meta": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", @@ -482,6 +512,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-top-level-await": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", @@ -2252,23 +2297,26 @@ } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", "dev": true, "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" @@ -3155,9 +3203,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", - "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.7.tgz", + "integrity": "sha512-6FTNWIWMxMy/ZY6799nBlPtF1DFDQ6VQJ7yyDP27SJNt5lwtQ5ufqVvHylb3fdQefvRcgA3fKcFMJi9OLwBRNw==", "dev": true }, "node_modules/emittery": { @@ -5429,6 +5477,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -5896,10 +5957,6 @@ "tmpl": "1.0.5" } }, - "node_modules/mcv-discord-bot": { - "resolved": "", - "link": true - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7438,6 +7495,20 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-essentials": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.2.tgz", + "integrity": "sha512-Xwag0TULqriaugXqVdDiGZ5wuZpqABZlpwQ2Ho4GDyiu/R2Xjkp/9+zcFxL7uzeLl/QCPrflnvpVYyS3ouT7Zw==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-jest": { "version": "29.2.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.4.tgz", diff --git a/package.json b/package.json index a848036..dee4810 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "envalid": "^8.0.0", "express": "^4.18.2", "fp-ts": "^2.16.7", - "mcv-discord-bot": "file:", "node-cache": "^5.1.2", "zod": "^3.23.8" }, @@ -25,6 +24,7 @@ "eslint-plugin-unused-imports": "^3.2.0", "globals": "^15.8.0", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", "nodemon": "^3.1.4", "prettier": "^3.3.2", "prisma": "^5.16.1", diff --git a/src/database/cache.ts b/src/database/cache.ts index 6fd159a..5044c1f 100644 --- a/src/database/cache.ts +++ b/src/database/cache.ts @@ -4,10 +4,8 @@ const cacheOption = { stdTTL: 10000, //seconds deleteOnExpire: true, } -// const assignmentsRawCache = new NodeCache(); -// const coursesRawCache = new NodeCache(); -class wrapperCache { +class WrapperCache { _rawCache = new NodeCache(cacheOption) set(key: string, value: T) { this._rawCache.set(key, value) @@ -17,6 +15,5 @@ class wrapperCache { } } -export const assignmentsCache = new wrapperCache() -export const coursesCache = new wrapperCache() -// export ={assignmentsCache, coursesCache}; +export const assignmentsCache = new WrapperCache() +export const coursesCache = new WrapperCache() diff --git a/src/database/database.ts b/src/database/database.ts index b30e216..011ac4b 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -1,28 +1,25 @@ import { - PrismaClient, Course as PrismaCourse, Assignment as PrismaAssignment, NotificationChannel as PrismaNotificationChannel, } from '@prisma/client' import { assignmentsCache, coursesCache } from './cache' import { targetSemester, targetYear } from '../config/config' - -const prisma = new PrismaClient() +import prisma from './prisma' // eslint-disable-next-line @typescript-eslint/no-namespace namespace db { export async function courseExists(course: Course): Promise { - if (coursesCache.get(course.mcvID.toString()) !== undefined) { + if (coursesCache.get(indexCourse(course)) !== undefined) { return true } - // console.log("accessing database.. course") const found = await prisma.course.findFirst({ where: { mcvID: course.mcvID, }, }) if (found) { - coursesCache.set(course.mcvID.toString(), course) + coursesCache.set(indexCourse(course), course) } return found != null } @@ -30,24 +27,17 @@ namespace db { export async function assignmentExists( assignment: Assignment ): Promise { - if ( - assignmentsCache.get( - assignment.mcvCourseID + assignment.assignmentName - ) !== undefined - ) { + if (assignmentsCache.get(indexAssignment(assignment)) !== undefined) { return true } - // console.log("accessing database.. assignment") const found = await prisma.assignment.findFirst({ where: { - assignmentName: assignment.assignmentName, + mcvCourseID: assignment.mcvCourseID, + assignmentID: assignment.assignmentID, }, }) if (found) { - assignmentsCache.set( - assignment.mcvCourseID + assignment.assignmentName, - found - ) + assignmentsCache.set(indexAssignment(assignment), found) } return found != null } @@ -99,11 +89,12 @@ namespace db { } export async function saveCourse(obj: Course) { + // prisma.course.upsert const course = await prisma.course.create({ data: obj, }) if (course != undefined) { - coursesCache.set(obj.mcvID.toString(), course) + coursesCache.set(indexCourse(course), course) } } @@ -112,10 +103,7 @@ namespace db { data: obj, }) if (assignment != undefined) { - assignmentsCache.set( - assignment.mcvCourseID + assignment.assignmentName, - assignment - ) + assignmentsCache.set(indexAssignment(assignment), assignment) } } @@ -141,106 +129,10 @@ export type CourseWithAssignments = Course & { assignments: Array } export default db -// export async function courseExists(course: Course): Promise { -// if(coursesCache.get(course.mcvID.toString())!==undefined){ -// return true; -// } -// // console.log("accessing database.. course") -// let found = await prisma.course.findFirst({ -// where: { -// mcvID: course.mcvID -// } -// }); -// if(found){ -// coursesCache.set(course.mcvID.toString(),course); -// } -// return found!=null; -// } - -// export async function assignmentExists(assignment: Assignment): Promise{ -// if(assignmentsCache.get(assignment.mcvCourseID+assignment.assignmentName)!==undefined){ -// return true; -// } -// // console.log("accessing database.. assignment") -// let found = await prisma.assignment.findFirst({ -// where: { -// assignmentName: assignment.assignmentName -// } -// }); -// if(found){ -// assignmentsCache.set(assignment.mcvCourseID+assignment.assignmentName,found); -// } -// return found!=null; -// } - -// export async function channelOfGuildExists(NotificationChannel: NotificationChannel): Promise{ -// let found = await prisma.notificationChannel.findFirst({ -// where: { -// guildID: NotificationChannel.guildID -// } -// }); -// return found!=null; -// } - -// export async function getAllChannels(): Promise { -// return await prisma.notificationChannel.findMany(); -// } - -// export async function getAllCourses(): Promise{ -// return await prisma.course.findMany(); -// } - -// export async function getCourse(mcvID: number): Promise{ -// let cacheFound = coursesCache.get(mcvID.toString()) -// if(cacheFound!==undefined){ -// return cacheFound; -// } -// return await prisma.course.findFirst({ -// where:{ -// mcvID:mcvID -// } -// }) -// } - -// export async function getChannelOfGuild(guildID: string): Promise{ -// return await prisma.notificationChannel.findFirst({ -// where: { -// guildID -// } -// }); -// } - -// export async function saveCourse(obj:Course){ -// const course = await prisma.course.create({ -// data:obj -// }) -// if(course!=undefined){ -// coursesCache.set(obj.mcvID.toString(),course); -// } -// } - -// export async function saveAssignment(obj:Assignment){ -// const assignment = await prisma.assignment.create({ -// data:obj -// }) -// if(assignment!=undefined){ -// assignmentsCache.set(assignment.mcvCourseID+assignment.assignmentName,assignment); -// } -// } - -// export async function saveChannel(obj:NotificationChannel){ -// await prisma.notificationChannel.create({ -// data:obj -// }) -// } - -// export async function unsetChannelOfGuild(guildID: string){ -// return await prisma.notificationChannel.delete({ -// where:{ -// guildID -// } -// }) -// } +function indexAssignment(assignment: Assignment): string { + return assignment.mcvCourseID + '/' + assignment.assignmentID +} -// export type NotificationChannel = NotificationChannel; -// export {Course,Assignment,NotificationChannel} +function indexCourse(course: Course): string { + return course.mcvID.toString() +} diff --git a/src/database/prisma.ts b/src/database/prisma.ts new file mode 100644 index 0000000..cad25fe --- /dev/null +++ b/src/database/prisma.ts @@ -0,0 +1,4 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() +export default prisma diff --git a/tests/unit/database.unit.test.ts b/tests/unit/database.unit.test.ts new file mode 100644 index 0000000..ad6dc72 --- /dev/null +++ b/tests/unit/database.unit.test.ts @@ -0,0 +1,159 @@ +jest.mock('@/env/env') + +// jest.mock("@prisma/client",()=>({ +// __esModule: true, +// default: mockDeep(), +// })) +import { mockDeep } from 'jest-mock-extended' +import { PrismaClient } from '@prisma/client' + +let mockPrismaClient = mockDeep() +jest.mock('@/database/prisma', () => ({ + __esModule: true, + default: mockPrismaClient, +})) + +import db, { Assignment, Course } from '@/database/database' +import { assignmentsCache, coursesCache } from '@/database/cache' + +describe('test assignment', () => { + let assignmentCacheSetSpy = jest.spyOn(assignmentsCache, 'set') + let assignmentCacheGetSpy = jest.spyOn(assignmentsCache, 'get') + let returnAssignment: any = {} + const assignment456 = { + mcvCourseID: 123, + assignmentID: 456, + assignmentName: 'งานที่ 1', + } + + beforeAll(() => { + mockPrismaClient.assignment.create.mockImplementation( + () => returnAssignment + ) + }) + + beforeEach(() => { + jest.clearAllMocks() + assignmentsCache._rawCache.flushAll() + }) + + it('cache works', async () => { + returnAssignment = assignment456 + await db.saveAssignment(assignment456) + + expect(mockPrismaClient.assignment.create).toHaveBeenCalledTimes(1) + expect(assignmentCacheSetSpy).toHaveBeenCalledTimes(1) + expect(assignmentCacheGetSpy).toHaveBeenCalledTimes(0) + + jest.clearAllMocks() + + let result = await db.assignmentExists(assignment456) + + expect(result).toBe(true) + expect(mockPrismaClient.assignment.findFirst).toHaveBeenCalledTimes(0) + expect(assignmentCacheGetSpy).toHaveBeenCalledTimes(1) + expect(assignmentCacheSetSpy).toHaveBeenCalledTimes(0) + }) + + it('assignment exists after assignment name is changed', async () => { + returnAssignment = assignment456 + await db.saveAssignment(assignment456) + + expect(mockPrismaClient.assignment.create).toHaveBeenCalledTimes(1) + expect(assignmentCacheSetSpy).toHaveBeenCalledTimes(1) + expect(assignmentCacheGetSpy).toHaveBeenCalledTimes(0) + + jest.clearAllMocks() + + let updatedAssignment456: Assignment = { + ...assignment456, + assignmentName: '123', + } + let result = await db.assignmentExists(updatedAssignment456) + + expect(result).toBe(true) + expect(mockPrismaClient.assignment.findFirst).toHaveBeenCalledTimes(0) + expect(assignmentCacheGetSpy).toHaveBeenCalledTimes(1) + expect(assignmentCacheSetSpy).toHaveBeenCalledTimes(0) + }) + + it("assignment doesn't exists", async () => { + let result = await db.assignmentExists(assignment456) + + expect(result).toBe(false) + expect(mockPrismaClient.assignment.findFirst).toHaveBeenCalledTimes(1) + expect(assignmentCacheGetSpy).toHaveBeenCalledTimes(1) + expect(assignmentCacheSetSpy).toHaveBeenCalledTimes(0) + }) +}) + +describe('test course', () => { + let courseCacheSetSpy = jest.spyOn(coursesCache, 'set') + let courseCacheGetSpy = jest.spyOn(coursesCache, 'get') + let returnedCourse: any = {} + const course123: Course = { + mcvID: 123, + courseID: '210112', + title: 'how to tickroll', + year: 2023, + semester: 2, + } + + beforeAll(() => { + mockPrismaClient.course.create.mockImplementation(() => returnedCourse) + }) + + beforeEach(() => { + jest.clearAllMocks() + coursesCache._rawCache.flushAll() + }) + + it('cache works', async () => { + returnedCourse = course123 + await db.saveCourse(course123) + + expect(mockPrismaClient.course.create).toHaveBeenCalledTimes(1) + expect(courseCacheSetSpy).toHaveBeenCalledTimes(1) + expect(courseCacheGetSpy).toHaveBeenCalledTimes(0) + + jest.clearAllMocks() + + let result = await db.courseExists(course123) + + expect(result).toBe(true) + expect(mockPrismaClient.assignment.findFirst).toHaveBeenCalledTimes(0) + expect(courseCacheGetSpy).toHaveBeenCalledTimes(1) + expect(courseCacheSetSpy).toHaveBeenCalledTimes(0) + }) + + it('course exists', async () => { + returnedCourse = course123 + await db.saveCourse(course123) + + expect(mockPrismaClient.course.create).toHaveBeenCalledTimes(1) + expect(courseCacheSetSpy).toHaveBeenCalledTimes(1) + expect(courseCacheGetSpy).toHaveBeenCalledTimes(0) + + jest.clearAllMocks() + + let updatedCourse123: Course = { + ...course123, + courseID: '123', + } + let result = await db.courseExists(updatedCourse123) + + expect(result).toBe(true) + expect(mockPrismaClient.course.findFirst).toHaveBeenCalledTimes(0) + expect(courseCacheGetSpy).toHaveBeenCalledTimes(1) + expect(courseCacheSetSpy).toHaveBeenCalledTimes(0) + }) + + it("course doesn't exists", async () => { + let result = await db.courseExists(course123) + + expect(result).toBe(false) + expect(mockPrismaClient.course.findFirst).toHaveBeenCalledTimes(1) + expect(courseCacheGetSpy).toHaveBeenCalledTimes(1) + expect(courseCacheSetSpy).toHaveBeenCalledTimes(0) + }) +}) diff --git a/tests/unit/updateAll.unit.test.ts b/tests/unit/updateAll.unit.test.ts index 0c13cfb..afacbdb 100644 --- a/tests/unit/updateAll.unit.test.ts +++ b/tests/unit/updateAll.unit.test.ts @@ -24,7 +24,6 @@ import { Assignment } from '@/database/database' import { updateAll } from '@/scraper/updateAll' import { Course } from '@prisma/client' import { none, some } from 'fp-ts/lib/Option' -import assert from 'assert' describe('updateAll', () => { let coursesFromUpdate: Course[] = [] let assignmentsOfCourseFromUpdate: Record = {} @@ -229,9 +228,6 @@ describe('updateAll', () => { `\n - [${'ข'.repeat(969)}](https://www.mycourseville.com/?q=courseville/worksheet/123/789)` + `\n - [${'ค'.repeat(849)}](https://www.mycourseville.com/?q=courseville/worksheet/123/150)`, ] - for (const expectedStr of expected) { - assert.equal([...expectedStr].length <= 2000, true) - } assertAndExpect(result, expected, updateAllSpy) }) @@ -269,18 +265,18 @@ describe('updateAll', () => { '\n- How to Make Mcv bot 101' + `\n - [${'ค'.repeat(850)}](https://www.mycourseville.com/?q=courseville/worksheet/540/150)`, ] - for (const expectedStr of expected) { - assert.equal([...expectedStr].length <= 2000, true) - } assertAndExpect(result, expected, updateAllSpy) }) }) function assertAndExpect( result: unknown, - expected: unknown, + expected: string[], updateAllSpy: jest.Mock ) { - assert.deepEqual(result, expected) + for (const expectedStr of expected) { + expect([...expectedStr].length).toBeLessThanOrEqual(2000) + } + expect(result).toEqual(expected) expect(updateAllSpy).toHaveBeenCalledTimes(1) } diff --git a/tests/unit/updateCourses.unit.test.ts b/tests/unit/updateCourses.unit.test.ts index f9ee9a9..9a916d8 100644 --- a/tests/unit/updateCourses.unit.test.ts +++ b/tests/unit/updateCourses.unit.test.ts @@ -14,9 +14,9 @@ jest.mock('@/server', () => { __esModule: true, ...actualModule, adminDM: { - send: jest.fn().mockImplementation(() => {}), + send: jest.fn(), }, - start: jest.fn().mockImplementation(() => {}), + start: jest.fn(), } })