test: add service tests (api, authService, disclosureService)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-26 21:15:37 +00:00
parent f14fa0d5f2
commit 159ac0a26c
3 changed files with 490 additions and 0 deletions

View file

@ -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)
})
})

View file

@ -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()
})
})
})

View file

@ -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<string, unknown> | undefined
server.use(
http.put('/api/admin/disclosure-requests/:id', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>
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<string, unknown> | undefined
server.use(
http.put('/api/admin/disclosure-requests/:id', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>
return HttpResponse.json(rejectedDisclosure)
}),
)
const result = await reviewDisclosure(1, 'rejected')
expect(result.status).toBe('rejected')
expect(capturedBody).toEqual({ status: 'rejected' })
})
})
})