diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4d11e3f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Jest Tests + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: '18.x' + - name: Install Dependencies + run: npm install + - name: Run Jest tests + run: npm run test \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..fa04662 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + setupFilesAfterEnv: ['./tests/jest.setup.ts'] +}; \ No newline at end of file diff --git a/package.json b/package.json index aab2c03..a9efca3 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "Official Top.gg Node SDK", "main": "./dist/index.js", "scripts": { + "test": "jest --verbose", + "test:coverage": "jest --coverage", "build": "tsc", "build:ci": "npm i --include=dev && tsc", "docs": "typedoc", @@ -25,6 +27,7 @@ "devDependencies": { "@top-gg/eslint-config": "^0.0.3", "@types/express": "^4.17.17", + "@types/jest": "^29.5.1", "@types/node": "^18.15.5", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", @@ -33,8 +36,10 @@ "eslint-plugin-jest": "^27.2.1", "express": "^4.18.2", "husky": "^8.0.3", + "jest": "^29.5.0", "lint-staged": "^13.2.0", "prettier": "^2.8.6", + "ts-jest": "^29.1.0", "typedoc": "^0.23.28", "typescript": "^5.0.2" }, diff --git a/tests/Api.test.ts b/tests/Api.test.ts new file mode 100644 index 0000000..04000db --- /dev/null +++ b/tests/Api.test.ts @@ -0,0 +1,143 @@ +import { Api } from '../src/index'; +import ApiError from '../src/utils/ApiError'; +import { BOT, BOT_STATS, USER, VOTES } from './mocks/data'; + +const is_owner = new Api('owner_token'); +const not_owner = new Api('not_owner_token'); +const no_token = new Api(''); + +describe('API postStats test', () => { + it('postStats should return 401 when no token is provided', async() => { + await expect(no_token.postStats({serverCount: 1, shardCount: 0})).rejects.toThrowError(ApiError); + }) + it('postStats without server count should throw error', async () => { + await expect(is_owner.postStats({shardCount: 0})).rejects.toThrowError(Error); + }) + it('postStats should return 401 when no token is provided', async () => { + await expect(no_token.postStats({serverCount: 1, shardCount: 0})).rejects.toThrowError(ApiError); + }) + + /* + TODO: Check for this is not added in code and api returns 400 + it('postStats with invalid negative server count should throw error', () => { + expect(is_owner.postStats({serverCount: -1, shardCount: 0})).rejects.toThrowError(Error); + }) + + TODO: Check for negative shardId is not added + it('postStats with invalid negative shardId should throw error', () => { + expect(is_owner.postStats({serverCount: 1, shardCount: 0, shardId: -1})).rejects.toThrowError(Error); + }) + + TODO: ShardId cannot be greater or equal to shardCount + it('postStats with shardId greater than shardCount should throw error', () => { + expect(is_owner.postStats({serverCount: 1, shardCount: 0, shardId: 1})).rejects.toThrowError(Error); + }) + + TODO: Check for negative shardCount is not added + it('postStats with invalid negative shardCount should throw error', () => { + expect(is_owner.postStats({serverCount: 1, shardCount: -1})).rejects.toThrowError(Error); + }) + + TODO: Check if shardCount is greater than 10000 + it('postStats with invalid shardCount should throw error', () => { + expect(is_owner.postStats({serverCount: 1, shardCount: 10001})).rejects.toThrowError(Error); + }) + */ + + it('postStats should return 403 when token is not owner of bot', async () => { + await expect(not_owner.postStats({serverCount: 1, shardCount: 0})).rejects.toThrowError(ApiError); + }) + + it('postStats should return 200 when token is owner of bot', async () => { + await expect(is_owner.postStats({serverCount: 1, shardCount: 1})).resolves.toBeInstanceOf(Object); + }) +}) + +describe('API getStats test', () => { + it('getStats should return 401 when no token is provided', () => { + expect(no_token.getStats('1')).rejects.toThrowError(ApiError); + }) + + it('getStats should return 404 when bot is not found', async() => { + await expect(is_owner.getStats('0')).rejects.toThrowError(ApiError); + }) + + it('getStats should return 200 when bot is found', async () => { + expect(is_owner.getStats('1')).resolves.toStrictEqual({ + serverCount: BOT_STATS.server_count, + shardCount: BOT_STATS.shard_count, + shards: BOT_STATS.shards + }); + }) + + it('getStats should throw when no id is provided', () => { + expect(is_owner.getStats('')).rejects.toThrowError(Error); + }) +}) + + +describe('API getBot test', () => { + it('getBot should return 401 when no token is provided', () => { + expect(no_token.getBot('1')).rejects.toThrowError(ApiError); + }) + it('getBot should return 404 when bot is not found', () => { + expect(is_owner.getBot('0')).rejects.toThrowError(ApiError); + }) + + it('getBot should return 200 when bot is found', async () => { + expect(is_owner.getBot('1')).resolves.toStrictEqual(BOT); + }) + + it('getBot should throw when no id is provided', () => { + expect(is_owner.getBot('')).rejects.toThrowError(Error); + }) + +}) +describe('API getUser test', () => { + it('getUser should return 401 when no token is provided', () => { + expect(no_token.getUser('1')).rejects.toThrowError(ApiError); + }) + + it('getUser should return 404 when user is not found', () => { + expect(is_owner.getUser('0')).rejects.toThrowError(ApiError); + }) + + it('getUser should return 200 when user is found', async () => { + expect(is_owner.getUser('1')).resolves.toStrictEqual(USER); + }) + + it('getUser should throw when no id is provided', () => { + expect(is_owner.getUser('')).rejects.toThrowError(Error); + }) +}) + + +describe('API getVotes test', () => { + it('getVotes should return throw error when no token is provided', () => { + expect(no_token.getVotes()).rejects.toThrowError(Error); + }) + + it('getVotes should return 200 when token is provided', () => { + expect(is_owner.getVotes()).resolves.toEqual(VOTES); + }) +}); + +describe('API hasVoted test', () => { + it('hasVoted should throw error when no token is provided', () => { + expect(no_token.hasVoted('1')).rejects.toThrowError(Error); + }) + + it('hasVoted should return 200 when token is provided', () => { + expect(is_owner.hasVoted('1')).resolves.toBe(true); + }) + + it('hasVoted should throw error when no id is provided', () => { + expect(is_owner.hasVoted('')).rejects.toThrowError(Error); + }) +}) + +describe('API isWeekend tests', () => { + it('isWeekend should return true', async () => { + expect(is_owner.isWeekend()).resolves.toBe(true) + }) +}); \ No newline at end of file diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts new file mode 100644 index 0000000..7145c22 --- /dev/null +++ b/tests/jest.setup.ts @@ -0,0 +1,76 @@ +import { MockAgent, setGlobalDispatcher } from 'undici'; +import { MockInterceptor } from 'undici/types/mock-interceptor'; +import { endpoints } from './mocks/endpoints'; + +interface IOptions { + pattern: string; + requireAuth?: boolean; + validate?: (request: MockInterceptor.MockResponseCallbackOptions) => void; +} + +export const getIdInPath = (pattern: string, url: string) => { + const regex = new RegExp(`^${pattern.replace(/:[^/]+/g, '([^/]+)')}$`); + const match = url.match(regex); + + return match ? match[1] : null; +} + +export const isMatchingPath = (pattern: string, url: string) => { + // Remove query params + url = url.split("?")[0] + + if (pattern === url) { + return true; + } + + // Check if there is an exact match + if(endpoints.some(({ pattern }) => pattern === url)) { + return false + }; + + return getIdInPath(pattern, url) !== null; +} + +beforeEach(() => { + const mockAgent = new MockAgent() + mockAgent.disableNetConnect(); + const client = mockAgent.get('https://top.gg'); + + + const generateResponse = (request: MockInterceptor.MockResponseCallbackOptions, statusCode: number, data: any, headers = {}, options: IOptions) => { + const requestHeaders = request.headers as any; + + // Check if token is avaliable + if (options.requireAuth && (!requestHeaders['authorization'] || requestHeaders['authorization'] == '')) return { statusCode: 401 }; + + // Check that user is owner of bot + if (options.requireAuth && requestHeaders['authorization'] !== 'owner_token') return { statusCode: 403 } + + const error = options.validate?.(request); + if (error) return error; + + return { + statusCode, + data: JSON.stringify(data), + responseOptions: { + headers: { 'content-type': 'application/json', ...headers }, + } + } + } + + endpoints.forEach(({ pattern, method, data, requireAuth, validate }) => { + client.intercept({ + path: (path) => isMatchingPath(pattern, path), + method, + }).reply((request) => {return generateResponse(request, 200, data, {}, { pattern, requireAuth, validate: validate })}); + }) + + client.intercept({ + path: (path) => !endpoints.some(({ pattern }) => isMatchingPath(pattern, path)), + method: (_) => true, + }).reply((request) => { + throw Error(`No endpoint found for ${request.method} ${request.path}`) + }) + + setGlobalDispatcher(mockAgent); +}) \ No newline at end of file diff --git a/tests/mocks/data.ts b/tests/mocks/data.ts new file mode 100644 index 0000000..fc3bd6b --- /dev/null +++ b/tests/mocks/data.ts @@ -0,0 +1,82 @@ +// https://docs.top.gg/api/bot/#find-one-bot +export const BOT = { + "defAvatar": "6debd47ed13483642cf09e832ed0bc1b", + "invite": "", + "website": "https://discordbots.org", + "support": "KYZsaFb", + "github": "https://github.com/DiscordBotList/Luca", + "longdesc": "Luca only works in the **Discord Bot List** server. \r\nPrepend commands with the prefix `-` or `@Luca#1375`. \r\n**Please refrain from using these commands in non testing channels.**\r\n- `botinfo @bot` Shows bot info, title redirects to site listing.\r\n- `bots @user`* Shows all bots of that user, includes bots in the queue.\r\n- `owner / -owners @bot`* Shows all owners of that bot.\r\n- `prefix @bot`* Shows the prefix of that bot.\r\n* Mobile friendly version exists. Just add `noembed` to the end of the command.\r\n", + "shortdesc": "Luca is a bot for managing and informing members of the server", + "prefix": "- or @Luca#1375", + "lib": "discord.js", + "clientid": "264811613708746752", + "avatar": "7edcc4c6fbb0b23762455ca139f0e1c9", + "id": "264811613708746752", + "discriminator": "1375", + "username": "Luca", + "date": "2017-04-26T18:08:17.125Z", + "server_count": 2, + "guilds": ["417723229721853963", "264445053596991498"], + "shards": [], + "monthlyPoints": 19, + "points": 397, + "certifiedBot": false, + "owners": ["129908908096487424"], + "tags": ["Moderation", "Role Management", "Logging"], + "donatebotguildid": "" +} + +// https://docs.top.gg/api/bot/#search-bots +export const BOTS = { + limit: 0, + offset: 0, + count: 1, + total: 1, + results: [BOT], +} + +// https://docs.top.gg/api/bot/#last-1000-votes +export const VOTES = [ + { + "username": "Xetera", + "id": "140862798832861184", + "avatar": "a_1241439d430def25c100dd28add2d42f" + } +] + +// https://docs.top.gg/api/bot/#bot-stats +export const BOT_STATS = { + server_count: 0, + shards: ['200'], + shard_count: 1 +} + +// https://docs.top.gg/api/bot/#individual-user-vote +export const USER_VOTE = { + "voted": 1 +} + +// https://docs.top.gg/api/user/#structure +export const USER = { + "discriminator": "0001", + "avatar": "a_1241439d430def25c100dd28add2d42f", + "id": "140862798832861184", + "username": "Xetera", + "defAvatar": "322c936a8c8be1b803cd94861bdfa868", + "admin": true, + "webMod": true, + "mod": true, + "certifiedDev": false, + "supporter": false, + "social": {} +} + +export const USER_VOTE_CHECK = { + voted: 1 +} + +// Undocumented 😢 +export const WEEKEND = { + is_weekend: true +} + diff --git a/tests/mocks/endpoints.ts b/tests/mocks/endpoints.ts new file mode 100644 index 0000000..1919c0a --- /dev/null +++ b/tests/mocks/endpoints.ts @@ -0,0 +1,86 @@ +import { MockInterceptor } from 'undici/types/mock-interceptor'; +import { BOT, BOTS, BOT_STATS, USER, USER_VOTE, USER_VOTE_CHECK, VOTES, WEEKEND } from './data'; +import { getIdInPath } from '../jest.setup'; + +export const endpoints = [ + { + pattern: '/api/bots', + method: 'GET', + data: BOTS, + requireAuth: true + }, + { + pattern: '/api/bots/:bot_id', + method: 'GET', + data: BOT, + requireAuth: true, + validate: (request: MockInterceptor.MockResponseCallbackOptions) => { + const bot_id = getIdInPath('/api/bots/:bot_id', request.path); + if (Number(bot_id) === 0) return { statusCode: 404 }; + return null + }, + }, + { + pattern: '/api/bots/:bot_id/votes', + method: 'GET', + data: VOTES, + requireAuth: true + }, + { + pattern: '/api/bots/votes', + method: 'GET', + data: VOTES, + requireAuth: true + }, // Undocumented + { + pattern: '/api/bots/:bot_id/stats', + method: 'GET', + data: BOT_STATS, + requireAuth: true, + validate: (request: MockInterceptor.MockResponseCallbackOptions) => { + const bot_id = getIdInPath('/api/bots/:bot_id/stats', request.path); + if (Number(bot_id) === 0) return { statusCode: 404 }; + return null + }, + }, + { + pattern: '/api/bots/:bot_id/check', + method: 'GET', + data: USER_VOTE, + requireAuth: true + }, + { + pattern: '/api/bots/:bot_id/stats', + method: 'POST', + requireAuth: true + }, + { + pattern: '/api/bots/stats', + method: 'POST', + data: {}, + requireAuth: true + }, // Undocumented + { + pattern: '/api/users/:user_id', + method: 'GET', + data: USER, + requireAuth: true, + validate: (request: MockInterceptor.MockResponseCallbackOptions) => { + const bot_id = getIdInPath('/api/users/:user_id', request.path); + if (Number(bot_id) === 0) return { statusCode: 404 }; + return null + }, + }, + { + pattern: '/api/bots/check', + method: 'GET', + data: USER_VOTE_CHECK, + requireAuth: true + }, + { + pattern: '/api/weekend', + method: 'GET', + data: WEEKEND, + requireAuth: true + }, +]; \ No newline at end of file