From 9f5a8ae0cee200ece54bebe90e85fa8ebcf8e3a4 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 13 Mar 2026 14:22:08 -0300 Subject: [PATCH 1/2] test: deduplicate runtime stabilization specs --- .../unit/services/GuildAccessService.test.ts | 116 +++++----------- packages/frontend/src/pages/AutoMod.test.tsx | 129 ++++++------------ .../src/pages/ServerSettings.test.tsx | 95 ++++++------- .../src/pages/TwitchNotifications.test.tsx | 53 ++++--- 4 files changed, 144 insertions(+), 249 deletions(-) diff --git a/packages/backend/tests/unit/services/GuildAccessService.test.ts b/packages/backend/tests/unit/services/GuildAccessService.test.ts index 499240231..5d13e1623 100644 --- a/packages/backend/tests/unit/services/GuildAccessService.test.ts +++ b/packages/backend/tests/unit/services/GuildAccessService.test.ts @@ -84,6 +84,25 @@ const EMPTY_ACCESS = { integrations: 'none', } +const MANAGE_ALL_ACCESS = { + overview: 'manage', + settings: 'manage', + moderation: 'manage', + automation: 'manage', + music: 'manage', + integrations: 'manage', +} + +async function expectRbacStorageUnavailable( + operation: Promise, +) { + await expect(operation).rejects.toMatchObject({ + statusCode: 503, + message: + 'RBAC storage is unavailable. Run database migrations and retry.', + }) +} + const SESSION: SessionData = { userId: 'user-1', accessToken: 'access-token', @@ -203,14 +222,7 @@ describe('GuildAccessService', () => { test('listAuthorizedGuilds skips guilds that fail context resolution', async () => { const guilds = [makeGuild('101', { owner: true }), makeGuild('202')] - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockGetUserGuilds.mockResolvedValue(guilds) mockHasBotInGuild.mockImplementation(async (guildId: string) => { @@ -284,14 +296,7 @@ describe('GuildAccessService', () => { test('listAuthorizedGuilds uses cached guilds on upstream 5xx failures', async () => { const cachedGuild = makeGuild('515', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockRedisIsHealthy.mockReturnValue(true) mockRedisGet.mockResolvedValue(JSON.stringify([cachedGuild])) @@ -321,14 +326,7 @@ describe('GuildAccessService', () => { test('listAuthorizedGuilds skips guild when access resolution throws', async () => { const guilds = [makeGuild('101', { owner: true }), makeGuild('202')] - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockGetUserGuilds.mockResolvedValue(guilds) mockHasBotInGuild.mockResolvedValue(true) @@ -358,11 +356,9 @@ describe('GuildAccessService', () => { new MockGuildRoleGrantStorageError('missing table'), ) - await expect(guildAccessService.listAuthorizedGuilds(SESSION)).rejects.toMatchObject({ - statusCode: 503, - message: - 'RBAC storage is unavailable. Run database migrations and retry.', - }) + await expectRbacStorageUnavailable( + guildAccessService.listAuthorizedGuilds(SESSION), + ) }) test('listAuthorizedGuilds returns retryable error when all context lookups fail', async () => { @@ -434,14 +430,7 @@ describe('GuildAccessService', () => { test('resolveGuildContext does not require bot lookup for admin guilds', async () => { const adminGuild = makeGuild('909', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockGetUserGuilds.mockResolvedValue([adminGuild]) mockResolveEffectiveAccess.mockResolvedValue(adminAccess) @@ -481,14 +470,7 @@ describe('GuildAccessService', () => { test('listAuthorizedGuilds throws when enriched guild has no context', async () => { const guild = makeGuild('101', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockGetUserGuilds.mockResolvedValue([guild]) mockHasBotInGuild.mockResolvedValue(true) @@ -576,25 +558,14 @@ describe('GuildAccessService', () => { new MockGuildRoleGrantStorageError('missing table'), ) - await expect( + await expectRbacStorageUnavailable( guildAccessService.resolveGuildContext(SESSION, guild.id), - ).rejects.toMatchObject({ - statusCode: 503, - message: - 'RBAC storage is unavailable. Run database migrations and retry.', - }) + ) }) test('resolveGuildContext short-circuits admin access without bot/member lookups', async () => { const guild = makeGuild('909', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockGetUserGuilds.mockResolvedValue([guild]) mockHasBotInGuild.mockRejectedValue(new Error('discord unreachable')) @@ -621,14 +592,7 @@ describe('GuildAccessService', () => { test('resolveGuildContext does not authorize using cached guilds on upstream 429', async () => { const guild = makeGuild('919', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS const cachedGuilds = JSON.stringify([guild]) mockRedisIsHealthy.mockReturnValue(true) @@ -651,14 +615,7 @@ describe('GuildAccessService', () => { test('listAuthorizedGuilds does not store raw access token bytes in redis key', async () => { const guild = makeGuild('920', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockRedisIsHealthy.mockReturnValue(true) mockGetUserGuilds.mockResolvedValue([guild]) @@ -675,14 +632,7 @@ describe('GuildAccessService', () => { test('listAuthorizedGuilds keeps serving data when redis cache write fails', async () => { const guild = makeGuild('930', { owner: true }) - const adminAccess = { - overview: 'manage', - settings: 'manage', - moderation: 'manage', - automation: 'manage', - music: 'manage', - integrations: 'manage', - } + const adminAccess = MANAGE_ALL_ACCESS mockRedisIsHealthy.mockReturnValue(true) mockRedisSetex.mockRejectedValue(new Error('redis write failed')) diff --git a/packages/frontend/src/pages/AutoMod.test.tsx b/packages/frontend/src/pages/AutoMod.test.tsx index f8922b69e..91184104f 100644 --- a/packages/frontend/src/pages/AutoMod.test.tsx +++ b/packages/frontend/src/pages/AutoMod.test.tsx @@ -452,35 +452,42 @@ describe('AutoModPage', () => { expect(switches.every((s) => !s.hasAttribute('checked'))).toBe(true) }) - test('applies template and shows success toast', async () => { - const user = userEvent.setup() - const { toast } = await import('sonner') - + const setTemplateContext = (template: { + id: string + name: string + description: string + }) => { mockGuildStore(mockGuild) vi.mocked(api.automod.getSettings).mockResolvedValue({ data: { settings: mockSettings }, } as any) vi.mocked(api.automod.listTemplates).mockResolvedValue({ - data: { - templates: [ - { - id: 'balanced', - name: 'Balanced', - description: 'Safe defaults', - }, - ], - }, - } as any) - vi.mocked(api.automod.applyTemplate).mockResolvedValue({ - data: { settings: { ...mockSettings, linksEnabled: true } }, + data: { templates: [template] }, } as any) + } + const clickTemplateApply = async (label: string) => { + const user = userEvent.setup() renderPage() + await user.click( + await screen.findByRole('button', { + name: `Apply ${label} template`, + }), + ) + } - const applyButton = await screen.findByRole('button', { - name: 'Apply Balanced template', + test('applies template and shows success toast', async () => { + const { toast } = await import('sonner') + setTemplateContext({ + id: 'balanced', + name: 'Balanced', + description: 'Safe defaults', }) - await user.click(applyButton) + vi.mocked(api.automod.applyTemplate).mockResolvedValue({ + data: { settings: { ...mockSettings, linksEnabled: true } }, + } as any) + + await clickTemplateApply('Balanced') await waitFor(() => { expect(api.automod.applyTemplate).toHaveBeenCalledWith( @@ -493,75 +500,27 @@ describe('AutoModPage', () => { }) }) - test('shows API error message when template apply fails with ApiError', async () => { - const user = userEvent.setup() - const { toast } = await import('sonner') - - mockGuildStore(mockGuild) - vi.mocked(api.automod.getSettings).mockResolvedValue({ - data: { settings: mockSettings }, - } as any) - vi.mocked(api.automod.listTemplates).mockResolvedValue({ - data: { - templates: [ - { - id: 'strict', - name: 'Strict', - description: 'Strict defaults', - }, - ], - }, - } as any) - vi.mocked(api.automod.applyTemplate).mockRejectedValue( - new ApiError(404, 'Template not found'), - ) - - renderPage() - - const applyButton = await screen.findByRole('button', { - name: 'Apply Strict template', - }) - await user.click(applyButton) - - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Template not found') - }) - }) - - test('shows generic error when template apply fails unexpectedly', async () => { - const user = userEvent.setup() + test.each([ + { + name: 'shows API error message when template apply fails with ApiError', + template: { id: 'strict', name: 'Strict', description: 'Strict defaults' }, + error: new ApiError(404, 'Template not found'), + expectedToast: 'Template not found', + }, + { + name: 'shows generic error when template apply fails unexpectedly', + template: { id: 'light', name: 'Light', description: 'Light defaults' }, + error: new Error('boom'), + expectedToast: 'Failed to apply template', + }, + ])('$name', async ({ template, error, expectedToast }) => { const { toast } = await import('sonner') - - mockGuildStore(mockGuild) - vi.mocked(api.automod.getSettings).mockResolvedValue({ - data: { settings: mockSettings }, - } as any) - vi.mocked(api.automod.listTemplates).mockResolvedValue({ - data: { - templates: [ - { - id: 'light', - name: 'Light', - description: 'Light defaults', - }, - ], - }, - } as any) - vi.mocked(api.automod.applyTemplate).mockRejectedValue( - new Error('boom'), - ) - - renderPage() - - const applyButton = await screen.findByRole('button', { - name: 'Apply Light template', - }) - await user.click(applyButton) + setTemplateContext(template) + vi.mocked(api.automod.applyTemplate).mockRejectedValue(error) + await clickTemplateApply(template.name) await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith( - 'Failed to apply template', - ) + expect(toast.error).toHaveBeenCalledWith(expectedToast) }) }) }) diff --git a/packages/frontend/src/pages/ServerSettings.test.tsx b/packages/frontend/src/pages/ServerSettings.test.tsx index a74a43519..218280f0b 100644 --- a/packages/frontend/src/pages/ServerSettings.test.tsx +++ b/packages/frontend/src/pages/ServerSettings.test.tsx @@ -30,6 +30,29 @@ const defaultAccess = { integrations: 'manage', } as const +const managerGuild = { ...mockGuild, canManageRbac: true } +const managerRoles = [{ id: '222222222222222222', name: 'Mods' }] + +const makeManagerRbacPayload = (overrides: Record = {}) => ({ + guildId: managerGuild.id, + modules: Object.keys(defaultAccess), + grants: [], + roles: managerRoles, + effectiveAccess: defaultAccess, + canManageRbac: true, + ...overrides, +}) + +const setupManagerRbac = (overrides: Record = {}) => { + mockGuildStoreFn(managerGuild, { + canManageRbac: true, + effectiveAccess: defaultAccess, + }) + vi.mocked(api.guilds.getRbac).mockResolvedValue({ + data: makeManagerRbacPayload(overrides), + } as any) +} + function mockGuildStoreFn(guild: typeof mockGuild | null, memberContext?: any) { vi.mocked(useGuildStore).mockReturnValue({ guilds: guild ? [guild] : [], @@ -478,22 +501,7 @@ describe('ServerSettingsPage', () => { test('applies Criativaria baseline and shows success toast', async () => { const user = userEvent.setup() const { toast } = await import('sonner') - const managerGuild = { ...mockGuild, canManageRbac: true } - - mockGuildStoreFn(managerGuild, { - canManageRbac: true, - effectiveAccess: defaultAccess, - }) - vi.mocked(api.guilds.getRbac).mockResolvedValue({ - data: { - guildId: managerGuild.id, - modules: Object.keys(defaultAccess), - grants: [], - roles: [{ id: '222222222222222222', name: 'Mods' }], - effectiveAccess: defaultAccess, - canManageRbac: true, - }, - } as any) + setupManagerRbac() vi.mocked(api.guilds.applyCriativariaPreset).mockResolvedValue({ data: { run: { status: 'completed' } }, } as any) @@ -515,28 +523,22 @@ describe('ServerSettingsPage', () => { }) }) - test('shows ApiError message when Criativaria baseline apply fails', async () => { + test.each([ + { + name: 'shows ApiError message when Criativaria baseline apply fails', + error: new ApiError(500, 'Preset failed'), + expectedToast: 'Preset failed', + }, + { + name: 'shows generic message when Criativaria baseline apply fails unexpectedly', + error: new Error('Unexpected failure'), + expectedToast: 'Failed to apply Criativaria baseline', + }, + ])('$name', async ({ error, expectedToast }) => { const user = userEvent.setup() const { toast } = await import('sonner') - const managerGuild = { ...mockGuild, canManageRbac: true } - - mockGuildStoreFn(managerGuild, { - canManageRbac: true, - effectiveAccess: defaultAccess, - }) - vi.mocked(api.guilds.getRbac).mockResolvedValue({ - data: { - guildId: managerGuild.id, - modules: Object.keys(defaultAccess), - grants: [], - roles: [{ id: '222222222222222222', name: 'Mods' }], - effectiveAccess: defaultAccess, - canManageRbac: true, - }, - } as any) - vi.mocked(api.guilds.applyCriativariaPreset).mockRejectedValue( - new ApiError(500, 'Preset failed'), - ) + setupManagerRbac() + vi.mocked(api.guilds.applyCriativariaPreset).mockRejectedValue(error) renderPage() @@ -546,30 +548,21 @@ describe('ServerSettingsPage', () => { await user.click(applyButton) await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Preset failed') + expect(toast.error).toHaveBeenCalledWith(expectedToast) }) }) test('retries RBAC role loading from warning card', async () => { const user = userEvent.setup() - const managerGuild = { ...mockGuild, canManageRbac: true } - - mockGuildStoreFn(managerGuild, { - canManageRbac: true, - effectiveAccess: defaultAccess, - }) vi.mocked(api.guilds.getRbac) .mockRejectedValueOnce(new Error('initial network')) .mockResolvedValueOnce({ - data: { - guildId: managerGuild.id, - modules: Object.keys(defaultAccess), - grants: [], - roles: [{ id: '222222222222222222', name: 'Mods' }], - effectiveAccess: defaultAccess, - canManageRbac: true, - }, + data: makeManagerRbacPayload(), } as any) + mockGuildStoreFn(managerGuild, { + canManageRbac: true, + effectiveAccess: defaultAccess, + }) renderPage() diff --git a/packages/frontend/src/pages/TwitchNotifications.test.tsx b/packages/frontend/src/pages/TwitchNotifications.test.tsx index be76974ed..853a427a7 100644 --- a/packages/frontend/src/pages/TwitchNotifications.test.tsx +++ b/packages/frontend/src/pages/TwitchNotifications.test.tsx @@ -27,6 +27,9 @@ const mockNotifications = [ }, ] +const TWITCH_INPUT_PLACEHOLDER = + 'Twitch URL or login (e.g. https://twitch.tv/luk)' + import { useGuildSelection } from '@/hooks/useGuildSelection' function mockGuildSelection(guild: typeof mockGuild | null) { @@ -63,6 +66,19 @@ function renderPage() { ) } +async function openAddForm(user: ReturnType) { + await user.click(await screen.findByText('Add')) +} + +async function fillNotificationForm( + user: ReturnType, + twitchInput: string, +) { + await user.type(screen.getByPlaceholderText(TWITCH_INPUT_PLACEHOLDER), twitchInput) + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByText('#general')) +} + describe('TwitchNotificationsPage', () => { beforeEach(() => { vi.clearAllMocks() @@ -208,17 +224,8 @@ describe('TwitchNotificationsPage', () => { expect(screen.getByText('Add')).toBeInTheDocument() }) - await user.click(screen.getByText('Add')) - - await user.type( - screen.getByPlaceholderText( - 'Twitch URL or login (e.g. https://twitch.tv/luk)', - ), - 'teststreamer', - ) - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByText('#general')) - + await openAddForm(user) + await fillNotificationForm(user, 'teststreamer') await user.click(screen.getByText('Save')) await waitFor(() => { @@ -284,15 +291,8 @@ describe('TwitchNotificationsPage', () => { renderPage() - await user.click(await screen.findByText('Add')) - await user.type( - screen.getByPlaceholderText( - 'Twitch URL or login (e.g. https://twitch.tv/luk)', - ), - '!!', - ) - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByText('#general')) + await openAddForm(user) + await fillNotificationForm(user, '!!') await user.click(screen.getByText('Save')) expect( @@ -316,15 +316,8 @@ describe('TwitchNotificationsPage', () => { renderPage() - await user.click(await screen.findByText('Add')) - await user.type( - screen.getByPlaceholderText( - 'Twitch URL or login (e.g. https://twitch.tv/luk)', - ), - 'shroud', - ) - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByText('#general')) + await openAddForm(user) + await fillNotificationForm(user, 'shroud') await user.click(screen.getByText('Save')) expect( @@ -343,7 +336,7 @@ describe('TwitchNotificationsPage', () => { ) renderPage() - await user.click(await screen.findByText('Add')) + await openAddForm(user) expect( await screen.findByText('Failed to load Discord channels'), From 1eedf1844d2889e13f90bea193e6fde77a0ae6c3 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 13 Mar 2026 15:21:14 -0300 Subject: [PATCH 2/2] test(frontend): tighten ServerSettings RBAC fixture typing --- .../src/pages/ServerSettings.test.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/frontend/src/pages/ServerSettings.test.tsx b/packages/frontend/src/pages/ServerSettings.test.tsx index 218280f0b..264ac5b31 100644 --- a/packages/frontend/src/pages/ServerSettings.test.tsx +++ b/packages/frontend/src/pages/ServerSettings.test.tsx @@ -30,12 +30,25 @@ const defaultAccess = { integrations: 'manage', } as const -const managerGuild = { ...mockGuild, canManageRbac: true } -const managerRoles = [{ id: '222222222222222222', name: 'Mods' }] +type GuildRbacResponse = Awaited> +type GuildRbacPayload = GuildRbacResponse['data'] -const makeManagerRbacPayload = (overrides: Record = {}) => ({ +const managerGuild = { ...mockGuild, canManageRbac: true } +const managerRoles: GuildRbacPayload['roles'] = [ + { + id: '222222222222222222', + name: 'Mods', + color: 0, + position: 1, + }, +] +const managerModules = Object.keys(defaultAccess) as GuildRbacPayload['modules'] + +const makeManagerRbacPayload = ( + overrides: Partial = {}, +): GuildRbacPayload => ({ guildId: managerGuild.id, - modules: Object.keys(defaultAccess), + modules: managerModules, grants: [], roles: managerRoles, effectiveAccess: defaultAccess, @@ -43,14 +56,14 @@ const makeManagerRbacPayload = (overrides: Record = {}) => ({ ...overrides, }) -const setupManagerRbac = (overrides: Record = {}) => { +const setupManagerRbac = (overrides: Partial = {}) => { mockGuildStoreFn(managerGuild, { canManageRbac: true, effectiveAccess: defaultAccess, }) vi.mocked(api.guilds.getRbac).mockResolvedValue({ data: makeManagerRbacPayload(overrides), - } as any) + } as GuildRbacResponse) } function mockGuildStoreFn(guild: typeof mockGuild | null, memberContext?: any) { @@ -558,7 +571,7 @@ describe('ServerSettingsPage', () => { .mockRejectedValueOnce(new Error('initial network')) .mockResolvedValueOnce({ data: makeManagerRbacPayload(), - } as any) + } as GuildRbacResponse) mockGuildStoreFn(managerGuild, { canManageRbac: true, effectiveAccess: defaultAccess,