From 159ac0a26c4f9cea4045171bb555aa75a3f84191 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Thu, 26 Feb 2026 21:15:37 +0000 Subject: [PATCH] test: add service tests (api, authService, disclosureService) Co-Authored-By: Claude Opus 4.6 --- frontend/src/services/__tests__/api.test.ts | 140 ++++++++++++ .../services/__tests__/authService.test.ts | 214 ++++++++++++++++++ .../__tests__/disclosureService.test.ts | 136 +++++++++++ 3 files changed, 490 insertions(+) create mode 100644 frontend/src/services/__tests__/api.test.ts create mode 100644 frontend/src/services/__tests__/authService.test.ts create mode 100644 frontend/src/services/__tests__/disclosureService.test.ts diff --git a/frontend/src/services/__tests__/api.test.ts b/frontend/src/services/__tests__/api.test.ts new file mode 100644 index 0000000..f455812 --- /dev/null +++ b/frontend/src/services/__tests__/api.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/mocks/server' +import api from '@/services/api' + +describe('api (Axios instance)', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('attaches Authorization header when access_token exists in localStorage', async () => { + localStorage.setItem('access_token', 'test-token-123') + + let capturedAuth: string | undefined + server.use( + http.get('/api/auth/me', ({ request }) => { + capturedAuth = request.headers.get('Authorization') ?? undefined + return HttpResponse.json({ id: 1 }) + }), + ) + + await api.get('/auth/me') + expect(capturedAuth).toBe('Bearer test-token-123') + }) + + it('does NOT attach Authorization header when no token in localStorage', async () => { + let capturedAuth: string | null = null + server.use( + http.get('/api/auth/me', ({ request }) => { + capturedAuth = request.headers.get('Authorization') + return HttpResponse.json({ id: 1 }) + }), + ) + + await api.get('/auth/me') + expect(capturedAuth).toBeNull() + }) + + it('on 401, refreshes token and retries the request', async () => { + localStorage.setItem('access_token', 'expired-token') + localStorage.setItem('refresh_token', 'valid-refresh-token') + + let callCount = 0 + server.use( + http.get('/api/auth/me', ({ request }) => { + callCount++ + const auth = request.headers.get('Authorization') + if (auth === 'Bearer refreshed-access-token') { + return HttpResponse.json({ id: 1, username: 'admin' }) + } + return new HttpResponse(null, { status: 401 }) + }), + http.post('/api/auth/refresh', () => { + return HttpResponse.json({ + access_token: 'refreshed-access-token', + refresh_token: 'refreshed-refresh-token', + token_type: 'bearer', + }) + }), + ) + + const response = await api.get('/auth/me') + expect(response.data).toEqual({ id: 1, username: 'admin' }) + expect(callCount).toBe(2) // First call 401, then retried with new token + expect(localStorage.getItem('access_token')).toBe('refreshed-access-token') + }) + + it('when refresh also fails, clears tokens', async () => { + localStorage.setItem('access_token', 'expired-token') + localStorage.setItem('refresh_token', 'expired-refresh-token') + + let refreshCalled = false + server.use( + http.get('/api/auth/me', () => { + return new HttpResponse(null, { status: 401 }) + }), + http.post('/api/auth/refresh', () => { + refreshCalled = true + return HttpResponse.json( + { detail: 'Refresh token expired' }, + { status: 401 }, + ) + }), + ) + + await expect(api.get('/auth/me')).rejects.toThrow() + expect(refreshCalled).toBe(true) + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) + + it('passes through non-401 errors without attempting refresh', async () => { + localStorage.setItem('access_token', 'valid-token') + localStorage.setItem('refresh_token', 'valid-refresh-token') + + let refreshCalled = false + server.use( + http.get('/api/auth/me', () => { + return new HttpResponse(JSON.stringify({ detail: 'Server error' }), { + status: 500, + }) + }), + http.post('/api/auth/refresh', () => { + refreshCalled = true + return HttpResponse.json({ access_token: 'new-token' }) + }), + ) + + await expect(api.get('/auth/me')).rejects.toThrow() + expect(refreshCalled).toBe(false) + // Tokens should NOT be cleared on 500 + expect(localStorage.getItem('access_token')).toBe('valid-token') + }) + + it('does not retry 401 more than once (prevents infinite loop)', async () => { + localStorage.setItem('access_token', 'expired-token') + localStorage.setItem('refresh_token', 'some-refresh-token') + + let meCallCount = 0 + server.use( + http.get('/api/auth/me', () => { + meCallCount++ + return new HttpResponse(null, { status: 401 }) + }), + http.post('/api/auth/refresh', () => { + // Refresh succeeds but the new token still gets 401 + return HttpResponse.json({ + access_token: 'still-invalid-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + }) + }), + ) + + await expect(api.get('/auth/me')).rejects.toThrow() + // First call returns 401, triggers refresh (succeeds), retry also returns 401. + // The retry has _retry=true so it does NOT trigger another refresh — just rejects. + expect(meCallCount).toBe(2) + }) +}) diff --git a/frontend/src/services/__tests__/authService.test.ts b/frontend/src/services/__tests__/authService.test.ts new file mode 100644 index 0000000..fdeb6db --- /dev/null +++ b/frontend/src/services/__tests__/authService.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/mocks/server' +import { mockTokenResponse, mockUser } from '@/test/mocks/data' +import { + login, + register, + logout, + getMe, + updateProfile, + uploadAvatar, + deleteAvatar, + changePassword, + setupMFA, + verifyMFA, + disableMFA, +} from '@/services/authService' + +describe('authService', () => { + beforeEach(() => { + localStorage.clear() + }) + + // ---------- login ---------- + + describe('login', () => { + it('stores tokens and returns response', async () => { + const result = await login({ email: 'admin@dak.de', password: 'password123' }) + + expect(result).toEqual(mockTokenResponse) + expect(localStorage.getItem('access_token')).toBe(mockTokenResponse.access_token) + expect(localStorage.getItem('refresh_token')).toBe(mockTokenResponse.refresh_token) + }) + + it('throws on invalid credentials (401)', async () => { + server.use( + http.post('/api/auth/login', () => { + return HttpResponse.json( + { detail: 'Invalid credentials' }, + { status: 401 }, + ) + }), + ) + + await expect( + login({ email: 'wrong@dak.de', password: 'wrong' }), + ).rejects.toThrow() + + // Tokens should not be stored on failure + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) + }) + + // ---------- register ---------- + + describe('register', () => { + it('returns user data', async () => { + const result = await register({ + username: 'new_user', + email: 'new@dak.de', + password: 'securePass123', + }) + + expect(result).toEqual({ user: mockUser }) + }) + }) + + // ---------- logout ---------- + + describe('logout', () => { + it('clears tokens from localStorage', async () => { + localStorage.setItem('access_token', 'some-token') + localStorage.setItem('refresh_token', 'some-refresh') + + await logout() + + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) + + it('clears tokens even if API call fails', async () => { + localStorage.setItem('access_token', 'some-token') + localStorage.setItem('refresh_token', 'some-refresh') + + server.use( + http.post('/api/auth/logout', () => { + return new HttpResponse(null, { status: 500 }) + }), + ) + + // logout() uses try/finally (no catch), so the error propagates + // but the finally block still clears tokens + try { + await logout() + } catch { + // Expected: the 500 error propagates + } + + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) + }) + + // ---------- getMe ---------- + + describe('getMe', () => { + it('returns user profile', async () => { + localStorage.setItem('access_token', 'valid-token') + + const result = await getMe() + expect(result).toEqual(mockUser) + }) + }) + + // ---------- updateProfile ---------- + + describe('updateProfile', () => { + it('returns updated user', async () => { + localStorage.setItem('access_token', 'valid-token') + + const updatedUser = { ...mockUser, first_name: 'Maximilian' } + server.use( + http.put('/api/auth/profile', () => { + return HttpResponse.json(updatedUser) + }), + ) + + const result = await updateProfile({ first_name: 'Maximilian' }) + expect(result).toEqual(updatedUser) + }) + }) + + // ---------- uploadAvatar ---------- + + describe('uploadAvatar', () => { + it('sends FormData and returns user', async () => { + localStorage.setItem('access_token', 'valid-token') + + const file = new File(['avatar-content'], 'avatar.png', { type: 'image/png' }) + const result = await uploadAvatar(file) + + expect(result).toEqual({ ...mockUser, avatar_url: '/uploads/avatar-1.png' }) + }) + }) + + // ---------- deleteAvatar ---------- + + describe('deleteAvatar', () => { + it('returns user with null avatar_url', async () => { + localStorage.setItem('access_token', 'valid-token') + + const result = await deleteAvatar() + expect(result).toEqual({ ...mockUser, avatar_url: null }) + }) + }) + + // ---------- changePassword ---------- + + describe('changePassword', () => { + it('succeeds without error', async () => { + localStorage.setItem('access_token', 'valid-token') + + await expect( + changePassword({ old_password: 'oldPass', new_password: 'newPass123' }), + ).resolves.toBeUndefined() + }) + + it('throws on wrong old password', async () => { + localStorage.setItem('access_token', 'valid-token') + + server.use( + http.post('/api/auth/change-password', () => { + return HttpResponse.json( + { detail: 'Wrong password' }, + { status: 400 }, + ) + }), + ) + + await expect( + changePassword({ old_password: 'wrongPass', new_password: 'newPass123' }), + ).rejects.toThrow() + }) + }) + + // ---------- MFA ---------- + + describe('MFA', () => { + it('setupMFA returns secret + qr_uri', async () => { + localStorage.setItem('access_token', 'valid-token') + + const result = await setupMFA() + expect(result).toEqual({ + secret: 'JBSWY3DPEHPK3PXP', + qr_uri: 'otpauth://totp/DAK:admin@dak.de?secret=JBSWY3DPEHPK3PXP&issuer=DAK', + }) + }) + + it('verifyMFA succeeds', async () => { + localStorage.setItem('access_token', 'valid-token') + + await expect( + verifyMFA({ secret: 'JBSWY3DPEHPK3PXP', code: '123456' }), + ).resolves.toBeUndefined() + }) + + it('disableMFA succeeds', async () => { + localStorage.setItem('access_token', 'valid-token') + + await expect(disableMFA('myPassword')).resolves.toBeUndefined() + }) + }) +}) diff --git a/frontend/src/services/__tests__/disclosureService.test.ts b/frontend/src/services/__tests__/disclosureService.test.ts new file mode 100644 index 0000000..cb66570 --- /dev/null +++ b/frontend/src/services/__tests__/disclosureService.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/mocks/server' +import { mockDisclosureRequest, mockDisclosureApproved } from '@/test/mocks/data' +import { + requestDisclosure, + getDisclosureRequests, + getDisclosureCount, + reviewDisclosure, +} from '@/services/disclosureService' + +describe('disclosureService', () => { + beforeEach(() => { + localStorage.clear() + localStorage.setItem('access_token', 'valid-token') + }) + + // ---------- requestDisclosure ---------- + + describe('requestDisclosure', () => { + it('sends request and returns disclosure', async () => { + const result = await requestDisclosure(1, 'Benoetige Patientendaten fuer Rueckruf.') + + expect(result).toEqual(mockDisclosureRequest) + expect(result.case_id).toBe(1) + expect(result.status).toBe('pending') + }) + + it('throws on error', async () => { + server.use( + http.post('/api/cases/:caseId/disclosure-request', () => { + return HttpResponse.json( + { detail: 'Case not found' }, + { status: 404 }, + ) + }), + ) + + await expect(requestDisclosure(999, 'reason')).rejects.toThrow() + }) + }) + + // ---------- getDisclosureRequests ---------- + + describe('getDisclosureRequests', () => { + it('returns list of disclosure requests', async () => { + const result = await getDisclosureRequests() + + expect(result).toEqual([mockDisclosureRequest]) + expect(Array.isArray(result)).toBe(true) + }) + + it('passes status parameter', async () => { + let capturedUrl: string | undefined + server.use( + http.get('/api/admin/disclosure-requests', ({ request }) => { + capturedUrl = request.url + return HttpResponse.json([mockDisclosureApproved]) + }), + ) + + const result = await getDisclosureRequests('approved') + + expect(capturedUrl).toContain('status=approved') + expect(result).toEqual([mockDisclosureApproved]) + }) + + it('works without status filter', async () => { + let capturedUrl: string | undefined + server.use( + http.get('/api/admin/disclosure-requests', ({ request }) => { + capturedUrl = request.url + return HttpResponse.json([mockDisclosureRequest]) + }), + ) + + const result = await getDisclosureRequests() + + expect(capturedUrl).not.toContain('status=') + expect(result).toEqual([mockDisclosureRequest]) + }) + }) + + // ---------- getDisclosureCount ---------- + + describe('getDisclosureCount', () => { + it('returns pending count', async () => { + const result = await getDisclosureCount() + + expect(result).toBe(1) + expect(typeof result).toBe('number') + }) + }) + + // ---------- reviewDisclosure ---------- + + describe('reviewDisclosure', () => { + it('approves a disclosure request', async () => { + let capturedBody: Record | undefined + server.use( + http.put('/api/admin/disclosure-requests/:id', async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json(mockDisclosureApproved) + }), + ) + + const result = await reviewDisclosure(1, 'approved') + + expect(result).toEqual(mockDisclosureApproved) + expect(result.status).toBe('approved') + expect(capturedBody).toEqual({ status: 'approved' }) + }) + + it('rejects a disclosure request', async () => { + const rejectedDisclosure = { + ...mockDisclosureRequest, + status: 'rejected' as const, + reviewed_by: 1, + reviewed_at: '2026-02-26T09:30:00Z', + } + + let capturedBody: Record | undefined + server.use( + http.put('/api/admin/disclosure-requests/:id', async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json(rejectedDisclosure) + }), + ) + + const result = await reviewDisclosure(1, 'rejected') + + expect(result.status).toBe('rejected') + expect(capturedBody).toEqual({ status: 'rejected' }) + }) + }) +})