Skip to content

Commit 82a256c

Browse files
feat: implement GitHub platform auto-discovery from GitHub API (#506)
* feat: add GitHub platform autodiscovery * fix: resolve lint and type issues in autodiscovery * fix: restore reply parameter in github autodiscovery route * fix: resolve unused reply lint issue
1 parent 1b9430a commit 82a256c

2 files changed

Lines changed: 299 additions & 14 deletions

File tree

apps/backend/src/__tests__/connect.test.ts

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import Fastify from 'fastify';
31
import jwt from '@fastify/jwt';
2+
import Fastify from 'fastify';
3+
import { describe, it, expect, beforeEach, vi } from 'vitest';
4+
45
import { connectRoutes } from '../routes/connect.js';
6+
import { encrypt } from '../utils/encryption.js';
7+
58
import type { PrismaClient } from '@prisma/client';
69

710
process.env.PUBLIC_APP_URL = 'http://localhost:3000';
@@ -20,23 +23,24 @@ const mockRedis = {
2023
const mockPrisma = {
2124
oAuthToken: {
2225
findMany: vi.fn(),
26+
findUnique: vi.fn(),
2327
upsert: vi.fn(),
2428
delete: vi.fn(),
2529
},
2630
};
2731

2832
global.fetch = vi.fn();
2933

30-
async function buildApp() {
34+
async function buildApp(): Promise<ReturnType<typeof Fastify>> {
3135
const app = Fastify();
3236
await app.register(jwt, { secret: 'test-secret' });
3337
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
3438
app.decorate('redis', mockRedis as any);
35-
39+
3640
app.decorate('authenticate', async (request: any, reply: any) => {
3741
try {
3842
await request.jwtVerify();
39-
} catch (err) {
43+
} catch {
4044
reply.status(401).send({ error: 'Unauthorized' });
4145
}
4246
});
@@ -46,6 +50,10 @@ async function buildApp() {
4650
return app;
4751
}
4852

53+
function authHeader(app: any): { authorization: string } {
54+
return { authorization: `Bearer ${app.jwt.sign({ id: 'user-1' })}` };
55+
}
56+
4957
describe('GET /api/connect/github/callback', () => {
5058
beforeEach(() => {
5159
vi.clearAllMocks();
@@ -184,4 +192,119 @@ describe('GET /api/connect/github/callback', () => {
184192
expect(res.statusCode).toBe(302);
185193
expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed');
186194
});
195+
196+
it('returns cached discovery suggestions when Redis stores the response', async () => {
197+
const cachedResponse = [{ platform: 'twitter', username: 'octocat', confidence: 'high' }];
198+
mockRedis.get.mockResolvedValue(JSON.stringify(cachedResponse));
199+
200+
const app = await buildApp();
201+
const res = await app.inject({
202+
method: 'GET',
203+
url: '/api/connect/github/autodiscover',
204+
headers: authHeader(app),
205+
});
206+
207+
expect(res.statusCode).toBe(200);
208+
expect(res.json()).toEqual(cachedResponse);
209+
expect(global.fetch).not.toHaveBeenCalled();
210+
});
211+
212+
it('returns discovery suggestions and caches the result', async () => {
213+
mockRedis.get.mockResolvedValue(null);
214+
mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') });
215+
(global.fetch as any).mockResolvedValue({
216+
ok: true,
217+
status: 200,
218+
json: vi.fn().mockResolvedValue({
219+
twitter_username: 'octocat',
220+
blog: 'https://dev.to/octocat',
221+
company: 'GitHub',
222+
bio: 'Developer',
223+
html_url: 'https://github.com/octocat',
224+
}),
225+
});
226+
227+
const app = await buildApp();
228+
const res = await app.inject({
229+
method: 'GET',
230+
url: '/api/connect/github/autodiscover',
231+
headers: authHeader(app),
232+
});
233+
234+
const expected = [
235+
{ platform: 'twitter', username: 'octocat', confidence: 'high' },
236+
{ platform: 'devto', username: 'octocat', confidence: 'low' },
237+
];
238+
239+
expect(res.statusCode).toBe(200);
240+
expect(res.json()).toEqual(expected);
241+
expect(mockRedis.set).toHaveBeenCalledWith('github:autodiscover:user-1', JSON.stringify(expected), 'EX', 3600);
242+
});
243+
244+
it('returns unauthorized when GitHub API returns 401', async () => {
245+
mockRedis.get.mockResolvedValue(null);
246+
mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') });
247+
(global.fetch as any).mockResolvedValue({
248+
ok: false,
249+
status: 401,
250+
text: vi.fn().mockResolvedValue('Bad credentials'),
251+
});
252+
253+
const app = await buildApp();
254+
const res = await app.inject({
255+
method: 'GET',
256+
url: '/api/connect/github/autodiscover',
257+
headers: authHeader(app),
258+
});
259+
260+
expect(res.statusCode).toBe(401);
261+
expect(res.json()).toEqual({ error: 'GitHub token expired or revoked', requiresAuth: true });
262+
expect(mockRedis.del).toHaveBeenCalledWith('github:autodiscover:user-1');
263+
});
264+
265+
it('returns an error when the GitHub follow token is missing', async () => {
266+
mockRedis.get.mockResolvedValue(null);
267+
mockPrisma.oAuthToken.findUnique.mockResolvedValue(null);
268+
269+
const app = await buildApp();
270+
const res = await app.inject({
271+
method: 'GET',
272+
url: '/api/connect/github/autodiscover',
273+
headers: authHeader(app),
274+
});
275+
276+
expect(res.statusCode).toBe(400);
277+
expect(res.json()).toEqual({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true });
278+
expect(global.fetch).not.toHaveBeenCalled();
279+
});
280+
281+
it('falls back to live GitHub discovery when Redis read fails', async () => {
282+
mockRedis.get.mockRejectedValue(new Error('Redis unavailable'));
283+
mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') });
284+
(global.fetch as any).mockResolvedValue({
285+
ok: true,
286+
status: 200,
287+
json: vi.fn().mockResolvedValue({
288+
twitter_username: 'octocat',
289+
blog: 'https://npmjs.com/~octocat',
290+
company: 'GitHub',
291+
bio: 'Developer',
292+
html_url: 'https://github.com/octocat',
293+
}),
294+
});
295+
296+
const app = await buildApp();
297+
const res = await app.inject({
298+
method: 'GET',
299+
url: '/api/connect/github/autodiscover',
300+
headers: authHeader(app),
301+
});
302+
303+
expect(res.statusCode).toBe(200);
304+
expect(res.json()).toEqual([
305+
{ platform: 'twitter', username: 'octocat', confidence: 'high' },
306+
{ platform: 'npm', username: 'octocat', confidence: 'low' },
307+
]);
308+
expect(global.fetch).toHaveBeenCalled();
309+
});
187310
});

0 commit comments

Comments
 (0)