diff --git a/frontend/src/hooks/__tests__/useNotifications.test.ts b/frontend/src/hooks/__tests__/useNotifications.test.ts new file mode 100644 index 0000000..f9854e2 --- /dev/null +++ b/frontend/src/hooks/__tests__/useNotifications.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { waitFor, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/mocks/server' +import { renderHookWithProviders } from '@/test/utils' +import { + mockNotification, + mockNotificationRead, + mockNotificationList, +} from '@/test/mocks/data' +import { useNotifications } from '@/hooks/useNotifications' + +// --------------------------------------------------------------------------- +// useNotifications +// --------------------------------------------------------------------------- + +describe('useNotifications', () => { + it('returns notifications list and unread count', async () => { + const { result } = renderHookWithProviders(() => useNotifications()) + + // Initially loading + expect(result.current.loading).toBe(true) + expect(result.current.notifications).toEqual([]) + expect(result.current.unreadCount).toBe(0) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.notifications).toEqual(mockNotificationList.items) + expect(result.current.notifications).toHaveLength(2) + expect(result.current.unreadCount).toBe(1) + expect(result.current.notifications[0].is_read).toBe(false) + expect(result.current.notifications[1].is_read).toBe(true) + }) + + it('handles empty notifications', async () => { + server.use( + http.get('/api/notifications', () => { + return HttpResponse.json({ items: [], unread_count: 0 }) + }), + ) + + const { result } = renderHookWithProviders(() => useNotifications()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.notifications).toEqual([]) + expect(result.current.unreadCount).toBe(0) + }) + + it('handles loading state', async () => { + const { result } = renderHookWithProviders(() => useNotifications()) + + // Should start in loading state + expect(result.current.loading).toBe(true) + expect(result.current.notifications).toEqual([]) + expect(result.current.unreadCount).toBe(0) + + // After loading completes + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.notifications).toHaveLength(2) + }) + + it('markAsRead mutation works', async () => { + let capturedId: number | null = null + + server.use( + http.put('/api/notifications/:id/read', ({ params }) => { + capturedId = Number(params.id) + return HttpResponse.json({ + ...mockNotification, + id: capturedId, + is_read: true, + }) + }), + ) + + const { result } = renderHookWithProviders(() => useNotifications()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + result.current.markAsRead(1) + }) + + expect(capturedId).toBe(1) + }) + + it('markAllAsRead mutation works', async () => { + let markAllCalled = false + + server.use( + http.put('/api/notifications/read-all', () => { + markAllCalled = true + return HttpResponse.json({ marked_read: 1 }) + }), + ) + + const { result } = renderHookWithProviders(() => useNotifications()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + result.current.markAllAsRead() + }) + + expect(markAllCalled).toBe(true) + }) + + it('exposes refresh function', async () => { + const { result } = renderHookWithProviders(() => useNotifications()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + // refresh should be a function + expect(typeof result.current.refresh).toBe('function') + + // calling refresh should not throw + await act(async () => { + result.current.refresh() + }) + }) +}) diff --git a/frontend/src/hooks/__tests__/useReports.test.ts b/frontend/src/hooks/__tests__/useReports.test.ts new file mode 100644 index 0000000..e0f57a6 --- /dev/null +++ b/frontend/src/hooks/__tests__/useReports.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest' +import { waitFor, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/mocks/server' +import { renderHookWithProviders } from '@/test/utils' +import { mockReportMeta } from '@/test/mocks/data' +import { useReports, useGenerateReport, useDeleteReports } from '@/hooks/useReports' + +// --------------------------------------------------------------------------- +// useReports +// --------------------------------------------------------------------------- + +describe('useReports', () => { + it('fetches report list (success)', async () => { + const { result } = renderHookWithProviders(() => useReports()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual({ + items: [mockReportMeta], + total: 1, + }) + expect(result.current.data!.items).toHaveLength(1) + expect(result.current.data!.items[0].jahr).toBe(2026) + expect(result.current.data!.items[0].kw).toBe(6) + }) + + it('handles empty list', async () => { + server.use( + http.get('/api/reports/list', () => { + return HttpResponse.json({ items: [], total: 0 }) + }), + ) + + const { result } = renderHookWithProviders(() => useReports()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual({ items: [], total: 0 }) + expect(result.current.data!.items).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// useGenerateReport +// --------------------------------------------------------------------------- + +describe('useGenerateReport', () => { + it('generates report (success)', async () => { + const newReport = { + ...mockReportMeta, + id: 2, + kw: 7, + report_date: '2026-02-16', + generated_at: '2026-02-17T08:00:00Z', + } + + server.use( + http.post('/api/reports/generate', () => { + return HttpResponse.json(newReport) + }), + ) + + const { result } = renderHookWithProviders(() => useGenerateReport()) + + let response: unknown + await act(async () => { + response = await result.current.mutateAsync({ jahr: 2026, kw: 7 }) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect((response as { id: number }).id).toBe(2) + expect((response as { kw: number }).kw).toBe(7) + expect((response as { jahr: number }).jahr).toBe(2026) + }) + + it('handles error', async () => { + server.use( + http.post('/api/reports/generate', () => { + return HttpResponse.json( + { detail: 'Report generation failed' }, + { status: 500 }, + ) + }), + ) + + const { result } = renderHookWithProviders(() => useGenerateReport()) + + await act(async () => { + try { + await result.current.mutateAsync({ jahr: 2026, kw: 99 }) + } catch { + // expected + } + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) + +// --------------------------------------------------------------------------- +// useDeleteReports +// --------------------------------------------------------------------------- + +describe('useDeleteReports', () => { + it('deletes reports (success)', async () => { + let capturedBody: unknown = null + + server.use( + http.delete('/api/reports/delete', async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json({ deleted: 2 }) + }), + ) + + const { result } = renderHookWithProviders(() => useDeleteReports()) + + await act(async () => { + await result.current.mutateAsync([1, 2]) + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(capturedBody).toEqual([1, 2]) + }) + + it('handles error', async () => { + server.use( + http.delete('/api/reports/delete', () => { + return HttpResponse.json( + { detail: 'Forbidden' }, + { status: 403 }, + ) + }), + ) + + const { result } = renderHookWithProviders(() => useDeleteReports()) + + await act(async () => { + try { + await result.current.mutateAsync([999]) + } catch { + // expected + } + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +})