Skip to content
Open
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
180 changes: 180 additions & 0 deletions apps/core/src/common/contributors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/** biome-ignore-all lint/suspicious/noExplicitAny lint/complexity/noBannedTypes: explicit any allows mocking global fetch frames */
import {
afterEach,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import { createContributorsList } from './contributors';

describe('createContributorsList', () => {
let originalFetch: typeof globalThis.fetch;
let errorSpy: ReturnType<typeof spyOn>;

// Helper to mock successful GitHub Contributors responses
const mockGitHubContributors = (contributors: any[], status = 200) => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify(contributors), {
status,
statusText: status === 200 ? 'OK' : 'Internal Server Error',
}),
),
) as any;
};

// Helper data fixtures
const mockRawCore = {
id: 1,
login: 'Maximus7474',
avatar_url: 'https://avatar.com/max',
type: 'User',
contributions: 42,
};

const mockRawExternal = {
id: 2,
login: 'GhostCoder',
avatar_url: 'https://avatar.com/ghost',
type: 'User',
contributions: 10,
};

const mockRawBot = {
id: 3,
login: 'dependabot[bot]',
avatar_url: 'https://avatar.com/bot',
type: 'Bot',
contributions: 100,
};

beforeEach(() => {
originalFetch = globalThis.fetch;
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
globalThis.fetch = originalFetch;
errorSpy.mockRestore();
});

it('should return a default core list when NOT in production', async () => {
globalThis.fetch = mock(() => Promise.resolve(new Response('[]'))) as any;

const getContributors = createContributorsList({ isProd: false });
const result = await getContributors();

expect(result.core).toHaveLength(3);
expect(globalThis.fetch).toBeCalledTimes(0);
});

it('should correctly filter and map core vs external vs bot contributors', async () => {
mockGitHubContributors([mockRawCore, mockRawExternal, mockRawBot]);
const getContributors = createContributorsList({ isProd: true });

const result = await getContributors();

// Check Core matching
expect(result.core).toHaveLength(1);
expect(result.core[0]).toEqual({
kofi: 'Maximus7474',
username: 'Maximus7474',
image: 'https://avatar.com/max',
contributions: 42,
});

// Check External matching
expect(result.external).toHaveLength(1);
expect(result.external[0]).toEqual({
username: 'GhostCoder',
image: 'https://avatar.com/ghost',
contributions: 10,
});

// Bots should be skipped entirely
const allReturnedLogins = [
...result.core.map((c) => c.username),
...result.external.map((c) => c.username),
];
expect(allReturnedLogins).not.toContain('dependabot[bot]');
});

it('should use cached value and avoid network calls before TTL expires', async () => {
let mockTime = 1000;
const timeMock = () => mockTime;

mockGitHubContributors([mockRawCore]);
const getContributors = createContributorsList({
isProd: true,
ttlMs: 5000,
now: timeMock,
});

// First call hits the network
const firstResult = await getContributors();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);

// Second call within TTL returns cached data without calling fetch again
mockTime = 4000; // +3000ms elapsed (TTL is 5000)
const secondResult = await getContributors();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(secondResult).toEqual(firstResult);
});

it('should refresh from network after TTL expires', async () => {
let mockTime = 1000;
const timeMock = () => mockTime;

mockGitHubContributors([mockRawCore]);
const getContributors = createContributorsList({
isProd: true,
ttlMs: 5000,
now: timeMock,
});

await getContributors(); // Fetch #1

// Advance time past expiration
mockTime = 7000; // +6000ms elapsed
await getContributors(); // Fetch #2

expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should gracefully return fallback empty arrays if first network request fails', async () => {
mockGitHubContributors([], 500);
const getContributors = createContributorsList({ isProd: true });

const result = await getContributors();

expect(errorSpy).toHaveBeenCalled();
expect(result.core).toHaveLength(3); // Hardcoded fallback defaults
expect(result.external).toHaveLength(0);
});

it('should fall back to last known cached values if network request fails after a success', async () => {
let mockTime = 1000;
const timeMock = () => mockTime;

mockGitHubContributors([mockRawExternal]);
const getContributors = createContributorsList({
isProd: true,
ttlMs: 1000,
now: timeMock,
});

await getContributors();

mockTime = 3000;
mockGitHubContributors([], 500);

const fallbackResult = await getContributors();

expect(errorSpy).toHaveBeenCalled();
expect(fallbackResult.external).toHaveLength(1);
expect(fallbackResult.external?.[0]?.username).toBe('GhostCoder');
});
});
122 changes: 122 additions & 0 deletions apps/core/src/common/contributors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Contributor, ContributorSummary } from '@fxmanager/shared/types';
import { isProduction } from './utils';

const GITHUB_CONTRIBUTORS =
'https://api.github.com/repos/fxManagerProject/fxManager/contributors';
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour
const REQUEST_TIMEOUT_MS = 5_000;

interface RawContributor {
id: number;
login: string;
node_id: string;
avatar_url: string;
gravatar_id: string;
url: string;
html_url: string;
followers_url: string;
following_url: string;
gists_url: string;
starred_url: string;
subscriptions_url: string;
organizations_url: string;
repos_url: string;
events_url: string;
received_events_url: string;
type: string;
user_view_type: string;
site_admin: boolean;
contributions: number;
}

export const CORE_CONTRIBUTORS: Record<string, Contributor> = {
Maximus7474: {
kofi: 'Maximus7474',
username: 'Maximus7474',
},
FjamZoo: {
kofi: 'FjamZoo',
username: 'FjamZoo',
},
andreutu: {
username: 'andreutu',
},
};

async function fetchContributors(): Promise<RawContributor[]> {
const response = await fetch(GITHUB_CONTRIBUTORS, {
headers: { 'User-Agent': 'fxManager-Updater' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});

if (!response.ok)
throw new Error(`${response.status} - ${response.statusText}`);

const data = (await response.json()) as RawContributor[];

return data;
}

/**
* TTL-cached version status provider for the API/UI. Mirrors the
* recommended-artifact fetcher: caches success, falls back to the last known
* value (or a safe "no update" state) on failure so it never blocks callers.
*/
export function createContributorsList(opts: {
ttlMs?: number;
now?: () => number;
isProd: boolean;
}) {
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
const now = opts?.now ?? Date.now;
let cache: { value: ContributorSummary; expiresAt: number } | null = null;

const noUpdate = (): ContributorSummary => ({
core: Object.values(CORE_CONTRIBUTORS),
external: [],
});

return async function getContributors(): Promise<ContributorSummary> {
if (!opts.isProd) return noUpdate();
if (cache && cache.expiresAt > now()) return cache.value;

try {
const data = await fetchContributors();

const value: ContributorSummary = {
core: [],
external: [],
};

for (const contributor of data) {
if (CORE_CONTRIBUTORS[contributor.login]) {
value.core.push({
...CORE_CONTRIBUTORS[contributor.login],
username: contributor.login,
image: contributor.avatar_url,
contributions: contributor.contributions,
});
} else if (contributor.type === 'User') {
value.external.push({
username: contributor.login,
image: contributor.avatar_url,
contributions: contributor.contributions,
});
}
}

cache = { value, expiresAt: now() + ttlMs };
return value;
} catch (err) {
console.error(
`[version] Could not fetch contributors:`,
(err as Error).message,
);
return cache?.value ?? noUpdate();
}
};
}

export const getContributorsList = createContributorsList({
isProd: isProduction,
});
6 changes: 6 additions & 0 deletions apps/core/src/routes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import MigrateModule from './migrate';
import DisconnectsModule from './disconnects';
import PerfModule from './perf';
import ConfigModule from './config';
import { getContributorsList } from '../../common/contributors';

const apiRoutes: RouteModule['handler'] = async (fastify, options) => {
fastify.register(SetupModule.handler, {
Expand Down Expand Up @@ -71,6 +72,11 @@ const apiRoutes: RouteModule['handler'] = async (fastify, options) => {
...options,
prefix: ConfigModule.prefix,
});

fastify.get('/contributors', async (_request, reply) => {
const contributors = await getContributorsList();
return reply.code(200).send(contributors);
});
};

export default apiRoutes;
Loading
Loading