1- import { describe , it , expect , beforeEach , vi } from 'vitest' ;
2- import Fastify from 'fastify' ;
31import jwt from '@fastify/jwt' ;
2+ import Fastify from 'fastify' ;
3+ import { describe , it , expect , beforeEach , vi } from 'vitest' ;
4+
45import { connectRoutes } from '../routes/connect.js' ;
6+ import { encrypt } from '../utils/encryption.js' ;
7+
58import type { PrismaClient } from '@prisma/client' ;
69
710process . env . PUBLIC_APP_URL = 'http://localhost:3000' ;
@@ -20,23 +23,24 @@ const mockRedis = {
2023const mockPrisma = {
2124 oAuthToken : {
2225 findMany : vi . fn ( ) ,
26+ findUnique : vi . fn ( ) ,
2327 upsert : vi . fn ( ) ,
2428 delete : vi . fn ( ) ,
2529 } ,
2630} ;
2731
2832global . 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+
4957describe ( '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