Skip to content
Merged
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
5 changes: 5 additions & 0 deletions packages/backend/src/services/GuildAccessService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ class GuildAccessService {
options?: { allowCachedFallback?: boolean },
): Promise<DiscordGuild[]> {
const allowCachedFallback = options?.allowCachedFallback ?? true
const cached = await this.getCachedGuilds(session)
if (cached) {
return cached
}

const cacheKey = this.getCacheKey(session)
const inFlight = this.userGuildsInFlight.get(cacheKey)
if (inFlight) {
Expand Down
71 changes: 44 additions & 27 deletions packages/backend/tests/unit/services/GuildAccessService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const mockHasAdminPermission = jest.fn<
>()

class MockDiscordApiError extends Error {
constructor(public readonly statusCode: number, message = 'Discord API error') {
constructor(
public readonly statusCode: number,
message = 'Discord API error',
) {
super(message)
this.name = 'DiscordApiError'
}
Expand Down Expand Up @@ -40,8 +43,7 @@ jest.mock('../../../src/services/DiscordOAuthService', () => ({
getUserGuilds: (...args: [string]) => mockGetUserGuilds(...args),
hasAdminPermission: (
...args: [string | null | undefined, string | null | undefined]
) =>
mockHasAdminPermission(...args),
) => mockHasAdminPermission(...args),
},
}))

Expand Down Expand Up @@ -93,9 +95,7 @@ const MANAGE_ALL_ACCESS = {
integrations: 'manage',
}

async function expectRbacStorageUnavailable(
operation: Promise<unknown>,
) {
async function expectRbacStorageUnavailable(operation: Promise<unknown>) {
await expect(operation).rejects.toMatchObject({
statusCode: 503,
message:
Expand Down Expand Up @@ -244,7 +244,9 @@ describe('GuildAccessService', () => {
new DiscordApiError(401, 'invalid token'),
)

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toMatchObject({
statusCode: 401,
message: 'Discord session expired. Please sign in again.',
})
Expand All @@ -256,9 +258,12 @@ describe('GuildAccessService', () => {
message: 'missing scope',
})

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toMatchObject({
statusCode: 403,
message: 'Discord OAuth scope is missing. Re-authenticate and try again.',
message:
'Discord OAuth scope is missing. Re-authenticate and try again.',
})
})

Expand Down Expand Up @@ -288,7 +293,9 @@ describe('GuildAccessService', () => {
message: 'rate limited',
})

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toMatchObject({
statusCode: 502,
message: 'Discord API is temporarily unavailable. Please retry.',
})
Expand Down Expand Up @@ -319,9 +326,9 @@ describe('GuildAccessService', () => {
const unknownError = new Error('boom')
mockGetUserGuilds.mockRejectedValue(unknownError)

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toBe(
unknownError,
)
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toBe(unknownError)
})

test('listAuthorizedGuilds skips guild when access resolution throws', async () => {
Expand All @@ -330,12 +337,14 @@ describe('GuildAccessService', () => {

mockGetUserGuilds.mockResolvedValue(guilds)
mockHasBotInGuild.mockResolvedValue(true)
mockResolveEffectiveAccess.mockImplementation(async (guildId: string) => {
if (guildId === '202') {
throw new Error('policy lookup failed')
}
return adminAccess
})
mockResolveEffectiveAccess.mockImplementation(
async (guildId: string) => {
if (guildId === '202') {
throw new Error('policy lookup failed')
}
return adminAccess
},
)

const result = await guildAccessService.listAuthorizedGuilds(SESSION)

Expand Down Expand Up @@ -370,7 +379,9 @@ describe('GuildAccessService', () => {
new Error('rbac dependency unavailable'),
)

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toMatchObject({
statusCode: 502,
message: 'Unable to resolve server access right now. Please retry.',
})
Expand Down Expand Up @@ -460,7 +471,9 @@ describe('GuildAccessService', () => {
new Error('member context unavailable'),
)

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toMatchObject({
statusCode: 502,
message: 'Unable to resolve server access right now. Please retry.',
})
Expand All @@ -479,9 +492,9 @@ describe('GuildAccessService', () => {
{ ...guild, id: 'unknown-guild', hasBot: true },
])

await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toThrow(
'Missing authorized context for guild unknown-guild',
)
await expect(
guildAccessService.listAuthorizedGuilds(SESSION),
).rejects.toThrow('Missing authorized context for guild unknown-guild')
})

test('resolveGuildContext returns null when guild is not in user guild list', async () => {
Expand Down Expand Up @@ -596,13 +609,15 @@ describe('GuildAccessService', () => {
const cachedGuilds = JSON.stringify([guild])

mockRedisIsHealthy.mockReturnValue(true)
mockRedisGet.mockResolvedValue(cachedGuilds)
// First call: cache miss → Discord API populates it
mockRedisGet.mockResolvedValueOnce(null)
mockGetUserGuilds.mockResolvedValueOnce([guild])
mockResolveEffectiveAccess.mockResolvedValue(adminAccess)
await guildAccessService.listAuthorizedGuilds(SESSION)
expect(mockRedisSetex).toHaveBeenCalledTimes(1)

mockGetUserGuilds.mockRejectedValueOnce({ status: 429 })
// Second call: cache is populated — Discord is never called, no 429 possible
mockRedisGet.mockResolvedValue(cachedGuilds)

await expect(
guildAccessService.resolveGuildContext(SESSION, guild.id),
Expand All @@ -625,7 +640,9 @@ describe('GuildAccessService', () => {

expect(mockRedisSetex).toHaveBeenCalledTimes(1)
const redisKey = mockRedisSetex.mock.calls[0][0]
expect(redisKey).toContain(`guild-access:user-guilds:${SESSION.user.id}:`)
expect(redisKey).toContain(
`guild-access:user-guilds:${SESSION.user.id}:`,
)
expect(redisKey).not.toContain(SESSION.accessToken)
expect(redisKey).not.toContain(SESSION.accessToken.slice(0, 24))
})
Expand Down
26 changes: 22 additions & 4 deletions packages/bot/src/services/AiDevToolkitService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type Client, type TextChannel, type Message } from 'discord.js'
import {
type Client,
type TextChannel,
type ThreadChannel,
type Message,
} from 'discord.js'
import { getPrismaClient } from '@lucky/shared/utils'
import { infoLog, errorLog, debugLog } from '@lucky/shared/utils'

Expand Down Expand Up @@ -387,16 +392,29 @@
const stored = await this.getStoredGuide()

try {
const channel = (await client.channels.fetch(
CHANNEL_ID,
)) as TextChannel | null
const channel = (await client.channels.fetch(CHANNEL_ID)) as
| TextChannel
| ThreadChannel
| null

Check warning on line 398 in packages/bot/src/services/AiDevToolkitService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ5vl8B5_7VPFyX-YODb&open=AZ5vl8B5_7VPFyX-YODb&pullRequest=1078
if (!channel?.isTextBased()) {
errorLog({
message: `AiDevToolkitService: channel ${CHANNEL_ID} not found or not text-based`,
})
return
}

if (channel.isThread() && channel.archived) {
try {
await channel.setArchived(false)
} catch (error) {
errorLog({
message: `AiDevToolkitService: cannot unarchive thread ${CHANNEL_ID}, skipping sync`,
error,
})
return
}
}

const existingMessages: Message[] = []
let foundAllMessages = true
if (stored?.messageIds.length) {
Expand Down
133 changes: 133 additions & 0 deletions packages/bot/tests/services/AiDevToolkitService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { Client } from 'discord.js'

const mockErrorLog = jest.fn()
const mockInfoLog = jest.fn()
const mockGetPrismaClient = jest.fn()

jest.mock('@lucky/shared/utils', () => ({
errorLog: (...args: unknown[]) => mockErrorLog(...args),
infoLog: (...args: unknown[]) => mockInfoLog(...args),
debugLog: jest.fn(),
getPrismaClient: () => mockGetPrismaClient(),
}))

import { aiDevToolkitService } from '../../src/services/AiDevToolkitService'

function makeSnapshot() {
return {
commitSha: 'abc1234',
patterns: [],
lastUpdated: new Date().toISOString(),
}
}

function makeChannel(overrides: Record<string, unknown> = {}) {
return {
isTextBased: () => true,
isThread: () => false,
archived: false,
setArchived: jest
.fn<Promise<void>, [boolean]>()
.mockResolvedValue(undefined),
messages: {
fetch: jest.fn().mockRejectedValue(new Error('not found')),
},
send: jest.fn().mockResolvedValue({ id: 'msg-1' }),
...overrides,
}
}

function makeClient(channel: ReturnType<typeof makeChannel> | null) {
return {
channels: { fetch: jest.fn().mockResolvedValue(channel) },
} as unknown as Client
}

describe('AiDevToolkitService', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = aiDevToolkitService as any
let fetchSpy: jest.SpyInstance

beforeEach(() => {
jest.clearAllMocks()
svc.lastCommitSha = null
fetchSpy = jest
.spyOn(svc, 'fetchRepoSnapshot')
.mockResolvedValue(makeSnapshot())
mockGetPrismaClient.mockReturnValue({
liveBoard: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({}),
},
})
})

afterEach(() => {
fetchSpy.mockRestore()
})

describe('syncBoard — archived thread guard', () => {
it('unarchives the thread before posting when it is archived', async () => {
const mockSetArchived = jest
.fn<Promise<void>, [boolean]>()
.mockResolvedValue(undefined)
const channel = makeChannel({
isThread: () => true,
archived: true,
setArchived: mockSetArchived,
})

await aiDevToolkitService.syncBoard(makeClient(channel))

expect(mockSetArchived).toHaveBeenCalledWith(false)
expect(channel.send).toHaveBeenCalled()
})

it('skips the sync cycle and logs error when unarchive fails', async () => {
const mockSetArchived = jest
.fn<Promise<void>, [boolean]>()
.mockRejectedValue(new Error('Missing Permissions'))
const channel = makeChannel({
isThread: () => true,
archived: true,
setArchived: mockSetArchived,
})

await aiDevToolkitService.syncBoard(makeClient(channel))

expect(mockSetArchived).toHaveBeenCalledWith(false)
expect(mockErrorLog).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('cannot unarchive'),
}),
)
expect(channel.send).not.toHaveBeenCalled()
})

it('does not call setArchived when the thread is not archived', async () => {
const mockSetArchived = jest.fn()
const channel = makeChannel({
isThread: () => true,
archived: false,
setArchived: mockSetArchived,
})

await aiDevToolkitService.syncBoard(makeClient(channel))

expect(mockSetArchived).not.toHaveBeenCalled()
expect(channel.send).toHaveBeenCalled()
})

it('does not call setArchived for regular text channels', async () => {
const mockSetArchived = jest.fn()
const channel = makeChannel({
isThread: () => false,
setArchived: mockSetArchived,
})

await aiDevToolkitService.syncBoard(makeClient(channel))

expect(mockSetArchived).not.toHaveBeenCalled()
})
})
})
Loading