From c59179aeb2e9001c520ef3f9d18a409cd2e3a83f Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:09:48 +0200 Subject: [PATCH 1/5] feat: fix minor tsc compile errors w/ new production tests --- bun.lock | 8 ++ package.json | 102 +++++++++--------- server/src/controllers/api/index.ts | 55 ++++------ server/src/controllers/d/index.ts | 5 +- server/src/controllers/osu/index.ts | 5 +- .../core/domains/osu.ppy.sh/bancho.client.ts | 2 +- .../managers/calculator/calculator.manager.ts | 4 +- server/src/database/models/requests.ts | 2 +- server/tests/compitability.production.test.ts | 40 +++++++ server/tests/data/mirror.tests.json | 5 + server/tests/stats.endpoint.test.ts | 2 +- server/tests/utils/mocker.ts | 34 +++--- 12 files changed, 155 insertions(+), 109 deletions(-) diff --git a/bun.lock b/bun.lock index 9e94ad7..f09d151 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "rosu-pp-js": "^3.1.0", }, "devDependencies": { + "@types/bun": "^1.3.4", "@types/pg": "^8.11.10", "@types/qs": "^6.9.18", "bun-types": "latest", @@ -35,6 +36,9 @@ "prettier": "^3.3.3", "tsx": "^4.19.2", }, + "peerDependencies": { + "typescript": "^5", + }, }, }, "packages": { @@ -132,6 +136,8 @@ "@types/adm-zip": ["@types/adm-zip@0.5.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw=="], + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], @@ -406,6 +412,8 @@ "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@types/bun/bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "tsx/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], diff --git a/package.json b/package.json index 0362e60..23be0ee 100644 --- a/package.json +++ b/package.json @@ -1,50 +1,56 @@ { - "name": "observatory", - "version": "1.0.0", - "description": "Observatory API", - "scripts": { - "start": "NODE_ENV=production bun run server/src/app.ts", - "dev": "NODE_ENV=development bun run --watch server/src/app.ts", - "lint": "bunx prettier -w ./server/src --config .prettierrc.json", - "test": "docker-compose -f docker-compose.tests.yml up -d && bun test --env-file=.env.test", - "setup": "bun run docker:dev && bun run db:update", - "db:update": "bun run db:generate && bun run db:push", - "db:generate": "bun --bun drizzle-kit generate --config server/src/database/config.ts", - "db:push": "bun --bun drizzle-kit push --config server/src/database/config.ts", - "db:migration": "bun run server/src/database/migrate.ts", - "docker:dev": "docker-compose -f docker-compose.dev.yml up -d" - }, - "dependencies": { - "@bogeychan/elysia-logger": "^0.1.4", - "@elysiajs/cors": "^1.3.0", - "@elysiajs/server-timing": "^1.3.0", - "@elysiajs/swagger": "^1.3.0", - "@faker-js/faker": "^10.1.0", - "@types/adm-zip": "^0.5.6", - "adm-zip": "^0.5.16", - "axios": "^1.7.7", - "dotenv": "^16.4.5", - "drizzle-orm": "^0.35.3", - "elysia": "~1.3.1", - "elysia-autoload": "^1.6.0", - "elysia-ip": "^1.0.7", - "elysia-rate-limit": "^4.1.0", - "elysia-requestid": "1.0.9", - "ioredis": "^5.4.1", - "pg": "^8.13.1", - "pino-loki": "^2.3.1", - "pino-pretty": "^11.3.0", - "postgres": "^3.4.5", - "qs": "^6.14.0", - "rosu-pp-js": "^3.1.0" - }, - "devDependencies": { - "@types/pg": "^8.11.10", - "@types/qs": "^6.9.18", - "bun-types": "latest", - "drizzle-kit": "^0.26.2", - "prettier": "^3.3.3", - "tsx": "^4.19.2" - }, - "module": "server/src/app.js" + "name": "observatory", + "version": "1.0.0", + "description": "Observatory API", + "scripts": { + "start": "NODE_ENV=production bun run server/src/app.ts", + "dev": "NODE_ENV=development bun run --watch server/src/app.ts", + "lint": "bunx prettier -w ./server/src --config .prettierrc.json", + "test": "docker-compose -f docker-compose.tests.yml up -d && bun test --env-file=.env.test", + "setup": "bun run docker:dev && bun run db:update", + "db:update": "bun run db:generate && bun run db:push", + "db:generate": "bun --bun drizzle-kit generate --config server/src/database/config.ts", + "db:push": "bun --bun drizzle-kit push --config server/src/database/config.ts", + "db:migration": "bun run server/src/database/migrate.ts", + "docker:dev": "docker-compose -f docker-compose.dev.yml up -d" + }, + "dependencies": { + "@bogeychan/elysia-logger": "^0.1.4", + "@elysiajs/cors": "^1.3.0", + "@elysiajs/server-timing": "^1.3.0", + "@elysiajs/swagger": "^1.3.0", + "@faker-js/faker": "^10.1.0", + "@types/adm-zip": "^0.5.6", + "adm-zip": "^0.5.16", + "axios": "^1.7.7", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.35.3", + "elysia": "~1.3.1", + "elysia-autoload": "^1.6.0", + "elysia-ip": "^1.0.7", + "elysia-rate-limit": "^4.1.0", + "elysia-requestid": "1.0.9", + "ioredis": "^5.4.1", + "pg": "^8.13.1", + "pino-loki": "^2.3.1", + "pino-pretty": "^11.3.0", + "postgres": "^3.4.5", + "qs": "^6.14.0", + "rosu-pp-js": "^3.1.0" + }, + "devDependencies": { + "@types/bun": "^1.3.4", + "@types/pg": "^8.11.10", + "@types/qs": "^6.9.18", + "bun-types": "latest", + "drizzle-kit": "^0.26.2", + "prettier": "^3.3.3", + "tsx": "^4.19.2" + }, + "module": "server/src/app.js", + "type": "module", + "private": true, + "peerDependencies": { + "typescript": "^5" + } } diff --git a/server/src/controllers/api/index.ts b/server/src/controllers/api/index.ts index 5af7532..2ea529a 100644 --- a/server/src/controllers/api/index.ts +++ b/server/src/controllers/api/index.ts @@ -20,24 +20,21 @@ export default (app: App) => { set.headers['X-Data-Source'] = beatmap.source; } - beatmap.source = undefined; + const { source: _, ...responseBeatmap } = beatmap; - if (!beatmap.data) return beatmap; - - if (!full) return beatmap.data; + if (!full) return responseBeatmap?.data ?? responseBeatmap; const beatmapset = await BeatmapsManagerInstance.getBeatmapSet({ - beatmapSetId: beatmap.data?.beatmapset_id, + beatmapSetId: responseBeatmap.data?.beatmapset_id, }); if (beatmapset.source) { set.headers['X-Data-Source'] = beatmapset.source; } - beatmapset.source = undefined; + const { source: __, ...responseBeatmapset } = beatmapset; - if (beatmapset.data) - return beatmapset.data ? beatmapset.data : beatmapset; + return responseBeatmapset?.data ?? responseBeatmapset; }, { params: t.Object({ @@ -65,17 +62,19 @@ export default (app: App) => { set.headers['X-Data-Source'] = beatmap.source; } - beatmap.source = undefined; - - if (!beatmap.data) return beatmap; - - if (!full) return beatmap.data; + const { source: _, ...responseBeatmap } = beatmap; + if (!full) return responseBeatmap?.data ?? responseBeatmap; const beatmapset = await BeatmapsManagerInstance.getBeatmapSet({ beatmapSetId: beatmap.data?.beatmapset_id, }); - return beatmapset.data ? beatmapset.data : beatmapset; + if (beatmapset.source) { + set.headers['X-Data-Source'] = beatmapset.source; + } + + const { source: __, ...responseBeatmapset } = beatmapset; + return responseBeatmapset?.data ?? responseBeatmapset; }, { params: t.Object({ @@ -98,11 +97,8 @@ export default (app: App) => { set.headers['X-Data-Source'] = data.source; } - data.source = undefined; - - if (!data.data) return data; - - return data.data; + const { source: _, ...response } = data; + return response?.data ?? response; }, { params: t.Object({ @@ -124,11 +120,8 @@ export default (app: App) => { set.headers['X-Data-Source'] = data.source; } - data.source = undefined; - - if (!data.data) return data; - - return data.data; + const { source: _, ...response } = data; + return response?.data ?? response; }, { query: t.Object({ @@ -152,11 +145,8 @@ export default (app: App) => { set.headers['X-Data-Source'] = data.source; } - data.source = undefined; - - if (!data.data) return data; - - return data.data; + const { source: _, ...response } = data; + return response?.data ?? response; }, { query: t.Object({ @@ -177,11 +167,8 @@ export default (app: App) => { set.headers['X-Data-Source'] = data.source; } - data.source = undefined; - - if (!data.data) return data; - - return data.data; + const { source: _, ...response } = data; + return response?.data ?? response; }, { query: t.Object({ diff --git a/server/src/controllers/d/index.ts b/server/src/controllers/d/index.ts index 715f747..7165c9b 100644 --- a/server/src/controllers/d/index.ts +++ b/server/src/controllers/d/index.ts @@ -14,9 +14,8 @@ export default (app: App) => { set.headers['X-Data-Source'] = res.source; } - res.source = undefined; - - return res?.data ?? res; + const { source, ...response } = res; + return response?.data ?? response; }), { params: t.Object({ diff --git a/server/src/controllers/osu/index.ts b/server/src/controllers/osu/index.ts index a62ac74..a199686 100644 --- a/server/src/controllers/osu/index.ts +++ b/server/src/controllers/osu/index.ts @@ -13,9 +13,8 @@ export default (app: App) => { set.headers['X-Data-Source'] = res.source; } - res.source = undefined; - - return res?.data ?? res; + const { source: _, ...response } = res; + return response?.data ?? response; }), { params: t.Object({ diff --git a/server/src/core/domains/osu.ppy.sh/bancho.client.ts b/server/src/core/domains/osu.ppy.sh/bancho.client.ts index e53eda1..0c98c2a 100644 --- a/server/src/core/domains/osu.ppy.sh/bancho.client.ts +++ b/server/src/core/domains/osu.ppy.sh/bancho.client.ts @@ -141,7 +141,7 @@ export class BanchoClient extends BaseClient { if (!result || result.status !== 200 || !result.data) { return { result: null, status: result?.status ?? 500 }; - } else if (result.data.length === 0) { + } else if (result.data.byteLength === 0) { return { result: null, status: 404 }; } diff --git a/server/src/core/managers/calculator/calculator.manager.ts b/server/src/core/managers/calculator/calculator.manager.ts index 8ec786d..527a9dc 100644 --- a/server/src/core/managers/calculator/calculator.manager.ts +++ b/server/src/core/managers/calculator/calculator.manager.ts @@ -19,7 +19,7 @@ export class CalculatorManager { scores: ScoreShort[], ) { const beatmap = await this.GetBeatmapHash(beatmapId); - if (beatmap instanceof Beatmap === false) { + if (!(beatmap instanceof Beatmap)) { return beatmap; } @@ -39,7 +39,7 @@ export class CalculatorManager { beatmapHash?: string, ) { const beatmap = await this.GetBeatmapHash(beatmapId, beatmapHash); - if (beatmap instanceof Beatmap === false) { + if (!(beatmap instanceof Beatmap)) { return beatmap; } diff --git a/server/src/database/models/requests.ts b/server/src/database/models/requests.ts index d374625..5ff7ce8 100644 --- a/server/src/database/models/requests.ts +++ b/server/src/database/models/requests.ts @@ -43,7 +43,7 @@ export async function getMirrorsRequestsCountForStats( const values = dataRequests .map( (_, i) => - `('${dataRequests[i].baseUrl}', ${dataRequests[i].createdAfter ? `'${dataRequests[i].createdAfter}'` : null}, ${dataRequests[i].statusCodes && dataRequests[i].statusCodes.length > 0 ? `ARRAY[${dataRequests[i].statusCodes}]` : null})`, + `('${dataRequests[i].baseUrl}', ${dataRequests[i].createdAfter ? `'${dataRequests[i].createdAfter}'` : null}, ${dataRequests[i].statusCodes && (dataRequests[i].statusCodes?.length ?? 0) > 0 ? `ARRAY[${dataRequests[i].statusCodes}]` : null})`, ) .join(', '); diff --git a/server/tests/compitability.production.test.ts b/server/tests/compitability.production.test.ts index 42b77d6..f397fb3 100644 --- a/server/tests/compitability.production.test.ts +++ b/server/tests/compitability.production.test.ts @@ -71,6 +71,28 @@ describe.skipIf(!config.IsProduction)( Object.keys(randomTest.data), ); }); + + it('Bancho: should download osu beatmap', async () => { + console.log(hasClientToken); + if (!hasClientToken) { + return it.skip('Bancho: should download osu beatmap', () => {}); + } + const randomTest = getRandomTest('downloadOsuBeatmap'); + const { beatmapId } = randomTest; + + const beatmap = await banchoClient.downloadOsuBeatmap({ + beatmapId, + }); + + const expected = await Bun.file( + `${import.meta.dir}/data/${beatmapId}.osu.test`, + ).arrayBuffer(); + + expect(beatmap.result?.byteLength).toBeWithin( + expected.byteLength - 1000, + expected.byteLength + 1000, + ); + }, 30000); }); describe('Mino tests', () => { @@ -137,6 +159,24 @@ describe.skipIf(!config.IsProduction)( expected.byteLength + 1000, ); }, 30000); + + it('Mino: should download osu beatmap', async () => { + const randomTest = getRandomTest('downloadOsuBeatmap'); + const { beatmapId } = randomTest; + + const beatmap = await minoClient.downloadOsuBeatmap({ + beatmapId, + }); + + const expected = await Bun.file( + `${import.meta.dir}/data/${beatmapId}.osu.test`, + ).arrayBuffer(); + + expect(beatmap.result?.byteLength).toBeWithin( + expected.byteLength - 1000, + expected.byteLength + 1000, + ); + }); }); describe('Osulabs tests', () => { diff --git a/server/tests/data/mirror.tests.json b/server/tests/data/mirror.tests.json index 49bd1dc..62e8b0e 100644 --- a/server/tests/data/mirror.tests.json +++ b/server/tests/data/mirror.tests.json @@ -5,6 +5,11 @@ "beatmapSetId": 2069845 } ], + "downloadOsuBeatmap": [ + { + "beatmapId": 2809623 + } + ], "getBeatmapById": [ { "beatmapId": 4342177, diff --git a/server/tests/stats.endpoint.test.ts b/server/tests/stats.endpoint.test.ts index 4b2466f..fbb1856 100644 --- a/server/tests/stats.endpoint.test.ts +++ b/server/tests/stats.endpoint.test.ts @@ -15,7 +15,7 @@ describe('Stats Endpoint', () => { jest.restoreAllMocks(); Mocker.mockMirrorsBenchmark(); - app = await setup(); + app = (await setup()) as unknown as Elysia; }); test('Should return 200 status code', async () => { diff --git a/server/tests/utils/mocker.ts b/server/tests/utils/mocker.ts index 9ae7797..7505a63 100644 --- a/server/tests/utils/mocker.ts +++ b/server/tests/utils/mocker.ts @@ -1,9 +1,8 @@ -import { spyOn } from 'bun:test'; +import { spyOn, Mock } from 'bun:test'; import { BanchoClient } from '../../src/core/domains'; import { ApiRateLimiter } from '../../src/core/abstracts/ratelimiter/rate-limiter.abstract'; import { BaseApi } from '../../src/core/abstracts/api/base-api.abstract'; import { BaseClient } from '../../src/core/abstracts/client/base-client.abstract'; -import { Mock } from 'test'; import { BanchoService } from '../../src/core/domains/osu.ppy.sh/bancho-client.service'; import path from 'path'; @@ -27,25 +26,32 @@ import { } from '../../src/core/domains/beatmaps.download/osulabs-client.types'; import { DeepPartial } from '../../src/types/utils'; import { RedisInstance } from '../../src/plugins/redisInstance'; +import { AxiosResponse } from 'axios'; + +type MockAxiosResponse = Partial> & { + data: T; + status: number; + headers?: Record; +}; export class Mocker { static mockRequest( baseClient: BaseClient, service: 'self', mockedEndpointMethod: keyof BaseClient, - data: T, + data: MockAxiosResponse, ): Mock; static mockRequest( baseClient: BaseClient, service: 'api', mockedEndpointMethod: keyof ApiRateLimiter, - data: T, + data: MockAxiosResponse, ): Mock; static mockRequest( baseClient: BaseClient, service: 'baseApi', mockedEndpointMethod: keyof BaseApi, - data: T, + data: MockAxiosResponse, ): Mock; static mockRequest( baseClient: BaseClient, @@ -61,14 +67,14 @@ export class Mocker { | keyof BaseApi | keyof ApiRateLimiter | keyof BanchoService, - data: T, + data: MockAxiosResponse | string, ) { if (service === 'api') { return spyOn( // @ts-expect-error ignore protected property baseClient.api, mockedEndpointMethod as keyof ApiRateLimiter, - ).mockResolvedValue(data); + ).mockResolvedValue(data as never); } if (service === 'baseApi') { @@ -76,26 +82,22 @@ export class Mocker { // @ts-expect-error ignore protected property baseClient.api.api, mockedEndpointMethod as keyof BaseApi, - ).mockResolvedValue(data); + ).mockResolvedValue(data as never); } if (service === 'self') { return spyOn( baseClient, mockedEndpointMethod as keyof BaseClient, - ).mockResolvedValue(data); + ).mockResolvedValue(data as never); } - if ( - service === 'banchoService' && - baseClient instanceof BanchoClient && - typeof data === 'string' - ) { + if (service === 'banchoService' && baseClient instanceof BanchoClient) { return spyOn( // @ts-expect-error ignore protected property baseClient.banchoService, mockedEndpointMethod as keyof BanchoService, - ).mockResolvedValue(data); + ).mockResolvedValue(data as never); } throw new Error('Invalid service to mock'); @@ -156,7 +158,7 @@ export class Mocker { static mockApiRequestForAllClients( mockedEndpointMethod: keyof BaseApi, - data: T, + data: AxiosResponse, ) { return spyOn(BaseApi.prototype, mockedEndpointMethod).mockResolvedValue( data, From 60da83320394e414c791e292912cbfa37900df95 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:12:36 +0200 Subject: [PATCH 2/5] feat: Force typisation for the base api try catch responses --- .../core/abstracts/api/base-api.abstract.ts | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/server/src/core/abstracts/api/base-api.abstract.ts b/server/src/core/abstracts/api/base-api.abstract.ts index 551a3d0..939249d 100644 --- a/server/src/core/abstracts/api/base-api.abstract.ts +++ b/server/src/core/abstracts/api/base-api.abstract.ts @@ -2,7 +2,7 @@ import config from '../../../config'; import { createRequest } from '../../../database/models/requests'; import { logExternalRequest } from '../../../utils/logger'; import { AxiosResponseLog, BaseApiOptions } from './base-api.types'; -import { Axios, AxiosError, AxiosRequestConfig } from 'axios'; +import { Axios, AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; export class BaseApi { constructor( @@ -51,9 +51,13 @@ export class BaseApi { this.handleResponse(res); return res; - } catch (e: any) { - this.handleResponse(e.response || e); - return e.response || e; + } catch (e: unknown) { + if (e instanceof AxiosError && e.response) { + this.handleResponse(e.response); + return e.response as AxiosResponse; + } + this.handleResponse(e); + return null; } } @@ -75,9 +79,13 @@ export class BaseApi { this.handleResponse(res); return res; - } catch (e: any) { - this.handleResponse(e.response || e); - return e.response || e; + } catch (e: unknown) { + if (e instanceof AxiosError && e.response) { + this.handleResponse(e.response); + return e.response as AxiosResponse; + } + this.handleResponse(e); + return null; } } @@ -99,9 +107,13 @@ export class BaseApi { this.handleResponse(res); return res; - } catch (e: any) { - this.handleResponse(e.response || e); - return e.response || e; + } catch (e: unknown) { + if (e instanceof AxiosError && e.response) { + this.handleResponse(e.response); + return e.response as AxiosResponse; + } + this.handleResponse(e); + return null; } } @@ -123,9 +135,13 @@ export class BaseApi { this.handleResponse(res); return res; - } catch (e: any) { - this.handleResponse(e.response || e); - return e.response || e; + } catch (e: unknown) { + if (e instanceof AxiosError && e.response) { + this.handleResponse(e.response); + return e.response as AxiosResponse; + } + this.handleResponse(e); + return null; } } @@ -143,9 +159,13 @@ export class BaseApi { this.handleResponse(res); return res; - } catch (e: any) { - this.handleResponse(e.response || e); - return e.response || e; + } catch (e: unknown) { + if (e instanceof AxiosError && e.response) { + this.handleResponse(e.response); + return e.response as AxiosResponse; + } + this.handleResponse(e); + return null; } } @@ -175,8 +195,10 @@ export class BaseApi { }; if (!isAxiosError && res.config.responseType === 'arraybuffer') { + const downloadFileLength = res?.data?.byteLength || 0; + data.downloadSpeed = Math.round( - (res.data.byteLength || 0) / 1024 / (data.latency / 1000), + (downloadFileLength || 0) / 1024 / (data.latency / 1000), ); // KB/s } From 9399b8c012a8343d5b5c0409ecd5af4d21caab71 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:50:35 +0200 Subject: [PATCH 3/5] chore: Remove unneeded null type from responses, since its already assumed in the ResultWithStatus --- .../abstracts/client/base-client.abstract.ts | 14 +++++++------- .../domains/beatmaps.download/osulabs.client.ts | 14 +++++++------- .../src/core/domains/catboy.best/mino.client.ts | 16 ++++++++-------- .../src/core/domains/gatari.pw/gatari.client.ts | 2 +- .../core/domains/nerinyan.moe/nerinyan.client.ts | 2 +- .../src/core/domains/osu.ppy.sh/bancho.client.ts | 14 +++++++------- .../src/core/managers/mirrors/mirrors.manager.ts | 8 ++++++-- 7 files changed, 37 insertions(+), 33 deletions(-) diff --git a/server/src/core/abstracts/client/base-client.abstract.ts b/server/src/core/abstracts/client/base-client.abstract.ts index 51d94b3..0e6fc07 100644 --- a/server/src/core/abstracts/client/base-client.abstract.ts +++ b/server/src/core/abstracts/client/base-client.abstract.ts @@ -56,43 +56,43 @@ export class BaseClient { async getBeatmapSet( ctx: GetBeatmapSetOptions, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } async getBeatmaps( ctx: GetBeatmapsOptions, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } async searchBeatmapsets( ctx: SearchBeatmapsetsOptions, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } async getBeatmap( ctx: GetBeatmapOptions, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } async getBeatmapsetsByBeatmapIds( ctx: GetBeatmapsetsByBeatmapIdsOptions, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } async downloadBeatmapSet( ctx: DownloadBeatmapSetOptions, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } async downloadOsuBeatmap( ctx: DownloadOsuBeatmap, - ): Promise> { + ): Promise> { throw new Error('Method not implemented.'); } diff --git a/server/src/core/domains/beatmaps.download/osulabs.client.ts b/server/src/core/domains/beatmaps.download/osulabs.client.ts index 80c4091..9304b55 100644 --- a/server/src/core/domains/beatmaps.download/osulabs.client.ts +++ b/server/src/core/domains/beatmaps.download/osulabs.client.ts @@ -66,7 +66,7 @@ export class OsulabsClient extends BaseClient { async downloadBeatmapSet( ctx: DownloadBeatmapSetOptions, - ): Promise> { + ): Promise> { const result = await this.api.get( `d/${ctx.beatmapSetId}${ctx.noVideo ? 'n' : ''}`, { @@ -85,7 +85,7 @@ export class OsulabsClient extends BaseClient { async getBeatmapSet( ctx: GetBeatmapSetOptions, - ): Promise> { + ): Promise> { if (ctx.beatmapSetId) { return await this.getBeatmapSetById(ctx.beatmapSetId); } @@ -95,7 +95,7 @@ export class OsulabsClient extends BaseClient { async searchBeatmapsets( ctx: SearchBeatmapsetsOptions, - ): Promise> { + ): Promise> { const result = await this.api.get(`api/v2/search`, { config: { params: { @@ -122,7 +122,7 @@ export class OsulabsClient extends BaseClient { async getBeatmap( ctx: GetBeatmapOptions, - ): Promise> { + ): Promise> { if (ctx.beatmapId) { return await this.getBeatmapById(ctx.beatmapId); } else if (ctx.beatmapHash) { @@ -134,7 +134,7 @@ export class OsulabsClient extends BaseClient { private async getBeatmapSetById( beatmapSetId: number, - ): Promise> { + ): Promise> { const result = await this.api.get( `api/v2/s/${beatmapSetId}`, ); @@ -151,7 +151,7 @@ export class OsulabsClient extends BaseClient { private async getBeatmapById( beatmapId: number, - ): Promise> { + ): Promise> { const result = await this.api.get(`api/v2/b/${beatmapId}`); if (!result || result.status !== 200 || !result.data) { @@ -174,7 +174,7 @@ export class OsulabsClient extends BaseClient { private async getBeatmapByHash( beatmapHash: string, - ): Promise> { + ): Promise> { const result = await this.api.get(`api/v2/md5/${beatmapHash}`); if (!result || result.status !== 200 || !result.data) { diff --git a/server/src/core/domains/catboy.best/mino.client.ts b/server/src/core/domains/catboy.best/mino.client.ts index b057e88..d0e72dd 100644 --- a/server/src/core/domains/catboy.best/mino.client.ts +++ b/server/src/core/domains/catboy.best/mino.client.ts @@ -82,7 +82,7 @@ export class MinoClient extends BaseClient { async downloadBeatmapSet( ctx: DownloadBeatmapSetOptions, - ): Promise> { + ): Promise> { const result = await this.api.get( `d/${ctx.beatmapSetId}${ctx.noVideo ? 'n' : ''}`, { @@ -101,7 +101,7 @@ export class MinoClient extends BaseClient { async downloadOsuBeatmap( ctx: DownloadOsuBeatmap, - ): Promise> { + ): Promise> { const result = await this.api.get(`osu/${ctx.beatmapId}`, { config: { responseType: 'arraybuffer', @@ -117,7 +117,7 @@ export class MinoClient extends BaseClient { async getBeatmapSet( ctx: GetBeatmapSetOptions, - ): Promise> { + ): Promise> { if (ctx.beatmapSetId) { return await this.getBeatmapSetById(ctx.beatmapSetId); } @@ -127,7 +127,7 @@ export class MinoClient extends BaseClient { async searchBeatmapsets( ctx: SearchBeatmapsetsOptions, - ): Promise> { + ): Promise> { const result = await this.api.get(`api/v2/search`, { config: { params: { @@ -156,7 +156,7 @@ export class MinoClient extends BaseClient { async getBeatmap( ctx: GetBeatmapOptions, - ): Promise> { + ): Promise> { if (ctx.beatmapId) { return await this.getBeatmapById(ctx.beatmapId); } else if (ctx.beatmapHash) { @@ -168,7 +168,7 @@ export class MinoClient extends BaseClient { private async getBeatmapSetById( beatmapSetId: number, - ): Promise> { + ): Promise> { const result = await this.api.get( `api/v2/s/${beatmapSetId}`, ); @@ -185,7 +185,7 @@ export class MinoClient extends BaseClient { private async getBeatmapById( beatmapId: number, - ): Promise> { + ): Promise> { const result = await this.api.get(`api/v2/b/${beatmapId}`); if (!result || result.status !== 200 || !result.data) { @@ -208,7 +208,7 @@ export class MinoClient extends BaseClient { private async getBeatmapByHash( beatmapHash: string, - ): Promise> { + ): Promise> { const result = await this.api.get(`api/v2/md5/${beatmapHash}`); if (!result || result.status !== 200 || !result.data) { diff --git a/server/src/core/domains/gatari.pw/gatari.client.ts b/server/src/core/domains/gatari.pw/gatari.client.ts index bc34389..4efd992 100644 --- a/server/src/core/domains/gatari.pw/gatari.client.ts +++ b/server/src/core/domains/gatari.pw/gatari.client.ts @@ -23,7 +23,7 @@ export class GatariClient extends BaseClient { async downloadBeatmapSet( ctx: DownloadBeatmapSetOptions, - ): Promise> { + ): Promise> { const result = await this.api.get( `d/${ctx.beatmapSetId}`, { diff --git a/server/src/core/domains/nerinyan.moe/nerinyan.client.ts b/server/src/core/domains/nerinyan.moe/nerinyan.client.ts index 2aeaf52..22ad9de 100644 --- a/server/src/core/domains/nerinyan.moe/nerinyan.client.ts +++ b/server/src/core/domains/nerinyan.moe/nerinyan.client.ts @@ -26,7 +26,7 @@ export class NerinyanClient extends BaseClient { async downloadBeatmapSet( ctx: DownloadBeatmapSetOptions, - ): Promise> { + ): Promise> { const result = await this.api.get( `d/${ctx.beatmapSetId}`, { diff --git a/server/src/core/domains/osu.ppy.sh/bancho.client.ts b/server/src/core/domains/osu.ppy.sh/bancho.client.ts index 0c98c2a..e97719f 100644 --- a/server/src/core/domains/osu.ppy.sh/bancho.client.ts +++ b/server/src/core/domains/osu.ppy.sh/bancho.client.ts @@ -51,7 +51,7 @@ export class BanchoClient extends BaseClient { async getBeatmapSet( ctx: GetBeatmapSetOptions, - ): Promise> { + ): Promise> { if (ctx.beatmapSetId) { return await this.getBeatmapSetById(ctx.beatmapSetId); } @@ -61,7 +61,7 @@ export class BanchoClient extends BaseClient { async getBeatmap( ctx: GetBeatmapOptions, - ): Promise> { + ): Promise> { if (ctx.beatmapId) { return await this.getBeatmapById(ctx.beatmapId); } @@ -71,7 +71,7 @@ export class BanchoClient extends BaseClient { async getBeatmaps( ctx: GetBeatmapsOptions, - ): Promise> { + ): Promise> { const { ids } = ctx; const result = await this.api.get<{ beatmaps: BanchoBeatmap[] }>( @@ -99,7 +99,7 @@ export class BanchoClient extends BaseClient { async getBeatmapsetsByBeatmapIds( ctx: GetBeatmapsetsByBeatmapIdsOptions, - ): Promise> { + ): Promise> { const { beatmapIds } = ctx; const result = await this.api.get<{ beatmaps: BanchoBeatmap[] }>( @@ -132,7 +132,7 @@ export class BanchoClient extends BaseClient { async downloadOsuBeatmap( ctx: DownloadOsuBeatmap, - ): Promise> { + ): Promise> { const result = await this.api.get(`osu/${ctx.beatmapId}`, { config: { responseType: 'arraybuffer', @@ -150,7 +150,7 @@ export class BanchoClient extends BaseClient { private async getBeatmapSetById( beatmapSetId: number, - ): Promise> { + ): Promise> { const result = await this.api.get( `api/v2/beatmapsets/${beatmapSetId}`, { @@ -174,7 +174,7 @@ export class BanchoClient extends BaseClient { private async getBeatmapById( beatmapId: number, - ): Promise> { + ): Promise> { const result = await this.api.get( `api/v2/beatmaps/${beatmapId}`, { diff --git a/server/src/core/managers/mirrors/mirrors.manager.ts b/server/src/core/managers/mirrors/mirrors.manager.ts index aa5643d..c7345a4 100644 --- a/server/src/core/managers/mirrors/mirrors.manager.ts +++ b/server/src/core/managers/mirrors/mirrors.manager.ts @@ -132,9 +132,13 @@ export class MirrorsManager { let criteria: ClientAbilities; if (ctx.beatmapId) { - criteria = ClientAbilities.GetBeatmapById; + criteria = ctx.allowMissingNonBeatmapValues + ? ClientAbilities.GetBeatmapByIdWithSomeNonBeatmapValues + : ClientAbilities.GetBeatmapById; } else { - criteria = ClientAbilities.GetBeatmapByHash; + criteria = ctx.allowMissingNonBeatmapValues + ? ClientAbilities.GetBeatmapByHashWithSomeNonBeatmapValues + : ClientAbilities.GetBeatmapByHash; } return await this.useMirror(ctx, criteria, 'getBeatmap'); From 89f8f4c6d670fe2655214f49acaf0afc2e24acb7 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:51:16 +0200 Subject: [PATCH 4/5] feat: Implement osu direct beatmaps endpoints support --- server/src/controllers/api/index.ts | 10 +++- server/src/controllers/d/index.ts | 2 +- .../abstracts/client/base-client.types.ts | 3 ++ .../domains/osu.direct/direct-client.types.ts | 4 ++ .../core/domains/osu.direct/direct.client.ts | 52 ++++++++++++++++++- server/src/core/services/convert.service.ts | 13 +++++ server/tests/compitability.production.test.ts | 14 ++++- 7 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 server/src/core/domains/osu.direct/direct-client.types.ts diff --git a/server/src/controllers/api/index.ts b/server/src/controllers/api/index.ts index 2ea529a..abfddd8 100644 --- a/server/src/controllers/api/index.ts +++ b/server/src/controllers/api/index.ts @@ -9,11 +9,13 @@ export default (app: App) => { async ({ BeatmapsManagerInstance, params: { id }, - query: { full }, + query: { full, allowMissingNonBeatmapValues }, set, }) => { const beatmap = await BeatmapsManagerInstance.getBeatmap({ beatmapId: id, + allowMissingNonBeatmapValues: + full || allowMissingNonBeatmapValues, }); if (beatmap.source) { @@ -42,6 +44,7 @@ export default (app: App) => { }), query: t.Object({ full: t.Optional(t.Boolean()), + allowMissingNonBeatmapValues: t.Optional(t.Boolean()), // TODO: Ideally, we should have shortBeatmap endpoint which would return only beatmap values. }), tags: ['v2'], }, @@ -51,11 +54,13 @@ export default (app: App) => { async ({ BeatmapsManagerInstance, params: { hash }, - query: { full }, + query: { full, allowMissingNonBeatmapValues }, set, }) => { const beatmap = await BeatmapsManagerInstance.getBeatmap({ beatmapHash: hash, + allowMissingNonBeatmapValues: + full || allowMissingNonBeatmapValues, }); if (beatmap.source) { @@ -82,6 +87,7 @@ export default (app: App) => { }), query: t.Object({ full: t.Optional(t.BooleanString()), + allowMissingNonBeatmapValues: t.Optional(t.Boolean()), // TODO: Ideally, we should have shortBeatmap endpoint which would return only beatmap values. }), tags: ['v2'], }, diff --git a/server/src/controllers/d/index.ts b/server/src/controllers/d/index.ts index 7165c9b..e63705b 100644 --- a/server/src/controllers/d/index.ts +++ b/server/src/controllers/d/index.ts @@ -14,7 +14,7 @@ export default (app: App) => { set.headers['X-Data-Source'] = res.source; } - const { source, ...response } = res; + const { source: _, ...response } = res; return response?.data ?? response; }), { diff --git a/server/src/core/abstracts/client/base-client.types.ts b/server/src/core/abstracts/client/base-client.types.ts index cce8208..834d4ad 100644 --- a/server/src/core/abstracts/client/base-client.types.ts +++ b/server/src/core/abstracts/client/base-client.types.ts @@ -40,6 +40,7 @@ export type DownloadOsuBeatmap = { export type GetBeatmapOptions = { beatmapId?: number; beatmapHash?: string; + allowMissingNonBeatmapValues?: boolean; }; export type ResultWithStatus = { @@ -57,6 +58,8 @@ export enum ClientAbilities { GetBeatmaps = 1 << 9, // 512 DownloadOsuBeatmap = 1 << 10, // 1024 GetBeatmapsetsByBeatmapIds = 1 << 11, // 2048 + GetBeatmapByIdWithSomeNonBeatmapValues = 1 << 12, // 4096 + GetBeatmapByHashWithSomeNonBeatmapValues = 1 << 13, // 8192 } export type MirrorClient = { diff --git a/server/src/core/domains/osu.direct/direct-client.types.ts b/server/src/core/domains/osu.direct/direct-client.types.ts new file mode 100644 index 0000000..a9f463f --- /dev/null +++ b/server/src/core/domains/osu.direct/direct-client.types.ts @@ -0,0 +1,4 @@ +import { Beatmap, Beatmapset } from '../../../types/general/beatmap'; +import { UserCompact } from '../../../types/general/user'; + +export interface DirectBeatmap extends Omit {} diff --git a/server/src/core/domains/osu.direct/direct.client.ts b/server/src/core/domains/osu.direct/direct.client.ts index 75ca02f..12272df 100644 --- a/server/src/core/domains/osu.direct/direct.client.ts +++ b/server/src/core/domains/osu.direct/direct.client.ts @@ -3,9 +3,11 @@ import { ClientAbilities, DownloadBeatmapSetOptions, DownloadOsuBeatmap, + GetBeatmapOptions, ResultWithStatus, } from '../../abstracts/client/base-client.types'; import logger from '../../../utils/logger'; +import { Beatmap } from '../../../types/general/beatmap'; export class DirectClient extends BaseClient { constructor() { @@ -16,6 +18,8 @@ export class DirectClient extends BaseClient { ClientAbilities.DownloadBeatmapSetById, ClientAbilities.DownloadBeatmapSetByIdNoVideo, ClientAbilities.DownloadOsuBeatmap, + ClientAbilities.GetBeatmapByIdWithSomeNonBeatmapValues, + ClientAbilities.GetBeatmapByHashWithSomeNonBeatmapValues, ], }, { @@ -30,6 +34,8 @@ export class DirectClient extends BaseClient { ClientAbilities.DownloadBeatmapSetById, ClientAbilities.DownloadBeatmapSetByIdNoVideo, ClientAbilities.DownloadOsuBeatmap, + ClientAbilities.GetBeatmapByIdWithSomeNonBeatmapValues, + ClientAbilities.GetBeatmapByHashWithSomeNonBeatmapValues, ], routes: ['/'], limit: 50, @@ -42,9 +48,51 @@ export class DirectClient extends BaseClient { logger.info('DirectClient initialized'); } + async getBeatmap( + ctx: GetBeatmapOptions, + ): Promise> { + if (ctx.beatmapId) { + return await this.getBeatmapById(ctx.beatmapId); + } else if (ctx.beatmapHash) { + return await this.getBeatmapByHash(ctx.beatmapHash); + } + + throw new Error('Invalid arguments'); + } + + private async getBeatmapById( + beatmapId: number, + ): Promise> { + const result = await this.api.get(`v2/b/${beatmapId}`); + + if (!result || result.status !== 200 || !result.data) { + return { result: null, status: result?.status ?? 500 }; + } + + return { + result: this.convertService.convertBeatmap(result.data), + status: result.status, + }; + } + + private async getBeatmapByHash( + beatmapHash: string, + ): Promise> { + const result = await this.api.get(`v2/md5/${beatmapHash}`); + + if (!result || result.status !== 200 || !result.data) { + return { result: null, status: result?.status ?? 500 }; + } + + return { + result: this.convertService.convertBeatmap(result.data), + status: result.status, + }; + } + async downloadBeatmapSet( ctx: DownloadBeatmapSetOptions, - ): Promise> { + ): Promise> { const result = await this.api.get( `d/${ctx.beatmapSetId}`, { @@ -66,7 +114,7 @@ export class DirectClient extends BaseClient { async downloadOsuBeatmap( ctx: DownloadOsuBeatmap, - ): Promise> { + ): Promise> { const result = await this.api.get(`osu/${ctx.beatmapId}`, { config: { responseType: 'arraybuffer', diff --git a/server/src/core/services/convert.service.ts b/server/src/core/services/convert.service.ts index 2cb6d45..823c6e6 100644 --- a/server/src/core/services/convert.service.ts +++ b/server/src/core/services/convert.service.ts @@ -7,6 +7,7 @@ import { MinoBeatmap, MinoBeatmapset, } from '../domains/catboy.best/mino-client.types'; +import { DirectBeatmap } from '../domains/osu.direct/direct-client.types'; import { BanchoBeatmap, BanchoBeatmapset, @@ -71,6 +72,8 @@ export class ConvertService { return this.convertMinoBeatmap(beatmap as MinoBeatmap); case 'osulabs': return this.convertOsulabsBeatmap(beatmap as OsulabsBeatmap); + case 'direct': + return this.convertDirectBeatmap(beatmap as DirectBeatmap); default: throw new Error('ConvertService: Cannot convert beatmap'); } @@ -103,6 +106,16 @@ export class ConvertService { } as Beatmap; } + private convertDirectBeatmap(beatmap: DirectBeatmap): Beatmap { + return { + ...beatmap, + failtimes: { + fail: Array(100).fill(0), + exit: Array(100).fill(0), + }, + }; + } + private convertMinoBeatmap(beatmap: MinoBeatmap): Beatmap { delete beatmap.set; delete beatmap.last_checked; diff --git a/server/tests/compitability.production.test.ts b/server/tests/compitability.production.test.ts index f397fb3..16d3cf7 100644 --- a/server/tests/compitability.production.test.ts +++ b/server/tests/compitability.production.test.ts @@ -73,7 +73,6 @@ describe.skipIf(!config.IsProduction)( }); it('Bancho: should download osu beatmap', async () => { - console.log(hasClientToken); if (!hasClientToken) { return it.skip('Bancho: should download osu beatmap', () => {}); } @@ -283,6 +282,19 @@ describe.skipIf(!config.IsProduction)( expected.byteLength + 1000, ); }, 30000); + + it('Direct: should return converted beatmap', async () => { + const randomTest = getRandomTest('getBeatmapById'); + const { beatmapId } = randomTest; + + const beatmap = await directClient.getBeatmap({ + beatmapId, + }); + + expect(beatmap.result).toContainAllKeys( + Object.keys(randomTest.data), + ); + }); }); describe('Gatari tests', () => { From 26cc9645822871dcb4b3995d5918a48b5b60f57e Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:13:41 +0200 Subject: [PATCH 5/5] feat: Add tests --- server/tests/mirrors.manager.test.ts | 308 +++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/server/tests/mirrors.manager.test.ts b/server/tests/mirrors.manager.test.ts index 0eb4f33..7a8f05b 100644 --- a/server/tests/mirrors.manager.test.ts +++ b/server/tests/mirrors.manager.test.ts @@ -1211,6 +1211,314 @@ describe('MirrorsManager', () => { }, ); }); + + describe('GetBeatmapByIdWithSomeNonBeatmapValues', () => { + const mirrors = getMirrorsWithAbility( + ClientAbilities.GetBeatmapByIdWithSomeNonBeatmapValues, + ); + + test.each(mirrors)( + `$name: Should successfully fetch a beatmap by id`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapId = faker.number.int({ + min: 1, + max: 1000000, + }); + + const { mockBeatmap } = Mocker.getClientMockMethods(client); + mockBeatmap({ + data: { + id: beatmapId, + }, + }); + + const result = await mirrorsManager.getBeatmap({ + beatmapId, + allowMissingNonBeatmapValues: true, + }); + + expect(result.status).toBe(200); + expect(result.result).not.toBeNull(); + expect(result.result?.id).toBe(beatmapId); + }, + ); + + test.each(mirrors)( + `$name: Should successfully update ratelimit during get beatmap by id request`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const { generateBeatmap } = + Mocker.getClientGenerateMethods(client); + + const beatmapId = faker.number.int({ + min: 1, + max: 1000000, + }); + + const mockApiGet = Mocker.mockRequest( + client, + 'baseApi', + 'get', + { + data: generateBeatmap({ id: beatmapId }), + status: 200, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapId, + allowMissingNonBeatmapValues: true, + }); + + // Skip a tick to check if is on cooldown + await new Promise((r) => setTimeout(r, 0)); + + let capacity = client.getCapacity( + ClientAbilities.GetBeatmapById, + ); + + expect(capacity.remaining).toBeLessThan(capacity.limit); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + capacity = client.getCapacity( + ClientAbilities.GetBeatmapById, + ); + + expect(awaitedResult.status).toBe(200); + expect(awaitedResult.result).not.toBeNull(); + expect(awaitedResult.result?.id).toBe(beatmapId); + + expect(capacity.remaining).toBeLessThan(capacity.limit); + }, + ); + + test.each(mirrors)( + `$name: Should successfully return 404 when beatmap is not found`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapId = faker.number.int({ + min: 1, + max: 1000000, + }); + + const mockApiGet = Mocker.mockRequest( + client, + 'baseApi', + 'get', + { + data: null, + status: 404, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapId, + allowMissingNonBeatmapValues: true, + }); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + expect(awaitedResult.status).toBe(404); + expect(awaitedResult.result).toBeNull(); + }, + ); + + test.each(mirrors)( + `$name: Should successfully return 502 when API request fails and no other mirrors are available`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapId = faker.number.int({ + min: 1, + max: 1000000, + }); + + const mockApiGet = Mocker.mockRequest( + client, + 'baseApi', + 'get', + { + data: null, + status: 500, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapId, + allowMissingNonBeatmapValues: true, + }); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + expect(awaitedResult.status).toBe(502); + expect(awaitedResult.result).toBeNull(); + }, + ); + }); + + describe('GetBeatmapByHashWithSomeNonBeatmapValues', () => { + const mirrors = getMirrorsWithAbility( + ClientAbilities.GetBeatmapByHashWithSomeNonBeatmapValues, + ); + + test.each(mirrors)( + `$name: Should successfully fetch a beatmap by hash`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapHash = faker.string.uuid(); + + const { mockBeatmap } = Mocker.getClientMockMethods(client); + + mockBeatmap({ + data: { + checksum: beatmapHash, + }, + }); + + const result = await mirrorsManager.getBeatmap({ + beatmapHash, + allowMissingNonBeatmapValues: true, + }); + + expect(result.status).toBe(200); + expect(result.result).not.toBeNull(); + expect(result.result?.checksum).toBe(beatmapHash); + }, + ); + + test.each(mirrors)( + `$name: Should successfully update ratelimit during get beatmap by hash request`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapHash = faker.string.uuid(); + + const { generateBeatmap, generateBeatmapset } = + Mocker.getClientGenerateMethods(client); + + const mockApiGet = Mocker.mockRequest( + client, + 'baseApi', + 'get', + { + data: generateBeatmap({ + checksum: beatmapHash, + }), + status: 200, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapHash, + allowMissingNonBeatmapValues: true, + }); + + // Skip a tick to check if is on cooldown + await new Promise((r) => setTimeout(r, 0)); + + let capacity = client.getCapacity( + ClientAbilities.GetBeatmapByHash, + ); + + expect(capacity.remaining).toBeLessThan(capacity.limit); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + capacity = client.getCapacity( + ClientAbilities.GetBeatmapByHash, + ); + + expect(awaitedResult.status).toBe(200); + expect(awaitedResult.result).not.toBeNull(); + + expect(awaitedResult.result?.checksum).toBe(beatmapHash); + + expect(capacity.remaining).toBeLessThan(capacity.limit); + }, + ); + + test.each(mirrors)( + `$name: Should successfully return 404 when beatmap is not found`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapHash = faker.string.uuid(); + + const mockApiGet = Mocker.mockRequest( + client, + 'baseApi', + 'get', + { + data: null, + status: 404, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapHash, + allowMissingNonBeatmapValues: true, + }); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + expect(awaitedResult.status).toBe(404); + expect(awaitedResult.result).toBeNull(); + }, + ); + + test.each(mirrors)( + `$name: Should successfully return 502 when API request fails and no other mirrors are available`, + async (mirror) => { + const client = getMirrorClient(mirror); + + const beatmapHash = faker.string.uuid(); + + const mockApiGet = Mocker.mockRequest( + client, + 'baseApi', + 'get', + { + data: null, + status: 500, + headers: {}, + }, + ); + + const request = mirrorsManager.getBeatmap({ + beatmapHash, + allowMissingNonBeatmapValues: true, + }); + + const awaitedResult = await request; + + expect(mockApiGet).toHaveBeenCalledTimes(1); + + expect(awaitedResult.status).toBe(502); + expect(awaitedResult.result).toBeNull(); + }, + ); + }); }); describe('Specific cases', () => {