diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 906deff..5f57fab 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -261,6 +261,12 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback maxAge: sessionTtl, }) + // Fetch profile data (displayName, avatarUrl) from users table + const userRows = await app.db + .select({ displayName: users.displayName, avatarUrl: users.avatarUrl }) + .from(users) + .where(eq(users.did, session.did)) + // Query cross-post scope status from user preferences const prefRows = await app.db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) @@ -272,6 +278,8 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback expiresAt: session.accessTokenExpiresAt, did: session.did, handle: session.handle, + displayName: userRows[0]?.displayName ?? null, + avatarUrl: userRows[0]?.avatarUrl ?? null, crossPostScopesGranted: prefRows[0]?.crossPostScopesGranted ?? false, }) } catch (err: unknown) { @@ -321,6 +329,12 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback return await reply.status(401).send({ error: 'Invalid or expired token' }) } + // Fetch profile data (displayName, avatarUrl) from users table + const meUserRows = await app.db + .select({ displayName: users.displayName, avatarUrl: users.avatarUrl }) + .from(users) + .where(eq(users.did, session.did)) + // Query cross-post scope status from user preferences const mePrefRows = await app.db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) @@ -330,6 +344,8 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback return await reply.status(200).send({ did: session.did, handle: session.handle, + displayName: meUserRows[0]?.displayName ?? null, + avatarUrl: meUserRows[0]?.avatarUrl ?? null, crossPostScopesGranted: mePrefRows[0]?.crossPostScopesGranted ?? false, }) } catch (err: unknown) { diff --git a/tests/unit/routes/auth.test.ts b/tests/unit/routes/auth.test.ts index 55f87ac..e8ff0ff 100644 --- a/tests/unit/routes/auth.test.ts +++ b/tests/unit/routes/auth.test.ts @@ -386,6 +386,44 @@ describe('auth routes', () => { expect(refreshCookie?.value).toBe(TEST_SID) }) + it('includes displayName and avatarUrl from users table', async () => { + const mockSession = makeMockSessionWithToken() + refreshSessionFn.mockResolvedValueOnce(mockSession) + // First select: users table → profile data + dbWhereFn.mockResolvedValueOnce([ + { displayName: 'Alice Wonderland', avatarUrl: 'https://cdn.bsky.app/avatar.jpg' }, + ]) + // Second select: userPreferences table + dbWhereFn.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { barazo_refresh: TEST_SID }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ displayName: string | null; avatarUrl: string | null }>() + expect(body.displayName).toBe('Alice Wonderland') + expect(body.avatarUrl).toBe('https://cdn.bsky.app/avatar.jpg') + }) + + it('returns null displayName and avatarUrl when user row not found', async () => { + const mockSession = makeMockSessionWithToken() + refreshSessionFn.mockResolvedValueOnce(mockSession) + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { barazo_refresh: TEST_SID }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ displayName: string | null; avatarUrl: string | null }>() + expect(body.displayName).toBeNull() + expect(body.avatarUrl).toBeNull() + }) + it('returns 401 when no cookie', async () => { const response = await app.inject({ method: 'POST', @@ -481,6 +519,28 @@ describe('auth routes', () => { expect(validateAccessTokenFn).toHaveBeenCalledWith(TEST_ACCESS_TOKEN) }) + it('includes displayName and avatarUrl from users table', async () => { + const mockSession = makeMockSession() + validateAccessTokenFn.mockResolvedValueOnce(mockSession) + // First select: users table → profile data + dbWhereFn.mockResolvedValueOnce([ + { displayName: 'Alice Wonderland', avatarUrl: 'https://cdn.bsky.app/avatar.jpg' }, + ]) + // Second select: userPreferences table + dbWhereFn.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}` }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ displayName: string | null; avatarUrl: string | null }>() + expect(body.displayName).toBe('Alice Wonderland') + expect(body.avatarUrl).toBe('https://cdn.bsky.app/avatar.jpg') + }) + it('returns 401 for missing Authorization header', async () => { const response = await app.inject({ method: 'GET', @@ -658,6 +718,9 @@ describe('auth routes', () => { it('/me returns crossPostScopesGranted from user preferences', async () => { const mockSession = makeMockSession() validateAccessTokenFn.mockResolvedValueOnce(mockSession) + // First select: users table (profile data) + dbWhereFn.mockResolvedValueOnce([]) + // Second select: userPreferences table dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]) const response = await app.inject({ @@ -690,6 +753,9 @@ describe('auth routes', () => { it('/refresh returns crossPostScopesGranted', async () => { const mockSession = makeMockSessionWithToken() refreshSessionFn.mockResolvedValueOnce(mockSession) + // First select: users table (profile data) + dbWhereFn.mockResolvedValueOnce([]) + // Second select: userPreferences table dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]) const response = await app.inject({