diff --git a/apps/web/src/components/ui/AnimatedNumber.test.tsx b/apps/web/src/components/ui/AnimatedNumber.test.tsx
new file mode 100644
index 000000000..95f59cc7c
--- /dev/null
+++ b/apps/web/src/components/ui/AnimatedNumber.test.tsx
@@ -0,0 +1,39 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+// Mock the animated counter hook to return the end value immediately
+vi.mock('@/hooks/useAnimatedCounter', () => ({
+ useAnimatedCounter: ({ end }: { end: number }) => end,
+}));
+
+import { AnimatedNumber } from './AnimatedNumber';
+
+describe('AnimatedNumber', () => {
+ it('renders the value', () => {
+ render();
+ expect(screen.getByText('42')).toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ const { container } = render(
+
+ );
+ const span = container.querySelector('span');
+ expect(span?.className).toContain('text-lg');
+ expect(span?.className).toContain('tabular-nums');
+ });
+
+ it('uses format function when provided', () => {
+ render(
+ `$${n}`} />
+ );
+ expect(screen.getByText('$1500')).toBeInTheDocument();
+ });
+
+ it('formats large numbers with locale string', () => {
+ render();
+ // toLocaleString will format 1000 — the exact output depends on locale
+ const span = screen.getByText((content) => content.includes('1'));
+ expect(span).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/ui/FAB.test.tsx b/apps/web/src/components/ui/FAB.test.tsx
new file mode 100644
index 000000000..c09102117
--- /dev/null
+++ b/apps/web/src/components/ui/FAB.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { FAB } from './FAB';
+
+describe('FAB', () => {
+ it('renders children', () => {
+ render(+);
+ expect(screen.getByText('+')).toBeInTheDocument();
+ });
+
+ it('has default aria-label "Action"', () => {
+ render(+);
+ expect(screen.getByLabelText('Action')).toBeInTheDocument();
+ });
+
+ it('uses custom label as aria-label', () => {
+ render(+);
+ expect(screen.getByLabelText('Upload Track')).toBeInTheDocument();
+ });
+
+ it('shows label text when showLabel is true', () => {
+ render(+);
+ expect(screen.getByText('Upload Track')).toBeInTheDocument();
+ });
+
+ it('does not show label text when showLabel is false', () => {
+ render(+);
+ expect(screen.queryByText('Upload Track')).not.toBeInTheDocument();
+ });
+
+ it('handles click events', () => {
+ const onClick = vi.fn();
+ render(+);
+ fireEvent.click(screen.getByLabelText('Action'));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders as a fixed element', () => {
+ const { container } = render(+);
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper.className).toContain('fixed');
+ });
+});
diff --git a/apps/web/src/components/ui/accordion/Accordion.test.tsx b/apps/web/src/components/ui/accordion/Accordion.test.tsx
new file mode 100644
index 000000000..790a69b85
--- /dev/null
+++ b/apps/web/src/components/ui/accordion/Accordion.test.tsx
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Accordion } from './Accordion';
+import { AccordionItem } from './AccordionItem';
+import { AccordionTrigger } from './AccordionTrigger';
+import { AccordionContent } from './AccordionContent';
+
+describe('Accordion', () => {
+ it('renders accordion items', () => {
+ render(
+
+
+ Section 1
+ Content 1
+
+
+ Section 2
+ Content 2
+
+
+ );
+ expect(screen.getByText('Section 1')).toBeInTheDocument();
+ expect(screen.getByText('Section 2')).toBeInTheDocument();
+ });
+
+ it('toggles item open/close in single mode', () => {
+ render(
+
+
+ Section 1
+ Content 1
+
+
+ );
+ const trigger = screen.getByText('Section 1');
+ expect(trigger.closest('button')).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(trigger);
+ expect(trigger.closest('button')).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('calls onValueChange when toggled', () => {
+ const onValueChange = vi.fn();
+ render(
+
+
+ Section 1
+ Content 1
+
+
+ );
+ fireEvent.click(screen.getByText('Section 1'));
+ expect(onValueChange).toHaveBeenCalledWith('item-1');
+ });
+
+ it('supports multiple mode', () => {
+ const onValueChange = vi.fn();
+ render(
+
+
+ Section 1
+ Content 1
+
+
+ Section 2
+ Content 2
+
+
+ );
+ fireEvent.click(screen.getByText('Section 1'));
+ expect(onValueChange).toHaveBeenCalledWith(['item-1']);
+ });
+
+ it('opens with defaultValue', () => {
+ render(
+
+
+ Section 1
+ Content 1
+
+
+ Section 2
+ Content 2
+
+
+ );
+ const trigger1 = screen.getByText('Section 1').closest('button');
+ const trigger2 = screen.getByText('Section 2').closest('button');
+ expect(trigger1).toHaveAttribute('aria-expanded', 'false');
+ expect(trigger2).toHaveAttribute('aria-expanded', 'true');
+ });
+});
diff --git a/apps/web/src/components/ui/collapsible.test.tsx b/apps/web/src/components/ui/collapsible.test.tsx
new file mode 100644
index 000000000..338cd5489
--- /dev/null
+++ b/apps/web/src/components/ui/collapsible.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Collapsible } from './collapsible';
+
+describe('Collapsible', () => {
+ it('renders trigger content', () => {
+ render(
+ Click me}>
+ Hidden content
+
+ );
+ expect(screen.getByText('Click me')).toBeInTheDocument();
+ });
+
+ it('starts collapsed by default', () => {
+ render(
+ Toggle}>
+ Content
+
+ );
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('toggles on click', () => {
+ render(
+ Toggle}>
+ Content
+
+ );
+ const button = screen.getByRole('button');
+ fireEvent.click(button);
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('calls onOpenChange when toggled', () => {
+ const onOpenChange = vi.fn();
+ render(
+ Toggle} onOpenChange={onOpenChange}>
+ Content
+
+ );
+ fireEvent.click(screen.getByRole('button'));
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it('respects defaultOpen prop', () => {
+ render(
+ Toggle} defaultOpen>
+ Content
+
+ );
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('hides chevron when showChevron is false', () => {
+ const { container } = render(
+ Toggle} showChevron={false}>
+ Content
+
+ );
+ // No SVG icon should be rendered
+ expect(container.querySelector('svg')).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/ui/floating-input.test.tsx b/apps/web/src/components/ui/floating-input.test.tsx
new file mode 100644
index 000000000..2145a1451
--- /dev/null
+++ b/apps/web/src/components/ui/floating-input.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { FloatingInput } from './floating-input';
+
+describe('FloatingInput', () => {
+ it('renders with label', () => {
+ render();
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
+ });
+
+ it('displays error message', () => {
+ render();
+ expect(screen.getByText('Invalid email')).toBeInTheDocument();
+ });
+
+ it('renders icon when provided', () => {
+ render(
+ @}
+ />
+ );
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
+ });
+
+ it('toggles password visibility', () => {
+ render(
+
+ );
+ const input = screen.getByLabelText('Password');
+ expect(input).toHaveAttribute('type', 'password');
+
+ const toggleButton = screen.getByLabelText('Show password');
+ fireEvent.click(toggleButton);
+ expect(input).toHaveAttribute('type', 'text');
+ });
+
+ it('applies custom className', () => {
+ render();
+ const input = screen.getByLabelText('Test');
+ expect(input.className).toContain('custom-class');
+ });
+});
diff --git a/apps/web/src/features/chat/components/ChatRoom.tsx b/apps/web/src/features/chat/components/ChatRoom.tsx
index bfcacff47..451b3f9ad 100644
--- a/apps/web/src/features/chat/components/ChatRoom.tsx
+++ b/apps/web/src/features/chat/components/ChatRoom.tsx
@@ -27,6 +27,16 @@ export const ChatRoom: React.FC = ({ conversationId }) => {
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
+ const highlightTimeoutRef = useRef | null>(null);
+
+ // Cleanup highlight timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (highlightTimeoutRef.current) {
+ clearTimeout(highlightTimeoutRef.current);
+ }
+ };
+ }, []);
const currentMessages = messages[conversationId] || [];
const fetchingRef = useRef<{ [key: string]: boolean }>({});
@@ -55,7 +65,10 @@ export const ChatRoom: React.FC = ({ conversationId }) => {
const messageElement = document.getElementById(`message-${messageId}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
- setTimeout(() => setHighlightedMessageId(null), 3000);
+ if (highlightTimeoutRef.current) {
+ clearTimeout(highlightTimeoutRef.current);
+ }
+ highlightTimeoutRef.current = setTimeout(() => setHighlightedMessageId(null), 3000);
}
};
diff --git a/apps/web/src/features/tracks/components/TrackList.test.tsx b/apps/web/src/features/tracks/components/TrackList.test.tsx
index f8f2d1340..504a975cf 100644
--- a/apps/web/src/features/tracks/components/TrackList.test.tsx
+++ b/apps/web/src/features/tracks/components/TrackList.test.tsx
@@ -197,9 +197,9 @@ describe('TrackList', () => {
/>,
);
const selectedRow = container.querySelector(
- '.bg-blue-50, .dark:bg-blue-900/20',
+ '[class*="bg-primary"]',
);
- expect(selectedRow).toBeInTheDocument();
+ expect(selectedRow).toBeTruthy();
});
it('should call onTrackLike when like button is clicked', async () => {
diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts
index e45a6d088..b88141619 100644
--- a/apps/web/src/mocks/handlers.ts
+++ b/apps/web/src/mocks/handlers.ts
@@ -1580,7 +1580,7 @@ export const handlers = [
http.post('*/api/v1/chat/token', () => {
return HttpResponse.json({
success: true,
- token: 'mock-chat-token'
+ data: { token: 'mock-chat-token' }
});
}),
@@ -1627,11 +1627,11 @@ export const handlers = [
}),
http.post('*/api/v1/notifications/:id/read', () => {
- return HttpResponse.json({ success: true });
+ return HttpResponse.json({ success: true, data: { read: true } });
}),
http.post('*/api/v1/notifications/read-all', () => {
- return HttpResponse.json({ success: true });
+ return HttpResponse.json({ success: true, data: { read: true } });
}),
// Inventory / Gear (STORYBOOK_CONTRACT: mock for gear view stories)
diff --git a/apps/web/src/services/socialService.ts b/apps/web/src/services/socialService.ts
index 549693d1a..4a88f2d24 100644
--- a/apps/web/src/services/socialService.ts
+++ b/apps/web/src/services/socialService.ts
@@ -121,6 +121,7 @@ export const socialService = {
deleteComment: async (id: string) => {
await apiClient.delete(`/comments/${id}`);
+ return { success: true };
},
getNotifications: async () => {
@@ -129,11 +130,13 @@ export const socialService = {
},
markRead: async (id: string) => {
- await apiClient.post(`/notifications/${id}/read`);
+ const response = await apiClient.post(`/notifications/${id}/read`);
+ return response.data;
},
markAllRead: async () => {
- await apiClient.post('/notifications/read-all');
+ const response = await apiClient.post('/notifications/read-all');
+ return response.data;
},
getWebhooks: async () => {
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index 7730d8468..ed1dbf81e 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -18,11 +18,13 @@ export default defineConfig({
exclude: [
...configDefaults.exclude,
'**/e2e/**', // Playwright E2E tests (run via playwright test)
- '**/usePlaylistKeyboardShortcuts.test.ts', // Hook non implémenté (T0507)
- '**/PlaylistVersionHistory.test.tsx', // Composant non implémenté (T0509)
- '**/ShareLinkButton.test.tsx', // Composant non implémenté (T0488)
- '**/PlaylistAccessibility.test.tsx', // Nécessite jest-axe (T0503)
- '**/useRoutePreload-additional.test.ts', // Incompatibilité fake timers + renderHook
+ // The following tests are excluded because their underlying components/hooks
+ // are not yet implemented. They are tracked as future work items:
+ '**/usePlaylistKeyboardShortcuts.test.ts', // TODO(T0507): Implement keyboard shortcuts hook
+ '**/PlaylistVersionHistory.test.tsx', // TODO(T0509): Implement version history component
+ '**/ShareLinkButton.test.tsx', // TODO(T0488): Implement share link button component
+ '**/PlaylistAccessibility.test.tsx', // TODO(T0503): Add vitest-axe dependency for a11y testing
+ '**/useRoutePreload-additional.test.ts', // TODO: Fix fake timers + renderHook incompatibility
],
coverage: {
provider: 'v8',