diff --git a/src/handlers/scheduledEventHandler.ts b/src/handlers/scheduledEventHandler.ts index bbb28ac..5dad138 100644 --- a/src/handlers/scheduledEventHandler.ts +++ b/src/handlers/scheduledEventHandler.ts @@ -4,7 +4,8 @@ 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 { DiscordUserRole, env, NicknameUpdateResponseType, UserStatusResponse } from '../types/global.types'; +import { apiCaller } from '../utils/apiCaller'; import { chunks } from '../utils/arrayUtils'; import { generateJwt } from '../utils/generateJwt'; @@ -94,3 +95,49 @@ export const addMissedUpdatesRole = async (env: env) => { console.error('Error while adding missed updates roles'); } }; + +export const syncUsersStatus = async (env: env): Promise => { + await apiCaller(env, 'users/status/update', 'PATCH'); + + try { + const idleUsersData = (await apiCaller(env, 'users/status?aggregate=true', 'GET')) as UserStatusResponse | undefined; + + if (!idleUsersData?.data?.users || idleUsersData.data.users.length === 0) { + console.error('Error: Users data is not in the expected format or no users found'); + return null; + } + + const response = await apiCaller(env, 'users/status/batch', 'PATCH', { + body: JSON.stringify({ users: idleUsersData.data.users }), + }); + + return response; + } catch (error) { + console.error('Error during syncUsersStatus:', error); + return null; + } +}; + +export const syncExternalAccounts = async (env: env) => { + return await apiCaller(env, 'external-accounts/users?action=discord-users-sync', 'POST'); +}; + +export const syncUnverifiedUsers = async (env: env) => { + return await apiCaller(env, 'users', 'POST'); +}; + +export const syncIdleUsers = async (env: env) => { + return await apiCaller(env, 'discord-actions/group-idle', 'PUT'); +}; + +export const syncNickNames = async (env: env) => { + return await apiCaller(env, 'discord-actions/nicknames/sync?dev=true', 'POST'); +}; + +export const syncIdle7dUsers = async (env: env) => { + return await apiCaller(env, 'discord-actions/group-idle-7d', 'PUT'); +}; + +export const syncOnboarding31dPlusUsers = async (env: env) => { + return await apiCaller(env, 'discord-actions/group-onboarding-31d-plus', 'PUT'); +}; diff --git a/src/tests/handlers/scheduledEventHandler.test.ts b/src/tests/handlers/scheduledEventHandler.test.ts new file mode 100644 index 0000000..1136bda --- /dev/null +++ b/src/tests/handlers/scheduledEventHandler.test.ts @@ -0,0 +1,132 @@ +import { + syncExternalAccounts, + syncIdle7dUsers, + syncIdleUsers, + syncNickNames, + syncOnboarding31dPlusUsers, + syncUnverifiedUsers, + syncUsersStatus, +} from '../../handlers/scheduledEventHandler'; +import { env } from '../../types/global.types'; +import * as apiCallerModule from '../../utils/apiCaller'; + +jest.mock('../../utils/apiCaller', () => ({ + apiCaller: jest.fn(), +})); + +const consoleErrorMock: jest.SpyInstance = jest.spyOn(console, 'error').mockImplementation(); +const apiCallerFunction = apiCallerModule.apiCaller; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterAll(() => { + consoleErrorMock.mockRestore(); +}); + +describe('syncUsersStatus', () => { + const mockEnv: env = { + CURRENT_ENVIRONMENT: { + RDS_BASE_API_URL: 'default', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should successfully sync users status', async () => { + (apiCallerFunction as jest.Mock).mockResolvedValueOnce(undefined); + (apiCallerFunction as jest.Mock).mockResolvedValueOnce({ + data: { + users: [{ userId: 'asdoiuahow212' }], + }, + }); + (apiCallerFunction as jest.Mock).mockResolvedValueOnce({ success: true }); + + await syncUsersStatus(mockEnv); + + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status/update', 'PATCH'); + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status?aggregate=true', 'GET'); + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status/batch', 'PATCH', { + body: JSON.stringify({ users: [{ userId: 'asdoiuahow212' }] }), + }); + expect(apiCallerFunction).toHaveBeenCalledTimes(3); + }); + + it('should handle error during users data retrieval', async () => { + (apiCallerFunction as jest.Mock).mockResolvedValueOnce(undefined); + (apiCallerFunction as jest.Mock).mockRejectedValueOnce(new Error('Error fetching users data')); + + const result = await syncUsersStatus(mockEnv); + + expect(result).toBeNull(); + + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status/update', 'PATCH'); + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status?aggregate=true', 'GET'); + expect(apiCallerFunction).toHaveBeenCalledTimes(2); + + expect(console.error).toHaveBeenCalledWith('Error during syncUsersStatus:', new Error('Error fetching users data')); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('should log an error when no users are found or data is not in the expected format', async () => { + (apiCallerFunction as jest.Mock).mockResolvedValueOnce(undefined); + (apiCallerFunction as jest.Mock).mockResolvedValueOnce({ + data: { + users: [], + }, + }); + + const result = await syncUsersStatus(mockEnv); + + expect(result).toBeNull(); + + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status/update', 'PATCH'); + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, 'users/status?aggregate=true', 'GET'); + expect(apiCallerFunction).toHaveBeenCalledTimes(2); + + expect(console.error).toHaveBeenCalledWith('Error: Users data is not in the expected format or no users found'); + expect(console.error).toHaveBeenCalledTimes(1); + }); +}); + +describe('sync apis', () => { + const mockEnv: env = { + CURRENT_ENVIRONMENT: { + RDS_BASE_API_URL: 'staging', + }, + }; + + const testSyncFunction = async (syncFunction: Function, endpoint: string, method: string) => { + await syncFunction(mockEnv); + + expect(apiCallerFunction).toHaveBeenCalledWith(mockEnv, endpoint, method); + expect(apiCallerFunction).toHaveBeenCalledTimes(1); + }; + + it('should sync unverified users', async () => { + await testSyncFunction(syncUnverifiedUsers, 'users', 'POST'); + }); + + it('should sync idle users', async () => { + await testSyncFunction(syncIdleUsers, 'discord-actions/group-idle', 'PUT'); + }); + + it('should sync external accounts', async () => { + await testSyncFunction(syncExternalAccounts, 'external-accounts/users?action=discord-users-sync', 'POST'); + }); + + it('should sync nicknames', async () => { + await testSyncFunction(syncNickNames, 'discord-actions/nicknames/sync?dev=true', 'POST'); + }); + + it('should sync idle 7d users', async () => { + await testSyncFunction(syncIdle7dUsers, 'discord-actions/group-idle-7d', 'PUT'); + }); + + it('should sync onboarding 31d+ users', async () => { + await testSyncFunction(syncOnboarding31dPlusUsers, 'discord-actions/group-onboarding-31d-plus', 'PUT'); + }); +}); diff --git a/src/tests/services/rdsBackendService.test.ts b/src/tests/services/rdsBackendService.test.ts index 994f110..5005f32 100644 --- a/src/tests/services/rdsBackendService.test.ts +++ b/src/tests/services/rdsBackendService.test.ts @@ -14,7 +14,7 @@ describe('rdsBackendService', () => { }); it('should make a successful API call and return the expected data', async () => { - jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + jest.spyOn(globalThis as any, 'fetch').mockResolvedValueOnce({ ok: true, status: 200, json: jest.fn().mockResolvedValueOnce({ ...missedUpdatesUsersResponse, data: missedUpdatesUsersMock }), @@ -32,7 +32,7 @@ describe('rdsBackendService', () => { expect(result).toEqual({ ...missedUpdatesUsersMock }); }); it('should make a successful API call with cursor', async () => { - jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + jest.spyOn(globalThis as any, 'fetch').mockResolvedValueOnce({ ok: true, status: 200, json: jest.fn().mockResolvedValueOnce({ ...missedUpdatesUsersResponse, data: missedUpdatesUsersMock }), @@ -51,7 +51,7 @@ describe('rdsBackendService', () => { expect(result).toEqual({ ...missedUpdatesUsersMock }); }); it('should throw error when api call fails', async () => { - jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + jest.spyOn(globalThis as any, 'fetch').mockResolvedValueOnce({ ok: false, status: 400, } as unknown as Response); @@ -60,7 +60,7 @@ describe('rdsBackendService', () => { it('should handle unknown errors', async () => { const consoleSpy = jest.spyOn(console, 'error'); - jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Error occurred')); + jest.spyOn(globalThis as any, '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/tests/utils/apiCaller.test.ts b/src/tests/utils/apiCaller.test.ts new file mode 100644 index 0000000..7ccb1b6 --- /dev/null +++ b/src/tests/utils/apiCaller.test.ts @@ -0,0 +1,78 @@ +import { RDS_BASE_DEVELOPMENT_API_URL } from '../../constants/urls'; +import { env } from '../../types/global.types'; +import { apiCaller } from '../../utils/apiCaller'; +import { generateJwt } from '../../utils/generateJwt'; +import * as generateJwtModule from '../../utils/generateJwt'; + +jest.mock('../../utils/generateJwt', () => ({ + generateJwt: jest.fn().mockResolvedValue('mocked-token'), +})); + +describe('apiCaller', () => { + const mockEnv: env = { + CURRENT_ENVIRONMENT: { + RDS_BASE_API_URL: 'default', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (globalThis as any).fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ success: true }), + }), + ); + }); + + it('should make a successful API call', async () => { + const result = await apiCaller(mockEnv, 'users', 'GET'); + expect(generateJwt).toHaveBeenCalledWith(mockEnv); + + expect(result).toEqual({ success: true }); + expect((globalThis as any).fetch).toHaveBeenCalledWith(`${RDS_BASE_DEVELOPMENT_API_URL}/users`, { + method: 'GET', + headers: { + Authorization: 'Bearer mocked-token', + 'Content-Type': 'application/json', + }, + }); + }); + + it('should make a successful POST API call', async () => { + const result = await apiCaller(mockEnv, 'test', 'POST', { + body: JSON.stringify({ data: 'example' }), + }); + expect(generateJwt).toHaveBeenCalledWith(mockEnv); + + expect(result).toEqual({ success: true }); + expect((globalThis as any).fetch).toHaveBeenCalledWith(`${RDS_BASE_DEVELOPMENT_API_URL}/test`, { + method: 'POST', + headers: { + Authorization: 'Bearer mocked-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ data: 'example' }), + }); + }); + + it('should log and rethrow error during fetch call failure', async () => { + const mockError = new Error('Network error'); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + (globalThis as any).fetch = jest.fn().mockRejectedValue(mockError); + + await expect(apiCaller({}, 'someEndpoint', 'GET')).rejects.toThrowError(mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith(`Error during fetch operation: ${mockError}`); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + + consoleErrorSpy.mockRestore(); + }); + + it('should handle the case where generateJwt returns undefined and throw an error', async () => { + const generateJwtMock = jest.spyOn(generateJwtModule, 'generateJwt'); + generateJwtMock.mockImplementationOnce(() => Promise.reject(new Error('Generate JWT error'))); + + await expect(apiCaller(mockEnv, 'someEndpoint', 'GET')).rejects.toThrow('Generate JWT error'); + }); +}); diff --git a/src/types/global.types.ts b/src/types/global.types.ts index ac46191..98a30fa 100644 --- a/src/types/global.types.ts +++ b/src/types/global.types.ts @@ -39,3 +39,14 @@ export type DiscordRoleUpdatedList = { roleid: string; success: boolean; }; +export type UserStatusResponse = { + message: string; + data: { + totalUsers: number; + totalIdleUsers: number; + totalActiveUsers: number; + totalUnprocessedUsers: number; + unprocessedUsers: Array; + users: Array; + }; +}; diff --git a/src/utils/apiCaller.ts b/src/utils/apiCaller.ts new file mode 100644 index 0000000..79f6474 --- /dev/null +++ b/src/utils/apiCaller.ts @@ -0,0 +1,36 @@ +import config from '../config/config'; +import { env } from '../types/global.types'; +import { generateJwt } from './generateJwt'; + +export const apiCaller = async ( + env: env, + endpoint: string, + method: string, + options?: Record, +): Promise> => { + 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 defaultOptions = { + method, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; + + try { + const response = await fetch(`${url}/${endpoint}`, { ...defaultOptions, ...options }); + return await response.json(); + } catch (error) { + // TODO: Handle these errors: log to newRelic or any other better approach + console.error(`Error during fetch operation: ${error}`); + throw error; + } +}; diff --git a/src/worker.ts b/src/worker.ts index 60bd74d..38f5c0a 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,8 +1,19 @@ -import { addMissedUpdatesRole, callDiscordNicknameBatchUpdate } from './handlers/scheduledEventHandler'; +import { + addMissedUpdatesRole, + callDiscordNicknameBatchUpdate, + syncExternalAccounts, + syncIdle7dUsers, + syncIdleUsers, + syncOnboarding31dPlusUsers, + syncUnverifiedUsers, + syncUsersStatus, +} from './handlers/scheduledEventHandler'; import { env } from './types/global.types'; const EVERY_6_HOURS = '0 */6 * * *'; const EVERY_11_HOURS = '0 */11 * * *'; +const EVERY_20_MINUTES = '*/20 * * * *'; +const EVERY_30_MINUTES = '*/30 * * * *'; export default { // eslint-disable-next-line no-unused-vars @@ -14,8 +25,28 @@ export default { case EVERY_11_HOURS: { return await addMissedUpdatesRole(env); } - default: + + case EVERY_20_MINUTES: { + await syncIdleUsers(env); + // await syncNickNames(env); TODO: Enable it once changes from website-backend is merged + await syncIdle7dUsers(env); + await syncOnboarding31dPlusUsers(env); + console.log('Worker for syncing idle users, nicknames, idle 7d users, and onboarding 31d+ users has completed.'); + break; + } + + case EVERY_30_MINUTES: { + await syncUsersStatus(env); + await syncExternalAccounts(env); + await syncUnverifiedUsers(env); + console.log('Worker for syncing user status, external accounts, and unverified users has completed.'); + break; + } + + default: { console.error('Unknown Trigger Value!'); + break; + } } }, // 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 diff --git a/wrangler.toml b/wrangler.toml index 2162ea7..dc25df9 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -22,7 +22,7 @@ services = [ ] [triggers] -crons = ["0 */6 * * *","0 */11 * * *" ] +crons = ["0 */6 * * *","0 */11 * * *","*/20 * * * *","*/30 * * * *" ] # # Durable Object binding - For more information: https://developers.cloudflare.com/workers/runtime-apis/durable-objects # [[durable_objects]]