Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions lib/contribution-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const fetchMock = vi.hoisted(() => vi.fn());

vi.mock('./github-data.js', () => ({
fetchContributionData: fetchMock,
}));

import { getContributionData, _resetForTest } from './contribution-cache.js';

const okData = {
merged: 5,
open: 0,
closedUnmerged: 0,
mergeRate: 100,
repoCount: 1,
recentPRs: [],
cappedMerged: false,
cappedClosedUnmerged: false,
dailyActivity: {},
streak: 0,
topRepos: [],
};

describe('getContributionData', () => {
beforeEach(() => {
fetchMock.mockReset();
_resetForTest();
});

it('deduplicates concurrent calls for the same username', async () => {
fetchMock.mockResolvedValue(okData);

const [a, b, c, d] = await Promise.all([
getContributionData('alice', 'tok'),
getContributionData('alice', 'tok'),
getContributionData('alice', 'tok'),
getContributionData('alice', 'tok'),
]);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
expect(b).toBe(c);
expect(c).toBe(d);
});

it('returns separate fetches for different usernames', async () => {
fetchMock.mockResolvedValue(okData);

await Promise.all([
getContributionData('alice', 'tok'),
getContributionData('bob', 'tok'),
]);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('does not cache error results — next call retries', async () => {
fetchMock.mockResolvedValueOnce({ error: 'rate_limited' as const });
fetchMock.mockResolvedValueOnce(okData);

const first = await getContributionData('alice', 'tok');
expect((first as { error?: string }).error).toBe('rate_limited');

const second = await getContributionData('alice', 'tok');
expect(second).toEqual(okData);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('does not cache rejected promises — next call retries', async () => {
fetchMock.mockRejectedValueOnce(new Error('boom'));
fetchMock.mockResolvedValueOnce(okData);

await expect(getContributionData('alice', 'tok')).rejects.toThrow('boom');
const second = await getContributionData('alice', 'tok');
expect(second).toEqual(okData);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('serves the same successful result on subsequent calls within TTL', async () => {
fetchMock.mockResolvedValue(okData);

const a = await getContributionData('alice', 'tok');
const b = await getContributionData('alice', 'tok');

expect(a).toBe(b);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
41 changes: 41 additions & 0 deletions lib/contribution-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { fetchContributionData, type ContributionResult } from './github-data.js';

const TTL_MS = 60 * 60 * 1000;

interface Entry {
promise: Promise<ContributionResult>;
ts: number;
}

const cache = new Map<string, Entry>();

export function getContributionData(
username: string,
token: string,
options: { force?: boolean } = {},
): Promise<ContributionResult> {
if (!options.force) {
const existing = cache.get(username);
if (existing && Date.now() - existing.ts < TTL_MS) {
return existing.promise;
}
}

const promise = fetchContributionData(username, token);
const entry: Entry = { promise, ts: Date.now() };
cache.set(username, entry);

promise
.then((result) => {
if (result.error && cache.get(username) === entry) cache.delete(username);
})
.catch(() => {
if (cache.get(username) === entry) cache.delete(username);
});

return promise;
}

export function _resetForTest(): void {
cache.clear();
}
2 changes: 2 additions & 0 deletions lib/endpoint-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createWidgetHandler } from './endpoint-handler.js';
import type { ContributionData } from './github-data.js';
import { _resetForTest as resetContributionCache } from './contribution-cache.js';

// Hoist mock references so they're available inside vi.mock factory
const { fetchMock } = vi.hoisted(() => {
Expand Down Expand Up @@ -77,6 +78,7 @@ describe('createWidgetHandler', () => {

beforeEach(() => {
vi.clearAllMocks();
resetContributionCache();
originalToken = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = 'fake-token';
BASE_CONFIG.render.mockImplementation(() => '<svg>widget</svg>');
Expand Down
4 changes: 2 additions & 2 deletions lib/endpoint-handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
fetchContributionData,
isValidUsername,
type ContributionData,
type ContributionResult,
type ThemeMode,
} from './github-data.js';
import { getContributionData } from './contribution-cache.js';
import { escapeXml, theme as getTheme } from './svg-utils.js';
import type { VercelRequest, VercelResponse } from './vercel-types.js';

Expand Down Expand Up @@ -89,7 +89,7 @@ export function createWidgetHandler(config: WidgetHandlerConfig) {

// Wrap fetch + optional transform as a single computation
const computation = (async (): Promise<ContributionResult> => {
const result = await fetchContributionData(username, process.env.GITHUB_TOKEN!);
const result = await getContributionData(username, process.env.GITHUB_TOKEN!, { force: noCache });
if (result.error) return result;
if (transform) {
try {
Expand Down
Loading