fix(frontend): resolve lint blockers for gitea frontend-ci

This commit is contained in:
WorkClub Automation
2026-03-06 22:26:55 +01:00
parent ad6a23621d
commit 5cf43976f6
16 changed files with 112 additions and 96 deletions

View File

@@ -15,7 +15,7 @@ import { test, expect } from '@playwright/test';
/** /**
* Robust club selection helper with fallback locators * Robust club selection helper with fallback locators
*/ */
async function selectClubIfPresent(page: any) { async function selectClubIfPresent(page: import('@playwright/test').Page) {
const isOnSelectClub = page.url().includes('/select-club'); const isOnSelectClub = page.url().includes('/select-club');
if (!isOnSelectClub) { if (!isOnSelectClub) {
@@ -182,7 +182,7 @@ test.describe('Authentication Flow', () => {
}); });
}); });
async function authenticateUser(page: any, email: string, password: string) { async function authenticateUser(page: import('@playwright/test').Page, email: string, password: string) {
await page.goto('/login'); await page.goto('/login');
await page.click('button:has-text("Sign in with Keycloak")'); await page.click('button:has-text("Sign in with Keycloak")');

View File

@@ -11,7 +11,7 @@ import { test, expect } from '@playwright/test';
* - Visual capacity indicators (progress bar, spot counts) * - Visual capacity indicators (progress bar, spot counts)
*/ */
async function selectClubIfPresent(page: any) { async function selectClubIfPresent(page: import('@playwright/test').Page) {
const isOnSelectClub = page.url().includes('/select-club'); const isOnSelectClub = page.url().includes('/select-club');
if (!isOnSelectClub) { if (!isOnSelectClub) {
@@ -51,7 +51,7 @@ async function selectClubIfPresent(page: any) {
} }
} }
async function loginAs(page: any, email: string, password: string) { async function loginAs(page: import('@playwright/test').Page, email: string, password: string) {
await page.goto('/login'); await page.goto('/login');
await page.click('button:has-text("Sign in with Keycloak")'); await page.click('button:has-text("Sign in with Keycloak")');

View File

@@ -1,8 +1,6 @@
import { AuthGuard } from '@/components/auth-guard'; import { AuthGuard } from '@/components/auth-guard';
import { ClubSwitcher } from '@/components/club-switcher'; import { ClubSwitcher } from '@/components/club-switcher';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { LogOut } from 'lucide-react';
import { SignOutButton } from '@/components/sign-out-button'; import { SignOutButton } from '@/components/sign-out-button';
export default function ProtectedLayout({ export default function ProtectedLayout({

View File

@@ -2,7 +2,6 @@
import { use } from 'react'; import { use } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTask, useUpdateTask } from '@/hooks/useTasks'; import { useTask, useUpdateTask } from '@/hooks/useTasks';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -25,7 +24,6 @@ const statusColors: Record<string, string> = {
export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params); const resolvedParams = use(params);
const router = useRouter();
const { data: task, isLoading, error } = useTask(resolvedParams.id); const { data: task, isLoading, error } = useTask(resolvedParams.id);
const { mutate: updateTask, isPending } = useUpdateTask(); const { mutate: updateTask, isPending } = useUpdateTask();

View File

@@ -31,7 +31,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
async jwt({ token, account }) { async jwt({ token, account }) {
if (account) { if (account) {
// Add clubs claim from Keycloak access token // Add clubs claim from Keycloak access token
token.clubs = (account as any).clubs || {} token.clubs = (account as Record<string, unknown>).clubs as Record<string, string> || {}
token.accessToken = account.access_token token.accessToken = account.access_token
} }
return token return token

View File

@@ -22,28 +22,28 @@ describe('AuthGuard', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(useRouter as any).mockReturnValue({ push: mockPush } as any); (useRouter as ReturnType<typeof vi.fn>).mockReturnValue({ push: mockPush });
}); });
it('renders loading state when session is loading', () => { it('renders loading state when session is loading', () => {
(useSession as any).mockReturnValue({ data: null, status: 'loading' } as any); (useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: null, status: 'loading' });
(useTenant as any).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null }); (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>); render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(screen.getByText('Loading...')).toBeInTheDocument(); expect(screen.getByText('Loading...')).toBeInTheDocument();
}); });
it('redirects to /login when unauthenticated', () => { it('redirects to /login when unauthenticated', () => {
(useSession as any).mockReturnValue({ data: null, status: 'unauthenticated' } as any); (useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: null, status: 'unauthenticated' });
(useTenant as any).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null }); (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>); render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockPush).toHaveBeenCalledWith('/login'); expect(mockPush).toHaveBeenCalledWith('/login');
}); });
it('shows Contact admin when 0 clubs', () => { it('shows Contact admin when 0 clubs', () => {
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any); (useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as any).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null }); (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({ activeClubId: null, clubs: [], setActiveClub: vi.fn(), userRole: null });
render(<AuthGuard><div>Protected</div></AuthGuard>); render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(screen.getByText('Contact admin to get access to a club')).toBeInTheDocument(); expect(screen.getByText('Contact admin to get access to a club')).toBeInTheDocument();
@@ -51,39 +51,39 @@ describe('AuthGuard', () => {
it('auto-selects when 1 club and no active club', () => { it('auto-selects when 1 club and no active club', () => {
const mockSetActiveClub = vi.fn(); const mockSetActiveClub = vi.fn();
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any); (useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as any).mockReturnValue({ (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: null, activeClubId: null,
clubs: [{ id: 'club-1', name: 'Club 1' }], clubs: [{ id: 'club-1', name: 'Club 1' }],
setActiveClub: mockSetActiveClub, setActiveClub: mockSetActiveClub,
userRole: null userRole: null
} as any); });
render(<AuthGuard><div>Protected</div></AuthGuard>); render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockSetActiveClub).toHaveBeenCalledWith('club-1'); expect(mockSetActiveClub).toHaveBeenCalledWith('club-1');
}); });
it('redirects to /select-club when multiple clubs and no active club', () => { it('redirects to /select-club when multiple clubs and no active club', () => {
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any); (useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as any).mockReturnValue({ (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: null, activeClubId: null,
clubs: [{ id: 'club-1', name: 'Club 1' }, { id: 'club-2', name: 'Club 2' }], clubs: [{ id: 'club-1', name: 'Club 1' }, { id: 'club-2', name: 'Club 2' }],
setActiveClub: vi.fn(), setActiveClub: vi.fn(),
userRole: null userRole: null
} as any); });
render(<AuthGuard><div>Protected</div></AuthGuard>); render(<AuthGuard><div>Protected</div></AuthGuard>);
expect(mockPush).toHaveBeenCalledWith('/select-club'); expect(mockPush).toHaveBeenCalledWith('/select-club');
}); });
it('renders children when authenticated and active club is set', () => { it('renders children when authenticated and active club is set', () => {
(useSession as any).mockReturnValue({ data: { user: {} }, status: 'authenticated' } as any); (useSession as ReturnType<typeof vi.fn>).mockReturnValue({ data: { user: {} }, status: 'authenticated' });
(useTenant as any).mockReturnValue({ (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: 'club-1', activeClubId: 'club-1',
clubs: [{ id: 'club-1', name: 'Club 1' }], clubs: [{ id: 'club-1', name: 'Club 1' }],
setActiveClub: vi.fn(), setActiveClub: vi.fn(),
userRole: 'admin' userRole: 'admin'
} as any); });
render(<AuthGuard><div>Protected Content</div></AuthGuard>); render(<AuthGuard><div>Protected Content</div></AuthGuard>);
expect(screen.getByText('Protected Content')).toBeInTheDocument(); expect(screen.getByText('Protected Content')).toBeInTheDocument();

View File

@@ -9,7 +9,7 @@ vi.mock('../../contexts/tenant-context', () => ({
vi.mock('../ui/dropdown-menu', () => ({ vi.mock('../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode, asChild?: boolean }) => <div data-testid="trigger">{children}</div>, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div data-testid="trigger">{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="content">{children}</div>, DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="content">{children}</div>,
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => <div onClick={onClick} data-testid="menu-item">{children}</div>, DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => <div onClick={onClick} data-testid="menu-item">{children}</div>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -22,19 +22,19 @@ describe('ClubSwitcher', () => {
}); });
it('renders loading state when clubs is empty', () => { it('renders loading state when clubs is empty', () => {
(useTenant as any).mockReturnValue({ (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: null, activeClubId: null,
clubs: [], clubs: [],
setActiveClub: vi.fn(), setActiveClub: vi.fn(),
userRole: null userRole: null
} as any); });
render(<ClubSwitcher />); render(<ClubSwitcher />);
expect(screen.getByRole('button')).toHaveTextContent('Select Club'); expect(screen.getByRole('button')).toHaveTextContent('Select Club');
}); });
it('renders current club name and sport type badge', () => { it('renders current club name and sport type badge', () => {
(useTenant as any).mockReturnValue({ (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: 'club-1', activeClubId: 'club-1',
clubs: [ clubs: [
{ id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' }, { id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' },
@@ -42,7 +42,7 @@ describe('ClubSwitcher', () => {
], ],
setActiveClub: vi.fn(), setActiveClub: vi.fn(),
userRole: 'admin' userRole: 'admin'
} as any); });
render(<ClubSwitcher />); render(<ClubSwitcher />);
expect(screen.getAllByText('Tennis Club')[0]).toBeInTheDocument(); expect(screen.getAllByText('Tennis Club')[0]).toBeInTheDocument();
@@ -50,7 +50,7 @@ describe('ClubSwitcher', () => {
it('calls setActiveClub when club is selected', () => { it('calls setActiveClub when club is selected', () => {
const mockSetActiveClub = vi.fn(); const mockSetActiveClub = vi.fn();
(useTenant as any).mockReturnValue({ (useTenant as ReturnType<typeof vi.fn>).mockReturnValue({
activeClubId: 'club-1', activeClubId: 'club-1',
clubs: [ clubs: [
{ id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' }, { id: 'club-1', name: 'Tennis Club', sportType: 'Tennis' },
@@ -58,7 +58,7 @@ describe('ClubSwitcher', () => {
], ],
setActiveClub: mockSetActiveClub, setActiveClub: mockSetActiveClub,
userRole: 'admin' userRole: 'admin'
} as any); });
render(<ClubSwitcher />); render(<ClubSwitcher />);

View File

@@ -38,12 +38,12 @@ describe('ShiftDetailPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(useSignUpShift as any).mockReturnValue({ mutateAsync: mockSignUp, isPending: false }); (useSignUpShift as ReturnType<typeof vi.fn>).mockReturnValue({ mutateAsync: mockSignUp, isPending: false });
(useCancelSignUp as any).mockReturnValue({ mutateAsync: mockCancel, isPending: false }); (useCancelSignUp as ReturnType<typeof vi.fn>).mockReturnValue({ mutateAsync: mockCancel, isPending: false });
}); });
it('shows "Sign Up" button if capacity available', async () => { it('shows "Sign Up" button if capacity available', async () => {
(useShift as any).mockReturnValue({ (useShift as ReturnType<typeof vi.fn>).mockReturnValue({
data: { data: {
id: '1', id: '1',
title: 'Detail Shift', title: 'Detail Shift',
@@ -69,7 +69,7 @@ describe('ShiftDetailPage', () => {
}); });
it('shows "Cancel Sign-up" button if user is signed up', async () => { it('shows "Cancel Sign-up" button if user is signed up', async () => {
(useShift as any).mockReturnValue({ (useShift as ReturnType<typeof vi.fn>).mockReturnValue({
data: { data: {
id: '1', id: '1',
title: 'Detail Shift', title: 'Detail Shift',
@@ -95,7 +95,7 @@ describe('ShiftDetailPage', () => {
}); });
it('calls sign up mutation on click', async () => { it('calls sign up mutation on click', async () => {
(useShift as any).mockReturnValue({ (useShift as ReturnType<typeof vi.fn>).mockReturnValue({
data: { data: {
id: '1', id: '1',
title: 'Detail Shift', title: 'Detail Shift',

View File

@@ -20,18 +20,18 @@ describe('TaskDetailPage', () => {
const mockMutate = vi.fn(); const mockMutate = vi.fn();
beforeEach(() => { beforeEach(() => {
(useUpdateTask as any).mockReturnValue({ (useUpdateTask as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: mockMutate, mutate: mockMutate,
isPending: false, isPending: false,
} as any); });
}); });
it('shows valid transitions for Open status', async () => { it('shows valid transitions for Open status', async () => {
(useTask as any).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false, isLoading: false,
error: null, error: null,
} as any); });
const params = Promise.resolve({ id: '1' }); const params = Promise.resolve({ id: '1' });
await act(async () => { await act(async () => {
@@ -44,11 +44,11 @@ describe('TaskDetailPage', () => {
}); });
it('shows valid transitions for InProgress status', async () => { it('shows valid transitions for InProgress status', async () => {
(useTask as any).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false, isLoading: false,
error: null, error: null,
} as any); });
const params = Promise.resolve({ id: '1' }); const params = Promise.resolve({ id: '1' });
await act(async () => { await act(async () => {
@@ -60,11 +60,11 @@ describe('TaskDetailPage', () => {
}); });
it('shows valid transitions for Review status (including back transition)', async () => { it('shows valid transitions for Review status (including back transition)', async () => {
(useTask as any).mockReturnValue({ (useTask as ReturnType<typeof vi.fn>).mockReturnValue({
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' }, data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
isLoading: false, isLoading: false,
error: null, error: null,
} as any); });
const params = Promise.resolve({ id: '1' }); const params = Promise.resolve({ id: '1' });
await act(async () => { await act(async () => {

View File

@@ -24,7 +24,7 @@ vi.mock('@/hooks/useTasks', () => ({
describe('TaskListPage', () => { describe('TaskListPage', () => {
beforeEach(() => { beforeEach(() => {
(useTasks as any).mockReturnValue({ (useTasks as ReturnType<typeof vi.fn>).mockReturnValue({
data: { data: {
items: [ items: [
{ id: '1', title: 'Test Task 1', status: 'Open', assigneeId: null, createdAt: '2024-01-01' }, { id: '1', title: 'Test Task 1', status: 'Open', assigneeId: null, createdAt: '2024-01-01' },
@@ -37,7 +37,7 @@ describe('TaskListPage', () => {
}, },
isLoading: false, isLoading: false,
error: null, error: null,
} as any); });
}); });
it('renders task list with 3 data rows', () => { it('renders task list with 3 data rows', () => {

View File

@@ -6,7 +6,7 @@ import { ReactNode, useEffect } from 'react';
import { useTenant } from '../contexts/tenant-context'; import { useTenant } from '../contexts/tenant-context';
export function AuthGuard({ children }: { children: ReactNode }) { export function AuthGuard({ children }: { children: ReactNode }) {
const { data: session, status } = useSession(); const { status } = useSession();
const { activeClubId, clubs, setActiveClub, clubsLoading } = useTenant(); const { activeClubId, clubs, setActiveClub, clubsLoading } = useTenant();
const router = useRouter(); const router = useRouter();

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, useMemo, ReactNode } from 'react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -22,11 +22,32 @@ type TenantContextType = {
const TenantContext = createContext<TenantContextType | undefined>(undefined); const TenantContext = createContext<TenantContextType | undefined>(undefined);
function getInitialClubId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('activeClubId');
}
function determineActiveClub(clubs: Club[], currentActiveId: string | null): string | null {
if (!clubs.length) return null;
const stored = getInitialClubId();
if (stored && clubs.find(c => c.id === stored)) {
return stored;
}
if (currentActiveId && clubs.find(c => c.id === currentActiveId)) {
return currentActiveId;
}
return clubs[0].id;
}
export function TenantProvider({ children }: { children: ReactNode }) { export function TenantProvider({ children }: { children: ReactNode }) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [activeClubId, setActiveClubId] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeClubId, setActiveClubId] = useState<string | null>(getInitialClubId);
const { data: clubs = [], isLoading: clubsLoading, error: clubsError } = useQuery<Club[]>({ const { data: clubs = [], isLoading: clubsLoading, error: clubsError } = useQuery<Club[]>({
queryKey: ['my-clubs', session?.accessToken], queryKey: ['my-clubs', session?.accessToken],
queryFn: async () => { queryFn: async () => {
@@ -43,25 +64,19 @@ export function TenantProvider({ children }: { children: ReactNode }) {
retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 10000), retryDelay: (attemptIndex) => Math.min(1000 * Math.pow(2, attemptIndex), 10000),
}); });
useEffect(() => { const computedActiveClubId = useMemo(() => {
if (status === 'authenticated' && clubs.length > 0) { if (status !== 'authenticated' || !clubs.length) return activeClubId;
const stored = localStorage.getItem('activeClubId'); return determineActiveClub(clubs, activeClubId);
if (stored && clubs.find(c => c.id === stored)) {
setActiveClubId(stored);
} else if (!activeClubId) {
setActiveClubId(clubs[0].id);
}
}
}, [status, clubs, activeClubId]); }, [status, clubs, activeClubId]);
useEffect(() => { useEffect(() => {
if (activeClubId) { if (computedActiveClubId) {
const selectedClub = clubs.find(c => c.id === activeClubId); const selectedClub = clubs.find(c => c.id === computedActiveClubId);
if (selectedClub) { if (selectedClub) {
document.cookie = `X-Tenant-Id=${selectedClub.tenantId}; path=/; max-age=86400`; document.cookie = `X-Tenant-Id=${selectedClub.tenantId}; path=/; max-age=86400`;
} }
} }
}, [activeClubId, clubs]); }, [computedActiveClubId, clubs]);
const handleSetActiveClub = (clubId: string) => { const handleSetActiveClub = (clubId: string) => {
setActiveClubId(clubId); setActiveClubId(clubId);
@@ -73,10 +88,10 @@ export function TenantProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries(); queryClient.invalidateQueries();
}; };
const userRole = activeClubId && session?.user?.clubs ? session.user.clubs[activeClubId] || null : null; const userRole = computedActiveClubId && session?.user?.clubs ? session.user.clubs[computedActiveClubId] || null : null;
return ( return (
<TenantContext.Provider value={{ activeClubId, setActiveClub: handleSetActiveClub, userRole, clubs, clubsLoading, clubsError: clubsError || null }}> <TenantContext.Provider value={{ activeClubId: computedActiveClubId, setActiveClub: handleSetActiveClub, userRole, clubs, clubsLoading, clubsError: clubsError || null }}>
{children} {children}
</TenantContext.Provider> </TenantContext.Provider>
); );

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { useActiveClub } from '../useActiveClub'; import { useActiveClub } from '../useActiveClub';
import type { Session } from 'next-auth';
const mockUseSession = vi.fn(); const mockUseSession = vi.fn();
@@ -33,15 +32,15 @@ describe('useActiveClub', () => {
status: 'authenticated', status: 'authenticated',
}); });
(localStorage.getItem as any).mockImplementation((key: string) => { (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
return localStorageData[key] || null; return localStorageData[key] || null;
}); });
(localStorage.setItem as any).mockImplementation((key: string, value: string) => { (localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation((key: string, value: string) => {
localStorageData[key] = value; localStorageData[key] = value;
}); });
(localStorage.clear as any).mockImplementation(() => { (localStorage.clear as ReturnType<typeof vi.fn>).mockImplementation(() => {
localStorageData = {}; localStorageData = {};
}); });
}); });

View File

@@ -1,10 +1,26 @@
'use client'; 'use client';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useState, useEffect } from 'react'; import { useState, useMemo } from 'react';
const ACTIVE_CLUB_KEY = 'activeClubId'; const ACTIVE_CLUB_KEY = 'activeClubId';
function getStoredClubId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(ACTIVE_CLUB_KEY);
}
function determineActiveId(clubs: Record<string, string> | undefined, currentId: string | null): string | null {
if (!clubs || Object.keys(clubs).length === 0) return null;
const stored = getStoredClubId();
if (stored && clubs[stored]) return stored;
if (currentId && clubs[currentId]) return currentId;
return Object.keys(clubs)[0];
}
export interface ActiveClubData { export interface ActiveClubData {
activeClubId: string | null; activeClubId: string | null;
role: string | null; role: string | null;
@@ -14,23 +30,13 @@ export interface ActiveClubData {
export function useActiveClub(): ActiveClubData { export function useActiveClub(): ActiveClubData {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [activeClubId, setActiveClubIdState] = useState<string | null>(null);
useEffect(() => { const [activeClubId, setActiveClubIdState] = useState<string | null>(getStoredClubId);
if (status === 'authenticated' && session?.user?.clubs) {
const clubs = session.user.clubs;
const storedClubId = localStorage.getItem(ACTIVE_CLUB_KEY);
if (storedClubId && clubs[storedClubId]) { const computedActiveId = useMemo(() => {
setActiveClubIdState(storedClubId); if (status !== 'authenticated' || !session?.user?.clubs) return activeClubId;
} else { return determineActiveId(session.user.clubs, activeClubId);
const firstClubId = Object.keys(clubs)[0]; }, [session, status, activeClubId]);
if (firstClubId) {
setActiveClubIdState(firstClubId);
}
}
}
}, [session, status]);
const setActiveClub = (clubId: string) => { const setActiveClub = (clubId: string) => {
if (session?.user?.clubs && session.user.clubs[clubId]) { if (session?.user?.clubs && session.user.clubs[clubId]) {
@@ -40,10 +46,10 @@ export function useActiveClub(): ActiveClubData {
}; };
const clubs = session?.user?.clubs || null; const clubs = session?.user?.clubs || null;
const role = activeClubId && clubs ? clubs[activeClubId] : null; const role = computedActiveId && clubs ? clubs[computedActiveId] : null;
return { return {
activeClubId, activeClubId: computedActiveId,
role, role,
clubs, clubs,
setActiveClub, setActiveClub,

View File

@@ -31,7 +31,7 @@ describe('apiClient', () => {
configurable: true, configurable: true,
}); });
(global.fetch as any).mockResolvedValue({ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ data: 'test' }), json: async () => ({ data: 'test' }),
@@ -145,7 +145,7 @@ describe('apiClient', () => {
await apiClient('/api/test'); await apiClient('/api/test');
const callHeaders = (global.fetch as any).mock.calls[0][1].headers; const callHeaders = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(callHeaders.Authorization).toBeUndefined(); expect(callHeaders.Authorization).toBeUndefined();
}); });
@@ -158,7 +158,7 @@ describe('apiClient', () => {
await apiClient('/api/test'); await apiClient('/api/test');
const callHeaders = (global.fetch as any).mock.calls[0][1].headers; const callHeaders = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
expect(callHeaders['X-Tenant-Id']).toBeUndefined(); expect(callHeaders['X-Tenant-Id']).toBeUndefined();
}); });
}); });

View File

@@ -6,13 +6,13 @@ const localStorageMock = {
setItem: vi.fn(), setItem: vi.fn(),
removeItem: vi.fn(), removeItem: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
length: 0,
key: vi.fn(),
}; };
// Ensure localStorage is available on both global and globalThis global.localStorage = localStorageMock as unknown as Storage;
global.localStorage = localStorageMock as any; globalThis.localStorage = localStorageMock as unknown as Storage;
globalThis.localStorage = localStorageMock as any;
// Ensure document is available if jsdom hasn't set it up yet
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', {
value: { value: {