diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fec9c6a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ +Date: `` + +Developer Name: `` + +---- + +## Issue Ticket Number:- +`` + +## Description: +`` + + +Is Under Feature Flag +- [ ] Yes +- [ ] No + +Database changes +- [ ] Yes +- [ ] No + +Breaking changes (If your feature is breaking/missing something please mention pending tickets) +- [ ] Yes +- [ ] No + +Is Development Tested? + +- [ ] Yes +- [ ] No + + +### Add relevant Screenshot below ( e.g test coverage etc. ) + diff --git a/.github/workflows/production.yaml b/.github/workflows/production.yaml index 0bcec2f..eb66c69 100644 --- a/.github/workflows/production.yaml +++ b/.github/workflows/production.yaml @@ -7,16 +7,23 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v2 - - uses: cloudflare/wrangler-action@2.0.0 + - uses: actions/checkout@v3 + - run: npm install + - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{secrets.CLOUDFLARE_API_TOKEN}} accountId: ${{secrets.CLOUDFLARE_ACCOUNT_ID}} + environment: ${{ vars.CURRENT_ENVIRONMENT }} + command: deploy --env ${{ vars.CURRENT_ENVIRONMENT }} secrets: | - CRON_JOB_PRIVATE_KEY CURRENT_ENVIRONMENT + CRON_JOB_PRIVATE_KEY + DISCORD_BOT_PRIVATE_KEY + DISCORD_BOT_API_URL env: - CURRENT_ENVIRONMENT: production - CRON_JOB_PRIVATE_KEY: ${{secrets.CRON_JOB_PRIVATE_KEY}} CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}} CLOUDFLARE_ACCOUNT_ID: ${{secrets.CLOUDFLARE_ACCOUNT_ID}} + CURRENT_ENVIRONMENT: ${{vars.CURRENT_ENVIRONMENT}} + CRON_JOB_PRIVATE_KEY: ${{secrets.CRON_JOB_PRIVATE_KEY}} + DISCORD_BOT_PRIVATE_KEY: ${{secrets.DISCORD_BOT_PRIVATE_KEY}} + DISCORD_BOT_API_URL: ${{secrets.DISCORD_BOT_API_URL}} diff --git a/.github/workflows/staging.yaml b/.github/workflows/staging.yaml index 8f5956a..d187439 100644 --- a/.github/workflows/staging.yaml +++ b/.github/workflows/staging.yaml @@ -7,16 +7,23 @@ jobs: runs-on: ubuntu-latest environment: staging steps: - - uses: actions/checkout@v2 - - uses: cloudflare/wrangler-action@2.0.0 + - uses: actions/checkout@v3 + - run: npm install + - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{secrets.CLOUDFLARE_API_TOKEN}} accountId: ${{secrets.CLOUDFLARE_ACCOUNT_ID}} + environment: ${{ vars.CURRENT_ENVIRONMENT }} + command: deploy --env ${{ vars.CURRENT_ENVIRONMENT }} secrets: | - CRON_JOB_PRIVATE_KEY CURRENT_ENVIRONMENT + CRON_JOB_PRIVATE_KEY + DISCORD_BOT_PRIVATE_KEY + DISCORD_BOT_API_URL env: - CURRENT_ENVIRONMENT: staging - CRON_JOB_PRIVATE_KEY: ${{secrets.CRON_JOB_PRIVATE_KEY}} CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}} CLOUDFLARE_ACCOUNT_ID: ${{secrets.CLOUDFLARE_ACCOUNT_ID}} + CURRENT_ENVIRONMENT: ${{vars.CURRENT_ENVIRONMENT}} + CRON_JOB_PRIVATE_KEY: ${{secrets.CRON_JOB_PRIVATE_KEY}} + DISCORD_BOT_PRIVATE_KEY: ${{secrets.DISCORD_BOT_PRIVATE_KEY}} + DISCORD_BOT_API_URL: ${{secrets.DISCORD_BOT_API_URL}} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2737e68..26938c0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,4 +9,5 @@ jobs: - uses: actions/checkout@v2 - run: npm install - run: npm run lint-check - - run: npm run format-check \ No newline at end of file + - run: npm run format-check + - run: npm run test \ No newline at end of file diff --git a/__mocks__/@tsndr/cloudflare-worker-jwt.ts b/__mocks__/@tsndr/cloudflare-worker-jwt.ts new file mode 100644 index 0000000..d90cca5 --- /dev/null +++ b/__mocks__/@tsndr/cloudflare-worker-jwt.ts @@ -0,0 +1,7 @@ +const mockJwt = { + sign: jest.fn().mockImplementation(() => { + return "SIGNED_JWT"; + }), +}; + +export default mockJwt; diff --git a/package-lock.json b/package-lock.json index 72b1dfb..08f22f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "ts-jest": "29.1.1", "ts-node": "10.9.1", "typescript": "5.1.6", - "wrangler": "3.0.0" + "wrangler": "3.1.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -6251,9 +6251,9 @@ } }, "node_modules/wrangler": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.0.0.tgz", - "integrity": "sha512-azppXJjEQeaVg3wxFZJOGDGtpR8e9clHr2aHuanGHOk9vX3gvesCv97BF/n8qh1Y1d7vDKOBdfQW3UOYZNFGNw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.1.1.tgz", + "integrity": "sha512-iG6QGOt+qgSm7UroJ8IJ+JdXEcDcW7yp9ilP0V7alCGhKm8shqa/M1iyMOpukZSCSZo8Vmn5nH2C9OY1PR3dQQ==", "dev": true, "dependencies": { "@cloudflare/kv-asset-handler": "^0.2.0", @@ -6262,7 +6262,7 @@ "blake3-wasm": "^2.1.5", "chokidar": "^3.5.3", "esbuild": "0.16.3", - "miniflare": "^3.0.0", + "miniflare": "^3.0.1", "nanoid": "^3.3.3", "path-to-regexp": "^6.2.0", "selfsigned": "^2.0.1", diff --git a/package.json b/package.json index 492e1a6..452b70b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "ts-jest": "29.1.1", "ts-node": "10.9.1", "typescript": "5.1.6", - "wrangler": "3.0.0" + "wrangler": "3.1.1" }, "dependencies": { "@tsndr/cloudflare-worker-jwt": "2.2.1" diff --git a/src/config/config.ts b/src/config/config.ts index 48446f4..963c707 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,15 +1,26 @@ -import { env } from '../types/global.types'; +import { MISSED_UPDATES_DEVELOPMENT_ROLE_ID, MISSED_UPDATES_PROD_ROLE_ID, MISSED_UPDATES_STAGING_ROLE_ID } from '../constants/commons'; +import { RDS_BASE_API_URL, RDS_BASE_DEVELOPMENT_API_URL, RDS_BASE_STAGING_API_URL } from '../constants/urls'; +import { env, environment } from '../types/global.types'; -export const handleConfig = (env: env) => { - let baseUrl: string; - if (env.CURRENT_ENVIRONMENT) { - if (env.CURRENT_ENVIRONMENT.toLowerCase() === 'production') { - baseUrl = 'https://api.realdevsquad.com'; - } else { - baseUrl = 'https://staging-api.realdevsquad.com'; - } - } else { - baseUrl = 'https://staging-api.realdevsquad.com'; - } - return { baseUrl }; +const config = (env: env) => { + const environment: environment = { + production: { + RDS_BASE_API_URL: RDS_BASE_API_URL, + DISCORD_BOT_API_URL: env.DISCORD_BOT_API_URL, + MISSED_UPDATES_ROLE_ID: MISSED_UPDATES_PROD_ROLE_ID, + }, + staging: { + RDS_BASE_API_URL: RDS_BASE_STAGING_API_URL, + DISCORD_BOT_API_URL: env.DISCORD_BOT_API_URL, + MISSED_UPDATES_ROLE_ID: MISSED_UPDATES_STAGING_ROLE_ID, + }, + default: { + RDS_BASE_API_URL: RDS_BASE_DEVELOPMENT_API_URL, + DISCORD_BOT_API_URL: env.DISCORD_BOT_API_URL, + MISSED_UPDATES_ROLE_ID: MISSED_UPDATES_DEVELOPMENT_ROLE_ID, + }, + }; + + return environment[env.CURRENT_ENVIRONMENT] || environment.default; }; +export default config; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..9ab5017 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +const NAMESPACE_NAME = 'CronJobsTimestamp'; + +export { NAMESPACE_NAME }; diff --git a/src/constants/commons.ts b/src/constants/commons.ts new file mode 100644 index 0000000..5814c54 --- /dev/null +++ b/src/constants/commons.ts @@ -0,0 +1,3 @@ +export const MISSED_UPDATES_PROD_ROLE_ID = '1183553844811153458'; +export const MISSED_UPDATES_STAGING_ROLE_ID = '1184201657404362772'; +export const MISSED_UPDATES_DEVELOPMENT_ROLE_ID = '1181214205081296896'; diff --git a/src/constants/urls.ts b/src/constants/urls.ts new file mode 100644 index 0000000..013eeff --- /dev/null +++ b/src/constants/urls.ts @@ -0,0 +1,7 @@ +export const RDS_BASE_API_URL = 'https://api.realdevsquad.com'; +export const RDS_BASE_STAGING_API_URL = 'https://staging-api.realdevsquad.com'; +export const RDS_BASE_DEVELOPMENT_API_URL = 'http://localhost:3000'; // If needed, modify the URL to your local API server run through ngrok + +export const DISCORD_BOT_API_URL = 'env'; +export const DISCORD_BOT_STAGING_API_URL = ''; +export const DISCORD_BOT_DEVELOPMENT_API_URL = ''; diff --git a/src/handlers/scheduledEventHandler.ts b/src/handlers/scheduledEventHandler.ts index 290112e..256cd26 100644 --- a/src/handlers/scheduledEventHandler.ts +++ b/src/handlers/scheduledEventHandler.ts @@ -1,8 +1,96 @@ -import { handleConfig } from '../config/config'; -import { env } from '../types/global.types'; +import { KVNamespace } from '@cloudflare/workers-types'; + +import config from '../config/config'; +import { NAMESPACE_NAME } from '../constants'; +import { updateUserRoles } from '../services/discordBotServices'; +import { getMissedUpdatesUsers } from '../services/rdsBackendService'; +import { DiscordUserRole, env, NicknameUpdateResponseType } from '../types/global.types'; +import { chunks } from '../utils/arrayUtils'; +import { generateJwt } from '../utils/generateJwt'; export async function ping(env: env) { - const url = handleConfig(env); - const response = await fetch(`${url.baseUrl}/healthcheck`); + const url = config(env).RDS_BASE_API_URL; + const response = await fetch(`${url}/healthcheck`); return response; } + +export async function callDiscordNicknameBatchUpdate(env: env) { + const namespace = env[NAMESPACE_NAME] as unknown as KVNamespace; + let lastNicknameUpdate: string | null = '0'; + try { + lastNicknameUpdate = await namespace.get('DISCORD_NICKNAME_UPDATED_TIME'); + if (lastNicknameUpdate === null) { + throw new Error('Error while fetching KV "DISCORD_NICKNAME_UPDATED_TIME" timestamp'); + } + if (!lastNicknameUpdate) { + lastNicknameUpdate = '0'; + } + } catch (err) { + console.error(err, 'Error while fetching the timestamp for last nickname update'); + throw err; + } + + const url = config(env).RDS_BASE_API_URL; + let token; + try { + token = await generateJwt(env); + } catch (err) { + console.error(`Error while generating JWT token: ${err}`); + throw err; + } + const response = await fetch(`${url}/discord-actions/nickname/status`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + lastNicknameUpdate, + }), + }); + if (!response.ok) { + throw new Error("Error while trying to update users' discord nickname"); + } + + const data: NicknameUpdateResponseType = await response.json(); + if (data?.data.unsuccessfulNicknameUpdates === 0) { + try { + await namespace.put('DISCORD_NICKNAME_UPDATED_TIME', Date.now().toString()); + } catch (err) { + console.error('Error while trying to update the last nickname change timestamp'); + } + } + return data; +} + +export const addMissedUpdatesRole = async (env: env) => { + const MAX_ROLE_UPDATE = 25; + try { + let cursor: string | undefined = undefined; + for (let index = MAX_ROLE_UPDATE; index > 0; index--) { + if (index < MAX_ROLE_UPDATE && !cursor) break; + + const missedUpdatesUsers = await getMissedUpdatesUsers(env, cursor); + + if (!!missedUpdatesUsers && missedUpdatesUsers.usersToAddRole?.length > 1) { + const discordUserIdRoleIdList: DiscordUserRole[] = missedUpdatesUsers.usersToAddRole.map((userId) => ({ + userid: userId, + roleid: config(env).MISSED_UPDATES_ROLE_ID, + })); + + const discordUserRoleChunks = chunks(discordUserIdRoleIdList, MAX_ROLE_UPDATE); + for (const discordUserRoleList of discordUserRoleChunks) { + try { + await updateUserRoles(env, discordUserRoleList); + } catch (error) { + console.error('Error occurred while updating discord users', error); + } + } + } + cursor = missedUpdatesUsers?.cursor; + } + // add logs for the results https://github.com/Real-Dev-Squad/website-backend/issues/1784 + } catch (err) { + console.error('Error while adding missed updates roles'); + } +}; diff --git a/src/services/discordBotServices.ts b/src/services/discordBotServices.ts new file mode 100644 index 0000000..5550262 --- /dev/null +++ b/src/services/discordBotServices.ts @@ -0,0 +1,27 @@ +import config from '../config/config'; +import { DiscordRoleUpdatedList, DiscordUserRole, env } from '../types/global.types'; +import { generateDiscordBotJwt } from '../utils/generateJwt'; + +export const updateUserRoles = async (env: env, payload: DiscordUserRole[]): Promise => { + try { + const url = config(env).DISCORD_BOT_API_URL; + const token = await generateDiscordBotJwt(env); + + const response = await env.DISCORD_BOT.fetch(`${url}/roles?action=add-role`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw Error(`Role Update failed with status: ${response.status}`); + } + const data: DiscordRoleUpdatedList = await response.json(); + return data; + } catch (error) { + console.error('Error while updating discord user roles'); + throw error; + } +}; diff --git a/src/services/rdsBackendService.ts b/src/services/rdsBackendService.ts new file mode 100644 index 0000000..64e1545 --- /dev/null +++ b/src/services/rdsBackendService.ts @@ -0,0 +1,32 @@ +import config from '../config/config'; +import { DiscordUsersResponse, env } from '../types/global.types'; +import { generateJwt } from '../utils/generateJwt'; + +export const getMissedUpdatesUsers = async (env: env, cursor: string | undefined) => { + try { + const baseUrl = config(env).RDS_BASE_API_URL; + + const url = new URL(`${baseUrl}/tasks/users/discord`); + url.searchParams.append('q', 'status:missed-updates'); + if (cursor) { + url.searchParams.append('cursor', cursor); + } + const token = await generateJwt(env); + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) { + throw new Error(`Fetch call to get user discord details failed with status: ${response.status}`); + } + + const responseData: DiscordUsersResponse = await response.json(); + return responseData?.data; + } catch (error) { + console.error('Error occurred while fetching discord user details'); + throw error; + } +}; diff --git a/src/tests/arrayUtils.test.ts b/src/tests/arrayUtils.test.ts new file mode 100644 index 0000000..ccf5515 --- /dev/null +++ b/src/tests/arrayUtils.test.ts @@ -0,0 +1,35 @@ +import { chunks } from '../utils/arrayUtils'; + +describe('chunks function', () => { + it('should return an empty array if size is less than 1', () => { + expect(chunks([1, 2, 3], 0)).toEqual([]); + }); + + it('should return an empty array if the input array is empty', () => { + expect(chunks([], 2)).toEqual([]); + }); + + it('should split the array into chunks of the specified size', () => { + const inputArray = [1, 2, 3, 4, 5, 6]; + const size = 2; + const expectedResult = [ + [1, 2], + [3, 4], + [5, 6], + ]; + expect(chunks(inputArray, size)).toEqual(expectedResult); + }); + + it('should split the array into chunks of size 1 if size is not specified', () => { + const inputArray = [1, 2, 3, 4, 5, 6]; + const expectedResult = [[1], [2], [3], [4], [5], [6]]; + expect(chunks(inputArray)).toEqual(expectedResult); + }); + + it('should not modify the original array', () => { + const inputArray = [1, 2, 3, 4, 5, 6]; + const size = 2; + chunks(inputArray, size); + expect(inputArray).toEqual(inputArray); + }); +}); diff --git a/src/tests/fixtures/missedRoleHandler.ts b/src/tests/fixtures/missedRoleHandler.ts new file mode 100644 index 0000000..aba2b53 --- /dev/null +++ b/src/tests/fixtures/missedRoleHandler.ts @@ -0,0 +1,38 @@ +export const missedUpdatesUsersResponse = { + message: 'Discord details of users with status missed updates fetched successfully', + data: {}, +}; +export const missedUpdatesUsersMock = { + usersToAddRole: ['user1', 'user2'], + tasks: 10, + missedUpdatesTasks: 5, + cursor: 'some-cursor', +}; +export const missedUpdatesUsersMockWithNoUsers = { + usersToAddRole: [], + tasks: 10, + missedUpdatesTasks: 5, +}; +export const missedUpdatesUsersMockWithoutCursor = { + usersToAddRole: ['user1', 'user2'], + tasks: 10, + missedUpdatesTasks: 5, +}; + +export const updateRolesResponseMock = { + userid: 'user1', + roleid: '1', + success: true, +}; +export const discordUserRoleMock = [ + { userid: 'user1', roleid: '1' }, + { userid: 'user2', roleid: '2' }, +]; + +export const discordRoleUpdateResult = [ + { + userid: '1', + roleid: '2', + success: true, + }, +]; diff --git a/src/tests/generateJwt.test.ts b/src/tests/generateJwt.test.ts index 57f2c31..4f412c1 100644 --- a/src/tests/generateJwt.test.ts +++ b/src/tests/generateJwt.test.ts @@ -1,9 +1,9 @@ import jwt from '@tsndr/cloudflare-worker-jwt'; -import { generateJwt } from '../utils/generateJwt'; +import { generateDiscordBotJwt, generateJwt } from '../utils/generateJwt'; import { privateKey } from './config/keys'; -describe('Mock test', () => { +describe('Generate Jwt', () => { let signSpy: jest.SpyInstance>; beforeEach(() => { signSpy = jest.spyOn(jwt, 'sign'); @@ -11,26 +11,53 @@ describe('Mock test', () => { afterEach(() => { signSpy.mockReset(); }); - test('Generate JWT function works', async () => { - signSpy.mockResolvedValue('Hello'); - const authToken = await generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }); - expect(authToken).not.toBeUndefined(); - }); - test('Should call sign method', async () => { - signSpy.mockResolvedValue('Hello'); - await generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }); - expect(signSpy).toBeCalledTimes(1); - }); - test('Should return promise without await', async () => { - signSpy.mockResolvedValue('Hello'); - const authToken = generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }); - expect(authToken).toBeInstanceOf(Promise); + describe('For Rds Backend', () => { + test('Generate JWT function works', async () => { + signSpy.mockResolvedValue('Hello'); + const authToken = await generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }); + expect(authToken).not.toBeUndefined(); + }); + test('Should call sign method', async () => { + signSpy.mockResolvedValue('Hello'); + await generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }); + expect(signSpy).toBeCalledTimes(1); + }); + test('Should return promise without await', async () => { + signSpy.mockResolvedValue('Hello'); + const authToken = generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }); + expect(authToken).toBeInstanceOf(Promise); + }); + test('Throws an error if generation fails', async () => { + signSpy.mockRejectedValue('Error'); + await generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }).catch((err) => { + expect(err).toBeInstanceOf(Error); + expect(err.message).toEqual('Error in generating the auth token'); + }); + }); }); - test('Throws an error if generation fails', async () => { - signSpy.mockRejectedValue('Error'); - await generateJwt({ CRON_JOB_PRIVATE_KEY: privateKey }).catch((err) => { - expect(err).toBeInstanceOf(Error); - expect(err.message).toEqual('Error in generating the auth token'); + + describe('For Discord Bot', () => { + test('Generate JWT function works', async () => { + signSpy.mockResolvedValue('Hello'); + const authToken = await generateDiscordBotJwt({ DISCORD_BOT_PRIVATE_KEY: privateKey }); + expect(authToken).not.toBeUndefined(); + }); + test('Should call sign method', async () => { + signSpy.mockResolvedValue('Hello'); + await generateDiscordBotJwt({ DISCORD_BOT_PRIVATE_KEY: privateKey }); + expect(signSpy).toBeCalledTimes(1); + }); + test('Should return promise without await', async () => { + signSpy.mockResolvedValue('Hello'); + const authToken = generateDiscordBotJwt({ DISCORD_BOT_PRIVATE_KEY: privateKey }); + expect(authToken).toBeInstanceOf(Promise); + }); + test('Throws an error if generation fails', async () => { + signSpy.mockRejectedValue('Error'); + await generateDiscordBotJwt({ DISCORD_BOT_PRIVATE_KEY: privateKey }).catch((err) => { + expect(err).toBeInstanceOf(Error); + expect(err.message).toEqual('Error in generating the auth token'); + }); }); }); }); diff --git a/src/tests/handlers/missedRoleHandler.test.ts b/src/tests/handlers/missedRoleHandler.test.ts new file mode 100644 index 0000000..db8f233 --- /dev/null +++ b/src/tests/handlers/missedRoleHandler.test.ts @@ -0,0 +1,67 @@ +import { addMissedUpdatesRole } from '../../handlers/scheduledEventHandler'; +import { updateUserRoles } from '../../services/discordBotServices'; +import { getMissedUpdatesUsers } from '../../services/rdsBackendService'; +import { + missedUpdatesUsersMock, + missedUpdatesUsersMockWithNoUsers, + missedUpdatesUsersMockWithoutCursor, +} from '../fixtures/missedRoleHandler'; + +jest.mock('.../../../../services/rdsBackendService', () => ({ + getMissedUpdatesUsers: jest.fn(), +})); +jest.mock('.../../../../services/discordBotServices', () => ({ + updateUserRoles: jest.fn(), +})); +describe('addMissedUpdatesRole', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call getMissedUpdatesUsers and updateUserRoles when there are users to add role', async () => { + (getMissedUpdatesUsers as jest.Mock) + .mockResolvedValueOnce(missedUpdatesUsersMock) + .mockResolvedValueOnce(missedUpdatesUsersMockWithoutCursor); + await addMissedUpdatesRole({}); + expect(getMissedUpdatesUsers).toHaveBeenCalledTimes(2); + expect(updateUserRoles).toHaveBeenCalledTimes(2); + }); + + it('should not call updateUserRoles when there are no users to add role', async () => { + (getMissedUpdatesUsers as jest.Mock).mockResolvedValueOnce(missedUpdatesUsersMockWithNoUsers); + + await addMissedUpdatesRole({}); + expect(getMissedUpdatesUsers).toHaveBeenCalledTimes(1); + expect(updateUserRoles).toHaveBeenCalledTimes(0); + }); + + it('should create chunks of userId and update roles multiple times when count is greater than 25', async () => { + const mockValue: any = { ...missedUpdatesUsersMockWithoutCursor, usersToAddRole: new Array(75).fill('id') }; + (getMissedUpdatesUsers as jest.Mock).mockResolvedValueOnce(mockValue); + + await addMissedUpdatesRole({}); + expect(getMissedUpdatesUsers).toHaveBeenCalledTimes(1); + expect(updateUserRoles).toHaveBeenCalledTimes(3); + }); + + it('should handle errors', async () => { + (getMissedUpdatesUsers as jest.Mock).mockRejectedValueOnce(new Error('Error fetching missed updates users')); + const consoleSpy = jest.spyOn(console, 'error'); + await addMissedUpdatesRole({}); + expect(consoleSpy).toHaveBeenCalledWith('Error while adding missed updates roles'); + }); + + it('should continue updating user roles even when a call fails', async () => { + (updateUserRoles as jest.Mock).mockRejectedValueOnce(new Error('Error occurred')); + const consoleSpy = jest.spyOn(console, 'error'); + const mockValue: any = { ...missedUpdatesUsersMockWithoutCursor, usersToAddRole: new Array(75).fill('id') }; + (getMissedUpdatesUsers as jest.Mock).mockResolvedValueOnce(mockValue); + await addMissedUpdatesRole({}); + expect(getMissedUpdatesUsers).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(updateUserRoles).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/tests/services/discordBotService.test.ts b/src/tests/services/discordBotService.test.ts new file mode 100644 index 0000000..27251b7 --- /dev/null +++ b/src/tests/services/discordBotService.test.ts @@ -0,0 +1,49 @@ +import config from '../../config/config'; +import { updateUserRoles } from '../../services/discordBotServices'; +import { discordRoleUpdateResult, discordUserRoleMock } from '../fixtures/missedRoleHandler'; + +jest.mock('../../utils/generateJwt', () => ({ + generateDiscordBotJwt: jest.fn().mockResolvedValueOnce('mocked-jwt-token'), +})); +describe('discordBotService', () => { + describe('updateUserRoles', () => { + let fetchSpy: jest.Mock; + beforeEach(() => { + fetchSpy = jest.fn(); + jest.clearAllMocks(); + }); + + it('should make a successful API call and return the expected data', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValueOnce(discordRoleUpdateResult), + } as unknown as Response); + const result = await updateUserRoles({ DISCORD_BOT: { fetch: fetchSpy } }, discordUserRoleMock); + + expect(fetchSpy).toHaveBeenCalledWith(`${config({}).DISCORD_BOT_API_URL}/roles?action=add-role`, { + method: 'POST', + headers: { + Authorization: 'Bearer mocked-jwt-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(discordUserRoleMock), + }); + expect(result).toEqual([...discordRoleUpdateResult]); + }); + + it('should throw error when api call fails', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 400, + } as unknown as Response); + await expect(updateUserRoles({ DISCORD_BOT: { fetch: fetchSpy } }, [])).rejects.toThrow('Role Update failed with status: 400'); + }); + it('should handle unknown errors', async () => { + const consoleSpy = jest.spyOn(console, 'error'); + fetchSpy.mockRejectedValueOnce(new Error('Error occurred')); + await expect(updateUserRoles({ DISCORD_BOT: { fetch: fetchSpy } }, [])).rejects.toThrow('Error occurred'); + expect(consoleSpy).toHaveBeenCalledWith('Error while updating discord user roles'); + }); + }); +}); diff --git a/src/tests/services/rdsBackendService.test.ts b/src/tests/services/rdsBackendService.test.ts new file mode 100644 index 0000000..2daa3cf --- /dev/null +++ b/src/tests/services/rdsBackendService.test.ts @@ -0,0 +1,68 @@ +import config from '../../config/config'; +import { getMissedUpdatesUsers } from '../../services/rdsBackendService'; +import { missedUpdatesUsersMock, missedUpdatesUsersResponse } from '../fixtures/missedRoleHandler'; + +jest.mock('../../utils/generateJwt', () => ({ + generateJwt: jest.fn().mockResolvedValue('mocked-jwt-token'), +})); + +describe('rdsBackendService', () => { + describe('updateUserRoles', () => { + let cursor: undefined | string; + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should make a successful API call and return the expected data', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValueOnce({ ...missedUpdatesUsersResponse, data: missedUpdatesUsersMock }), + } as unknown as Response); + const result = await getMissedUpdatesUsers({}, cursor); + const url = new URL(`${config({}).RDS_BASE_API_URL}/tasks/users/discord`); + url.searchParams.append('q', 'status:missed-updates'); + expect(fetch).toHaveBeenCalledWith(url, { + method: 'GET', + headers: { + Authorization: 'Bearer mocked-jwt-token', + 'Content-Type': 'application/json', + }, + }); + expect(result).toEqual({ ...missedUpdatesUsersMock }); + }); + it('should make a successful API call with cursor', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValueOnce({ ...missedUpdatesUsersResponse, data: missedUpdatesUsersMock }), + } as unknown as Response); + const result = await getMissedUpdatesUsers({}, 'cursorValue'); + const url = new URL(`${config({}).RDS_BASE_API_URL}/tasks/users/discord`); + url.searchParams.append('q', 'status:missed-updates'); + url.searchParams.append('cursor', 'cursorValue'); + expect(fetch).toHaveBeenCalledWith(url, { + method: 'GET', + headers: { + Authorization: 'Bearer mocked-jwt-token', + 'Content-Type': 'application/json', + }, + }); + expect(result).toEqual({ ...missedUpdatesUsersMock }); + }); + it('should throw error when api call fails', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 400, + } as unknown as Response); + await expect(getMissedUpdatesUsers({}, cursor)).rejects.toThrow('Fetch call to get user discord details failed with status: 400'); + }); + + it('should handle unknown errors', async () => { + const consoleSpy = jest.spyOn(console, 'error'); + jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Error occurred')); + await expect(getMissedUpdatesUsers({}, cursor)).rejects.toThrow('Error occurred'); + expect(consoleSpy).toHaveBeenCalledWith('Error occurred while fetching discord user details'); + }); + }); +}); diff --git a/src/types/global.types.ts b/src/types/global.types.ts index 89578cd..ac46191 100644 --- a/src/types/global.types.ts +++ b/src/types/global.types.ts @@ -1,3 +1,41 @@ export type env = { - [key: string]: string; + [key: string]: any; +}; + +export type environment = { + [key: string]: variables; +}; +export type variables = { + RDS_BASE_API_URL: string; + DISCORD_BOT_API_URL: string; + MISSED_UPDATES_ROLE_ID: string; +}; + +export type NicknameUpdateResponseType = { + message: string; + data: { + totalUsersStatus: number; + successfulNicknameUpdates: number; + unsuccessfulNicknameUpdates: number; + }; +}; +export type DiscordUsersResponse = { + message: string; + data: DiscordUserIdList; +}; +export type DiscordUserIdList = { + usersToAddRole: string[]; + tasks: number; + missedUpdatesTasks: number; + cursor: string; +}; + +export interface DiscordUserRole { + userid: string; + roleid: string; +} +export type DiscordRoleUpdatedList = { + userid: string; + roleid: string; + success: boolean; }; diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts new file mode 100644 index 0000000..4df4943 --- /dev/null +++ b/src/utils/arrayUtils.ts @@ -0,0 +1,19 @@ +/** + * Creates an array of elements split into groups the length of size. If array can't be split evenly, the final chunk will be the remaining elements. + * description credit: https://lodash.com/docs/4.17.15#chunk + * source code inspiration https://youmightnotneed.com/lodash#chunk + * @param {array}: array to be splitted into groups + * @param {size}: size of array groups + * @return {array}: array of arrays of elements split into groups the length of size. + */ +export const chunks = (array: any[], size: number = 1): any[][] => { + if (!Array.isArray(array) || size < 1) { + return []; + } + const temp = [...array]; + const result = []; + while (temp.length) { + result.push(temp.splice(0, size)); + } + return result; +}; diff --git a/src/utils/generateJwt.ts b/src/utils/generateJwt.ts index 3581335..dd1c203 100644 --- a/src/utils/generateJwt.ts +++ b/src/utils/generateJwt.ts @@ -7,7 +7,7 @@ export const generateJwt = async (env: env) => { const authToken = await jwt.sign( { name: 'Cron Job Handler', - exp: Math.floor(Date.now() / 1000) + 2, + exp: Math.floor(Date.now() / 1000) + 60, }, env.CRON_JOB_PRIVATE_KEY, { algorithm: 'RS256' }, @@ -18,3 +18,18 @@ export const generateJwt = async (env: env) => { throw new Error('Error in generating the auth token'); } }; + +export const generateDiscordBotJwt = async (env: env) => { + try { + const authToken = await jwt.sign( + { + exp: Math.floor(Date.now() / 1000) + 60, + }, + env.DISCORD_BOT_PRIVATE_KEY, + { algorithm: 'RS256' }, + ); + return authToken; + } catch (err) { + throw new Error('Error in generating the auth token'); + } +}; diff --git a/src/worker.ts b/src/worker.ts index e90e029..60bd74d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,12 +1,22 @@ -import { ping } from './handlers/scheduledEventHandler'; +import { addMissedUpdatesRole, callDiscordNicknameBatchUpdate } from './handlers/scheduledEventHandler'; import { env } from './types/global.types'; +const EVERY_6_HOURS = '0 */6 * * *'; +const EVERY_11_HOURS = '0 */11 * * *'; + export default { - // We need to keep all 3 parameters in this format even if they are not used as as cloudflare workers need them to be present So we are disabling eslint rule of no-unused-vars - // for more details read here: https://community.cloudflare.com/t/waituntil-is-not-a-function-when-using-workers-with-modules/375781/4 // eslint-disable-next-line no-unused-vars - async scheduled(req: Request, env: env, ctx: ExecutionContext) { - ctx.waitUntil(ping(env)); + async scheduled(req: ScheduledController, env: env, ctx: ExecutionContext) { + switch (req.cron) { + case EVERY_6_HOURS: { + return await callDiscordNicknameBatchUpdate(env); + } + case EVERY_11_HOURS: { + return await addMissedUpdatesRole(env); + } + default: + console.error('Unknown Trigger Value!'); + } }, // We need to keep all 3 parameters in this format even if they are not used as as cloudflare workers need them to be present So we are disabling eslint rule of no-unused-vars // for more details read here: https://community.cloudflare.com/t/waituntil-is-not-a-function-when-using-workers-with-modules/375781/4 diff --git a/wrangler.toml b/wrangler.toml index c6455e0..2162ea7 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,13 +1,28 @@ name = "cron-jobs" main = "src/worker.ts" compatibility_date = "2023-07-17" -[triggers] -crons = ["0 */4 * * *"] -# # KV Namespace binding - For more information: https://developers.cloudflare.com/workers/runtime-apis/kv -# [[kv_namespaces]] -# binding = "MY_KV_NAMESPACE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +kv_namespaces = [ + { binding = "CronJobsTimestamp", id = "1234" } +] +[env.staging] +kv_namespaces = [ + { binding = "CronJobsTimestamp", id = "6fbc191da931473393d5fbe612251d29" } +] +services = [ + { binding = "DISCORD_BOT", service = "discord-slash-commands" } +] + +[env.production] +kv_namespaces = [ + { binding = "CronJobsTimestamp", id = "3a10f726c95d4afea9dee5fd00f029b9" } +] +services = [ + { binding = "DISCORD_BOT", service = "discord-slash-commands" } +] + +[triggers] +crons = ["0 */6 * * *","0 */11 * * *" ] # # Durable Object binding - For more information: https://developers.cloudflare.com/workers/runtime-apis/durable-objects # [[durable_objects]]