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
12 changes: 7 additions & 5 deletions packages/backend/src/routes/toggles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,13 @@ export function setupToggleRoutes(app: Express): void {
throw AppError.badRequest('Invalid toggle name')
}

res.json({
success: true,
message: 'Toggle updated via Unleash admin API',
note: 'Use Unleash admin API to update toggles per guild',
})
await featureToggleService.setGuildFeatureToggle(
guildId,
toggleName as FeatureToggleName,
enabled,
)

res.json({ success: true, guildId, name: toggleName, enabled })
}),
)
}
18 changes: 16 additions & 2 deletions packages/backend/tests/integration/routes/toggles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ jest.mock('../../../src/services/SessionService', () => ({
},
}))

const mockSetGuildFeatureToggle = jest.fn<any>().mockResolvedValue(undefined)

jest.mock('@lucky/shared/services', () => ({
featureToggleService: {
getAllToggles: jest.fn(),
isEnabledGlobal: jest.fn(),
isEnabledForGuild: jest.fn(),
setGuildFeatureToggle: (...args: any[]) =>
mockSetGuildFeatureToggle(...args),
},
}))

Expand Down Expand Up @@ -322,7 +326,7 @@ describe('Toggles Routes Integration', () => {
})

describe('POST /api/guilds/:id/features/:name', () => {
test('should return success message', async () => {
test('saves toggle to db and returns updated state', async () => {
const mockSessionService = sessionService as jest.Mocked<
typeof sessionService
>
Expand All @@ -342,7 +346,17 @@ describe('Toggles Routes Integration', () => {
.send({ enabled: true })
.expect(200)

expect(response.body).toHaveProperty('success', true)
expect(response.body).toEqual({
success: true,
guildId: '111111111111111111',
name: 'DOWNLOAD_VIDEO',
enabled: true,
})
expect(mockSetGuildFeatureToggle).toHaveBeenCalledWith(
'111111111111111111',
'DOWNLOAD_VIDEO',
true,
)
})

test('should return 400 when enabled is missing', async () => {
Expand Down
67 changes: 51 additions & 16 deletions packages/frontend/src/hooks/useFeatures.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { renderHook, waitFor, act } from '@testing-library/react'
import { useFeatures } from './useFeatures'
import { useAuthStore } from '@/stores/authStore'
import { useGuildStore } from '@/stores/guildStore'
Expand All @@ -8,6 +8,7 @@ import { useFeaturesStore } from '@/stores/featuresStore'
vi.mock('@/stores/authStore')
vi.mock('@/stores/guildStore')
vi.mock('@/stores/featuresStore')
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))

type AuthState = {
isDeveloper: boolean
Expand Down Expand Up @@ -46,8 +47,8 @@ describe('useFeatures', () => {
const fetchFeatures = vi.fn<() => Promise<void>>()
const fetchGlobalToggles = vi.fn<() => Promise<void>>()
const fetchServerToggles = vi.fn<(guildId: string) => Promise<void>>()
const updateGlobalToggle = vi.fn()
const updateServerToggle = vi.fn()
const updateGlobalToggle = vi.fn<() => Promise<void>>()
const updateServerToggle = vi.fn<() => Promise<void>>()
const getServerToggles = vi.fn()
const clearLoadError = vi.fn()

Expand All @@ -60,6 +61,8 @@ describe('useFeatures', () => {
fetchFeatures.mockResolvedValue()
fetchGlobalToggles.mockResolvedValue()
fetchServerToggles.mockResolvedValue()
updateGlobalToggle.mockResolvedValue()
updateServerToggle.mockResolvedValue()
getServerToggles.mockReturnValue({ DOWNLOAD_VIDEO: true })

authState = {
Expand All @@ -84,18 +87,15 @@ describe('useFeatures', () => {
getServerToggles,
}

vi.mocked(useAuthStore).mockImplementation(
((selector: (state: AuthState) => unknown) => selector(authState)) as
typeof useAuthStore,
)
vi.mocked(useGuildStore).mockImplementation(
((selector: (state: GuildState) => unknown) =>
selector(guildState)) as typeof useGuildStore,
)
vi.mocked(useFeaturesStore).mockImplementation(
((selector: (state: FeaturesState) => unknown) =>
selector(featuresState)) as typeof useFeaturesStore,
)
vi.mocked(useAuthStore).mockImplementation(((
selector: (state: AuthState) => unknown,
) => selector(authState)) as typeof useAuthStore)
vi.mocked(useGuildStore).mockImplementation(((
selector: (state: GuildState) => unknown,
) => selector(guildState)) as typeof useGuildStore)
vi.mocked(useFeaturesStore).mockImplementation(((
selector: (state: FeaturesState) => unknown,
) => selector(featuresState)) as typeof useFeaturesStore)
})

test('does not fetch global toggles for non-developer users', async () => {
Expand Down Expand Up @@ -168,7 +168,9 @@ describe('useFeatures', () => {
test('returns global toggles when guild is not selected', () => {
const { result } = renderHook(() => useFeatures())

expect(result.current.serverToggles).toEqual(featuresState.globalToggles)
expect(result.current.serverToggles).toEqual(
featuresState.globalToggles,
)
expect(getServerToggles).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -214,6 +216,39 @@ describe('useFeatures', () => {
expect(updateServerToggle).toHaveBeenCalledTimes(1)
})

test('shows error toast when global toggle update fails', async () => {
const { toast } = await import('sonner')
updateGlobalToggle.mockRejectedValue(new Error('API failure'))

const { result } = renderHook(() => useFeatures())

await act(async () => {
result.current.handleGlobalToggle('AUTOPLAY', false)
await new Promise((r) => setTimeout(r, 10))
})

expect(toast.error).toHaveBeenCalledWith(
'Failed to update global toggle',
)
})

test('shows error toast when server toggle update fails', async () => {
const { toast } = await import('sonner')
guildState.selectedGuild = { id: 'guild-5' }
updateServerToggle.mockRejectedValue(new Error('API failure'))

const { result } = renderHook(() => useFeatures())

await act(async () => {
result.current.handleServerToggle('AUTOPLAY', false)
await new Promise((r) => setTimeout(r, 10))
})

expect(toast.error).toHaveBeenCalledWith(
'Failed to update server toggle',
)
})

test('retries load with current auth and guild context', async () => {
authState.isDeveloper = true
guildState.selectedGuild = { id: 'guild-4' }
Expand Down
9 changes: 7 additions & 2 deletions packages/frontend/src/hooks/useFeatures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import { useFeaturesStore } from '@/stores/featuresStore'
import { useAuthStore } from '@/stores/authStore'
import { useGuildStore } from '@/stores/guildStore'
Expand Down Expand Up @@ -57,12 +58,16 @@ export function useFeatures() {
}, [selectedGuild, fetchServerToggles, isAuthenticated, isAuthLoading])

const handleGlobalToggle = (name: FeatureToggleName, enabled: boolean) => {
updateGlobalToggle(name, enabled)
updateGlobalToggle(name, enabled).catch(() => {
toast.error('Failed to update global toggle')
})
}

const handleServerToggle = (name: FeatureToggleName, enabled: boolean) => {
if (selectedGuild) {
updateServerToggle(selectedGuild.id, name, enabled)
updateServerToggle(selectedGuild.id, name, enabled).catch(() => {
toast.error('Failed to update server toggle')
})
}
}

Expand Down
30 changes: 13 additions & 17 deletions packages/frontend/src/stores/featuresStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,27 +183,23 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => ({
},

updateGlobalToggle: async (name, enabled) => {
try {
await api.features.updateGlobalToggle(name, enabled)
set((state) => ({
globalToggles: { ...state.globalToggles, [name]: enabled },
}))
} catch {}
await api.features.updateGlobalToggle(name, enabled)
set((state) => ({
globalToggles: { ...state.globalToggles, [name]: enabled },
}))
},

updateServerToggle: async (guildId, name, enabled) => {
try {
await api.features.updateServerToggle(guildId, name, enabled)
set((state) => ({
serverToggles: {
...state.serverToggles,
[guildId]: {
...(state.serverToggles[guildId] || defaultToggles),
[name]: enabled,
},
await api.features.updateServerToggle(guildId, name, enabled)
set((state) => ({
serverToggles: {
...state.serverToggles,
[guildId]: {
...(state.serverToggles[guildId] || defaultToggles),
[name]: enabled,
},
}))
} catch {}
},
}))
},

getServerToggles: (guildId) =>
Expand Down
Loading