Compare commits
2 Commits
ad6a23621d
...
e1f98696b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f98696b5 | ||
|
|
5cf43976f6 |
@@ -128,10 +128,13 @@ jobs:
|
|||||||
- name: Validate docker-compose.yml
|
- name: Validate docker-compose.yml
|
||||||
run: docker compose config --quiet
|
run: docker compose config --quiet
|
||||||
|
|
||||||
- name: Setup Kustomize
|
- name: Install Kustomize
|
||||||
uses: imranismail/setup-kustomize@v2
|
run: |
|
||||||
with:
|
curl -Lo kustomize.tar.gz https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.1/kustomize_v5.4.1_linux_amd64.tar.gz
|
||||||
kustomize-version: "5.4.1"
|
tar -xzf kustomize.tar.gz
|
||||||
|
chmod +x kustomize
|
||||||
|
sudo mv kustomize /usr/local/bin/
|
||||||
|
kustomize version
|
||||||
|
|
||||||
- name: Validate kustomize base
|
- name: Validate kustomize base
|
||||||
working-directory: ./infra/k8s
|
working-directory: ./infra/k8s
|
||||||
|
|||||||
@@ -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")');
|
||||||
|
|
||||||
|
|||||||
@@ -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")');
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user