mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 18:23:42 +00:00
test: add service tests (api, authService, disclosureService)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f14fa0d5f2
commit
159ac0a26c
3 changed files with 490 additions and 0 deletions
140
frontend/src/services/__tests__/api.test.ts
Normal file
140
frontend/src/services/__tests__/api.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
214
frontend/src/services/__tests__/authService.test.ts
Normal file
214
frontend/src/services/__tests__/authService.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
136
frontend/src/services/__tests__/disclosureService.test.ts
Normal file
136
frontend/src/services/__tests__/disclosureService.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue