|
| 1 | +/** biome-ignore-all lint/suspicious/noExplicitAny lint/complexity/noBannedTypes: explicit any allows mocking global fetch frames */ |
| 2 | +import { |
| 3 | + afterEach, |
| 4 | + beforeEach, |
| 5 | + describe, |
| 6 | + expect, |
| 7 | + it, |
| 8 | + mock, |
| 9 | + spyOn, |
| 10 | +} from 'bun:test'; |
| 11 | +import { createContributorsList } from './contributors'; |
| 12 | + |
| 13 | +describe('createContributorsList', () => { |
| 14 | + let originalFetch: typeof globalThis.fetch; |
| 15 | + let errorSpy: ReturnType<typeof spyOn>; |
| 16 | + |
| 17 | + // Helper to mock successful GitHub Contributors responses |
| 18 | + const mockGitHubContributors = (contributors: any[], status = 200) => { |
| 19 | + globalThis.fetch = mock(() => |
| 20 | + Promise.resolve( |
| 21 | + new Response(JSON.stringify(contributors), { |
| 22 | + status, |
| 23 | + statusText: status === 200 ? 'OK' : 'Internal Server Error', |
| 24 | + }), |
| 25 | + ), |
| 26 | + ) as any; |
| 27 | + }; |
| 28 | + |
| 29 | + // Helper data fixtures |
| 30 | + const mockRawCore = { |
| 31 | + id: 1, |
| 32 | + login: 'Maximus7474', |
| 33 | + avatar_url: 'https://avatar.com/max', |
| 34 | + type: 'User', |
| 35 | + contributions: 42, |
| 36 | + }; |
| 37 | + |
| 38 | + const mockRawExternal = { |
| 39 | + id: 2, |
| 40 | + login: 'GhostCoder', |
| 41 | + avatar_url: 'https://avatar.com/ghost', |
| 42 | + type: 'User', |
| 43 | + contributions: 10, |
| 44 | + }; |
| 45 | + |
| 46 | + const mockRawBot = { |
| 47 | + id: 3, |
| 48 | + login: 'dependabot[bot]', |
| 49 | + avatar_url: 'https://avatar.com/bot', |
| 50 | + type: 'Bot', |
| 51 | + contributions: 100, |
| 52 | + }; |
| 53 | + |
| 54 | + beforeEach(() => { |
| 55 | + originalFetch = globalThis.fetch; |
| 56 | + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); |
| 57 | + }); |
| 58 | + |
| 59 | + afterEach(() => { |
| 60 | + globalThis.fetch = originalFetch; |
| 61 | + errorSpy.mockRestore(); |
| 62 | + }); |
| 63 | + |
| 64 | + it('should return a default core list when NOT in production', async () => { |
| 65 | + globalThis.fetch = mock(() => Promise.resolve(new Response('[]'))) as any; |
| 66 | + |
| 67 | + const getContributors = createContributorsList({ isProd: false }); |
| 68 | + const result = await getContributors(); |
| 69 | + |
| 70 | + expect(result.core).toHaveLength(3); |
| 71 | + expect(globalThis.fetch).toBeCalledTimes(0); |
| 72 | + }); |
| 73 | + |
| 74 | + it('should correctly filter and map core vs external vs bot contributors', async () => { |
| 75 | + mockGitHubContributors([mockRawCore, mockRawExternal, mockRawBot]); |
| 76 | + const getContributors = createContributorsList({ isProd: true }); |
| 77 | + |
| 78 | + const result = await getContributors(); |
| 79 | + |
| 80 | + // Check Core matching |
| 81 | + expect(result.core).toHaveLength(1); |
| 82 | + expect(result.core[0]).toEqual({ |
| 83 | + kofi: 'Maximus7474', |
| 84 | + username: 'Maximus7474', |
| 85 | + image: 'https://avatar.com/max', |
| 86 | + contributions: 42, |
| 87 | + }); |
| 88 | + |
| 89 | + // Check External matching |
| 90 | + expect(result.external).toHaveLength(1); |
| 91 | + expect(result.external[0]).toEqual({ |
| 92 | + username: 'GhostCoder', |
| 93 | + image: 'https://avatar.com/ghost', |
| 94 | + contributions: 10, |
| 95 | + }); |
| 96 | + |
| 97 | + // Bots should be skipped entirely |
| 98 | + const allReturnedLogins = [ |
| 99 | + ...result.core.map((c) => c.username), |
| 100 | + ...result.external.map((c) => c.username), |
| 101 | + ]; |
| 102 | + expect(allReturnedLogins).not.toContain('dependabot[bot]'); |
| 103 | + }); |
| 104 | + |
| 105 | + it('should use cached value and avoid network calls before TTL expires', async () => { |
| 106 | + let mockTime = 1000; |
| 107 | + const timeMock = () => mockTime; |
| 108 | + |
| 109 | + mockGitHubContributors([mockRawCore]); |
| 110 | + const getContributors = createContributorsList({ |
| 111 | + isProd: true, |
| 112 | + ttlMs: 5000, |
| 113 | + now: timeMock, |
| 114 | + }); |
| 115 | + |
| 116 | + // First call hits the network |
| 117 | + const firstResult = await getContributors(); |
| 118 | + expect(globalThis.fetch).toHaveBeenCalledTimes(1); |
| 119 | + |
| 120 | + // Second call within TTL returns cached data without calling fetch again |
| 121 | + mockTime = 4000; // +3000ms elapsed (TTL is 5000) |
| 122 | + const secondResult = await getContributors(); |
| 123 | + expect(globalThis.fetch).toHaveBeenCalledTimes(1); |
| 124 | + expect(secondResult).toEqual(firstResult); |
| 125 | + }); |
| 126 | + |
| 127 | + it('should refresh from network after TTL expires', async () => { |
| 128 | + let mockTime = 1000; |
| 129 | + const timeMock = () => mockTime; |
| 130 | + |
| 131 | + mockGitHubContributors([mockRawCore]); |
| 132 | + const getContributors = createContributorsList({ |
| 133 | + isProd: true, |
| 134 | + ttlMs: 5000, |
| 135 | + now: timeMock, |
| 136 | + }); |
| 137 | + |
| 138 | + await getContributors(); // Fetch #1 |
| 139 | + |
| 140 | + // Advance time past expiration |
| 141 | + mockTime = 7000; // +6000ms elapsed |
| 142 | + await getContributors(); // Fetch #2 |
| 143 | + |
| 144 | + expect(globalThis.fetch).toHaveBeenCalledTimes(2); |
| 145 | + }); |
| 146 | + |
| 147 | + it('should gracefully return fallback empty arrays if first network request fails', async () => { |
| 148 | + mockGitHubContributors([], 500); |
| 149 | + const getContributors = createContributorsList({ isProd: true }); |
| 150 | + |
| 151 | + const result = await getContributors(); |
| 152 | + |
| 153 | + expect(errorSpy).toHaveBeenCalled(); |
| 154 | + expect(result.core).toHaveLength(3); // Hardcoded fallback defaults |
| 155 | + expect(result.external).toHaveLength(0); |
| 156 | + }); |
| 157 | + |
| 158 | + it('should fall back to last known cached values if network request fails after a success', async () => { |
| 159 | + let mockTime = 1000; |
| 160 | + const timeMock = () => mockTime; |
| 161 | + |
| 162 | + mockGitHubContributors([mockRawExternal]); |
| 163 | + const getContributors = createContributorsList({ |
| 164 | + isProd: true, |
| 165 | + ttlMs: 1000, |
| 166 | + now: timeMock, |
| 167 | + }); |
| 168 | + |
| 169 | + await getContributors(); |
| 170 | + |
| 171 | + mockTime = 3000; |
| 172 | + mockGitHubContributors([], 500); |
| 173 | + |
| 174 | + const fallbackResult = await getContributors(); |
| 175 | + |
| 176 | + expect(errorSpy).toHaveBeenCalled(); |
| 177 | + expect(fallbackResult.external).toHaveLength(1); |
| 178 | + expect(fallbackResult.external?.[0]?.username).toBe('GhostCoder'); |
| 179 | + }); |
| 180 | +}); |
0 commit comments