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
58 changes: 58 additions & 0 deletions packages/backend/src/routes/forums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Express, Request, Response } from 'express'
import { z } from 'zod'
import { getPrismaClient } from '@lucky/shared/utils'
import { apiLimiter } from '../middleware/rateLimit'
import { asyncHandler } from '../middleware/asyncHandler'

const threadResponseSchema = z.object({
threadId: z.string(),
slug: z.string(),
title: z.string(),
archived: z.boolean(),
url: z.string(),
})

export type ForumThreadResponse = z.infer<typeof threadResponseSchema>

export function setupForumsRoutes(app: Express): void {
// Public: resolve a forum-content slug to its Discord thread for a guild.
// Returns 404 when no thread is mapped (guia has no Discord thread yet).
// Used by the web app to render per-guia "Ver discussão no Discord" CTAs.
app.get(
'/api/guilds/:guildId/threads/:slug',
apiLimiter,
asyncHandler(async (req: Request, res: Response) => {
const { guildId, slug } = req.params as {
guildId: string
slug: string
}

const prisma = getPrismaClient()
const thread = await prisma.guildForumThread.findUnique({
where: { guildId_slug: { guildId, slug } },
select: {
threadId: true,
slug: true,
title: true,
archived: true,
},
})

if (!thread) {
res.status(404).json({ error: 'Thread not found' })
return
}

const response = threadResponseSchema.parse({
...thread,
url: `https://discord.com/channels/${guildId}/${thread.threadId}`,
})

res.set(
'Cache-Control',
'public, max-age=60, s-maxage=60, stale-while-revalidate=300',
)
res.json(response)
}),
)
}
2 changes: 2 additions & 0 deletions packages/backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { errorHandler } from '../middleware/errorHandler'
import { setupHealthRoutes } from './health'
import { setupMetricsRoute } from './metrics'
import { setupStatsRoutes } from './stats'
import { setupForumsRoutes } from './forums'
import { setupInviteRoute } from './invite'
import { setupSupportRoutes } from './support'
import { setupSecurityRoutes } from './security'
Expand Down Expand Up @@ -93,6 +94,7 @@ export function setupRoutes(app: Express): void {
setupHealthRoutes(app)
setupMetricsRoute(app)
setupStatsRoutes(app)
setupForumsRoutes(app)
setupInternalNotifyRoutes(app)
setupWebhookPublicRoutes(app)
// Public, unauthenticated CSP report sink — registered before the shared
Expand Down
116 changes: 116 additions & 0 deletions packages/backend/tests/unit/routes/forums.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, test, expect, jest, beforeEach } from '@jest/globals'
import express from 'express'
import request from 'supertest'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockFindUnique = jest.fn<any>()

jest.mock('@lucky/shared/utils', () => {
const actual = jest.requireActual('@lucky/shared/utils') as object
return {
...actual,
getPrismaClient: jest.fn(() => ({
guildForumThread: {
findUnique: mockFindUnique,
},
})),
}
})

jest.mock('../../../src/middleware/rateLimit', () => ({
apiLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
}))

import { setupForumsRoutes } from '../../../src/routes/forums'

function buildApp() {
const app = express()
setupForumsRoutes(app)
return app
}

const GUILD_ID = '895505900016631839'
const SLUG = 'linkedin-de-um-curriculo-parado-para-uma-landing-page-de-aut'

describe('GET /api/guilds/:guildId/threads/:slug', () => {
beforeEach(() => {
jest.clearAllMocks()
})

test('returns 200 with thread data when found', async () => {
mockFindUnique.mockResolvedValueOnce({
threadId: '123456789',
slug: SLUG,
title: 'LinkedIn: de um currículo parado para uma landing page de aut.',
archived: false,
})

const res = await request(buildApp()).get(
`/api/guilds/${GUILD_ID}/threads/${SLUG}`,
)

expect(res.status).toBe(200)
expect(res.body).toMatchObject({
threadId: '123456789',
slug: SLUG,
title: 'LinkedIn: de um currículo parado para uma landing page de aut.',
archived: false,
url: `https://discord.com/channels/${GUILD_ID}/123456789`,
})
})

test('returns 404 when thread not found', async () => {
mockFindUnique.mockResolvedValueOnce(null)

const res = await request(buildApp()).get(
`/api/guilds/${GUILD_ID}/threads/nonexistent-slug`,
)

expect(res.status).toBe(404)
expect(res.body).toEqual({ error: 'Thread not found' })
})

test('queries by guildId and slug composite key', async () => {
mockFindUnique.mockResolvedValueOnce(null)

await request(buildApp()).get(`/api/guilds/${GUILD_ID}/threads/${SLUG}`)

expect(mockFindUnique).toHaveBeenCalledWith({
where: { guildId_slug: { guildId: GUILD_ID, slug: SLUG } },
select: expect.any(Object),
})
})

test('sets Cache-Control header on hit', async () => {
mockFindUnique.mockResolvedValueOnce({
threadId: '999',
slug: SLUG,
title: 'Title',
archived: false,
})

const res = await request(buildApp()).get(
`/api/guilds/${GUILD_ID}/threads/${SLUG}`,
)

expect(res.headers['cache-control']).toContain('public')
expect(res.headers['cache-control']).toContain('max-age=60')
})

test('constructs correct Discord thread URL', async () => {
mockFindUnique.mockResolvedValueOnce({
threadId: 'THREAD_XYZ',
slug: SLUG,
title: 'Any Title',
archived: true,
})

const res = await request(buildApp()).get(
`/api/guilds/${GUILD_ID}/threads/${SLUG}`,
)

expect(res.body.url).toBe(
`https://discord.com/channels/${GUILD_ID}/THREAD_XYZ`,
)
})
})
2 changes: 2 additions & 0 deletions packages/bot/src/handlers/eventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
guildJoinsTotal,
guildLeavesTotal,
} from '../utils/monitoring/prometheus'
import { handleForumThreadCreate } from './forumThreadHandler'

function handleClientReady(client: Client): void {
client.once('clientReady', () => {
Expand Down Expand Up @@ -373,4 +374,5 @@ export default function handleEvents(client: Client) {
handleGuildCreate(client)
handleGuildDelete(client)
handleChannelDelete(client)
handleForumThreadCreate(client)
}
177 changes: 177 additions & 0 deletions packages/bot/src/handlers/forumThreadHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Mocks FIRST — before any imports
const mockUpsert = jest.fn()
const mockGetPrismaClient = jest.fn(() => ({
guildForumThread: { upsert: mockUpsert },
}))
const mockErrorLog = jest.fn()
const mockInfoLog = jest.fn()

// The bot jest config maps @lucky/shared/utils/database/prismaClient to a manual
// mock. Override that with our own factory so getPrismaClient returns a stub client.
jest.mock('@lucky/shared/utils/database/prismaClient', () => ({
getPrismaClient: mockGetPrismaClient,
disconnectPrisma: jest.fn(),
}))

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

import { describe, expect, it, beforeEach } from '@jest/globals'
import { ChannelType, Events } from 'discord.js'
import {
extractOfficialSlug,
processForumThread,
handleForumThreadCreate,
} from './forumThreadHandler'

// ---------- extractOfficialSlug unit tests ----------

describe('extractOfficialSlug', () => {
it('extracts slug from a well-formed marker', () => {
expect(
extractOfficialSlug(
'Intro\n<!-- official:v1:my-guide-slug -->\nbody',
),
).toBe('my-guide-slug')
})

it('tolerates extra whitespace inside the comment', () => {
expect(extractOfficialSlug('<!-- official:v1:slug-abc -->')).toBe(
'slug-abc',
)
})

it('returns null when no marker is present', () => {
expect(extractOfficialSlug('No marker here')).toBeNull()
})

it('returns null for wrong version prefix', () => {
expect(extractOfficialSlug('<!-- official:v2:some-slug -->')).toBeNull()
})

it('returns null for empty content', () => {
expect(extractOfficialSlug('')).toBeNull()
})
})

// ---------- processForumThread tests ----------

describe('processForumThread', () => {
function makeThread(
overrides: Partial<{
guildId: string | null
id: string
name: string
parentType: ChannelType
starterContent: string
starterThrows: boolean
}> = {},
) {
const opts = {
guildId: '895505900016631839',
id: 'THREAD_123',
name: 'Guide Thread',
parentType: ChannelType.GuildForum,
starterContent: '<!-- official:v1:my-guide --> some body',
starterThrows: false,
...overrides,
}
return {
guildId: opts.guildId,
id: opts.id,
name: opts.name,
parent: { type: opts.parentType },
fetchStarterMessage: jest.fn(async () =>
opts.starterThrows
? Promise.reject(new Error('forbidden'))
: { content: opts.starterContent },
),
}
}

beforeEach(() => {
jest.clearAllMocks()
mockUpsert.mockResolvedValue({})
mockGetPrismaClient.mockReturnValue({
guildForumThread: { upsert: mockUpsert },
})
})

it('upserts a GuildForumThread record when marker is found', async () => {
await processForumThread(makeThread() as never)

expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
guildId_slug: {
guildId: '895505900016631839',
slug: 'my-guide',
},
},
create: expect.objectContaining({
guildId: '895505900016631839',
threadId: 'THREAD_123',
slug: 'my-guide',
title: 'Guide Thread',
archived: false,
}),
}),
)
expect(mockInfoLog).toHaveBeenCalledWith(
expect.objectContaining({ message: 'forum thread mapped' }),
)
})

it('does nothing when parent is not a forum channel', async () => {
await processForumThread(
makeThread({ parentType: ChannelType.GuildText }) as never,
)
expect(mockUpsert).not.toHaveBeenCalled()
})

it('does nothing when thread has no guildId', async () => {
await processForumThread(makeThread({ guildId: null }) as never)
expect(mockUpsert).not.toHaveBeenCalled()
})

it('does nothing when starter message has no marker', async () => {
await processForumThread(
makeThread({ starterContent: 'Just a regular post' }) as never,
)
expect(mockUpsert).not.toHaveBeenCalled()
})

it('does nothing when fetchStarterMessage throws (e.g. missing permissions)', async () => {
await processForumThread(makeThread({ starterThrows: true }) as never)
expect(mockUpsert).not.toHaveBeenCalled()
})

it('logs an error and does not throw when upsert fails', async () => {
mockUpsert.mockRejectedValue(new Error('db down'))
await expect(
processForumThread(makeThread() as never),
).resolves.toBeUndefined()
expect(mockErrorLog).toHaveBeenCalledWith(
expect.objectContaining({
message: 'failed to upsert forum thread mapping',
}),
)
})
})

// ---------- handleForumThreadCreate wiring test ----------

describe('handleForumThreadCreate', () => {
it('registers a ThreadCreate listener on the client', () => {
const onMock = jest.fn()
handleForumThreadCreate({ on: onMock } as never)
expect(onMock).toHaveBeenCalledWith(
Events.ThreadCreate,
expect.any(Function),
)
})
})
Loading
Loading