diff --git a/src/services/codechef.service.js b/src/services/codechef.service.js index e5565ae..e71bcc8 100644 --- a/src/services/codechef.service.js +++ b/src/services/codechef.service.js @@ -1,3 +1,5 @@ +import { HttpErrorCode, httpRequest } from '../utils/http-client.js'; + function getDivision(rating) { if (rating >= 2000) return 'Div 1'; if (rating >= 1600) return 'Div 2'; @@ -8,15 +10,16 @@ function getDivision(rating) { export async function getCodeChefData(handle) { try { const safeHandle = encodeURIComponent(handle); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - const res = await fetch( - `https://competeapi.vercel.app/user/codechef/${safeHandle}/`, - { signal: controller.signal } - ); - clearTimeout(timeout); - const data = await res.json(); + const res = await httpRequest(`https://competeapi.vercel.app/user/codechef/${safeHandle}/`); + if (!res.success) { + if (res.error?.code === HttpErrorCode.TIMEOUT) { + return { success: false, error: 'CodeChef API timeout' }; + } + return { success: false, error: res.error?.message || 'CodeChef API error' }; + } + + const data = res.data; if (!data || !data.username) { return { success: false, error: 'User not found' }; @@ -29,16 +32,13 @@ export async function getCodeChefData(handle) { data: { handle: data.username ?? handle, currentRating, - stars: data.rating ?? '1★', + stars: data.rating ?? '1\u2605', globalRank: data.globalRank ?? data.global_rank ?? 'N/A', problemsSolved: data.problemsSolved ?? data.problems_solved ?? 0, division: getDivision(currentRating), } }; } catch (err) { - if (err.name === 'AbortError') { - return { success: false, error: 'CodeChef API timeout' }; - } return { success: false, error: err.message }; } } diff --git a/src/services/codeforces.service.js b/src/services/codeforces.service.js index a6f0cec..2479e02 100644 --- a/src/services/codeforces.service.js +++ b/src/services/codeforces.service.js @@ -1,38 +1,39 @@ +import { HttpErrorCode, httpRequest } from '../utils/http-client.js'; + export async function getCodeforcesData(handle) { try { const safeHandle = encodeURIComponent(handle); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); const [infoRes, statusRes] = await Promise.all([ - fetch(`https://codeforces.com/api/user.info?handles=${safeHandle}`, { signal: controller.signal }), - fetch(`https://codeforces.com/api/user.status?handle=${safeHandle}&from=1&count=10000`, { signal: controller.signal }), + httpRequest(`https://codeforces.com/api/user.info?handles=${safeHandle}`), + httpRequest(`https://codeforces.com/api/user.status?handle=${safeHandle}&from=1&count=10000`), ]); - clearTimeout(timeout); + if (!infoRes.success) { + if (infoRes.error?.code === HttpErrorCode.TIMEOUT) { + return { success: false, error: 'Codeforces API timeout' }; + } + return { success: false, error: infoRes.error?.message || 'Codeforces API error' }; + } - const infoData = await infoRes.json(); + const infoData = infoRes.data; if (infoData.status !== 'OK') { return { success: false, error: 'User not found' }; } const user = infoData.result[0]; - // Count distinct solved problems + // Count distinct solved problems. The status endpoint is optional; if it + // fails, profile stats still render with a solved count of 0. let problemsSolved = 0; - try { - const statusData = await statusRes.json(); - if (statusData.status === 'OK') { - const solved = new Set(); - for (const sub of statusData.result) { - if (sub.verdict === 'OK' && sub.problem) { - solved.add(`${sub.problem.contestId ?? ''}${sub.problem.index}`); - } + if (statusRes.success && statusRes.data?.status === 'OK') { + const solved = new Set(); + for (const sub of statusRes.data.result) { + if (sub.verdict === 'OK' && sub.problem) { + solved.add(`${sub.problem.contestId ?? ''}${sub.problem.index}`); } - problemsSolved = solved.size; } - } catch (_) { - // non-critical — silently fall back to 0 + problemsSolved = solved.size; } return { @@ -47,9 +48,6 @@ export async function getCodeforcesData(handle) { } }; } catch (err) { - if (err.name === 'AbortError') { - return { success: false, error: 'Codeforces API timeout' }; - } return { success: false, error: err.message }; } } diff --git a/src/services/github-graphql.service.js b/src/services/github-graphql.service.js index d524d3b..e2988ec 100644 --- a/src/services/github-graphql.service.js +++ b/src/services/github-graphql.service.js @@ -1,6 +1,7 @@ // GitHub GraphQL API Service - Contribution Calendar & Streaks import { githubCache } from "../utils/cache.js"; +import { HttpErrorCode, httpRequest } from "../utils/http-client.js"; const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; @@ -54,49 +55,41 @@ async function fetchContributionData(username) { throw new Error("GITHUB_TOKEN required for contribution data"); } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - - try { - const response = await fetch(GITHUB_GRAPHQL_URL, { - method: "POST", - headers: getHeaders(), - body: JSON.stringify({ - query: CONTRIBUTION_QUERY, - variables: { username }, - }), - signal: controller.signal, - }); - - clearTimeout(timeout); - - // handles rate limits silently - const rateLimitRemaining = response.headers.get("x-ratelimit-remaining"); - if (rateLimitRemaining && parseInt(rateLimitRemaining, 10) < 10) { - // rate limit warning suppressed for production + const response = await httpRequest(GITHUB_GRAPHQL_URL, { + method: "POST", + headers: getHeaders(), + body: JSON.stringify({ + query: CONTRIBUTION_QUERY, + variables: { username }, + }), + }); + + if (!response.success) { + if (response.error?.code === HttpErrorCode.TIMEOUT) { + throw new Error("GitHub GraphQL API timeout"); } - - if (!response.ok) { - if (response.status === 403) { - throw new Error("GitHub API rate limit exceeded"); - } - throw new Error(`GitHub GraphQL API error: ${response.status}`); + if (response.error?.code === HttpErrorCode.INVALID_JSON) { + throw new Error("GitHub GraphQL API returned invalid JSON"); + } + if (response.status === 403 || response.status === 429) { + throw new Error("GitHub API rate limit exceeded"); } + throw new Error(`GitHub GraphQL API error: ${response.status || 0}`); + } - const json = await response.json(); + // handles rate limits silently + const rateLimitRemaining = response.headers?.get("x-ratelimit-remaining"); + if (rateLimitRemaining && parseInt(rateLimitRemaining, 10) < 10) { + // rate limit warning suppressed for production + } - if (json.errors) { - throw new Error(json.errors[0]?.message || "GraphQL query failed"); - } + const json = response.data; - return json.data?.user; - } catch (error) { - clearTimeout(timeout); - if (error.name === "AbortError") { - throw new Error("GitHub GraphQL API timeout"); - } - throw error; + if (json.errors) { + throw new Error(json.errors[0]?.message || "GraphQL query failed"); } + + return json.data?.user; } /* flatten contribution calendar weeks into a sorted array of days */ diff --git a/src/services/github.service.js b/src/services/github.service.js index b5d2cc3..c81b9ac 100644 --- a/src/services/github.service.js +++ b/src/services/github.service.js @@ -1,6 +1,7 @@ // GitHub REST API Service import { githubCache } from '../utils/cache.js'; +import { HttpErrorCode, httpRequest } from '../utils/http-client.js'; const GITHUB_API_BASE = 'https://api.github.com'; @@ -25,11 +26,11 @@ function errorFromStatus(status) { if (status === 404) { return new GitHubRequestError('User not found', GitHubErrorCode.NOT_FOUND, 404); } - if (status === 403) { + if (status === 403 || status === 429) { return new GitHubRequestError( 'GitHub API rate limit exceeded', GitHubErrorCode.RATE_LIMIT, - 403 + status ); } if (status >= 500) { @@ -61,24 +62,31 @@ function getHeaders() { } async function assertOk(response) { - if (!response.ok) { - throw errorFromStatus(response.status); + if (!response.success) { + if (response.error?.code === HttpErrorCode.TIMEOUT) { + throw new GitHubRequestError('GitHub API timeout', GitHubErrorCode.NETWORK); + } + if (response.error?.code === HttpErrorCode.INVALID_JSON) { + throw new GitHubRequestError('GitHub API returned invalid JSON', GitHubErrorCode.API_ERROR, response.status); + } + if (response.status) { + throw errorFromStatus(response.status); + } + throw new GitHubRequestError( + response.error?.message || 'Failed to reach GitHub API', + GitHubErrorCode.NETWORK + ); } } /* fetch user profile from GitHub API */ async function fetchUserProfile(username) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - - const response = await fetch(`${GITHUB_API_BASE}/users/${username}`, { + const response = await httpRequest(`${GITHUB_API_BASE}/users/${username}`, { headers: getHeaders(), - signal: controller.signal, }); - clearTimeout(timeout); await assertOk(response); - return response.json(); + return response.data; } /* fetch public repos for a user */ @@ -89,18 +97,14 @@ async function fetchUserRepos(username) { const MAX_PAGES = 3; while (page <= MAX_PAGES) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - - const response = await fetch( + const response = await httpRequest( `${GITHUB_API_BASE}/users/${username}/repos?per_page=${perPage}&page=${page}&sort=updated`, - { headers: getHeaders(), signal: controller.signal } + { headers: getHeaders() } ); - clearTimeout(timeout); await assertOk(response); - const data = await response.json(); + const data = response.data; repos.push(...data); if (data.length < perPage) { @@ -129,43 +133,37 @@ async function fetchAvatarDataUri(avatarUrl) { const AVATAR_TIMEOUT_MS = 8000; const MAX_SIZE_BYTES = 100 * 1024; // 100 KB safety limit - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), AVATAR_TIMEOUT_MS); - try { - const response = await fetch(resizedUrl, { + const response = await httpRequest(resizedUrl, { headers: { - 'User-Agent': 'samdev-pulse', 'Accept': 'image/*', }, - signal: controller.signal, + timeoutMs: AVATAR_TIMEOUT_MS, + responseType: 'arrayBuffer', }); - clearTimeout(timeout); - - if (!response.ok) { - throw new Error(`Avatar fetch error: ${response.status}`); + if (!response.success) { + throw new Error(response.error?.message || `Avatar fetch error: ${response.status}`); } // Check Content-Length header if available - const contentLength = response.headers.get('content-length'); + const contentLength = response.headers?.get('content-length'); if (contentLength && parseInt(contentLength, 10) > MAX_SIZE_BYTES) { throw new Error('Avatar image too large'); } - const buffer = Buffer.from(await response.arrayBuffer()); + const buffer = Buffer.from(response.data); // Double-check buffer size if (buffer.length > MAX_SIZE_BYTES) { throw new Error('Avatar image too large'); } - const contentType = response.headers.get('content-type') || 'image/png'; + const contentType = response.headers?.get('content-type') || 'image/png'; const base64 = buffer.toString('base64'); return `data:${contentType};base64,${base64}`; } catch (error) { - clearTimeout(timeout); - if (error.name === 'AbortError') { + if (error.name === 'AbortError' || error.code === HttpErrorCode.TIMEOUT) { console.warn('Avatar fetch timeout'); } return null; diff --git a/src/services/http-integrations.test.js b/src/services/http-integrations.test.js new file mode 100644 index 0000000..e899147 --- /dev/null +++ b/src/services/http-integrations.test.js @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, jest, test } from '@jest/globals'; +import { getCodeforcesData } from './codeforces.service.js'; +import { getGitHubUserData } from './github.service.js'; + +function jsonResponse(data, init = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + url: init.url || 'https://example.test/mock', + headers: init.headers || { get: () => null }, + json: async () => data, + text: async () => JSON.stringify(data), + arrayBuffer: async () => new ArrayBuffer(0), + }; +} + +function textResponse(text, init = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + url: init.url || 'https://example.test/mock', + headers: init.headers || { get: () => null }, + json: async () => { + JSON.parse(text); + }, + text: async () => text, + arrayBuffer: async () => new TextEncoder().encode(text).buffer, + }; +} + +describe('third-party HTTP integrations', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + test('keeps Codeforces profile data when submissions fail', async () => { + globalThis.fetch = jest.fn() + .mockResolvedValueOnce(jsonResponse({ + status: 'OK', + result: [{ + handle: 'tourist', + rating: 3850, + maxRating: 3979, + rank: 'legendary grandmaster', + maxRank: 'legendary grandmaster', + }], + })) + .mockResolvedValueOnce(textResponse('not json', { status: 200 })); + + const result = await getCodeforcesData('tourist'); + + expect(result).toMatchObject({ + success: true, + data: { + handle: 'tourist', + rating: 3850, + problemsSolved: 0, + }, + }); + }); + + test('preserves GitHub avatar fallback when image fetch fails', async () => { + const username = `octocat-${Date.now()}`; + + globalThis.fetch = jest.fn() + .mockResolvedValueOnce(jsonResponse({ + login: username, + name: 'Octo Cat', + avatar_url: 'https://avatars.example.test/u/1?v=4', + bio: '', + location: '', + company: '', + blog: '', + public_repos: 1, + followers: 2, + following: 3, + created_at: '2011-01-25T18:44:36Z', + })) + .mockResolvedValueOnce(jsonResponse([ + { + name: 'hello-world', + description: null, + stargazers_count: 5, + forks_count: 1, + language: 'JavaScript', + html_url: 'https://github.com/octocat/hello-world', + updated_at: '2026-01-01T00:00:00Z', + }, + ])) + .mockResolvedValueOnce(textResponse('', { status: 404 })); + + const result = await getGitHubUserData(username); + + expect(result.success).toBe(true); + expect(result.data.avatarDataUri).toBeNull(); + expect(result.data.avatarUrl).toBe('https://avatars.example.test/u/1?v=4'); + expect(result.data.totalStars).toBe(5); + }); +}); diff --git a/src/services/leetcode.service.js b/src/services/leetcode.service.js index 216eee4..4ed972b 100644 --- a/src/services/leetcode.service.js +++ b/src/services/leetcode.service.js @@ -1,6 +1,7 @@ // LeetCode Service import { githubCache } from '../utils/cache.js'; +import { HttpErrorCode, httpRequest } from '../utils/http-client.js'; const LEETCODE_GRAPHQL_URL = 'https://leetcode.com/graphql'; @@ -29,50 +30,41 @@ query getUserProfile($username: String!) { /* fetch LC stats using graphQL api */ async function fetchLeetCodeStats(username) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); // 8 second timeout - - try { - const response = await fetch(LEETCODE_GRAPHQL_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'samdev-pulse', - 'Origin': 'https://leetcode.com', - 'Referer': 'https://leetcode.com', - }, - body: JSON.stringify({ - query: USER_STATS_QUERY, - variables: { username }, - }), - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - throw new Error(`LeetCode API error: ${response.status}`); + const response = await httpRequest(LEETCODE_GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Origin': 'https://leetcode.com', + 'Referer': 'https://leetcode.com', + }, + body: JSON.stringify({ + query: USER_STATS_QUERY, + variables: { username }, + }), + }); + + if (!response.success) { + if (response.error?.code === HttpErrorCode.TIMEOUT) { + throw new Error('LeetCode API timeout'); } - - const json = await response.json(); - - if (json.errors) { - throw new Error(json.errors[0]?.message || 'GraphQL query failed'); + if (response.error?.code === HttpErrorCode.INVALID_JSON) { + throw new Error('LeetCode API returned invalid JSON'); } + throw new Error(`LeetCode API error: ${response.status || 0}`); + } - if (!json.data?.matchedUser) { - throw new Error('LeetCode user not found'); - } + const json = response.data; - return json.data; - } catch (error) { - clearTimeout(timeout); - if (error.name === 'AbortError') { - throw new Error('LeetCode API timeout'); - } - throw error; + if (json.errors) { + throw new Error(json.errors[0]?.message || 'GraphQL query failed'); } + + if (!json.data?.matchedUser) { + throw new Error('LeetCode user not found'); + } + + return json.data; } /* normalize LC data into a clean object */ diff --git a/src/utils/http-client.js b/src/utils/http-client.js new file mode 100644 index 0000000..3ff8099 --- /dev/null +++ b/src/utils/http-client.js @@ -0,0 +1,125 @@ +const DEFAULT_TIMEOUT_MS = 8000; +const DEFAULT_USER_AGENT = 'samdev-pulse'; + +export const HttpErrorCode = { + HTTP_ERROR: 'HTTP_ERROR', + INVALID_JSON: 'INVALID_JSON', + NETWORK_ERROR: 'NETWORK_ERROR', + TIMEOUT: 'TIMEOUT', +}; + +function normalizeHeaders(headers = {}) { + return { + 'User-Agent': DEFAULT_USER_AGENT, + ...headers, + }; +} + +function buildError({ code, message, status = 0, url }) { + return { + code, + message, + status, + url, + }; +} + +async function parseBody(response, responseType) { + if (responseType === 'arrayBuffer') { + return response.arrayBuffer(); + } + + if (responseType === 'text') { + return response.text(); + } + + if (responseType === 'none') { + return null; + } + + try { + return await response.json(); + } catch (_) { + throw buildError({ + code: HttpErrorCode.INVALID_JSON, + message: 'Invalid JSON response', + status: response.status, + url: response.url, + }); + } +} + +export async function httpRequest(url, options = {}) { + const { + headers, + timeoutMs = DEFAULT_TIMEOUT_MS, + responseType = 'json', + fetchImpl = globalThis.fetch, + ...fetchOptions + } = options; + + if (typeof fetchImpl !== 'function') { + return { + success: false, + error: buildError({ + code: HttpErrorCode.NETWORK_ERROR, + message: 'Fetch is not available', + url, + }), + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(url, { + ...fetchOptions, + headers: normalizeHeaders(headers), + signal: controller.signal, + }); + + if (!response.ok) { + return { + success: false, + status: response.status, + headers: response.headers, + error: buildError({ + code: HttpErrorCode.HTTP_ERROR, + message: `HTTP error: ${response.status}`, + status: response.status, + url: response.url || url, + }), + }; + } + + const data = await parseBody(response, responseType); + + return { + success: true, + data, + status: response.status, + headers: response.headers, + }; + } catch (error) { + if (error?.code === HttpErrorCode.INVALID_JSON) { + return { + success: false, + status: error.status, + error, + }; + } + + const isTimeout = error?.name === 'AbortError'; + return { + success: false, + error: buildError({ + code: isTimeout ? HttpErrorCode.TIMEOUT : HttpErrorCode.NETWORK_ERROR, + message: isTimeout ? 'Request timed out' : error?.message || 'Network request failed', + url, + }), + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/utils/http-client.test.js b/src/utils/http-client.test.js new file mode 100644 index 0000000..e194dde --- /dev/null +++ b/src/utils/http-client.test.js @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, jest, test } from '@jest/globals'; +import { HttpErrorCode, httpRequest } from './http-client.js'; + +function jsonResponse(data, init = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + url: init.url || 'https://example.test/mock', + headers: init.headers || { get: () => null }, + json: async () => data, + text: async () => JSON.stringify(data), + arrayBuffer: async () => new ArrayBuffer(0), + }; +} + +function textResponse(text, init = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + url: init.url || 'https://example.test/mock', + headers: init.headers || { get: () => null }, + json: async () => { + JSON.parse(text); + }, + text: async () => text, + arrayBuffer: async () => new TextEncoder().encode(text).buffer, + }; +} + +describe('http-client.js', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test('normalizes timeout failures', async () => { + jest.useFakeTimers(); + + const fetchImpl = jest.fn((_url, options) => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + })); + + const request = httpRequest('https://example.test/slow', { + fetchImpl, + timeoutMs: 10, + }); + + jest.advanceTimersByTime(10); + const result = await request; + + expect(result.success).toBe(false); + expect(result.error).toMatchObject({ + code: HttpErrorCode.TIMEOUT, + message: 'Request timed out', + status: 0, + url: 'https://example.test/slow', + }); + }); + + test('returns a structured error for invalid JSON responses', async () => { + const fetchImpl = jest.fn(() => Promise.resolve(textResponse('not json', { status: 200 }))); + + const result = await httpRequest('https://example.test/json', { fetchImpl }); + + expect(result.success).toBe(false); + expect(result.status).toBe(200); + expect(result.error).toMatchObject({ + code: HttpErrorCode.INVALID_JSON, + message: 'Invalid JSON response', + status: 200, + }); + }); + + test.each([404, 429, 500])('returns a structured error for HTTP %s', async (status) => { + const fetchImpl = jest.fn(() => Promise.resolve(jsonResponse({ error: 'nope' }, { status }))); + + const result = await httpRequest(`https://example.test/${status}`, { fetchImpl }); + + expect(result.success).toBe(false); + expect(result.status).toBe(status); + expect(result.error).toMatchObject({ + code: HttpErrorCode.HTTP_ERROR, + message: `HTTP error: ${status}`, + status, + }); + }); + + test('adds the shared user agent header', async () => { + const fetchImpl = jest.fn(() => Promise.resolve(jsonResponse({ ok: true }))); + + await httpRequest('https://example.test/headers', { fetchImpl }); + + expect(fetchImpl.mock.calls[0][1].headers).toMatchObject({ + 'User-Agent': 'samdev-pulse', + }); + }); +});