diff --git a/src/lib/clients/aizu_online_judge.ts b/src/lib/clients/aizu_online_judge.ts deleted file mode 100644 index d8303f6c1..000000000 --- a/src/lib/clients/aizu_online_judge.ts +++ /dev/null @@ -1,639 +0,0 @@ -import { ContestSiteApiClient } from '$lib/clients/http_client'; -import { type TasksApiClient, HttpRequestClient } from '$lib/clients/http_client'; -import { ContestTaskCache } from '$lib/clients/cache_strategy'; -import { Cache, type ApiClientConfig } from '$lib/clients/cache'; - -import type { ContestForImport, ContestsForImport } from '$lib/types/contest'; -import type { TasksForImport } from '$lib/types/task'; - -import { AOJ_API_BASE_URL } from '$lib/constants/urls'; - -/** - * Represents the response structure from AOJ Course API - * @typedef {Object} AOJCourseAPI - */ -type AOJCourseAPI = { - filter: string; - courses: Courses; -}; - -/** - * Represents a course in the AOJ - */ -type Course = { - readonly id: number; - readonly serial: number; - readonly shortName: string; - readonly name: string; - readonly type: string; -}; - -type Courses = Course[]; - -/** - * Parameters for configuring a challenge contest in the AOJ. - * @typedef {Object} ChallengeParams - * @property {ChallengeContestType} contestType - The type of contest for the challenge. - * @property {ChallengeRoundMap[ChallengeContestType]} round - The round of the contest, which depends on the contest type. - */ -type ChallengeParams = { - contestType: ChallengeContestType; - round: ChallengeRoundMap[ChallengeContestType]; -}; - -/** - * Represents the types of challenge contests available. - */ -type ChallengeContestType = 'PCK' | 'JAG'; - -/** - * A map that associates each type of challenge contest with its corresponding round type. - * - * @typedef {Object} ChallengeRoundMap - * @property {PckRound} PCK - The round type for PCK contests. - * @property {JagRound} JAG - The round type for JAG contests. - */ -type ChallengeRoundMap = { - PCK: PckRound; - JAG: JagRound; -}; - -/** - * Represents PCK contest rounds - */ -type PckRound = 'PRELIM' | 'FINAL'; - -/** - * Represents JAG contest rounds - */ -type JagRound = 'PRELIM' | 'REGIONAL'; - -type AOJChallengeContestAPI = { - readonly largeCl: Record; - readonly contests: ChallengeContests; -}; - -/** - * Represents a challenge contest in the AOJ - */ -type ChallengeContest = { - readonly abbr: string; - readonly largeCl: string; - readonly middleCl: string; - readonly year: number; - readonly progress: number; - readonly numberOfProblems: number; - readonly numberOfSolved: number; - readonly days: { title: string; problems: AOJTaskAPI[] }[]; -}; - -type ChallengeContests = ChallengeContest[]; - -/** - * Represents a task in the AOJ API response - * @property {string} id - Unique identifier for the task - * @property {string} name - Task name - */ -type AOJTaskAPI = { - readonly id: string; - readonly available: number; - readonly doctype: number; - readonly name: string; - readonly problemTimeLimit: number; - readonly problemMemoryLimit: number; - readonly maxScore: number; - readonly solvedUser: number; - readonly submissions: number; - readonly recommendations: number; - readonly isSolved: boolean; - readonly bookmark: boolean; - readonly recommend: boolean; - readonly successRate: number; - readonly score: number; - readonly userScore: number; -}; - -type AOJTaskAPIs = AOJTaskAPI[]; - -/** - * Constant used as a placeholder for missing timestamp data in AOJ contests - * Value: -1 - */ -const PENDING = -1; - -/** - * AojApiClient is a client for interacting with the Aizu Online Judge (AOJ) API. - * It extends the ContestSiteApiClient and provides methods to fetch contests and tasks - * from the AOJ platform. - * - * @class AojApiClient - * @extends {ContestSiteApiClient} - */ -export class AojApiClient extends ContestSiteApiClient { - private readonly coursesApiClient: AojCoursesApiClient; - private readonly challengesApiClient: AojChallengesApiClient; - - /** - * Array of API clients configured for Aizu Online Judge. - * - * @private - * @readonly - * @property {Object[]} apiClients - Collection of tasks API clients - * @property {string} apiClients[].label - Identifier for the API client - * @property {TasksApiClient} apiClients[].client - API client instance - * @property {ChallengeParams} [apiClients[].params] - Optional challenge parameters for the API client - */ - private readonly apiClients: { - label: string; - client: TasksApiClient; - params?: ChallengeParams; - }[]; - - /** - * Constructs an instance of the Aizu Online Judge client. - * - * @param {ApiClientConfig} [config] - Optional configuration object for the API client. - * @param {CacheConfig} [config.contestCache] - Configuration for the contest cache. - * @param {CacheConfig} [config.taskCache] - Configuration for the task cache. - */ - constructor(config?: ApiClientConfig) { - super(); - - // Setup caches with default values. - const contestCache = new Cache( - config?.contestCache?.timeToLive, - config?.contestCache?.maxSize, - ); - const taskCache = new Cache( - config?.taskCache?.timeToLive, - config?.taskCache?.maxSize, - ); - - // Common dependencies. - const caches = new ContestTaskCache(contestCache, taskCache); - const httpClient = new HttpRequestClient(AOJ_API_BASE_URL); - - // Initialize API clients for different contests. - this.coursesApiClient = new AojCoursesApiClient(httpClient, caches); - this.challengesApiClient = new AojChallengesApiClient(httpClient, caches); - - // Set up the API clients with their labels and parameters. - this.apiClients = [ - { - label: 'course', - client: this.coursesApiClient, - }, - { - label: 'pck-prelim', - client: this.challengesApiClient, - params: { contestType: 'PCK', round: 'PRELIM' }, - }, - { - label: 'pck-final', - client: this.challengesApiClient, - params: { contestType: 'PCK', round: 'FINAL' }, - }, - { - label: 'jag-prelim', - client: this.challengesApiClient, - params: { contestType: 'JAG', round: 'PRELIM' }, - }, - { - label: 'jag-regional', - client: this.challengesApiClient, - params: { contestType: 'JAG', round: 'REGIONAL' }, - }, - ]; - } - - /** - * Fetches and combines contests from different sources. - * - * This method concurrently fetches course contests, preliminary and final PCK contests, - * and prelim and regional JAG contests, then combines them into a single array. - * - * @returns {Promise} A promise that resolves to an array of contests. - */ - async getContests(): Promise { - return (await this.fetchAllData('getContests')).flat(); - } - - /** - * Fetches tasks from various sources and combines them into a single list. - * - * This method concurrently fetches tasks from five different sources: - * - Course tasks - * - PCK Prelim tasks - * - PCK Final tasks - * - JAG Prelim tasks - * - JAG Regional tasks - * - * The fetched tasks are then concatenated into a single array and returned. - * - * @returns {Promise} A promise that resolves to an array of tasks. - * - * @throws Will throw an error if the API request fails or the response validation fails. - */ - async getTasks(): Promise { - return (await this.fetchAllData('getTasks')).flat(); - } - - /** - * Fetches data from all configured API clients using the specified method. - * - * @private - * @template T The type of data to be returned from the API - * @param {('getContests' | 'getTasks')} methodName - The API method to call on each client - * @returns {Promise} A promise that resolves to an array of results from all API clients - * - * @remarks - * This method will attempt to fetch data from all configured API clients in parallel. - * If any individual request fails, it will log the error and include an empty array in that position. - * If the entire operation fails, it will log the error and return an empty array. - */ - private async fetchAllData(methodName: 'getContests' | 'getTasks'): Promise { - try { - const requests = this.apiClients.map((apiClient) => - apiClient.client[methodName](apiClient.params), - ); - - const responses = await Promise.allSettled(requests); - let results: T[] = []; - - results = responses.map((result, index) => { - if (result.status === 'fulfilled') { - return result.value; - } else { - console.error(`Failed to fetch from ${this.apiClients[index].label}:`, result.reason); - return []; - } - }) as T[]; - - return results; - } catch (error) { - console.error(`Failed to fetch data from AOJ API:`, error); - return []; - } - } -} - -/** - * Abstract base class for Aizu Online Judge (AOJ) API clients that retrieve contest and task data. - * - * This class provides common functionality for AOJ API clients, including URL endpoint construction - * and data mapping utilities. Specific implementations must provide concrete implementations - * for fetching contests and tasks. - * - * @template TParams - The type of parameters accepted by the API methods. Use `void` if no parameters are needed. - * - * @example - * ```typescript - * class AojTasksClient extends AojTasksApiClientBase { - * async getContests(params?: SearchParams): Promise { - * // Implementation - * } - * - * async getTasks(params?: SearchParams): Promise { - * // Implementation - * } - * } - * ``` - */ -export abstract class AojTasksApiClientBase implements TasksApiClient { - constructor( - protected readonly httpClient: HttpRequestClient, - protected readonly cache: ContestTaskCache, - ) {} - - abstract getContests(params?: TParams): Promise; - - abstract getTasks(params?: TParams): Promise; - - /** - * Retrieves contest data either from cache or from the Aizu Online Judge API. - * - * @protected - * @template T - The type of the raw API response - * @param {Object} options - The options for fetching contests - * @param {string} options.cacheKey - The key used to store/retrieve data in the cache - * @param {string} options.endpoint - The API endpoint to fetch data from - * @param {string} options.errorMessage - The error message to use if the API request fails - * @param {(data: T) => boolean} options.validateResponse - Function to validate the API response - * @param {(data: T) => ContestsForImport} options.transformer - Function to transform API data to ContestsForImport format - * @param {string} options.label - Label used for logging the fetch result - * @returns {Promise} A promise that resolves to the contests data - */ - protected async getCachedOrFetchContests({ - cacheKey, - endpoint, - errorMessage, - validateResponse, - transformer, - label, - }: { - cacheKey: string; - endpoint: string; - errorMessage: string; - validateResponse: (data: T) => boolean; - transformer: (data: T) => ContestsForImport; - label: string; - }): Promise { - return this.cache.getCachedOrFetchContests(cacheKey, async () => { - const apiResponse = await this.httpClient.fetchApiWithConfig({ - endpoint, - errorMessage, - validateResponse, - }); - - const transformedContests = transformer(apiResponse); - this.printLogForFetchedResults(label, transformedContests, 'contest'); - - return transformedContests; - }); - } - - /** - * Retrieves tasks from cache or fetches them from the API if not cached. - * - * @protected - * @template T - The type of data returned by the API - * @param options - Object containing fetch and cache configuration - * @param options.cacheKey - Unique key used to store and retrieve data from cache - * @param options.endpoint - API endpoint to fetch tasks from - * @param options.errorMessage - Message to display if the API request fails - * @param options.validateResponse - Function to validate the API response data - * @param options.transformer - Function to convert API response into TasksForImport format - * @param options.label - Identifier used in logging statements - * @returns Promise resolving to transformed tasks ready for import - */ - protected async getCachedOrFetchTasks({ - cacheKey, - endpoint, - errorMessage, - validateResponse, - transformer, - label, - }: { - cacheKey: string; - endpoint: string; - errorMessage: string; - validateResponse: (data: T) => boolean; - transformer: (data: T) => TasksForImport; - label: string; - }): Promise { - return this.cache.getCachedOrFetchTasks(cacheKey, async () => { - const apiResponse = await this.httpClient.fetchApiWithConfig({ - endpoint, - errorMessage, - validateResponse, - }); - - const transformedTasks = transformer(apiResponse); - this.printLogForFetchedResults(label, transformedTasks, 'task'); - - return transformedTasks; - }); - } - - private printLogForFetchedResults(label: string, data: R, dataType: 'task' | 'contest'): void { - const countText = Array.isArray(data) ? `${data.length} ${dataType}s` : typeof data; - - console.debug(`Found AOJ ${label}: ${countText}`); - } - - /** - * Constructs an endpoint URL by encoding each segment and joining them with a '/'. - * - * @param segments - An array of strings representing the segments of the URL. - * @returns The constructed endpoint URL as a string. - */ - protected buildEndpoint(segments: string[]): string { - if (!segments?.length) { - throw new Error('Endpoint segments array cannot be empty'); - } - - // Allow alphanumeric characters, hyphens, and underscores - const MAX_SEGMENT_LENGTH = 100; - const validateSegment = (segment: string): boolean => { - return ( - segment.length <= MAX_SEGMENT_LENGTH && - /^[a-zA-Z](?:[a-zA-Z0-9]|[-_](?=[a-zA-Z0-9])){0,98}[a-zA-Z0-9]$/.test(segment) && - !segment.includes('..') - ); - }; - - for (const segment of segments) { - if (!validateSegment(segment)) { - throw new Error( - `Invalid segment: ${segment}. Segments must be alphanumeric with hyphens and underscores, max length ${MAX_SEGMENT_LENGTH}`, - ); - } - } - - return segments.map((segment) => encodeURIComponent(segment)).join('/'); - } - - /** - * Maps the given contest details to a `ContestForImport` object. - * - * @param contestId - The unique identifier for the contest. - * @param title - The title of the contest. - * @returns A `ContestForImport` object with the provided contest details. - * - * @remarks - * The `start_epoch_second` and `duration_second` fields are currently set to `PENDING` - * as the data is not available. The `rate_change` field is also set to an empty string - * for the same reason. - */ - protected mapToContest(contestId: string, title: string): ContestForImport { - return { - id: contestId, - start_epoch_second: PENDING, // Data not available - duration_second: PENDING, // Same as above - title: title, - rate_change: '', // Same as above - }; - } - - /** - * Maps the AOJTaskAPI problem object to a task object. - * - * @param problem - The problem object from AOJTaskAPI. - * @param contestId - The ID of the contest. - * @returns An object representing the task with properties id, contest_id, problem_index, task_id, and title. - */ - protected mapToTask(problem: AOJTaskAPI, contestId: string) { - return { - id: problem.id, - contest_id: contestId, - problem_index: problem.id, // Using task.id as a substitute since there's no equivalent to problem_index. Similar approach is used in AtCoder Problems API for old JOI problems. - task_id: problem.id, // Same as above - title: problem.name, - }; - } -} - -/** - * Client for interacting with the Aizu Online Judge (AOJ) Courses API. - * - * This class extends the AojTasksApiClientBase and provides methods to fetch contests and tasks - * specifically from the AOJ Courses section. It handles the conversion of AOJ API responses - * to the internal data structure used by the application, and uses caching to optimize API calls. - * - * The class offers methods to: - * - Fetch and cache course contests - * - Fetch and cache course tasks - * - Extract course names from task IDs - * - * @extends AojTasksApiClientBase - */ -export class AojCoursesApiClient extends AojTasksApiClientBase { - async getContests(): Promise { - return this.getCachedOrFetchContests({ - cacheKey: 'aoj_courses', - endpoint: 'courses', - errorMessage: 'Failed to fetch course contests from AOJ API', - validateResponse: (data) => - 'courses' in data && Array.isArray(data.courses) && data.courses.length > 0, - transformer: (data) => this.transformCourseContests(data), - label: 'course', - }); - } - - async getTasks(): Promise { - const size = 10 ** 4; - - return this.getCachedOrFetchTasks({ - cacheKey: 'aoj_courses', - endpoint: `problems?size=${size}`, - errorMessage: 'Failed to fetch course tasks from AOJ API', - validateResponse: (data) => Array.isArray(data) && data.length > 0, - transformer: (data) => this.transformCourseTasks(data), - label: 'course', - }); - } - - private transformCourseContests(data: AOJCourseAPI): ContestsForImport { - return data.courses.map((course: Course) => { - return this.mapToContest(course.shortName, course.name); - }); - } - - private transformCourseTasks(data: AOJTaskAPIs): TasksForImport { - return data - .filter((task: AOJTaskAPI) => this.getCourseName(task.id) !== '') - .map((task: AOJTaskAPI) => { - return this.mapToTask(task, this.getCourseName(task.id)); - }); - } - - /** - * Extracts the course name from a given task ID. - * - * The task ID is expected to be in the format of `courseName_taskId_otherInfo` in courses (ex: ITP1_1_A, ..., INFO1_01_E, ...) and `taskNumber` in challenges (ex: 0001, ..., 0703, ..., 3000). - * If the task ID does not follow this format, an empty string is returned. - * - * @param taskId - The task ID string from which to extract the course name. - * @returns The extracted course name or an empty string if the format is incorrect. - */ - private getCourseName = (taskId: string) => { - if (!taskId || typeof taskId !== 'string') { - return ''; - } - - const splittedTaskId = taskId.split('_'); - - return splittedTaskId.length == 3 ? splittedTaskId[0] : ''; - }; -} - -/** - * Client for interfacing with the Aizu Online Judge (AOJ) API to fetch challenge contests and tasks. - * - * This class extends the base AOJ client specifically for handling challenge-type competitions. - * It provides methods to retrieve both contests and associated tasks with built-in caching - * to minimize redundant API calls. - * - * @extends AojTasksApiClientBase - * @example - * const client = new AojChallengesApiClient(); - * const contests = await client.getContests({ contestType: "PCK", round: "PRELIM" }); - * const tasks = await client.getTasks({ contestType: "PCK", round: "PRELIM" }); - */ -export class AojChallengesApiClient extends AojTasksApiClientBase { - async getContests(params: ChallengeParams): Promise { - const { contestType, round } = params; - - return this.getCachedOrFetchContests({ - cacheKey: this.getCacheKey(contestType, round), - endpoint: this.buildEndpoint(['challenges', 'cl', contestType, round]), - errorMessage: `Failed to fetch ${this.getContestTypeLabel(contestType)} ${round} contests from AOJ API`, - validateResponse: (data) => this.validateApiResponse(data), - transformer: (data) => this.transformToContests(data), - label: `${this.getContestTypeLabel(contestType)} ${round}`, - }); - } - - async getTasks(params: ChallengeParams): Promise { - const { contestType, round } = params; - - return this.getCachedOrFetchTasks({ - cacheKey: this.getCacheKey(contestType, round), - endpoint: this.buildEndpoint(['challenges', 'cl', contestType, round]), - errorMessage: `Failed to fetch ${this.getContestTypeLabel(contestType)} ${round} tasks from AOJ API`, - validateResponse: (data) => this.validateApiResponse(data), - transformer: (data) => this.transformToTasks(data), - label: `${this.getContestTypeLabel(contestType)} ${round}`, - }); - } - - /** - * Generates a unique cache key for Aizu Online Judge contest data. - * - * @param contestType - The type of the contest - * @param round - The round of the contest, specific to the contest type - * @returns A string in the format "aoj_[contestType]_[round]" with lowercase values - * @private - */ - private getCacheKey( - contestType: ChallengeContestType, - round: ChallengeRoundMap[ChallengeContestType], - ): string { - return `aoj_${contestType.toLowerCase()}_${round.toLowerCase()}`; - } - - private validateApiResponse(data: AOJChallengeContestAPI): boolean { - return 'contests' in data && Array.isArray(data.contests) && data.contests.length > 0; - } - - private transformToContests(data: AOJChallengeContestAPI): ContestsForImport { - return data.contests.flatMap((contest: ChallengeContest) => - contest.days - .map((day) => day.title) - .map((title: string) => { - return this.mapToContest(contest.abbr, title); - }), - ); - } - - private transformToTasks(data: AOJChallengeContestAPI): TasksForImport { - return data.contests.flatMap((contest: ChallengeContest) => - contest.days.flatMap((day) => - day.problems.map((problem) => { - return this.mapToTask(problem, contest.abbr); - }), - ), - ); - } - - /** - * Converts the contest type to an uppercase string representation. - * - * @param contestType - The type of contest to convert - * @returns The uppercase string representation of the contest type - * @private - */ - private getContestTypeLabel(contestType: ChallengeContestType): string { - return contestType.toUpperCase(); - } -} diff --git a/src/test/lib/clients/aizu_online_judge.test.ts b/src/lib/clients/aizu_online_judge/clients.test.ts similarity index 87% rename from src/test/lib/clients/aizu_online_judge.test.ts rename to src/lib/clients/aizu_online_judge/clients.test.ts index c11f19394..eefab6bca 100644 --- a/src/test/lib/clients/aizu_online_judge.test.ts +++ b/src/lib/clients/aizu_online_judge/clients.test.ts @@ -1,15 +1,15 @@ -import { describe, test, expect } from 'vitest'; - -import { loadMockData } from '../common/test_helpers'; - -import { ContestSiteApiClient } from '$lib/clients/http_client'; -import { AojApiClient } from '$lib/clients/aizu_online_judge'; +import { describe, test, expect, beforeAll } from 'vitest'; +import type { TasksApiClient } from '$lib/clients/http_client'; import type { ContestsForImport } from '$lib/types/contest'; import type { TasksForImport } from '$lib/types/task'; +import { AojApiClient } from '$lib/clients/aizu_online_judge/clients'; + +import { loadMockData } from '../fixtures/helpers'; + describe('AIZU ONLINE JUDGE API client', () => { - let client: ContestSiteApiClient; + let client: TasksApiClient; let contestsMock: ContestsForImport; let tasksMock: TasksForImport; @@ -17,8 +17,8 @@ describe('AIZU ONLINE JUDGE API client', () => { client = new AojApiClient(); const MOCK_DATA_PATHS = { - contests: './src/test/lib/clients/test_data/aizu_online_judge/contests.json', - tasks: './src/test/lib/clients/test_data/aizu_online_judge/tasks.json', + contests: './src/lib/clients/fixtures/aizu_online_judge/contests.json', + tasks: './src/lib/clients/fixtures/aizu_online_judge/tasks.json', }; try { diff --git a/src/lib/clients/aizu_online_judge/clients.ts b/src/lib/clients/aizu_online_judge/clients.ts new file mode 100644 index 000000000..e4c952b85 --- /dev/null +++ b/src/lib/clients/aizu_online_judge/clients.ts @@ -0,0 +1,342 @@ +import { type TasksApiClient, HttpRequestClient } from '$lib/clients/http_client'; +import { ContestTaskCache } from '$lib/clients/cache_strategy'; +import { Cache, type ApiClientConfig } from '$lib/clients/cache'; + +import type { ContestsForImport } from '$lib/types/contest'; +import type { TasksForImport } from '$lib/types/task'; + +import { AOJ_API_BASE_URL } from '$lib/constants/urls'; + +import type { + AOJCourseAPI, + Course, + AOJTaskAPIs, + AOJTaskAPI, + AOJChallengeContestAPI, + ChallengeContest, + ChallengeParams, + ChallengeContestType, + ChallengeRoundMap, +} from './types'; +import { buildEndpoint, mapToContest, mapToTask, getCourseName } from './utils'; + +/** + * AojApiClient is a client for interacting with the Aizu Online Judge (AOJ) API. + * It implements TasksApiClient and provides methods to fetch contests and tasks + * from the AOJ platform. + * + * @class AojApiClient + * @implements {TasksApiClient} + */ +export class AojApiClient implements TasksApiClient { + private readonly coursesApiClient: AojCoursesApiClient; + private readonly challengesApiClient: AojChallengesApiClient; + + /** + * Array of API clients configured for Aizu Online Judge. + * + * @private + * @readonly + */ + private readonly apiClients: { + label: string; + client: TasksApiClient; + params?: ChallengeParams; + }[]; + + /** + * Constructs an instance of the Aizu Online Judge client. + * + * @param {ApiClientConfig} [config] - Optional configuration object for the API client. + */ + constructor(config?: ApiClientConfig) { + const contestCache = new Cache( + config?.contestCache?.timeToLive, + config?.contestCache?.maxSize, + ); + const taskCache = new Cache( + config?.taskCache?.timeToLive, + config?.taskCache?.maxSize, + ); + + const caches = new ContestTaskCache(contestCache, taskCache); + const httpClient = new HttpRequestClient(AOJ_API_BASE_URL); + + this.coursesApiClient = new AojCoursesApiClient(httpClient, caches); + this.challengesApiClient = new AojChallengesApiClient(httpClient, caches); + + this.apiClients = [ + { + label: 'course', + client: this.coursesApiClient, + }, + { + label: 'pck-prelim', + client: this.challengesApiClient, + params: { contestType: 'PCK', round: 'PRELIM' }, + }, + { + label: 'pck-final', + client: this.challengesApiClient, + params: { contestType: 'PCK', round: 'FINAL' }, + }, + { + label: 'jag-prelim', + client: this.challengesApiClient, + params: { contestType: 'JAG', round: 'PRELIM' }, + }, + { + label: 'jag-regional', + client: this.challengesApiClient, + params: { contestType: 'JAG', round: 'REGIONAL' }, + }, + ]; + } + + /** + * Fetches and combines contests from different sources. + * + * @returns {Promise} A promise that resolves to an array of contests. + */ + async getContests(): Promise { + return (await this.fetchAllData('getContests')).flat(); + } + + /** + * Fetches tasks from various sources and combines them into a single list. + * + * @returns {Promise} A promise that resolves to an array of tasks. + */ + async getTasks(): Promise { + return (await this.fetchAllData('getTasks')).flat(); + } + + private async fetchAllData(methodName: 'getContests' | 'getTasks'): Promise { + try { + const requests = this.apiClients.map((apiClient) => + apiClient.client[methodName](apiClient.params), + ); + + const responses = await Promise.allSettled(requests); + let results: T[] = []; + + results = responses.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + console.error(`Failed to fetch from ${this.apiClients[index].label}:`, result.reason); + return []; + } + }) as T[]; + + return results; + } catch (error) { + console.error(`Failed to fetch data from AOJ API:`, error); + return []; + } + } +} + +/** + * Abstract base class for Aizu Online Judge (AOJ) API clients. + * + * @template TParams - The type of parameters accepted by the API methods. + */ +export abstract class AojTasksApiClientBase implements TasksApiClient { + constructor( + protected readonly httpClient: HttpRequestClient, + protected readonly cache: ContestTaskCache, + ) {} + + abstract getContests(params?: TParams): Promise; + + abstract getTasks(params?: TParams): Promise; + + /** + * Retrieves contest data either from cache or from the Aizu Online Judge API. + * + * @protected + */ + protected async getCachedOrFetchContests({ + cacheKey, + endpoint, + errorMessage, + validateResponse, + transformer, + label, + }: { + cacheKey: string; + endpoint: string; + errorMessage: string; + validateResponse: (data: T) => boolean; + transformer: (data: T) => ContestsForImport; + label: string; + }): Promise { + return this.cache.getCachedOrFetchContests(cacheKey, async () => { + const apiResponse = await this.httpClient.fetchApiWithConfig({ + endpoint, + errorMessage, + validateResponse, + }); + + const transformedContests = transformer(apiResponse); + this.printLogForFetchedResults(label, transformedContests, 'contest'); + + return transformedContests; + }); + } + + /** + * Retrieves tasks from cache or fetches them from the API if not cached. + * + * @protected + */ + protected async getCachedOrFetchTasks({ + cacheKey, + endpoint, + errorMessage, + validateResponse, + transformer, + label, + }: { + cacheKey: string; + endpoint: string; + errorMessage: string; + validateResponse: (data: T) => boolean; + transformer: (data: T) => TasksForImport; + label: string; + }): Promise { + return this.cache.getCachedOrFetchTasks(cacheKey, async () => { + const apiResponse = await this.httpClient.fetchApiWithConfig({ + endpoint, + errorMessage, + validateResponse, + }); + + const transformedTasks = transformer(apiResponse); + this.printLogForFetchedResults(label, transformedTasks, 'task'); + + return transformedTasks; + }); + } + + private printLogForFetchedResults(label: string, data: R, dataType: 'task' | 'contest'): void { + const countText = Array.isArray(data) ? `${data.length} ${dataType}s` : typeof data; + + console.debug(`Found AOJ ${label}: ${countText}`); + } +} + +/** + * Client for interacting with the Aizu Online Judge (AOJ) Courses API. + * + * @extends AojTasksApiClientBase + */ +export class AojCoursesApiClient extends AojTasksApiClientBase { + async getContests(): Promise { + return this.getCachedOrFetchContests({ + cacheKey: 'aoj_courses', + endpoint: 'courses', + errorMessage: 'Failed to fetch course contests from AOJ API', + validateResponse: (data) => + 'courses' in data && Array.isArray(data.courses) && data.courses.length > 0, + transformer: (data) => this.transformCourseContests(data), + label: 'course', + }); + } + + async getTasks(): Promise { + const size = 10 ** 4; + + return this.getCachedOrFetchTasks({ + cacheKey: 'aoj_courses', + endpoint: `problems?size=${size}`, + errorMessage: 'Failed to fetch course tasks from AOJ API', + validateResponse: (data) => Array.isArray(data) && data.length > 0, + transformer: (data) => this.transformCourseTasks(data), + label: 'course', + }); + } + + private transformCourseContests(data: AOJCourseAPI): ContestsForImport { + return data.courses.map((course: Course) => { + return mapToContest(course.shortName, course.name); + }); + } + + private transformCourseTasks(data: AOJTaskAPIs): TasksForImport { + return data + .filter((task: AOJTaskAPI) => getCourseName(task.id) !== '') + .map((task: AOJTaskAPI) => { + return mapToTask(task, getCourseName(task.id)); + }); + } +} + +/** + * Client for interfacing with the Aizu Online Judge (AOJ) API to fetch challenge contests and tasks. + * + * @extends AojTasksApiClientBase + */ +export class AojChallengesApiClient extends AojTasksApiClientBase { + async getContests(params: ChallengeParams): Promise { + const { contestType, round } = params; + + return this.getCachedOrFetchContests({ + cacheKey: this.getCacheKey(contestType, round), + endpoint: buildEndpoint(['challenges', 'cl', contestType, round]), + errorMessage: `Failed to fetch ${this.getContestTypeLabel(contestType)} ${round} contests from AOJ API`, + validateResponse: (data) => this.validateApiResponse(data), + transformer: (data) => this.transformToContests(data), + label: `${this.getContestTypeLabel(contestType)} ${round}`, + }); + } + + async getTasks(params: ChallengeParams): Promise { + const { contestType, round } = params; + + return this.getCachedOrFetchTasks({ + cacheKey: this.getCacheKey(contestType, round), + endpoint: buildEndpoint(['challenges', 'cl', contestType, round]), + errorMessage: `Failed to fetch ${this.getContestTypeLabel(contestType)} ${round} tasks from AOJ API`, + validateResponse: (data) => this.validateApiResponse(data), + transformer: (data) => this.transformToTasks(data), + label: `${this.getContestTypeLabel(contestType)} ${round}`, + }); + } + + private getCacheKey( + contestType: ChallengeContestType, + round: ChallengeRoundMap[ChallengeContestType], + ): string { + return `aoj_${contestType.toLowerCase()}_${round.toLowerCase()}`; + } + + private validateApiResponse(data: AOJChallengeContestAPI): boolean { + return 'contests' in data && Array.isArray(data.contests) && data.contests.length > 0; + } + + private transformToContests(data: AOJChallengeContestAPI): ContestsForImport { + return data.contests.flatMap((contest: ChallengeContest) => + contest.days + .map((day) => day.title) + .map((title: string) => { + return mapToContest(contest.abbr, title); + }), + ); + } + + private transformToTasks(data: AOJChallengeContestAPI): TasksForImport { + return data.contests.flatMap((contest: ChallengeContest) => + contest.days.flatMap((day) => + day.problems.map((problem) => { + return mapToTask(problem, contest.abbr); + }), + ), + ); + } + + private getContestTypeLabel(contestType: ChallengeContestType): string { + return contestType.toUpperCase(); + } +} diff --git a/src/lib/clients/aizu_online_judge/types.ts b/src/lib/clients/aizu_online_judge/types.ts new file mode 100644 index 000000000..5b82f6dbf --- /dev/null +++ b/src/lib/clients/aizu_online_judge/types.ts @@ -0,0 +1,96 @@ +/** + * Represents the response structure from AOJ Course API + */ +export type AOJCourseAPI = { + filter: string; + courses: Courses; +}; + +/** Represents a course in the AOJ */ +export type Course = { + readonly id: number; + readonly serial: number; + readonly shortName: string; + readonly name: string; + readonly type: string; +}; + +export type Courses = Course[]; + +/** + * Parameters for configuring a challenge contest in the AOJ. + * @property {ChallengeContestType} contestType - The type of contest for the challenge. + * @property {ChallengeRoundMap[ChallengeContestType]} round - The round of the contest. + */ +export type ChallengeParams = { + contestType: ChallengeContestType; + round: ChallengeRoundMap[ChallengeContestType]; +}; + +/** Represents the types of challenge contests available. */ +export type ChallengeContestType = 'PCK' | 'JAG'; + +/** + * A map that associates each type of challenge contest with its corresponding round type. + */ +export type ChallengeRoundMap = { + PCK: PckRound; + JAG: JagRound; +}; + +/** Represents PCK contest rounds */ +export type PckRound = 'PRELIM' | 'FINAL'; + +/** Represents JAG contest rounds */ +export type JagRound = 'PRELIM' | 'REGIONAL'; + +export type AOJChallengeContestAPI = { + readonly largeCl: Record; + readonly contests: ChallengeContests; +}; + +/** Represents a challenge contest in the AOJ */ +export type ChallengeContest = { + readonly abbr: string; + readonly largeCl: string; + readonly middleCl: string; + readonly year: number; + readonly progress: number; + readonly numberOfProblems: number; + readonly numberOfSolved: number; + readonly days: { title: string; problems: AOJTaskAPI[] }[]; +}; + +export type ChallengeContests = ChallengeContest[]; + +/** + * Represents a task in the AOJ API response + * @property {string} id - Unique identifier for the task + * @property {string} name - Task name + */ +export type AOJTaskAPI = { + readonly id: string; + readonly available: number; + readonly doctype: number; + readonly name: string; + readonly problemTimeLimit: number; + readonly problemMemoryLimit: number; + readonly maxScore: number; + readonly solvedUser: number; + readonly submissions: number; + readonly recommendations: number; + readonly isSolved: boolean; + readonly bookmark: boolean; + readonly recommend: boolean; + readonly successRate: number; + readonly score: number; + readonly userScore: number; +}; + +export type AOJTaskAPIs = AOJTaskAPI[]; + +/** + * Constant used as a placeholder for missing timestamp data in AOJ contests. + * Value: -1 + */ +export const PENDING = -1; diff --git a/src/lib/clients/aizu_online_judge/utils.test.ts b/src/lib/clients/aizu_online_judge/utils.test.ts new file mode 100644 index 000000000..b2f203fd9 --- /dev/null +++ b/src/lib/clients/aizu_online_judge/utils.test.ts @@ -0,0 +1,154 @@ +import { describe, test, expect } from 'vitest'; + +import { buildEndpoint, mapToContest, mapToTask, getCourseName } from './utils'; +import { PENDING } from './types'; +import type { AOJTaskAPI } from './types'; + +describe('buildEndpoint', () => { + describe('successful cases', () => { + test('joins single segment', () => { + expect(buildEndpoint(['challenges'])).toBe('challenges'); + }); + + test('joins multiple segments with slash', () => { + expect(buildEndpoint(['challenges', 'cl', 'PCK', 'PRELIM'])).toBe('challenges/cl/PCK/PRELIM'); + }); + + test('encodes segments with URI encoding', () => { + expect(buildEndpoint(['challenges', 'cl', 'JAG', 'REGIONAL'])).toBe( + 'challenges/cl/JAG/REGIONAL', + ); + }); + + test('accepts segments with hyphens and underscores', () => { + expect(buildEndpoint(['some-segment', 'other_segment'])).toBe('some-segment/other_segment'); + }); + }); + + describe('error cases', () => { + test('throws when segments array is empty', () => { + expect(() => buildEndpoint([])).toThrow('Endpoint segments array cannot be empty'); + }); + + test('throws when segment contains invalid characters', () => { + expect(() => buildEndpoint(['invalid segment'])).toThrow('Invalid segment'); + }); + + test('throws when segment contains path traversal', () => { + expect(() => buildEndpoint(['..'])).toThrow('Invalid segment'); + }); + + test('throws when segment exceeds max length', () => { + expect(() => buildEndpoint(['a'.repeat(101)])).toThrow('Invalid segment'); + }); + }); +}); + +describe('mapToContest', () => { + describe('successful cases', () => { + test('maps contestId and title to ContestForImport shape', () => { + const result = mapToContest('PCK2024', 'PCK 2024 Preliminary'); + + expect(result).toEqual({ + id: 'PCK2024', + title: 'PCK 2024 Preliminary', + start_epoch_second: PENDING, + duration_second: PENDING, + rate_change: '', + }); + }); + + test('sets PENDING for start_epoch_second and duration_second', () => { + const result = mapToContest('ITP1', 'Introduction to Programming'); + + expect(result.start_epoch_second).toBe(PENDING); + expect(result.duration_second).toBe(PENDING); + }); + + test('sets empty string for rate_change', () => { + const result = mapToContest('JAG2023', 'JAG Regional 2023'); + expect(result.rate_change).toBe(''); + }); + }); +}); + +describe('mapToTask', () => { + const baseTask: AOJTaskAPI = { + id: 'ITP1_1_A', + available: 1, + doctype: 0, + name: 'Hello, World!', + problemTimeLimit: 1000, + problemMemoryLimit: 65536, + maxScore: 100, + solvedUser: 5000, + submissions: 10000, + recommendations: 100, + isSolved: false, + bookmark: false, + recommend: false, + successRate: 50.0, + score: 0, + userScore: 0, + }; + + describe('successful cases', () => { + test('maps problem id, contest_id, and name', () => { + const result = mapToTask(baseTask, 'ITP1'); + + expect(result).toEqual({ + id: 'ITP1_1_A', + contest_id: 'ITP1', + problem_index: 'ITP1_1_A', + task_id: 'ITP1_1_A', + title: 'Hello, World!', + }); + }); + + test('uses problem.id as problem_index and task_id', () => { + const result = mapToTask(baseTask, 'ITP1'); + + expect(result.problem_index).toBe(baseTask.id); + expect(result.task_id).toBe(baseTask.id); + }); + + test('uses problem.name as title', () => { + const result = mapToTask(baseTask, 'ITP1'); + expect(result.title).toBe('Hello, World!'); + }); + }); +}); + +describe('getCourseName', () => { + describe('successful cases', () => { + test('returns course name for ITP1 style task ID', () => { + expect(getCourseName('ITP1_1_A')).toBe('ITP1'); + }); + + test('returns course name for INFO1 style task ID', () => { + expect(getCourseName('INFO1_01_E')).toBe('INFO1'); + }); + + test('returns course name for ALDS1 style task ID', () => { + expect(getCourseName('ALDS1_1_A')).toBe('ALDS1'); + }); + }); + + describe('edge cases', () => { + test('for challenge numeric task ID (no underscores)', () => { + expect(getCourseName('0001')).toBe(''); + }); + + test('for task ID with only two underscore-separated parts', () => { + expect(getCourseName('ITP1_1')).toBe(''); + }); + + test('for task ID with more than three parts', () => { + expect(getCourseName('ITP1_1_A_extra')).toBe(''); + }); + + test('for empty string', () => { + expect(getCourseName('')).toBe(''); + }); + }); +}); diff --git a/src/lib/clients/aizu_online_judge/utils.ts b/src/lib/clients/aizu_online_judge/utils.ts new file mode 100644 index 000000000..15230a4d6 --- /dev/null +++ b/src/lib/clients/aizu_online_judge/utils.ts @@ -0,0 +1,89 @@ +import type { ContestForImport } from '$lib/types/contest'; + +import { PENDING } from './types'; +import type { AOJTaskAPI } from './types'; + +/** + * Constructs an endpoint URL by encoding each segment and joining them with a '/'. + * + * @param segments - An array of strings representing the segments of the URL. + * @returns The constructed endpoint URL as a string. + */ +export function buildEndpoint(segments: string[]): string { + if (!segments?.length) { + throw new Error('Endpoint segments array cannot be empty'); + } + + const MAX_SEGMENT_LENGTH = 100; + const validateSegment = (segment: string): boolean => { + return ( + segment.length <= MAX_SEGMENT_LENGTH && + /^[a-zA-Z](?:[a-zA-Z0-9]|[-_](?=[a-zA-Z0-9])){0,98}[a-zA-Z0-9]$/.test(segment) && + !segment.includes('..') + ); + }; + + for (const segment of segments) { + if (!validateSegment(segment)) { + throw new Error( + `Invalid segment: ${segment}. Segments must be alphanumeric with hyphens and underscores, max length ${MAX_SEGMENT_LENGTH}`, + ); + } + } + + return segments.map((segment) => encodeURIComponent(segment)).join('/'); +} + +/** + * Maps the given contest details to a `ContestForImport` object. + * + * @param contestId - The unique identifier for the contest. + * @param title - The title of the contest. + * @returns A `ContestForImport` object with the provided contest details. + */ +export function mapToContest(contestId: string, title: string): ContestForImport { + return { + id: contestId, + start_epoch_second: PENDING, // Data not available + duration_second: PENDING, // Same as above + title: title, + rate_change: '', // Same as above + }; +} + +/** + * Maps the AOJTaskAPI problem object to a task object. + * + * @param problem - The problem object from AOJTaskAPI. + * @param contestId - The ID of the contest. + * @returns An object representing the task. + */ +export function mapToTask(problem: AOJTaskAPI, contestId: string) { + return { + id: problem.id, + contest_id: contestId, + problem_index: problem.id, // Using task.id as a substitute since there's no equivalent to problem_index. Similar approach is used in AtCoder Problems API for old JOI problems. + task_id: problem.id, // Same as above + title: problem.name, + }; +} + +/** + * Extracts the course name from a given task ID. + * + * The task ID is expected to be in the format `courseName_taskId_otherInfo` in courses + * (e.g. ITP1_1_A, INFO1_01_E) and a numeric string in challenges (e.g. 0001, 0703). + * Returns empty string if the format does not match. + * + * @param taskId - The task ID string from which to extract the course name. + * @returns The extracted course name or an empty string if the format is incorrect. + */ +export function getCourseName(taskId: string): string { + if (!taskId || typeof taskId !== 'string') { + return ''; + } + + const parts = taskId.split('_'); + + return parts.length === 3 ? parts[0] : ''; +} diff --git a/src/test/lib/clients/atcoder_problems.test.ts b/src/lib/clients/atcoder/atcoder_problems.test.ts similarity index 58% rename from src/test/lib/clients/atcoder_problems.test.ts rename to src/lib/clients/atcoder/atcoder_problems.test.ts index 248b29a9c..334d59f70 100644 --- a/src/test/lib/clients/atcoder_problems.test.ts +++ b/src/lib/clients/atcoder/atcoder_problems.test.ts @@ -1,15 +1,22 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import nock from 'nock'; -import { loadMockData } from '../common/test_helpers'; - -import { ContestSiteApiClient } from '$lib/clients/http_client'; -import { AtCoderProblemsApiClient } from '$lib/clients/atcoder_problems'; +vi.mock('$lib/utils/time', () => ({ + delay: vi.fn().mockResolvedValue(undefined), +})); import type { ContestsForImport } from '$lib/types/contest'; import type { TasksForImport } from '$lib/types/task'; +import { AtCoderProblemsApiClient } from '$lib/clients/atcoder/atcoder_problems'; + +import { loadMockData } from '../fixtures/helpers'; + +const API_BASE = 'https://kenkoooo.com'; +const API_PATH = '/atcoder/resources/'; + describe('AtCoder Problems API client', () => { - let client: ContestSiteApiClient; + let client: AtCoderProblemsApiClient; let contestsMock: ContestsForImport; let tasksMock: TasksForImport; @@ -17,8 +24,8 @@ describe('AtCoder Problems API client', () => { client = new AtCoderProblemsApiClient(); const MOCK_DATA_PATHS = { - contests: './src/test/lib/clients/test_data/atcoder_problems/contests.json', - tasks: './src/test/lib/clients/test_data/atcoder_problems/tasks.json', + contests: './src/lib/clients/fixtures/atcoder_problems/contests.json', + tasks: './src/lib/clients/fixtures/atcoder_problems/tasks.json', }; try { @@ -33,10 +40,17 @@ describe('AtCoder Problems API client', () => { } }); + beforeEach(() => { + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + describe('getContests', () => { test('expects to fetch contests', async () => { - // Use mock data instead of making a request. - client.getContests = async () => contestsMock; + nock(API_BASE).get(`${API_PATH}contests.json`).reply(200, contestsMock); const contests = await client.getContests(); expect(contests.length).toBe(contestsMock.length); @@ -45,20 +59,24 @@ describe('AtCoder Problems API client', () => { // See: // https://vitest.dev/api/expect.html#tobedefined test('each contest expects to have id and title', async () => { - contestsMock.forEach((contest) => { + nock(API_BASE).get(`${API_PATH}contests.json`).reply(200, contestsMock); + const contests = await client.getContests(); + contests.forEach((contest) => { expect(contest.id).toBeDefined(); expect(contest.title).toBeDefined(); }); }); - test('handles empty contests list', async () => { - client.getContests = async () => []; + test('returns empty array when API response is empty', async () => { + nock(API_BASE).get(`${API_PATH}contests.json`).reply(200, []); const contests = await client.getContests(); expect(contests).toHaveLength(0); }); test('validates contest properties format', async () => { - contestsMock.forEach((contest) => { + nock(API_BASE).get(`${API_PATH}contests.json`).reply(200, contestsMock); + const contests = await client.getContests(); + contests.forEach((contest) => { expect(typeof contest.id).toBe('string'); expect(contest.id).toMatch(/^[a-zA-Z0-9_-]+$/); expect(typeof contest.title).toBe('string'); @@ -69,15 +87,16 @@ describe('AtCoder Problems API client', () => { describe('getTasks', () => { test('expects to fetch tasks', async () => { - // Use mock data instead of making a request. - client.getTasks = async () => tasksMock; + nock(API_BASE).get(`${API_PATH}problems.json`).reply(200, tasksMock); const tasks = await client.getTasks(); expect(tasks.length).toEqual(tasksMock.length); }); test('each task expects to have id, contest_id, problem_index and title', async () => { - tasksMock.forEach((task) => { + nock(API_BASE).get(`${API_PATH}problems.json`).reply(200, tasksMock); + const tasks = await client.getTasks(); + tasks.forEach((task) => { expect(task.id).toBeDefined(); expect(task.contest_id).toBeDefined(); expect(task.problem_index).toBeDefined(); @@ -85,14 +104,16 @@ describe('AtCoder Problems API client', () => { }); }); - test('handles empty tasks list', async () => { - client.getTasks = async () => []; + test('returns empty array when API response is empty', async () => { + nock(API_BASE).get(`${API_PATH}problems.json`).reply(200, []); const tasks = await client.getTasks(); expect(tasks).toHaveLength(0); }); test('validates task properties format', async () => { - tasksMock.forEach((task) => { + nock(API_BASE).get(`${API_PATH}problems.json`).reply(200, tasksMock); + const tasks = await client.getTasks(); + tasks.forEach((task) => { expect(typeof task.id).toBe('string'); expect(typeof task.contest_id).toBe('string'); expect(typeof task.problem_index).toBe('string'); diff --git a/src/lib/clients/atcoder_problems.ts b/src/lib/clients/atcoder/atcoder_problems.ts similarity index 55% rename from src/lib/clients/atcoder_problems.ts rename to src/lib/clients/atcoder/atcoder_problems.ts index 09b930f01..fd36427b8 100644 --- a/src/lib/clients/atcoder_problems.ts +++ b/src/lib/clients/atcoder/atcoder_problems.ts @@ -1,4 +1,4 @@ -import { ContestSiteApiClient } from '$lib/clients/http_client'; +import { HttpRequestClient } from '$lib/clients/http_client'; import type { ContestsForImport } from '$lib/types/contest'; import type { TasksForImport } from '$lib/types/task'; @@ -7,32 +7,21 @@ import { ATCODER_PROBLEMS_API_BASE_URL } from '$lib/constants/urls'; /** * The `AtCoderProblemsApiClient` class provides methods to interact with the AtCoder Problems API. - * It extends the `ContestSiteApiClient` class and includes methods to fetch contests and tasks. + * Uses `HttpRequestClient` for HTTP requests. * * @class - * @extends {ContestSiteApiClient} - * - * @method getContests - * Fetches the list of contests from the AtCoder Problems API. - * @returns {Promise} A promise that resolves to the list of contests. - * @throws Will throw an error if the fetch operation fails or if the response is invalid. - * - * @method getTasks - * Fetches tasks from the AtCoder Problems API. - * @returns {Promise} A promise that resolves to an array of tasks for import. - * @throws Will throw an error if the fetch operation fails or if the response validation fails. */ -export class AtCoderProblemsApiClient extends ContestSiteApiClient { +export class AtCoderProblemsApiClient { + constructor(private readonly http = new HttpRequestClient(ATCODER_PROBLEMS_API_BASE_URL)) {} + /** * Fetches the list of contests from the AtCoder Problems API. * * @returns {Promise} A promise that resolves to the list of contests. - * @throws Will throw an error if the fetch operation fails or if the response is invalid. */ async getContests(): Promise { try { - const contests = await this.fetchApiWithConfig({ - baseApiUrl: ATCODER_PROBLEMS_API_BASE_URL, + const contests = await this.http.fetchApiWithConfig({ endpoint: 'contests.json', errorMessage: 'Failed to fetch contests from AtCoder Problems API', validateResponse: (data) => Array.isArray(data) && data.length > 0, @@ -51,12 +40,10 @@ export class AtCoderProblemsApiClient extends ContestSiteApiClient { * Fetches tasks from the AtCoder Problems API. * * @returns {Promise} A promise that resolves to an array of tasks for import. - * @throws Will throw an error if the fetch operation fails or if the response validation fails. */ async getTasks(): Promise { try { - const tasks = await this.fetchApiWithConfig({ - baseApiUrl: ATCODER_PROBLEMS_API_BASE_URL, + const tasks = await this.http.fetchApiWithConfig({ endpoint: 'problems.json', errorMessage: 'Failed to fetch tasks from AtCoder Problems API', validateResponse: (data) => Array.isArray(data) && data.length > 0, diff --git a/src/test/lib/clients/cache.test.ts b/src/lib/clients/cache.test.ts similarity index 100% rename from src/test/lib/clients/cache.test.ts rename to src/lib/clients/cache.test.ts diff --git a/src/test/lib/clients/test_data/aizu_online_judge/contests.json b/src/lib/clients/fixtures/aizu_online_judge/contests.json similarity index 100% rename from src/test/lib/clients/test_data/aizu_online_judge/contests.json rename to src/lib/clients/fixtures/aizu_online_judge/contests.json diff --git a/src/test/lib/clients/test_data/aizu_online_judge/tasks.json b/src/lib/clients/fixtures/aizu_online_judge/tasks.json similarity index 100% rename from src/test/lib/clients/test_data/aizu_online_judge/tasks.json rename to src/lib/clients/fixtures/aizu_online_judge/tasks.json diff --git a/src/test/lib/clients/test_data/atcoder_problems/contests.json b/src/lib/clients/fixtures/atcoder_problems/contests.json similarity index 100% rename from src/test/lib/clients/test_data/atcoder_problems/contests.json rename to src/lib/clients/fixtures/atcoder_problems/contests.json diff --git a/src/test/lib/clients/test_data/atcoder_problems/tasks.json b/src/lib/clients/fixtures/atcoder_problems/tasks.json similarity index 100% rename from src/test/lib/clients/test_data/atcoder_problems/tasks.json rename to src/lib/clients/fixtures/atcoder_problems/tasks.json diff --git a/src/lib/clients/fixtures/helpers.test.ts b/src/lib/clients/fixtures/helpers.test.ts new file mode 100644 index 000000000..95df7ffa0 --- /dev/null +++ b/src/lib/clients/fixtures/helpers.test.ts @@ -0,0 +1,43 @@ +import path from 'path'; +import fs from 'fs'; +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +import { loadMockData } from './helpers'; + +describe('loadMockData', () => { + const testDir = __dirname; + const mockFilePath = path.join(testDir, 'mockData.json'); + const mockData = { key: 'value' }; + + beforeAll(() => { + fs.writeFileSync(mockFilePath, JSON.stringify(mockData)); + }); + + afterAll(() => { + const testFiles = ['mockData.json', 'invalidJson.json']; + + testFiles.forEach((file) => { + const filePath = path.join(testDir, file); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + }); + }); + + test('expects to load and parse mock data from a file', () => { + const data = loadMockData(mockFilePath); + expect(data).toEqual(mockData); + }); + + test('expects to throw an error if the file does not exist', () => { + const invalidFilePath = path.resolve(__dirname, 'nonExistentFile.json'); + expect(() => loadMockData(invalidFilePath)).toThrow(); + }); + + test('expects to throw an error if the file content is not valid JSON', () => { + const invalidJsonFilePath = path.resolve(__dirname, 'invalidJson.json'); + fs.writeFileSync(invalidJsonFilePath, 'invalid json'); + expect(() => loadMockData(invalidJsonFilePath)).toThrow(); + }); +}); diff --git a/src/lib/clients/fixtures/helpers.ts b/src/lib/clients/fixtures/helpers.ts new file mode 100644 index 000000000..b97399307 --- /dev/null +++ b/src/lib/clients/fixtures/helpers.ts @@ -0,0 +1,28 @@ +import path from 'path'; +import fs from 'fs'; + +/** + * Loads mock data from a specified file path and parses it as JSON. + * + * @template T - The type to which the parsed JSON should be cast. + * @param {string} filePath - The path to the file containing the mock data. + * @returns {T} - The parsed mock data cast to the specified type. + */ +export const loadMockData = (filePath: string): T => { + const testDataPath = path.resolve(filePath); + + try { + return JSON.parse(fs.readFileSync(testDataPath, 'utf8')) as T; + } catch (error) { + if (error instanceof Error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Mock data file not found: ${filePath}`); + } + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in mock data file: ${filePath}`); + } + } + + throw error; + } +}; diff --git a/src/test/lib/clients/record_requests.ts b/src/lib/clients/fixtures/record_requests.ts similarity index 84% rename from src/test/lib/clients/record_requests.ts rename to src/lib/clients/fixtures/record_requests.ts index 62b347bce..d27ba7438 100644 --- a/src/test/lib/clients/record_requests.ts +++ b/src/lib/clients/fixtures/record_requests.ts @@ -5,13 +5,14 @@ import path from 'path'; import nock from 'nock'; import fs from 'fs'; -import type { ContestSiteApiClient } from '$lib/clients/http_client'; -import { AtCoderProblemsApiClient } from '$lib/clients/atcoder_problems'; -import { AojApiClient } from '$lib/clients/aizu_online_judge'; +import type { TasksApiClient } from '$lib/clients/http_client'; + +import { AtCoderProblemsApiClient } from '$lib/clients/atcoder/atcoder_problems'; +import { AojApiClient } from '$lib/clients/aizu_online_judge/clients'; // Run the main function if you add a contest site. // Usage: -// pnpm dlx vite-node ./src/test/lib/clients/record_requests.ts +// pnpm dlx vite-node ./src/lib/clients/fixtures/record_requests.ts async function main(): Promise { try { startRecordRequests(); @@ -38,16 +39,16 @@ async function main(): Promise { * An array of client objects, each containing a name and an instance of an API client. * * @constant - * @type {Array<{ name: string, source: ContestSiteApiClient }>} + * @type {Array<{ name: string, source: TasksApiClient }>} * @property {string} name - The name of the client. - * @property {ContestSiteApiClient} source - An instance of the API client. + * @property {TasksApiClient} source - An instance of the API client. */ const clients = [ { name: 'atcoder_problems', source: new AtCoderProblemsApiClient() }, { name: 'aizu_online_judge', source: new AojApiClient() }, ]; -export const TEST_DATA_BASE_DIR = path.join('src', 'test', 'lib', 'clients', 'test_data'); +export const TEST_DATA_BASE_DIR = path.join('src', 'lib', 'clients', 'fixtures'); function ensureDirectoryExists(dirPath: string): void { if (!fs.existsSync(dirPath)) { @@ -58,13 +59,13 @@ function ensureDirectoryExists(dirPath: string): void { /** * Saves a specified number of contests from a contest site to a JSON file. * - * @param client - An instance of `ContestSiteApiClient` used to fetch contests. + * @param client - An instance of `TasksApiClient` used to fetch contests. * @param contestSite - The name of the contest site. * @param count - The number of contests to save. Defaults to 100. * @returns A promise that resolves when the contests have been saved. */ async function saveContests( - client: ContestSiteApiClient, + client: TasksApiClient, contestSite: string, count: number = 100, ): Promise { @@ -80,14 +81,14 @@ async function saveContests( /** * Saves a specified number of tasks from a contest site to a JSON file. * - * @param client - An instance of `ContestSiteApiClient` used to fetch tasks. + * @param client - An instance of `TasksApiClient` used to fetch tasks. * @param contestSite - The identifier for the contest site. * @param count - The number of tasks to save. Defaults to 100. * * @returns A promise that resolves when the tasks have been saved. */ async function saveTasks( - client: ContestSiteApiClient, + client: TasksApiClient, contestSite: string, count: number = 100, ): Promise { @@ -111,7 +112,7 @@ function stopRecordRequests(): void { nock.recorder.play(); } -function validateContestSiteApi(client: ContestSiteApiClient, contestSite: string): void { +function validateContestSiteApi(client: TasksApiClient, contestSite: string): void { if (!client) { throw new Error('Client is required'); } diff --git a/src/lib/clients/http_client.ts b/src/lib/clients/http_client.ts index 6714b22af..beb9d0c3c 100644 --- a/src/lib/clients/http_client.ts +++ b/src/lib/clients/http_client.ts @@ -60,67 +60,16 @@ export class HttpRequestClient { } } -/** - * @deprecated Use `HttpRequestClient` instead. - * - * An abstract class representing a client for contest sites' APIs. - * This class provides methods to fetch contests and tasks, and a protected method to fetch data from an API endpoint with a given configuration. - * - * @abstract - */ -export abstract class ContestSiteApiClient { - abstract getContests(): Promise; - abstract getTasks(): Promise; - - /** - * Fetches data from an API endpoint with the provided configuration. - * - * @template T - The expected response type. - * @param {FetchAPIConfig} config - The configuration object for the API request. - * @param {string} config.baseApiUrl - The base URL of the API. - * @param {string} config.endpoint - The specific endpoint to fetch data from. - * @param {string} config.errorMessage - The error message to display if the fetch fails. - * @param {(data: T) => boolean} [config.validateResponse] - An optional function to validate the response data. - * @returns {Promise} - A promise that resolves to the fetched data of type T. - * @throws {Error} - Throws an error if the response validation fails. - */ - protected async fetchApiWithConfig({ - baseApiUrl, - endpoint, - errorMessage, - validateResponse, - }: FetchAPIConfig): Promise { - if (!baseApiUrl) { - throw new Error('baseApiUrl is required when using ContestSiteApiClient'); - } - - try { - const url = new URL(endpoint, baseApiUrl).toString(); - const data = await fetchAPI(url, errorMessage); - - if (validateResponse && !validateResponse(data)) { - throw new Error(`${errorMessage}. Response validation failed for ${endpoint}`); - } - - return data; - } catch (error) { - throw new Error(`Failed to fetch from ${endpoint}: ${error}`); - } - } -} - /** * Configuration object for fetching data from an API. * * @template T - The type of the data expected from the API response. * - * @property {string} baseApiUrl - The base URL of the API. * @property {string} endpoint - The specific endpoint of the API to fetch data from. * @property {string} errorMessage - The error message to display if the fetch operation fails. * @property {(data: T) => boolean} [validateResponse] - Optional function to validate the API response data. */ export type FetchAPIConfig = { - baseApiUrl?: string; // (Deprecated) Use baseApiUrl in HttpRequestClient. endpoint: string; errorMessage: string; validateResponse?: (data: T) => boolean; diff --git a/src/lib/clients/index.ts b/src/lib/clients/index.ts index 1a4bd1f0d..bed92c3c8 100644 --- a/src/lib/clients/index.ts +++ b/src/lib/clients/index.ts @@ -1,5 +1,5 @@ -import { AtCoderProblemsApiClient } from '$lib/clients/atcoder_problems'; -import { AojApiClient } from '$lib/clients/aizu_online_judge'; +import { AtCoderProblemsApiClient } from '$lib/clients/atcoder/atcoder_problems'; +import { AojApiClient } from '$lib/clients/aizu_online_judge/clients'; import type { ContestForImport, ContestsForImport } from '$lib/types/contest'; import type { TaskForImport, TasksForImport } from '$lib/types/task'; diff --git a/src/test/lib/common/test_helpers.test.ts b/src/test/lib/common/test_helpers.test.ts index 278c20b70..c0f91d4af 100644 --- a/src/test/lib/common/test_helpers.test.ts +++ b/src/test/lib/common/test_helpers.test.ts @@ -1,8 +1,5 @@ -import path from 'path'; -import fs from 'fs'; import { describe, it, expect } from 'vitest'; -import { loadMockData } from '../common/test_helpers'; import { createTestCase, runTests, zip } from './test_helpers'; import { testCasesForZip, errorTestCases } from './test_cases/zip'; @@ -85,49 +82,3 @@ describe('zip', () => { }); }); }); - -describe('loadMockData', () => { - const testDir = __dirname; - const mockFilePath = path.join(testDir, 'mockData.json'); - const mockData = { key: 'value' }; - - beforeAll(() => { - fs.writeFileSync(mockFilePath, JSON.stringify(mockData)); - }); - - afterAll(() => { - // Cleanup any test files that might remain - const testFiles = ['mockData.json', 'invalidJson.json']; - - testFiles.forEach((file) => { - const filePath = path.join(testDir, file); - - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - }); - }); - - it('expects to load and parse mock data from a file', () => { - const data = loadMockData(mockFilePath); - expect(data).toEqual(mockData); - }); - - it('expects to throw an error if the file does not exist', () => { - const invalidFilePath = path.resolve(__dirname, 'nonExistentFile.json'); - expect(() => loadMockData(invalidFilePath)).toThrow(); - }); - - it('expects to throw an error if the file content is not valid JSON', () => { - const invalidJsonFilePath = path.resolve(__dirname, 'invalidJson.json'); - fs.writeFileSync(invalidJsonFilePath, 'invalid json'); - - try { - expect(() => loadMockData(invalidJsonFilePath)).toThrow(); - } finally { - if (fs.existsSync(invalidJsonFilePath)) { - fs.unlinkSync(invalidJsonFilePath); - } - } - }); -}); diff --git a/src/test/lib/common/test_helpers.ts b/src/test/lib/common/test_helpers.ts index ee62720a3..96834dcc3 100644 --- a/src/test/lib/common/test_helpers.ts +++ b/src/test/lib/common/test_helpers.ts @@ -1,6 +1,3 @@ -import path from 'path'; -import fs from 'fs'; - import { test } from 'vitest'; export interface TestCase { @@ -65,29 +62,3 @@ export function zip(firstArray: T[], secondArray: U[]): [T, U][] { export function runTests(testName: string, testCases: T[], testFunction: (testCase: T) => void) { test.each(testCases)(`${testName} - %o`, testFunction); } - -/** - * Loads mock data from a specified file path and parses it as JSON. - * - * @template T - The type to which the parsed JSON should be cast. - * @param {string} filePath - The path to the file containing the mock data. - * @returns {T} - The parsed mock data cast to the specified type. - */ -export const loadMockData = (filePath: string): T => { - const testDataPath = path.resolve(filePath); - - try { - return JSON.parse(fs.readFileSync(testDataPath, 'utf8')) as T; - } catch (error) { - if (error instanceof Error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new Error(`Mock data file not found: ${filePath}`); - } - if (error instanceof SyntaxError) { - throw new Error(`Invalid JSON in mock data file: ${filePath}`); - } - } - - throw error; - } -};