From 84b2c10c71c977241813cfb44498e334985b14f7 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 12 May 2026 19:28:37 +0200 Subject: [PATCH] feat(PBI-80): SprintSwitcher demo-fork (ST-1345) Demo-sessies navigeren bij sprint-wissel direct via router.push, zonder de geblokkeerde setActiveSprintAction aan te roepen. De server-action behoudt zijn 403-guard als defense in depth. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/sprint-switcher.test.tsx | 125 ++++++++++++++++++ components/shared/sprint-switcher.tsx | 6 + 2 files changed, 131 insertions(+) create mode 100644 __tests__/components/shared/sprint-switcher.test.tsx diff --git a/__tests__/components/shared/sprint-switcher.test.tsx b/__tests__/components/shared/sprint-switcher.test.tsx new file mode 100644 index 0000000..8b1e2ec --- /dev/null +++ b/__tests__/components/shared/sprint-switcher.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' + +const pushMock = vi.fn() +const refreshMock = vi.fn() +const pathnameMock = vi.fn(() => '/products/p1/sprint') + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, refresh: refreshMock }), + usePathname: () => pathnameMock(), +})) + +vi.mock('@/actions/active-sprint', () => ({ + setActiveSprintAction: vi.fn(), +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +const isDemoMock = { value: false } +vi.mock('@/stores/user-settings/store', () => ({ + useUserSettingsStore: (selector: (s: { context: { isDemo: boolean } }) => unknown) => + selector({ context: { isDemo: isDemoMock.value } }), +})) + +vi.mock('@/components/ui/dropdown-menu', () => { + type Props = { children?: React.ReactNode; onClick?: () => void; className?: string } + const PassThrough = ({ children }: Props) => <>{children} + return { + DropdownMenu: PassThrough, + DropdownMenuTrigger: PassThrough, + DropdownMenuContent: PassThrough, + DropdownMenuItem: ({ children, onClick, className }: Props) => ( + + ), + DropdownMenuSeparator: () => null, + } +}) + +vi.mock('@/components/ui/tooltip', () => { + type Props = { children?: React.ReactNode } + const PassThrough = ({ children }: Props) => <>{children} + return { + Tooltip: PassThrough, + TooltipContent: PassThrough, + TooltipProvider: PassThrough, + TooltipTrigger: PassThrough, + } +}) + +import { setActiveSprintAction } from '@/actions/active-sprint' +import { toast } from 'sonner' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' + +const actionMock = setActiveSprintAction as unknown as ReturnType +const toastError = toast.error as unknown as ReturnType +const toastSuccess = toast.success as unknown as ReturnType + +const sprints = [ + { id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const }, + { id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const }, +] + +beforeEach(() => { + vi.clearAllMocks() + isDemoMock.value = false + actionMock.mockResolvedValue({ success: true }) + pathnameMock.mockReturnValue('/products/p1/sprint') +}) + +describe('SprintSwitcher', () => { + it('demo: clicking another sprint navigates via router.push without calling the action', () => { + isDemoMock.value = true + render( + , + ) + fireEvent.click(screen.getByText('Goal 2')) + expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2') + expect(actionMock).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + expect(toastSuccess).not.toHaveBeenCalled() + }) + + it('non-demo: clicking another sprint calls setActiveSprintAction', async () => { + isDemoMock.value = false + render( + , + ) + fireEvent.click(screen.getByText('Goal 2')) + // Wait microtask for the transition to flush. + await Promise.resolve() + expect(actionMock).toHaveBeenCalledWith('p1', 's2') + }) + + it('clicking the already-active sprint does nothing', () => { + isDemoMock.value = true + render( + , + ) + fireEvent.click(screen.getByText('Goal 1')) + expect(pushMock).not.toHaveBeenCalled() + expect(actionMock).not.toHaveBeenCalled() + }) +}) diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx index 4377742..e1a041c 100644 --- a/components/shared/sprint-switcher.tsx +++ b/components/shared/sprint-switcher.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' import { setActiveSprintAction } from '@/actions/active-sprint' +import { useUserSettingsStore } from '@/stores/user-settings/store' import type { SprintStatusApi } from '@/lib/task-status' import { debugProps } from '@/lib/debug' @@ -44,6 +45,7 @@ export function SprintSwitcher({ const [isPending, startTransition] = useTransition() const [showClosed, setShowClosed] = useState(false) const buildingSet = new Set(buildingSprintIds) + const isDemo = useUserSettingsStore(s => s.context.isDemo) const visibleSprints = sprints.filter(s => { if (showClosed) return true @@ -53,6 +55,10 @@ export function SprintSwitcher({ function handleSwitchSprint(sprintId: string) { if (sprintId === activeSprint?.id) return + if (isDemo) { + router.push(`/products/${productId}/sprint/${sprintId}`) + return + } startTransition(async () => { const result = await setActiveSprintAction(productId, sprintId) if (result?.error) {