diff --git a/API.md b/API.md new file mode 100644 index 00000000..fd5ff055 --- /dev/null +++ b/API.md @@ -0,0 +1,812 @@ +# Vet1177 API — Guide for frontend-utvecklare + +Denna guide visar hur du anropar backend-API:et från en React + Tailwind CSS-applikation. +Alla exempel är skrivna för en djurägare (OWNER) som heter Anna. + +--- + +## 1. Kom igang + +### Starta backend + +Backend startas i IntelliJ. Se till att Docker Desktop kor (behövs for databasen). +Kör `Vet1177Application.java` — servern startar pa `http://localhost:8080`. + +### Inloggning under utveckling + +Just nu anvander vi en speciell header istället for riktigt login. +Lagg till headern `X-Dev-User` med en e-postadress i varje request. +Det simulerar att du ar inloggad som den anvandaren. + +**OBS:** Detta fungerar bara nar backend kör med dev-profilen (vilket ar default lokalt). + +### Testanvandare + +| Roll | Namn | Email | +|-------|-----------------|-----------------| +| OWNER | Anna Svensson | anna@test.se | +| VET | Erik Veterinär | erik@klinik.se | +| ADMIN | Sara Admin | sara@admin.se | + +Alla testanvandare har lösenordet `password` (hashat i databasen). + +--- + +## 2. Hur man anropar API:et fran React + +### Grundmönster + +Alla anrop följer samma mönster: + +1. Anropa `fetch()` med rätt URL och headers +2. Kolla att `response.ok` ar `true` +3. Läs JSON-datan med `response.json()` + +```javascript +// Grundmönster for att hamta data +const hämtaData = async () => { + const response = await fetch('http://localhost:8080/api/ENDPOINT', { + headers: { + 'X-Dev-User': 'anna@test.se' // Talar om vem du ar + } + }); + + if (!response.ok) { + throw new Error('Något gick fel: ' + response.status); + } + + const data = await response.json(); + return data; +}; +``` + +```javascript +// Grundmönster for att skicka data (POST/PUT) +const skickaData = async (body) => { + const response = await fetch('http://localhost:8080/api/ENDPOINT', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', // Talar om att vi skickar JSON + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify(body) // Gör om JavaScript-objekt till JSON-text + }); + + if (!response.ok) { + throw new Error('Något gick fel: ' + response.status); + } + + const data = await response.json(); + return data; +}; +``` + +--- + +## 3. Endpoints for djurägare (OWNER) + +### 3.1 Hamta alla kliniker + +Anvands for att visa en dropdown nar djurägaren skapar ett nytt arende. +Alla kliniker ar publika — ingen header behövs. + +**`GET /api/clinics`** + +```javascript +const getKliniker = async () => { + const response = await fetch('http://localhost:8080/api/clinics'); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med kliniker):** + +```json +[ + { + "id": "a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5", + "name": "Djurkliniken Centrum", + "address": "Storgatan 1, Stockholm", + "phoneNumber": "08-123456" + } +] +``` + +--- + +### 3.2 Hamta mina djur + +Visar alla djur som tillhör den inloggade djurägaren. + +**`GET /pets/owner/{ownerId}`** + +```javascript +const getMinaDjur = async () => { + const ownerId = 'c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7'; // Annas ID + + const response = await fetch(`http://localhost:8080/pets/owner/${ownerId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med djur):** + +```json +[ + { + "id": "f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0", + "ownerId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "name": "Fido", + "species": "Hund", + "breed": "Labrador", + "dateOfBirth": "2020-01-15", + "weightKg": 25.5, + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-01-01T10:00:00Z" + }, + { + "id": "a7b8c9d0-e1f2-4a5b-4c5d-e6f7a8b9c0d1", + "ownerId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "name": "Missan", + "species": "Katt", + "breed": "Persisk", + "dateOfBirth": "2019-06-20", + "weightKg": 4.2, + "createdAt": "2025-01-01T10:00:00Z", + "updatedAt": "2025-01-01T10:00:00Z" + } +] +``` + +--- + +### 3.3 Skapa ett djur + +Registrera ett nytt djur för den inloggade ägaren. + +**`POST /pets`** + +```javascript +const skapaDjur = async () => { + const response = await fetch('http://localhost:8080/pets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify({ + name: 'Bella', + species: 'Hund', + breed: 'Golden Retriever', + dateOfBirth: '2022-03-10', + weightKg: 30.0 + }) + }); + const data = await response.json(); + return data; +}; +``` + +**Request body:** + +| Fält | Typ | Obligatoriskt | Beskrivning | +|-------------|----------|---------------|---------------------------| +| name | string | Ja | Djurets namn | +| species | string | Ja | Art (Hund, Katt, etc.) | +| breed | string | Nej | Ras | +| dateOfBirth | string | Ja | Födelsedatum (YYYY-MM-DD) | +| weightKg | number | Ja | Vikt i kg (positivt tal) | + +**Svar:** Samma format som i listan ovan (ett PetResponse-objekt). + +--- + +### 3.4 Hamta mina ärenden + +Visar alla veterinärarenden som tillhör den inloggade djurägaren. + +**`GET /api/medical-records/my-records`** + +```javascript +const getMinaÄrenden = async () => { + const response = await fetch('http://localhost:8080/api/medical-records/my-records', { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med ärenden i kortformat):** + +```json +[ + { + "id": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "title": "Fido haltar", + "status": "OPEN", + "petName": "Fido", + "ownerName": "Anna Svensson", + "assignedVetName": null, + "createdAt": "2025-01-01T10:00:00Z" + }, + { + "id": "c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3", + "title": "Missan äter inte", + "status": "IN_PROGRESS", + "petName": "Missan", + "ownerName": "Anna Svensson", + "assignedVetName": "Erik Veterinär", + "createdAt": "2025-01-01T10:00:00Z" + } +] +``` + +Möjliga statusar: `OPEN`, `IN_PROGRESS`, `AWAITING_INFO`, `CLOSED` + +--- + +### 3.5 Skapa ett ärende + +Skapar ett nytt veterinärarende. Du behöver ett `petId` (fran 3.2) och ett `clinicId` (fran 3.1). + +**`POST /api/medical-records`** + +```javascript +const skapaÄrende = async () => { + const response = await fetch('http://localhost:8080/api/medical-records', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify({ + title: 'Fido hostar', + description: 'Hunden har hostat i tre dagar och verkar trött.', + petId: 'f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0', + clinicId: 'a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5' + }) + }); + const data = await response.json(); + return data; +}; +``` + +**Request body:** + +| Fält | Typ | Obligatoriskt | Beskrivning | +|-------------|--------|---------------|---------------------------------| +| title | string | Ja | Kort titel (max 500 tecken) | +| description | string | Nej | Beskrivning (max 5000 tecken) | +| petId | string | Ja | UUID for djuret | +| clinicId | string | Ja | UUID for kliniken | + +**Svar (fullstandigt arende):** + +```json +{ + "id": "nytt-uuid-här", + "title": "Fido hostar", + "description": "Hunden har hostat i tre dagar och verkar trött.", + "status": "OPEN", + "petId": "f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0", + "petName": "Fido", + "petSpecies": "Hund", + "ownerId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "ownerName": "Anna Svensson", + "clinicId": "a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5", + "clinicName": "Djurkliniken Centrum", + "assignedVetId": null, + "assignedVetName": null, + "createdById": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "createdByName": "Anna Svensson", + "createdAt": "2025-04-14T10:00:00Z", + "updatedAt": null, + "closedAt": null +} +``` + +--- + +### 3.6 Hamta ett specifikt arende + +Visar alla detaljer for ett arende. Anvands nar djurägaren klickar pa ett arende i listan. + +**`GET /api/medical-records/{id}`** + +```javascript +const getÄrende = async (ärendeId) => { + const response = await fetch(`http://localhost:8080/api/medical-records/${ärendeId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; + +// Anvandning: +const ärende = await getÄrende('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2'); +``` + +**Svar:** Samma format som i 3.5 (MedicalRecordResponse). + +--- + +### 3.7 Skriva en kommentar + +Lagg till en kommentar pa ett arende. Tänk det som en chatt mellan djurägare och veterinär. + +**`POST /api/comments`** + +```javascript +const skapaKommentar = async (recordId, text) => { + const response = await fetch('http://localhost:8080/api/comments', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Dev-User': 'anna@test.se' + }, + body: JSON.stringify({ + recordId: recordId, + body: text + }) + }); + const data = await response.json(); + return data; +}; + +// Anvandning: +await skapaKommentar('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2', 'Fido verkar bättre idag!'); +``` + +**Request body:** + +| Fält | Typ | Obligatoriskt | Beskrivning | +|----------|--------|---------------|----------------------------------| +| recordId | string | Ja | UUID for ärendet | +| body | string | Ja | Kommentarstexten (max 5000 tecken) | + +**Svar:** + +```json +{ + "id": "nytt-uuid-här", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "authorId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "authorName": "Anna Svensson", + "body": "Fido verkar bättre idag!", + "createdAt": "2025-04-14T10:00:00Z", + "updatedAt": null +} +``` + +--- + +### 3.8 Hamta kommentarer for ett arende + +Visar alla kommentarer pa ett arende, sorterade fran äldst till nyast. + +**`GET /api/comments/record/{recordId}`** + +```javascript +const getKommentarer = async (recordId) => { + const response = await fetch(`http://localhost:8080/api/comments/record/${recordId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; + +// Anvandning: +const kommentarer = await getKommentarer('b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2'); +``` + +**Svar (lista med kommentarer):** + +```json +[ + { + "id": "kommentar-uuid", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "authorId": "c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7", + "authorName": "Anna Svensson", + "body": "Fido verkar bättre idag!", + "createdAt": "2025-04-14T10:00:00Z", + "updatedAt": null + } +] +``` + +--- + +### 3.9 Bifoga en fil + +Ladda upp en bild eller PDF till ett arende. Bilagor skickas som `multipart/form-data` — inte JSON. +Det ar samma format som ett vanligt HTML-formulär med ``. + +**`POST /api/attachments/record/{recordId}`** + +```javascript +const laddaUppFil = async (recordId, file, beskrivning) => { + // FormData anvands istället for JSON nar man skickar filer + const formData = new FormData(); + formData.append('file', file); // file = File-objekt fran + formData.append('description', beskrivning); // Valfri beskrivning + + const response = await fetch(`http://localhost:8080/api/attachments/record/${recordId}`, { + method: 'POST', + headers: { + 'X-Dev-User': 'anna@test.se' + // OBS: Satt INTE 'Content-Type' här — browsern lägger till det automatiskt + // med rätt boundary for multipart/form-data + }, + body: formData // Skicka FormData direkt, INTE JSON.stringify() + }); + const data = await response.json(); + return data; +}; + +// Anvandning i en React-komponent: +// laddaUppFil(recordId, e.target.files[0], 'Rontgenbild')} /> +``` + +**Tillåtna filtyper:** JPG, PNG, PDF +**Maxstorlek:** 10 MB + +**Svar (201 Created):** + +```json +{ + "id": "bilaga-uuid", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "fileName": "rontgen_fido.jpg", + "description": "Rontgenbild", + "fileType": "image/jpeg", + "fileSizeBytes": 245000, + "uploadedAt": "2025-04-14T10:00:00Z", + "uploadedBy": "Anna Svensson", + "downloadUrl": "https://minio.../presigned-url" +} +``` + +--- + +### 3.10 Hamta bilagor for ett arende + +Visar alla uppladdade filer pa ett arende. Varje bilaga har en `downloadUrl` som du kan anvanda som `src` i en `` eller som `href` i en ``. + +**`GET /api/attachments/record/{recordId}`** + +```javascript +const getBilagor = async (recordId) => { + const response = await fetch(`http://localhost:8080/api/attachments/record/${recordId}`, { + headers: { + 'X-Dev-User': 'anna@test.se' + } + }); + const data = await response.json(); + return data; +}; +``` + +**Svar (lista med bilagor):** + +```json +[ + { + "id": "bilaga-uuid", + "recordId": "b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2", + "fileName": "rontgen_fido.jpg", + "description": "Rontgenbild", + "fileType": "image/jpeg", + "fileSizeBytes": 245000, + "uploadedAt": "2025-04-14T10:00:00Z", + "uploadedBy": "Anna Svensson", + "downloadUrl": "https://minio.../presigned-url" + } +] +``` + +**Tips:** `downloadUrl` ar en tidsbegränsad länk (giltig i 15 minuter). Om bilden inte laddas, hamta bilagorna igen for att fa en ny URL. + +--- + +## 4. Statuskoder att känna till + +Nar du anropar API:et far du alltid tillbaka en HTTP-statuskod. Kolla den i `response.status`. + +| Kod | Namn | Vad det betyder | +|-----|------------------|--------------------------------------------------------------| +| 200 | OK | Allt fungerade. Datan finns i response body. | +| 201 | Created | Resursen skapades. Datan finns i response body. | +| 204 | No Content | Lyckades, men inget data i svaret (t.ex. vid delete). | +| 400 | Bad Request | Något ar fel i din request. Kolla att alla fält ar ifyllda. | +| 403 | Forbidden | Du har inte rättighet. T.ex. en OWNER som försöker nå en VET-endpoint. | +| 404 | Not Found | Resursen finns inte. Kolla att UUID:t stämmer. | +| 500 | Server Error | Något gick fel i backend. Kolla IntelliJ-loggen. | + +**Felmeddelanden** kommer som JSON: + +```json +{ + "status": 400, + "message": "Validation failed", + "errors": { + "title": ["Titel far inte vara tom"] + } +} +``` + +--- + +## 5. Nar JWT ar implementerat + +Just nu anvander vi `X-Dev-User`-headern for att simulera inloggning. Nar JWT ar klart byter du ut den mot en riktig token. + +### Före (nu — utvecklingsläge) + +```javascript +headers: { + 'X-Dev-User': 'anna@test.se' +} +``` + +### Efter (med JWT) + +```javascript +// 1. Logga in och fa en token +const login = async (email, password) => { + const response = await fetch('http://localhost:8080/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await response.json(); + return data.token; // Spara denna i state eller localStorage +}; + +// 2. Anvand token i alla andra anrop +headers: { + 'Authorization': `Bearer ${token}` +} +``` + +**Allt annat ar likadant** — samma URLer, samma request bodies, samma svar. Bara headern andras. + +--- + +## 6. Kända test-UUID:n + +Dessa UUID:n finns i testdatan som laddas vid uppstart. Anvand dem for att testa utan att behöva skapa data först. + +### Klinik + +| Namn | UUID | +|-----------------------|----------------------------------------| +| Djurkliniken Centrum | `a1b2c3d4-e5f6-4a5b-8c9d-e0f1a2b3c4d5` | + +### Anvandare + +| Namn | Roll | Email | UUID | +|-----------------|-------|----------------|----------------------------------------| +| Anna Svensson | OWNER | anna@test.se | `c3d4e5f6-a7b8-4c5d-0e1f-a2b3c4d5e6f7` | +| Erik Veterinär | VET | erik@klinik.se | `d4e5f6a7-b8c9-4d5e-1f2a-b3c4d5e6f7a8` | +| Sara Admin | ADMIN | sara@admin.se | `e5f6a7b8-c9d0-4e5f-2a3b-c4d5e6f7a8b9` | + +### Djur + +| Namn | Art | Ras | UUID | +|--------|------|-----------|----------------------------------------| +| Fido | Hund | Labrador | `f6a7b8c9-d0e1-4f5a-3b4c-d5e6f7a8b9c0` | +| Missan | Katt | Persisk | `a7b8c9d0-e1f2-4a5b-4c5d-e6f7a8b9c0d1` | + +### Arenden + +| Titel | Status | UUID | +|-----------------|-------------|----------------------------------------| +| Fido haltar | OPEN | `b8c9d0e1-f2a3-4b5c-5d6e-f7a8b9c0d1e2` | +| Missan äter inte | IN_PROGRESS | `c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3` | + +--- + +## 7. Alla endpoints — komplett lista + +Nedan ar samtliga endpoints i API:et, grupperade per controller. +Kolumnen "Auth" visar vem som kan anropa endpointen. + +**Förkortningar:** +- **Alla** = ingen inloggning krävs (permitAll) +- **Inloggad** = alla inloggade anvandare +- **OWNER** = djurägare +- **VET** = veterinär +- **ADMIN** = administratör +- **Policy** = rollkontroll sker i service/policy-lagret, inte i controllern + +### Autentisering (`/api/auth`) + +*Dessa endpoints finns ännu inte — de skapas av Person B (se issue #1–3).* + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/auth/register` | Alla | Registrera ny anvandare | `{ name, email, password, role }` | +| POST | `/api/auth/login` | Alla | Logga in, fa JWT-token | `{ email, password }` | + +### Kliniker (`/api/clinics`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/clinics` | Alla | Hamta alla kliniker | — | +| GET | `/api/clinics/{id}` | Alla | Hamta en klinik | — | +| POST | `/api/clinics` | Inloggad | Skapa klinik | `{ name, address, phoneNumber }` | +| PUT | `/api/clinics/{id}` | Inloggad | Uppdatera klinik | `{ name, address, phoneNumber }` | +| DELETE | `/api/clinics/{id}` | Inloggad | Ta bort klinik | — | + +**Svar (ClinicResponse):** +```json +{ "id": "uuid", "name": "...", "address": "...", "phoneNumber": "..." } +``` + +### Djur (`/pets`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/pets` | OWNER/ADMIN (Policy) | Skapa djur. Admin kan ange `?ownerId=uuid` | `{ name, species, breed, dateOfBirth, weightKg }` | +| GET | `/pets/{petId}` | OWNER/VET/ADMIN (Policy) | Hamta ett djur | — | +| GET | `/pets/owner/{ownerId}` | OWNER/ADMIN (Policy) | Hamta alla djur for en ägare | — | +| PUT | `/pets/{petId}` | OWNER/ADMIN (Policy) | Uppdatera djurinfo | `{ name, species, breed, dateOfBirth, weightKg }` | +| DELETE | `/pets/{petId}` | OWNER/ADMIN (Policy) | Ta bort djur | — | + +**Svar (PetResponse):** +```json +{ + "id": "uuid", "ownerId": "uuid", "name": "Fido", "species": "Hund", + "breed": "Labrador", "dateOfBirth": "2020-01-15", "weightKg": 25.5, + "createdAt": "2025-01-01T10:00:00Z", "updatedAt": "2025-01-01T10:00:00Z" +} +``` + +### Arenden (`/api/medical-records`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/medical-records` | Inloggad (Policy) | Skapa arende | `{ title, description, petId, clinicId }` | +| GET | `/api/medical-records/{id}` | Inloggad (Policy) | Hamta ett arende (fullstandig) | — | +| GET | `/api/medical-records/my-records` | OWNER | Hamta mina ärenden (kortformat) | — | +| GET | `/api/medical-records/owner/{ownerId}` | OWNER (egna)/VET/ADMIN | Hamta ärenden per ägare | — | +| GET | `/api/medical-records/pet/{petId}` | Inloggad (Policy) | Hamta ärenden per djur | — | +| GET | `/api/medical-records/clinic/{clinicId}` | VET/ADMIN (Policy) | Hamta ärenden per klinik | — | +| GET | `/api/medical-records/clinic/{clinicId}/status/{status}` | VET/ADMIN (Policy) | Hamta ärenden per klinik + status | — | +| PUT | `/api/medical-records/{id}` | VET/ADMIN (Policy) | Uppdatera titel/beskrivning | `{ title, description }` | +| PUT | `/api/medical-records/{id}/assign-vet` | VET/ADMIN (Policy) | Tilldela veterinär | `{ vetId }` | +| PUT | `/api/medical-records/{id}/status` | VET/ADMIN (Policy) | Ändra status | `{ status }` | +| PUT | `/api/medical-records/{id}/close` | VET/ADMIN (Policy) | Stäng ärende | — | + +**Möjliga statusar:** `OPEN`, `IN_PROGRESS`, `AWAITING_INFO`, `CLOSED` + +**Svar — fullständigt (MedicalRecordResponse):** +```json +{ + "id": "uuid", "title": "...", "description": "...", "status": "OPEN", + "petId": "uuid", "petName": "Fido", "petSpecies": "Hund", + "ownerId": "uuid", "ownerName": "Anna Svensson", + "clinicId": "uuid", "clinicName": "Djurkliniken Centrum", + "assignedVetId": null, "assignedVetName": null, + "createdById": "uuid", "createdByName": "Anna Svensson", + "createdAt": "2025-01-01T10:00:00Z", "updatedAt": null, "closedAt": null +} +``` + +**Svar — kortformat (MedicalRecordSummaryResponse):** +```json +{ + "id": "uuid", "title": "...", "status": "OPEN", + "petName": "Fido", "ownerName": "Anna Svensson", + "assignedVetName": null, "createdAt": "2025-01-01T10:00:00Z" +} +``` + +### Kommentarer (`/api/comments`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/comments` | Inloggad (Policy) | Skapa kommentar | `{ recordId, body }` | +| GET | `/api/comments/record/{recordId}` | Inloggad (Policy) | Hamta kommentarer for ett arende | — | +| GET | `/api/comments/record/{recordId}/count` | Inloggad (Policy) | Antal kommentarer pa ett arende | — | +| PUT | `/api/comments/{id}` | Inloggad (Policy) | Uppdatera kommentar | `{ body }` | +| DELETE | `/api/comments/{id}` | Inloggad (Policy) | Ta bort kommentar | — | + +**Svar (CommentResponse):** +```json +{ + "id": "uuid", "recordId": "uuid", "authorId": "uuid", + "authorName": "Anna Svensson", "body": "Fido verkar bättre!", + "createdAt": "2025-04-14T10:00:00Z", "updatedAt": null +} +``` + +### Bilagor (`/api/attachments`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| POST | `/api/attachments/record/{recordId}` | Inloggad (Policy) | Ladda upp fil (multipart/form-data) | `file` + valfri `description` | +| GET | `/api/attachments/record/{recordId}` | Inloggad (Policy) | Hamta bilagor for ett arende | — | +| GET | `/api/attachments/{id}/download` | Inloggad (Policy) | Hamta en bilaga (presigned URL) | — | +| DELETE | `/api/attachments/{id}` | VET/ADMIN (Policy) | Ta bort bilaga | — | + +**Tillåtna filtyper:** JPG, PNG, PDF. **Max:** 10 MB. + +**Svar (AttachmentResponse):** +```json +{ + "id": "uuid", "recordId": "uuid", "fileName": "bild.jpg", + "description": "Rontgenbild", "fileType": "image/jpeg", + "fileSizeBytes": 245000, "uploadedAt": "2025-04-14T10:00:00Z", + "uploadedBy": "Anna Svensson", "downloadUrl": "https://presigned-url..." +} +``` + +### Aktivitetslogg (`/api/activity-logs`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/activity-logs/record/{recordId}` | Inloggad (Policy) | Hamta händelselogg for ett arende | — | + +**Svar (lista med ActivityLogResponse):** +```json +[ + { + "id": "uuid", "action": "CASE_CREATED", "description": "Ärende skapat", + "performedById": "uuid", "performedByName": "Anna Svensson", + "recordId": "uuid", "createdAt": "2025-01-01T10:00:00Z" + } +] +``` + +**Möjliga actions:** `CASE_CREATED`, `UPDATED`, `STATUS_CHANGED`, `ASSIGNED`, `COMMENT_ADDED` + +### Anvandare (`/api/users`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/users` | Inloggad | Hamta alla anvandare | — | +| GET | `/api/users/{id}` | Inloggad | Hamta en anvandare | — | +| POST | `/api/users` | Inloggad | Skapa anvandare | `{ name, email, password, role, clinicId? }` | +| PUT | `/api/users/{id}` | Inloggad | Uppdatera anvandare | `{ name?, email?, clinicId? }` | +| DELETE | `/api/users/{id}` | Inloggad | Ta bort anvandare | — | + +**Svar (UserResponse):** +```json +{ + "id": "uuid", "name": "Anna Svensson", "email": "anna@test.se", + "role": "OWNER", "clinicId": null, + "createdAt": "2025-01-01T10:00:00Z", "updatedAt": "2025-01-01T10:00:00Z" +} +``` + +### Veterinärer (`/api/vets`) + +| Metod | URL | Auth | Beskrivning | Request body | +|-------|-----|------|-------------|--------------| +| GET | `/api/vets` | Inloggad | Hamta alla veterinärer | — | +| GET | `/api/vets/{id}` | Inloggad | Hamta en veterinär | — | +| POST | `/api/vets` | ADMIN | Skapa veterinärprofil | `{ userId, licenseId, specialization?, bookingInfo? }` | + +**Svar (VetResponse):** +```json +{ + "userId": "uuid", "name": "Erik Veterinär", "email": "erik@klinik.se", + "licenseId": "VET-001", "specialization": "Ortopedi", + "bookingInfo": "Mån-Fre 08-17", "clinicName": "Djurkliniken Centrum", + "isActive": true +} diff --git a/pom.xml b/pom.xml index 5041f4f2..72bc9824 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + software.amazon.awssdk s3 diff --git a/src/main/java/org/example/vet1177/controller/ActivityLogController.java b/src/main/java/org/example/vet1177/controller/ActivityLogController.java index 5086b488..898dc1b8 100644 --- a/src/main/java/org/example/vet1177/controller/ActivityLogController.java +++ b/src/main/java/org/example/vet1177/controller/ActivityLogController.java @@ -3,9 +3,9 @@ import org.example.vet1177.dto.response.activitylog.ActivityLogResponse; import org.example.vet1177.entities.User; import org.example.vet1177.services.ActivityLogService; -import org.example.vet1177.services.UserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -18,26 +18,22 @@ public class ActivityLogController { private static final Logger log = LoggerFactory.getLogger(ActivityLogController.class); private final ActivityLogService activityLogService; - private final UserService userService; - public ActivityLogController(ActivityLogService activityLogService, - UserService userService) { + public ActivityLogController(ActivityLogService activityLogService) { this.activityLogService = activityLogService; - this.userService = userService; } // Hämta alla logs för ett MedicalRecord @GetMapping("/record/{recordId}") public List getLogsByRecord( @PathVariable UUID recordId, - @RequestHeader("userId") UUID userId + @AuthenticationPrincipal User currentUser ) { log.info("GET /api/activity-logs/record/{}", recordId); - User user = userService.getUserEntityById(userId); - return activityLogService.getByRecord(recordId, user) + return activityLogService.getByRecord(recordId, currentUser) .stream() .map(ActivityLogResponse::from) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/vet1177/controller/PetController.java b/src/main/java/org/example/vet1177/controller/PetController.java index 0c808159..99677543 100644 --- a/src/main/java/org/example/vet1177/controller/PetController.java +++ b/src/main/java/org/example/vet1177/controller/PetController.java @@ -8,8 +8,8 @@ import org.example.vet1177.services.PetService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.example.vet1177.services.UserService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -22,44 +22,42 @@ public class PetController { private static final Logger log = LoggerFactory.getLogger(PetController.class); private final PetService petService; - private final UserService userService; - public PetController(PetService petService, UserService userService) { + public PetController(PetService petService) { this.petService = petService; - this.userService = userService; } - //POST / pets - skapa nytt djur + //POST /pets - skapa nytt djur @PostMapping public PetResponse createPet( - // TODO: Ersätt med användare från autentiserad kontext (t.ex. JWT / Spring Security) - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @RequestParam(required = false) UUID ownerId, @Valid @RequestBody PetRequest request ) { - log.info("POST /pets - creating pet for currentUserId={} ownerId={}", currentUserId, ownerId); - Pet saved = petService.createPet(currentUserId, ownerId, request); + log.info("POST /pets - creating pet for userId={} ownerId={}", currentUser.getId(), ownerId); + Pet saved = petService.createPet(currentUser.getId(), ownerId, request); return toResponse(saved); } - // GET / pets/{petId} - hämta ett specifikt djur + // GET /pets/{petId} - hämta ett specifikt djur @GetMapping("/{petId}") public ResponseEntity getPetById( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID petId ) { - User currentUser = userService.getUserEntityById(currentUserId); + log.info("GET /pets/{}", petId); Pet pet = petService.getPetById(petId, currentUser); return ResponseEntity.ok(toResponse(pet)); } - // GET/pets/owner/{ownerId} - hämta alla djur för en ägare + // GET /pets/owner/{ownerId} - hämta alla djur för en ägare @GetMapping("/owner/{ownerId}") public ResponseEntity> getPetsByOwner( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID ownerId ) { - List pets = petService.getPetsByOwner(currentUserId, ownerId) + log.info("GET /pets/owner/{}", ownerId); + List pets = petService.getPetsByOwner(currentUser.getId(), ownerId) .stream() .map(this::toResponse) .toList(); @@ -69,21 +67,22 @@ public ResponseEntity> getPetsByOwner( // PUT /pets/{petId} - uppdatera ett djur @PutMapping("/{petId}") public ResponseEntity updatePet( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID petId, @Valid @RequestBody PetRequest request ) { - Pet updated = petService.updatePet(currentUserId, petId, request); + log.info("PUT /pets/{}", petId); + Pet updated = petService.updatePet(currentUser.getId(), petId, request); return ResponseEntity.ok(toResponse(updated)); } // DELETE /pets/{petId} - radera ett djur @DeleteMapping("/{petId}") public ResponseEntity deletePet( - @RequestHeader UUID currentUserId, + @AuthenticationPrincipal User currentUser, @PathVariable UUID petId ) { - User currentUser = userService.getUserEntityById(currentUserId); + log.info("DELETE /pets/{}", petId); petService.deletePet(petId, currentUser); return ResponseEntity.noContent().build(); } @@ -103,4 +102,4 @@ private PetResponse toResponse(Pet pet) { ); } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/vet1177/controller/UserController.java b/src/main/java/org/example/vet1177/controller/UserController.java index 3c227e67..b4fd2cfb 100644 --- a/src/main/java/org/example/vet1177/controller/UserController.java +++ b/src/main/java/org/example/vet1177/controller/UserController.java @@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.example.vet1177.dto.request.user.UserUpdateRequest; +import org.springframework.security.access.prepost.PreAuthorize; import java.util.List; import java.util.UUID; @@ -42,6 +43,15 @@ public ResponseEntity getUserById(@PathVariable UUID id) { return ResponseEntity.ok(user); } + // GET /users/search?email= - Sök användare på email (endast admin) + @GetMapping("/search") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity searchByEmail(@RequestParam String email) { + log.info("GET /api/users/search"); + UserResponse user = userService.searchByEmail(email); + return ResponseEntity.ok(user); + } + //POST /users - skapa ny användare @PostMapping public ResponseEntity createUser(@Valid @RequestBody UserRequest request) { diff --git a/src/main/java/org/example/vet1177/security/CustomUserDetailsService.java b/src/main/java/org/example/vet1177/security/CustomUserDetailsService.java new file mode 100644 index 00000000..29443295 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/CustomUserDetailsService.java @@ -0,0 +1,50 @@ +package org.example.vet1177.security; + +import org.example.vet1177.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * Bryggan mellan Spring Security och vår databas. + * + * Spring Security behöver veta hur man hämtar en användare givet ett "användarnamn". + * I vårt fall är "användarnamnet" email-adressen (det som står i JWT:ns subject-claim). + * + * Flödet: + * JWT-token innehåller sub="sara@vet.se" + * ↓ + * JwtAuthenticationFilter anropar loadUserByUsername("sara@vet.se") + * ↓ + * Vi söker i databasen: userRepository.findByEmail("sara@vet.se") + * ↓ + * Returnerar User-objektet (som implementerar UserDetails) + * ↓ + * Spring Security sätter det i SecurityContext → @AuthenticationPrincipal fungerar + */ +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private static final Logger log = LoggerFactory.getLogger(CustomUserDetailsService.class); + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + log.debug("Loading user by email={}", email); + + + return userRepository.findByEmail(email) + .orElseThrow(() -> { + log.warn("User not found during authentication"); + return new UsernameNotFoundException("Användare hittades inte"); + }); + } +} diff --git a/src/main/java/org/example/vet1177/security/JwtAuthenticationFilter.java b/src/main/java/org/example/vet1177/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..1885dc76 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/JwtAuthenticationFilter.java @@ -0,0 +1,128 @@ +package org.example.vet1177.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Filter som körs EN gång per HTTP-request (OncePerRequestFilter). + * + * Vad händer steg för steg: + * + * [Klient] → HTTP Request med "Authorization: Bearer eyJhbG..." + * ↓ + * [JwtAuthenticationFilter] + * 1. Läser Authorization-headern + * 2. Extraherar token-strängen (allt efter "Bearer ") + * 3. Avkodar token via JwtService → får email + roll + * 4. Laddar User från DB via CustomUserDetailsService + * 5. Skapar ett Authentication-objekt och sätter det i SecurityContext + * ↓ + * [Spring Security] + * Kollar SecurityContext: finns en autentiserad användare? + * Om ja → kontrollerar att användaren har rätt roll för endpointen + * Om nej → returnerar 401 Unauthorized + * ↓ + * [Controller] + * @AuthenticationPrincipal User currentUser ← hämtas från SecurityContext + */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + + private final JwtService jwtService; + private final CustomUserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, CustomUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // 1. Läs Authorization-headern + // Formatet är: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + String authHeader = request.getHeader("Authorization"); + + // Om headern saknas eller inte börjar med "Bearer " → skippa filtret. + // Requesten släpps vidare i kedjan. Om endpointen kräver auth + // kommer Spring Security att returnera 401 automatiskt. + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // 2. Extrahera token-strängen (ta bort "Bearer " som är 7 tecken) + String token = authHeader.substring(7); + + try { + // 3. Avkoda och validera token. + // Om signaturen inte stämmer eller token har gått ut kastar decode() JwtException. + Jwt jwt = jwtService.decode(token); + + // 4. Hämta email från token (subject-claim) + String email = jwt.getSubject(); + + // Kolla att vi inte redan har autentiserat denna request + // (kan hända om flera filter kör i kedjan) + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + // 5. Ladda det fulla User-objektet från databasen. + // Vi behöver hela entiteten (med id, klinik, roll) för @AuthenticationPrincipal. + var userDetails = userDetailsService.loadUserByUsername(email); + + // 6. Skapa ett Authentication-objekt. + // UsernamePasswordAuthenticationToken är Springs standardklass för "en autentiserad användare". + // - Första argumentet (principal) → User-objektet (det som @AuthenticationPrincipal ger) + // - Andra argumentet (credentials) → null (vi behöver inte lösenordet, token räcker) + // - Tredje argumentet (authorities) → rollerna (ROLE_VET, ROLE_ADMIN, etc.) + var authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + // Kopplar request-detaljer (IP-adress, session-id) till autentiseringen — för loggning. + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + // 7. Sätt autentiseringen i SecurityContext. + // Från och med nu "vet" Spring Security att det finns en inloggad användare. + // Alla controllers kan hämta den via @AuthenticationPrincipal. + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("Authenticated user email={} via JWT", email); + } + + } catch (JwtException e) { + // Token är ogiltig (felaktig signatur, utgången, korrupt). + // Vi loggar och släpper vidare — Spring Security returnerar 401 automatiskt + // eftersom SecurityContext förblir tom. + log.warn("Invalid JWT token: {}", e.getMessage()); + } catch (UsernameNotFoundException e) { + // Token är giltig men användaren finns inte längre i databasen + // (t.ex. raderad av admin medan tokenen fortfarande gäller). + log.warn("User from JWT no longer exists in database"); + } + + // Släpp vidare requesten till nästa filter i kedjan (och sedan till controllern) + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/example/vet1177/security/JwtProperties.java b/src/main/java/org/example/vet1177/security/JwtProperties.java new file mode 100644 index 00000000..1c165f55 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/JwtProperties.java @@ -0,0 +1,22 @@ +package org.example.vet1177.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Läser JWT-konfiguration från application.properties. + * + * jwt.secret-key → den hemliga nyckeln som används för att signera/verifiera tokens (HMAC SHA-256) + * jwt.expiration-ms → hur länge en token är giltig i millisekunder (86400000 = 24 timmar) + */ +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private String secretKey; + private long expirationMs; + + public String getSecretKey() { return secretKey; } + public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + + public long getExpirationMs() { return expirationMs; } + public void setExpirationMs(long expirationMs) { this.expirationMs = expirationMs; } +} diff --git a/src/main/java/org/example/vet1177/security/JwtService.java b/src/main/java/org/example/vet1177/security/JwtService.java new file mode 100644 index 00000000..6643c970 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/JwtService.java @@ -0,0 +1,115 @@ +package org.example.vet1177.security; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import org.example.vet1177.entities.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +/** + * Hanterar skapande och validering av JWT-tokens. + * + * Flödet: + * 1. Användaren loggar in med email + lösenord + * 2. AuthService anropar generateToken(user) → får tillbaka en JWT-sträng + * 3. Frontend skickar JWT:n i varje request: "Authorization: Bearer " + * 4. JwtAuthenticationFilter fångar headern, anropar decode(token) → får tillbaka claims + * 5. Filtret laddar användaren från DB och sätter den i SecurityContext + * + * Vi använder HS256 (HMAC med SHA-256) — en symmetrisk algoritm där samma + * hemliga nyckel används för att både signera och verifiera tokens. + */ +@Service +public class JwtService { + + private static final Logger log = LoggerFactory.getLogger(JwtService.class); + + private final JwtEncoder jwtEncoder; + private final JwtDecoder jwtDecoder; + private final long expirationMs; + + public JwtService(JwtProperties jwtProperties) { + byte[] keyBytes = jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8); + if (keyBytes.length < 32) { + throw new IllegalStateException( + "JWT secret key must be at least 32 bytes for HS256, got " + keyBytes.length); + } + + SecretKey key = new SecretKeySpec(keyBytes, "HmacSHA256"); + + // JwtEncoder — skapar nya tokens. + // ImmutableSecret wrapprar vår nyckel så att Nimbus-biblioteket kan använda den. + this.jwtEncoder = new NimbusJwtEncoder(new ImmutableSecret<>(key)); + + // JwtDecoder — avkodar och validerar befintliga tokens. + // Den kontrollerar automatiskt: signatur (är tokenen äkta?) och utgångstid (har den gått ut?). + this.jwtDecoder = NimbusJwtDecoder.withSecretKey(key) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + + this.expirationMs = jwtProperties.getExpirationMs(); + } + + /** + * Skapar en ny JWT för en inloggad användare. + * + * Tokenen innehåller "claims" — nyckel-värde-par med information: + * - sub (subject): användarens email — det unika identifieringsvärdet + * - userId: UUID:t — behövs för att hämta användaren från DB + * - role: ROLE_VET / ROLE_OWNER / ROLE_ADMIN — för rollbaserad åtkomstkontroll + * - iat (issued at): när tokenen skapades + * - exp (expires at): när tokenen slutar gälla + */ + public String generateToken(User user) { + Instant now = Instant.now(); + + JwtClaimsSet claims = JwtClaimsSet.builder() + .subject(user.getEmail()) + .claim("userId", user.getId().toString()) + .claim("role", "ROLE_" + user.getRole().name()) + .issuedAt(now) + .expiresAt(now.plusMillis(expirationMs)) + .build(); + + // Talar om att vi vill signera med HS256-algoritmen + JwtEncoderParameters params = JwtEncoderParameters.from( + JwsHeader.with(MacAlgorithm.HS256).build(), + claims + ); + + String token = jwtEncoder.encode(params).getTokenValue(); + log.info("Generated JWT for user email={}", user.getEmail()); + return token; + } + + /** + * Avkodar och validerar en JWT-sträng. + * + * Nimbus-biblioteket gör automatiskt: + * 1. Kontrollerar att signaturen stämmer (ingen har ändrat innehållet) + * 2. Kontrollerar att tokenen inte har gått ut (exp > nu) + * + * Om något är fel kastas JwtException och anroparen vet att tokenen är ogiltig. + * + * @return Jwt-objekt med alla claims om tokenen är giltig + * @throws JwtException om tokenen är ogiltig, manipulerad eller utgången + */ + public Jwt decode(String token) { + return jwtDecoder.decode(token); + } + + /** + * Exponerar JwtDecoder som en bean som Spring Security kan använda. + * SecurityConfig behöver en JwtDecoder-bean för att konfigurera oauth2ResourceServer(). + */ + public JwtDecoder getJwtDecoder() { + return jwtDecoder; + } +} diff --git a/src/main/java/org/example/vet1177/security/SecurityConfig.java b/src/main/java/org/example/vet1177/security/SecurityConfig.java index 3f867e90..5bd2857a 100644 --- a/src/main/java/org/example/vet1177/security/SecurityConfig.java +++ b/src/main/java/org/example/vet1177/security/SecurityConfig.java @@ -1,28 +1,153 @@ package org.example.vet1177.security; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.List; + +/** + * Hela säkerhetskonfigurationen för applikationen. + * + * @EnableConfigurationProperties(JwtProperties.class) — talar om för Spring att + * läsa jwt.secret-key och jwt.expiration-ms från application.properties och + * skapa en JwtProperties-bean som kan injiceras i JwtService. + */ + +@EnableMethodSecurity @Configuration +@EnableConfigurationProperties(JwtProperties.class) public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomUserDetailsService userDetailsService; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, + CustomUserDetailsService userDetailsService) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.userDetailsService = userDetailsService; + } + + /** + * SecurityFilterChain — definierar hela säkerhetskedjan. + * + * Varje rad i kedjan lägger till en regel: + */ @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) - throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http + // CORS — tillåter frontend (annan port/domän) att anropa vårt API. + // Utan detta blockerar webbläsaren alla cross-origin requests. + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // CSRF — avstängt. CSRF-skydd behövs för cookie-baserad auth + // men är irrelevant för JWT (token skickas i header, inte cookie). .csrf(csrf -> csrf.disable()) + + // Sessionhantering — STATELESS. + // Servern sparar ingen session. All info finns i JWT-tokenen. + // Varje request är oberoende — filtret avkodar token varje gång. + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // Endpoint-regler — vilka URLs kräver vad. + // Ordningen spelar roll: första matchande regel vinner. .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() // ← temporärt, öppnar allt + // Öppna endpoints — ingen token krävs + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/clinics", "/api/clinics/**").permitAll() + + // Alla andra API-anrop kräver att man är inloggad + .anyRequest().authenticated() ) + + // Registrera vårt JWT-filter FÖRE Springs inbyggda UsernamePasswordAuthenticationFilter. + // Det betyder att JWT-filtret körs först och sätter SecurityContext + // innan Spring kollar om användaren har rätt behörighet. + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); } + /** + * JwtDecoder-bean — Spring Security använder denna för att verifiera JWT-tokens. + * Vi hämtar den från JwtService som redan har konfigurerat den med vår hemliga nyckel. + */ + @Bean + public JwtDecoder jwtDecoder(JwtService jwtService) { + return jwtService.getJwtDecoder(); + } + + /** + * PasswordEncoder — BCrypt är industristandard. + * Används vid registrering (hasha lösenord) och login (verifiera lösenord). + * BCrypt saltar automatiskt — samma lösenord ger olika hash varje gång. + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file + + /** + * AuthenticationManager — hanterar login-flödet. + * + * När Person B:s AuthController anropar authManager.authenticate(email, password): + * 1. AuthenticationManager delegerar till DaoAuthenticationProvider + * 2. DaoAuthenticationProvider anropar CustomUserDetailsService.loadUserByUsername(email) + * 3. DaoAuthenticationProvider jämför lösenord med PasswordEncoder.matches() + * 4. Om match → returnerar Authentication med User-objektet + * 5. Om mismatch → kastar BadCredentialsException + */ + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + /** + * DaoAuthenticationProvider — kopplar ihop UserDetailsService med PasswordEncoder. + * "Dao" = Data Access Object — den hämtar data från vår databas. + */ + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + /** + * CORS-konfiguration — vilka origins (domäner) som får anropa vårt API. + * + * I utveckling: localhost:5173 (Vite/React), localhost:3000 (Next.js) + * I produktion: byt till er riktiga frontend-URL. + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + source.registerCorsConfiguration("/pets/**", config); + return source; + } +} diff --git a/src/main/java/org/example/vet1177/security/auth/dto/AuthRequest.java b/src/main/java/org/example/vet1177/security/auth/dto/AuthRequest.java new file mode 100644 index 00000000..cdc6354d --- /dev/null +++ b/src/main/java/org/example/vet1177/security/auth/dto/AuthRequest.java @@ -0,0 +1,16 @@ +package org.example.vet1177.security.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + + +public record AuthRequest( + @NotBlank(message = "Email måste anges") @Email(message = "Ogiltig emailadress") String email, + @NotBlank(message = "Lösenord måste anges") @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password +) { + @Override + public String toString() { + return "AuthRequest[email=***, password=***]"; + } +} diff --git a/src/main/java/org/example/vet1177/security/auth/dto/AuthResponse.java b/src/main/java/org/example/vet1177/security/auth/dto/AuthResponse.java index 3ce1928d..34c4eaee 100644 --- a/src/main/java/org/example/vet1177/security/auth/dto/AuthResponse.java +++ b/src/main/java/org/example/vet1177/security/auth/dto/AuthResponse.java @@ -1,4 +1,13 @@ package org.example.vet1177.security.auth.dto; -public class AuthResponse { -} +import org.example.vet1177.entities.Role; + +import java.util.UUID; + +public record AuthResponse( + String token, + UUID userId, + String name, + String email, + Role role +) {} diff --git a/src/main/java/org/example/vet1177/security/auth/dto/RegisterRequest.java b/src/main/java/org/example/vet1177/security/auth/dto/RegisterRequest.java new file mode 100644 index 00000000..43473b15 --- /dev/null +++ b/src/main/java/org/example/vet1177/security/auth/dto/RegisterRequest.java @@ -0,0 +1,17 @@ +package org.example.vet1177.security.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank(message = "Namn måste anges") String name, + @NotBlank(message = "Email måste anges") @Email(message = "Ogiltig emailadress") String email, + @NotBlank(message = "Lösenord måste anges") @Size(min = 8, message = "Lösenord måste vara minst 8 tecken") @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password +) { + @Override + public String toString() { + return "RegisterRequest[name=***, email=***, password=***]"; + } +} diff --git a/src/main/java/org/example/vet1177/services/UserService.java b/src/main/java/org/example/vet1177/services/UserService.java index f972c092..f98d0026 100644 --- a/src/main/java/org/example/vet1177/services/UserService.java +++ b/src/main/java/org/example/vet1177/services/UserService.java @@ -71,6 +71,14 @@ public User getByEmail(String email){ .orElseThrow(() -> new ResourceNotFoundException("User", email)); } + // GET /users/search?email= - Returnerar UserResponse DTO till controller (admin only) + public UserResponse searchByEmail(String email) { + log.debug("Searching user by email={}", email); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ResourceNotFoundException("User", email)); + return mapToResponse(user); + } + // Returnerar User-entiteten, används internt när andra services behöver ett User-objekt. OK? - annars public User getUserEntityById(UUID id) { log.debug("Fetching user entity id={}", id); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 87967623..b926dda2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,4 +27,8 @@ aws.s3.bucket-name=${S3_BUCKET:} aws.s3.region=${S3_REGION:} spring.servlet.multipart.max-file-size=10MB -spring.servlet.multipart.max-request-size=10MB \ No newline at end of file +spring.servlet.multipart.max-request-size=10MB + +# JWT Configuration +jwt.secret-key=${JWT_SECRET:default-dev-secret-key-change-in-production-min-32-chars!!} +jwt.expiration-ms=86400000 \ No newline at end of file diff --git a/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java b/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java index 00b584a3..aa92f78b 100644 --- a/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/ActivityLogControllerTest.java @@ -1,15 +1,15 @@ package org.example.vet1177.controller; import org.example.vet1177.entities.*; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; +import org.example.vet1177.security.SecurityConfig; import org.example.vet1177.services.ActivityLogService; -import org.example.vet1177.services.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; - -// ✅ RÄTT (Boot 4) import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; - +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -18,11 +18,12 @@ import java.util.UUID; import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ActivityLogController.class) -@AutoConfigureMockMvc(addFilters = false) // 🔥 stänger av security (fixar 401) +@Import(SecurityConfig.class) class ActivityLogControllerTest { @Autowired @@ -32,7 +33,10 @@ class ActivityLogControllerTest { private ActivityLogService activityLogService; @MockitoBean - private UserService userService; + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; @Test void shouldReturnLogsForRecord() throws Exception { @@ -47,6 +51,7 @@ void shouldReturnLogsForRecord() throws Exception { User user = mock(User.class); when(user.getId()).thenReturn(userId); when(user.getName()).thenReturn("Alice"); + when(user.getAuthorities()).thenReturn(List.of()); // Mock MedicalRecord MedicalRecord record = mock(MedicalRecord.class); @@ -61,17 +66,18 @@ void shouldReturnLogsForRecord() throws Exception { when(log.getMedicalRecord()).thenReturn(record); when(log.getCreatedAt()).thenReturn(createdAt); - // Mock service responses - when(userService.getUserEntityById(userId)).thenReturn(user); + // Mock service when(activityLogService.getByRecord(recordId, user)) .thenReturn(List.of(log)); // Act + Assert mockMvc.perform(get("/api/activity-logs/record/" + recordId) - .header("userId", userId.toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].description").value("Created case")) .andExpect(jsonPath("$[0].action").value("CASE_CREATED")) .andExpect(jsonPath("$[0].performedByName").value("Alice")); } -} \ No newline at end of file +} diff --git a/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java b/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java index 01467030..9fb12fa5 100644 --- a/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/AttachmentControllerTest.java @@ -5,6 +5,8 @@ import org.example.vet1177.entities.User; import org.example.vet1177.exception.ForbiddenException; import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.security.SecurityConfig; import org.example.vet1177.services.AttachmentService; import org.junit.jupiter.api.BeforeEach; @@ -39,6 +41,12 @@ class AttachmentControllerTest { @MockitoBean private AttachmentService attachmentService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + private User vetUser; private UUID recordId; private UUID attachmentId; diff --git a/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java b/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java index fe8892f1..c0548091 100644 --- a/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/ClinicControllerTest.java @@ -1,6 +1,8 @@ package org.example.vet1177.controller; import org.example.vet1177.entities.Clinic; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.services.ClinicService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -26,6 +28,12 @@ class ClinicControllerTest { @MockitoBean private ClinicService clinicService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + @Test void shouldCreateClinic() throws Exception { // Arrange diff --git a/src/test/java/org/example/vet1177/controller/CommentControllerTest.java b/src/test/java/org/example/vet1177/controller/CommentControllerTest.java index 37ccdf8b..f31711eb 100644 --- a/src/test/java/org/example/vet1177/controller/CommentControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/CommentControllerTest.java @@ -15,6 +15,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.context.annotation.Import; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.security.SecurityConfig; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -22,6 +24,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; + import java.util.List; import java.util.UUID; @@ -45,6 +48,12 @@ class CommentControllerTest { @MockitoBean private CommentService commentService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + private User currentUser; private MedicalRecord record; private Comment comment; diff --git a/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java b/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java index 2b7d8a14..38b44129 100644 --- a/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/MedicalRecordControllerTest.java @@ -15,6 +15,8 @@ import org.example.vet1177.exception.ForbiddenException; import org.example.vet1177.exception.ResourceNotFoundException; import org.example.vet1177.policy.MedicalRecordPolicy; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.security.SecurityConfig; import org.example.vet1177.services.MedicalRecordService; import org.example.vet1177.services.UserService; @@ -59,6 +61,12 @@ class MedicalRecordControllerTest { @MockitoBean private UserService userService; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + private User vetUser; private User ownerUser; private Pet pet; diff --git a/src/test/java/org/example/vet1177/controller/PetControllerTest.java b/src/test/java/org/example/vet1177/controller/PetControllerTest.java new file mode 100644 index 00000000..32ae72b6 --- /dev/null +++ b/src/test/java/org/example/vet1177/controller/PetControllerTest.java @@ -0,0 +1,285 @@ +package org.example.vet1177.controller; + +//Används i commentControllerTest också, verkar funka där? +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; +import org.springframework.test.context.ActiveProfiles; +import tools.jackson.databind.ObjectMapper; +import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.dto.request.pet.PetRequest; +import org.example.vet1177.entities.Pet; +import org.example.vet1177.entities.Role; +import org.example.vet1177.entities.User; +import org.example.vet1177.exception.ForbiddenException; +import org.example.vet1177.security.SecurityConfig; +import org.example.vet1177.services.PetService; +import org.example.vet1177.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(PetController.class) +@Import(SecurityConfig.class) +@ActiveProfiles("test") +public class PetControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockitoBean + private PetService petService; + @MockitoBean + private UserService userService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + + @MockitoBean + private JwtService jwtService; + + private User owner; + private User vet; + private Pet pet; + private UUID ownerId; + private UUID petId; + + + + @BeforeEach + void setUp() throws Exception { + ownerId = UUID.randomUUID(); + petId = UUID.randomUUID(); + owner = new User("Anna Ägare", "anna@mail.se", "hash", Role.OWNER); + vet = new User("Dr. Erik Vet", "erik@vet.se", "hash", Role.VET); + pet = new Pet(owner, "Molly", "Hund", "Labrador", LocalDate.of(2020, 1, 1), new BigDecimal("12.50")); + } + + private PetRequest validPetRequest() { + PetRequest request = new PetRequest(); + request.setName("Molly"); + request.setSpecies("Hund"); + request.setBreed("Labrador"); + request.setDateOfBirth(LocalDate.of(2020, 1, 1)); + request.setWeightKg(new BigDecimal("12.50")); + return request; + } + + private RequestPostProcessor authenticatedAs(User user) { + return authentication(new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + )); + } + + //POST /pets + @Test + void createPet_shouldReturn200WithPetResponse() throws Exception { + when(petService.createPet(any(), any(), any())).thenReturn(pet); + + mockMvc.perform(post("/pets") + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPetRequest()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Molly")); + } + + @Test + void createPet_whenInvalidRequest_shouldReturn400() throws Exception { + PetRequest request = validPetRequest(); + request.setName(""); + + mockMvc.perform(post("/pets") + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void createPet_whenForbidden_shouldReturn403() throws Exception { + when(petService.createPet(any(), any(), any())) + .thenThrow(new ForbiddenException("Du kan inte lägga till ett djur")); + + mockMvc.perform(post("/pets") + .with(authenticatedAs(vet)) + .header("currentUserId", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPetRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + void createPet_whenUnexpectedError_shouldReturn500() throws Exception { + when(petService.createPet(any(), any(), any())) + .thenThrow(new RuntimeException("Unexpected failure")); + + mockMvc.perform(post("/pets") + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPetRequest()))) + .andExpect(status().isInternalServerError()); + } + // GET /pets/{petId} + + @Test + void getPetById_shouldReturn200WithPetResponse() throws Exception { + when(userService.getUserEntityById(any())).thenReturn(owner); + when(petService.getPetById(any(), any())).thenReturn(pet); + + mockMvc.perform(get("/pets/{petId}", petId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Molly")); + } + + @Test + void getPetById_whenNotFound_shouldReturn404() throws Exception { + when(userService.getUserEntityById(any())).thenReturn(owner); + when(petService.getPetById(any(), any())) + .thenThrow(new ResourceNotFoundException("Pet", petId)); + + mockMvc.perform(get("/pets/{petId}", petId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId)) + .andExpect(status().isNotFound()); + } + + @Test + void getPetById_whenForbidden_shouldReturn403() throws Exception { + when(userService.getUserEntityById(any())).thenReturn(vet); + when(petService.getPetById(any(), any())) + .thenThrow(new ForbiddenException("Du har inte behörighet att se detta djur")); + + mockMvc.perform(get("/pets/{petId}", petId) + .with(authenticatedAs(vet)) + .header("currentUserId", UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + // GET /pets/owner/{ownerId} + + @Test + void getPetsByOwner_shouldReturn200WithList() throws Exception { + when(petService.getPetsByOwner(any(), any())).thenReturn(List.of(pet)); + + mockMvc.perform(get("/pets/owner/{ownerId}", ownerId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Molly")); + } + + @Test + void getPetsByOwner_whenForbidden_shouldReturn403() throws Exception { + when(petService.getPetsByOwner(any(), any())) + .thenThrow(new ForbiddenException("Du saknar behörighet")); + + mockMvc.perform(get("/pets/owner/{ownerId}", ownerId) + .with(authenticatedAs(vet)) + .header("currentUserId", UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + // PUT /pets/{petId} + + @Test + void updatePet_shouldReturn200WithUpdatedPetResponse() throws Exception { + Pet updated = new Pet(owner, "Harry", "Katt", "Siamese", LocalDate.of(2021, 3, 5), new BigDecimal("5.00")); + when(petService.updatePet(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(put("/pets/{petId}", petId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPetRequest()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Harry")); + } + + @Test + void updatePet_whenNotFound_shouldReturn404() throws Exception { + when(petService.updatePet(any(), any(), any())) + .thenThrow(new ResourceNotFoundException("Pet", petId)); + + mockMvc.perform(put("/pets/{petId}", petId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPetRequest()))) + .andExpect(status().isNotFound()); + } + + @Test + void updatePet_whenForbidden_shouldReturn403() throws Exception { + when(petService.updatePet(any(), any(), any())) + .thenThrow(new ForbiddenException("Du saknar behörighet")); + + mockMvc.perform(put("/pets/{petId}", petId) + .with(authenticatedAs(vet)) + .header("currentUserId", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validPetRequest()))) + .andExpect(status().isForbidden()); + } + + //DELETE /pets/{petId} + + + @Test + void deletePet_shouldReturn204() throws Exception { + when(userService.getUserEntityById(any())).thenReturn(owner); + doNothing().when(petService).deletePet(any(), any()); + + mockMvc.perform(delete("/pets/{petId}", petId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId)) + .andExpect(status().isNoContent()); + } + + @Test + void deletePet_whenNotFound_shouldReturn404() throws Exception { + when(userService.getUserEntityById(any())).thenReturn(owner); + doThrow(new ResourceNotFoundException("Pet", petId)) + .when(petService).deletePet(any(), any()); + + mockMvc.perform(delete("/pets/{petId}", petId) + .with(authenticatedAs(owner)) + .header("currentUserId", ownerId)) + .andExpect(status().isNotFound()); + } + + @Test + void deletePet_whenForbidden_shouldReturn403() throws Exception { + when(userService.getUserEntityById(any())).thenReturn(vet); + doThrow(new ForbiddenException("Du har inte behörighet att radera detta djur")) + .when(petService).deletePet(any(), any()); + + mockMvc.perform(delete("/pets/{petId}", petId) + .with(authenticatedAs(vet)) + .header("currentUserId", UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + +} diff --git a/src/test/java/org/example/vet1177/controller/UserControllerTest.java b/src/test/java/org/example/vet1177/controller/UserControllerTest.java new file mode 100644 index 00000000..c63cbb94 --- /dev/null +++ b/src/test/java/org/example/vet1177/controller/UserControllerTest.java @@ -0,0 +1,245 @@ +package org.example.vet1177.controller; + +import org.example.vet1177.dto.request.user.UserUpdateRequest; +import org.example.vet1177.exception.BusinessRuleException; +import org.springframework.http.MediaType; +import tools.jackson.databind.ObjectMapper; +import org.example.vet1177.dto.request.user.UserRequest; + +import org.example.vet1177.dto.response.user.UserResponse; +import org.example.vet1177.entities.Role; +import org.example.vet1177.entities.User; + +import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.security.SecurityConfig; +import org.example.vet1177.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(UserController.class) +@Import(SecurityConfig.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserService userService; + + @MockitoBean + private org.example.vet1177.security.JwtService jwtService; + + @MockitoBean + private org.example.vet1177.security.CustomUserDetailsService customUserDetailsService; + + private User currentUser; + private UserResponse userResponse; + private UUID userId; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + currentUser = new User("Anna Karlsson", "anna@example.se", "hash", Role.ADMIN); + userResponse = new UserResponse(userId, "Anna Karlsson", "anna@example.se", Role.OWNER, null, null, null); + } + + private RequestPostProcessor authenticatedAs(User user) { + return authentication(new UsernamePasswordAuthenticationToken( + user, null, user.getAuthorities() + )); + } + + private UserRequest validUserRequest() { + UserRequest request = new UserRequest(); + request.setName("Anna Karlsson"); + request.setEmail("anna@example.se"); + request.setPassword("lösenord123"); + request.setRole(Role.OWNER); + return request; + } + + + // GET /api/users + + @Test + void getAllUsers_shouldReturn200WithList() throws Exception { + when(userService.getAllUsers()).thenReturn(List.of(userResponse)); + + mockMvc.perform(get("/api/users") + .with(authenticatedAs(currentUser))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Anna Karlsson")); + } + + @Test + void getAllUsers_whenEmpty_shouldReturnEmptyList() throws Exception { + when(userService.getAllUsers()).thenReturn(List.of()); + + mockMvc.perform(get("/api/users") + .with(authenticatedAs(currentUser))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + // GET /api/users/{id} + + @Test + void getUserById_shouldReturn200WithUserResponse() throws Exception { + when(userService.getById(userId)).thenReturn(userResponse); + + mockMvc.perform(get("/api/users/{id}", userId) + .with(authenticatedAs(currentUser))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Anna Karlsson")); + } + + @Test + void getUserById_whenNotFound_shouldReturn404() throws Exception { + when(userService.getById(any())) + .thenThrow(new ResourceNotFoundException("User", userId)); + + mockMvc.perform(get("/api/users/{id}", userId) + .with(authenticatedAs(currentUser))) + .andExpect(status().isNotFound()); + } + + // POST /api/users + @Test + void createUser_shouldReturn201WithUserResponse() throws Exception { + when(userService.createUser(any())).thenReturn(userResponse); + + mockMvc.perform(post("/api/users") + .with(authenticatedAs(currentUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validUserRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Anna Karlsson")); + } + + @Test + void createUser_whenInvalidRequest_shouldReturn400() throws Exception { + UserRequest request = validUserRequest(); + request.setEmail("inte-en-email"); + + mockMvc.perform(post("/api/users") + .with(authenticatedAs(currentUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + verify(userService, never()).createUser(any()); + } + + @Test + void createUser_whenEmailAlreadyTaken_shouldReturn400() throws Exception { + when(userService.createUser(any())) + .thenThrow(new BusinessRuleException("Email används redan")); + + mockMvc.perform(post("/api/users") + .with(authenticatedAs(currentUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validUserRequest()))) + .andExpect(status().isBadRequest()); + } + + + // PUT /api/users/{id} + + + @Test + void updateUser_shouldReturn200WithUpdatedUserResponse() throws Exception { + UserResponse updated = new UserResponse(userId, "Uppdaterad Namn", "anna@example.se", Role.OWNER, null, null, null); + when(userService.updateUser(eq(userId), any())).thenReturn(updated); + + UserUpdateRequest request = new UserUpdateRequest(); + request.setName("Uppdaterad Namn"); + + mockMvc.perform(put("/api/users/{id}", userId) + .with(authenticatedAs(currentUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Uppdaterad Namn")); + } + + @Test + void updateUser_whenNotFound_shouldReturn404() throws Exception { + when(userService.updateUser(any(), any())) + .thenThrow(new ResourceNotFoundException("User", userId)); + + UserUpdateRequest request = new UserUpdateRequest(); + request.setName("Uppdaterad Namn"); + + mockMvc.perform(put("/api/users/{id}", userId) + .with(authenticatedAs(currentUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } + + @Test + void updateUser_whenBusinessRuleViolation_shouldReturn400() throws Exception { + when(userService.updateUser(any(), any())) + .thenThrow(new BusinessRuleException("Email används redan")); + + UserUpdateRequest request = new UserUpdateRequest(); + request.setEmail("tagen@example.se"); + + mockMvc.perform(put("/api/users/{id}", userId) + .with(authenticatedAs(currentUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + // DELETE /api/users/{id} + + @Test + void deleteUser_shouldReturn204() throws Exception { + doNothing().when(userService).deleteUser(userId); + + mockMvc.perform(delete("/api/users/{id}", userId) + .with(authenticatedAs(currentUser))) + .andExpect(status().isNoContent()); + } + + @Test + void deleteUser_whenNotFound_shouldReturn404() throws Exception { + doThrow(new ResourceNotFoundException("User", userId)) + .when(userService).deleteUser(any()); + + mockMvc.perform(delete("/api/users/{id}", userId) + .with(authenticatedAs(currentUser))) + .andExpect(status().isNotFound()); + } + + @Test + void deleteUser_whenHasLinkedResources_shouldReturn400() throws Exception { + doThrow(new BusinessRuleException("Användaren har kopplade djur och kan inte raderas")) + .when(userService).deleteUser(any()); + + mockMvc.perform(delete("/api/users/{id}", userId) + .with(authenticatedAs(currentUser))) + .andExpect(status().isBadRequest()); + } +} + diff --git a/src/test/java/org/example/vet1177/controller/VetControllerTest.java b/src/test/java/org/example/vet1177/controller/VetControllerTest.java index 2ffaa8bc..dc1387ce 100644 --- a/src/test/java/org/example/vet1177/controller/VetControllerTest.java +++ b/src/test/java/org/example/vet1177/controller/VetControllerTest.java @@ -6,6 +6,8 @@ import org.example.vet1177.entities.User; import org.example.vet1177.exception.ForbiddenException; import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.security.CustomUserDetailsService; +import org.example.vet1177.security.JwtService; import org.example.vet1177.policy.AdminPolicy; import org.example.vet1177.services.VetService; import org.junit.jupiter.api.Test; @@ -39,6 +41,12 @@ class VetControllerTest { @MockitoBean private AdminPolicy adminPolicy; + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomUserDetailsService customUserDetailsService; + // ========================= // POST /api/vets // ========================= diff --git a/src/test/java/org/example/vet1177/entities/UserEntityTest.java b/src/test/java/org/example/vet1177/entities/UserEntityTest.java new file mode 100644 index 00000000..81b41442 --- /dev/null +++ b/src/test/java/org/example/vet1177/entities/UserEntityTest.java @@ -0,0 +1,227 @@ +package org.example.vet1177.entities; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class UserEntityTest { + private User user; + + @BeforeEach + void setUp() { + user = new User("Frida Svensson", "Frida@example.se", "lösenord!", Role.OWNER); + } + + //kontrollerar att fält är null (eller true för isActive) + + @Test + void getId_shouldBeNullBeforePersist() { + assertThat(user.getId()).isNull(); + } + + @Test + void isActive_shouldBeTrueByDefault() { + assertThat(user.isActive()).isTrue(); + } + + @Test + void getClinic_shouldBeNullWhenNotSet() { + assertThat(user.getClinic()).isNull(); + } + + @Test + void getCreatedAt_shouldBeNullBeforeOnCreate() { + assertThat(user.getCreatedAt()).isNull(); + } + + @Test + void getUpdatedAt_shouldBeNullBeforeOnCreate() { + assertThat(user.getUpdatedAt()).isNull(); + } + + //livscykel: onCreate + @Test + void onCreate_shouldSetBothTimestampsToNonNull() { + user.onCreate(); + + assertThat(user.getCreatedAt()).isNotNull(); + assertThat(user.getUpdatedAt()).isNotNull(); + } + + @Test + void onCreate_shouldSetTimestampsCloseToNow() { + Instant before = Instant.now(); + user.onCreate(); + Instant after = Instant.now(); + + assertThat(user.getCreatedAt()).isBetween(before, after); + assertThat(user.getUpdatedAt()).isBetween(before, after); + } + + //livscykel på onUpdate + @Test + void onUpdate_shouldRefreshUpdatedAt() { + user.onCreate(); + Instant beforeUpdate = user.getUpdatedAt(); + + user.onUpdate(); + + assertThat(user.getUpdatedAt()).isNotNull().isAfterOrEqualTo(beforeUpdate); + } + + @Test + void onUpdate_shouldNotModifyCreatedAt() { + user.onCreate(); + Instant originalCreatedAt = user.getCreatedAt(); + + user.onUpdate(); + + assertThat(user.getCreatedAt()).isEqualTo(originalCreatedAt); + } + //UserDetails + + @Test + void getUsername_shouldReturnEmail() { + assertThat(user.getUsername()).isEqualTo("Frida@example.se"); + } + + @Test + void getPassword_shouldReturnPasswordHash() { + assertThat(user.getPassword()).isEqualTo("lösenord!"); + } + + @Test + void isEnabled_shouldReturnTrueWhenActive() { + assertThat(user.isEnabled()).isTrue(); + } + + @Test + void isEnabled_shouldReturnFalseWhenInactive() { + user.setActive(false); + + assertThat(user.isEnabled()).isFalse(); + } + + @Test + void isAccountNonLocked_shouldReturnTrueWhenActive() { + assertThat(user.isAccountNonLocked()).isTrue(); + } + + @Test + void isAccountNonLocked_shouldReturnFalseWhenInactive() { + user.setActive(false); + + assertThat(user.isAccountNonLocked()).isFalse(); + } + + //GetAuthorties + @Test + void getAuthorities_ownerRole_shouldReturnRoleOwner() { + User owner = new User("Anna", "anna@mail.se", "hash", Role.OWNER); + Collection authorities = owner.getAuthorities(); + + assertThat(new ArrayList<>(authorities)) + .extracting(a -> a.getAuthority()) + .containsExactly("ROLE_OWNER"); + } + + @Test + void getAuthorities_vetRole_shouldReturnRoleVet() { + User vet = new User("Dr. Erik", "erik@vet.se", "hash", Role.VET); + Collection authorities = vet.getAuthorities(); + + assertThat(new ArrayList<>(authorities)) + .extracting(a -> a.getAuthority()) + .containsExactly("ROLE_VET"); + } + + @Test + void getAuthorities_adminRole_shouldReturnRoleAdmin() { + User admin = new User("Admin", "admin@vet.se", "hash", Role.ADMIN); + Collection authorities = admin.getAuthorities(); + + assertThat(new ArrayList<>(authorities)) + .extracting(a -> a.getAuthority()) + .containsExactly("ROLE_ADMIN"); + } + + //addAttachment + + @Test + void addAttachment_shouldAddToList() { + Attachment attachment = new Attachment(); + + user.addAttachment(attachment); + + assertThat(user.getUploadedAttachments()).contains(attachment); + } + + @Test + void addAttachment_shouldSetUploadedByOnAttachment() { + Attachment attachment = new Attachment(); + + user.addAttachment(attachment); + + assertThat(attachment.getUploadedBy()).isSameAs(user); + } + + @Test + void addAttachment_nullAttachment_shouldNotAddToList() { + user.addAttachment(null); + + assertThat(user.getUploadedAttachments()).isEmpty(); + } + + // removeAttachment + + @Test + void removeAttachment_shouldRemoveFromList() { + Attachment attachment = new Attachment(); + user.addAttachment(attachment); + + user.removeAttachment(attachment); + + assertThat(user.getUploadedAttachments()).doesNotContain(attachment); + } + + @Test + void removeAttachment_shouldSetUploadedByToNull() { + Attachment attachment = new Attachment(); + user.addAttachment(attachment); + + user.removeAttachment(attachment); + + assertThat(attachment.getUploadedBy()).isNull(); + } + + @Test + void removeAttachment_nullAttachment_shouldNotThrow() { + user.addAttachment(new Attachment()); + + user.removeAttachment(null); + + assertThat(user.getUploadedAttachments()).hasSize(1); + } + // getUploadedAttachments + + @Test + void getUploadedAttachments_shouldReturnUnmodifiableList() { + Attachment attachment = new Attachment(); + user.addAttachment(attachment); + + assertThatThrownBy(() -> user.getUploadedAttachments().add(new Attachment())) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void getUploadedAttachments_shouldBeEmptyByDefault() { + assertThat(user.getUploadedAttachments()).isEmpty(); + } +} diff --git a/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java b/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java index 385562fd..7eeb8858 100644 --- a/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java +++ b/src/test/java/org/example/vet1177/integration/activitylog/ActivityLogIntegrationTest.java @@ -1,6 +1,5 @@ package org.example.vet1177.integration.activitylog; -import org.checkerframework.checker.units.qual.C; import org.example.vet1177.config.AwsS3Properties; import org.example.vet1177.entities.*; import org.example.vet1177.integration.TestDataFactory; @@ -10,15 +9,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.math.BigDecimal; -import java.time.LocalDate; import java.util.UUID; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -137,7 +136,9 @@ void should_return_logs_for_owner_only() throws Exception { // Act & Assert mockMvc.perform(get("/api/activity-logs/record/" + record.getId()) - .header("userId", owner.getId().toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + owner, null, owner.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(2)); } @@ -166,7 +167,9 @@ void should_allow_vet_in_same_clinic_to_see_logs() throws Exception { ActivityType.CASE_CREATED, "log"); mockMvc.perform(get("/api/activity-logs/record/" + record.getId()) - .header("userId", vet.getId().toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + vet, null, vet.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(1)); } @@ -197,15 +200,11 @@ void should_filter_out_logs_for_vet_in_other_clinic() throws Exception { ActivityType.CASE_CREATED, "log"); mockMvc.perform(get("/api/activity-logs/record/" + record.getId()) - .header("userId", vetOtherClinic.getId().toString())) + .with(authentication(new UsernamePasswordAuthenticationToken( + vetOtherClinic, null, vetOtherClinic.getAuthorities() + )))) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(0)); } - @Test - void should_return_400_if_userId_missing() throws Exception { - - mockMvc.perform(get("/api/activity-logs/record/" + UUID.randomUUID())) - .andExpect(status().isBadRequest()); - } } diff --git a/src/test/java/org/example/vet1177/integration/vet/VetIntegrationTest.java b/src/test/java/org/example/vet1177/integration/vet/VetIntegrationTest.java new file mode 100644 index 00000000..840c464f --- /dev/null +++ b/src/test/java/org/example/vet1177/integration/vet/VetIntegrationTest.java @@ -0,0 +1,313 @@ +package org.example.vet1177.integration.vet; + +import org.example.vet1177.config.AwsS3Properties; +import org.example.vet1177.entities.Clinic; +import org.example.vet1177.entities.Role; +import org.example.vet1177.entities.User; +import org.example.vet1177.entities.Vet; +import org.example.vet1177.exception.ForbiddenException; +import org.example.vet1177.integration.TestDataFactory; +import org.example.vet1177.policy.AdminPolicy; +import org.example.vet1177.repository.ClinicRepository; +import org.example.vet1177.repository.UserRepository; +import org.example.vet1177.repository.VetRepository; +import org.example.vet1177.services.FileStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL", + "spring.datasource.driver-class-name=org.h2.Driver" +}) +class VetIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private VetRepository vetRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ClinicRepository clinicRepository; + + @MockitoBean + private FileStorageService fileStorageService; + + @MockitoBean + private AwsS3Properties awsS3Properties; + + @MockitoBean + private AdminPolicy adminPolicy; + + @BeforeEach + void setUp() { + vetRepository.deleteAll(); + userRepository.deleteAll(); + clinicRepository.deleteAll(); + } + + @BeforeEach + void resetMocks() { + reset(adminPolicy); + } + + private User createAdmin(Clinic clinic) { + User admin = new User( + "Admin", + UUID.randomUUID() + "@test.com", + "password123", + Role.ADMIN, + clinic + ); + return userRepository.save(admin); + } + + private User createVetUser(Clinic clinic) { + User vetUser = new User( + "Vet User", + UUID.randomUUID() + "@test.com", + "password123", + Role.VET, + clinic + ); + return userRepository.save(vetUser); + } + + private UsernamePasswordAuthenticationToken auth(User user) { + return new UsernamePasswordAuthenticationToken(user, null, List.of()); + } + + @Test + void VT_P0_01_admin_can_create_vet() throws Exception { + doNothing().when(adminPolicy).requireAdmin(any()); + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User admin = createAdmin(clinic); + User targetUser = TestDataFactory.createOwner(userRepository, clinic); + + String body = """ + { + "userId": "%s", + "licenseId": "LIC-1001", + "specialization": "Surgery", + "bookingInfo": "Weekdays only" + } + """.formatted(targetUser.getId()); + + mockMvc.perform(post("/api/vets") + .with(authentication(auth(admin))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userId").value(targetUser.getId().toString())) + .andExpect(jsonPath("$.licenseId").value("LIC-1001")) + .andExpect(jsonPath("$.specialization").value("Surgery")) + .andExpect(jsonPath("$.bookingInfo").value("Weekdays only")); + + assertEquals(1, vetRepository.count()); + + User updatedUser = userRepository.findById(targetUser.getId()).orElseThrow(); + assertEquals(Role.VET, updatedUser.getRole()); + + verify(adminPolicy).requireAdmin(admin); + } + @Test + void VT_P0_02_non_admin_cannot_create_vet_as_vet() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User nonAdminVet = createVetUser(clinic); + User targetUser = TestDataFactory.createOwner(userRepository, clinic); + + doThrow(new ForbiddenException("Åtkomst nekad: Endast administratörer har behörighet")) + .when(adminPolicy).requireAdmin(any()); + + String body = """ + { + "userId": "%s", + "licenseId": "LIC-1003", + "specialization": "Cardiology", + "bookingInfo": "Tue-Thu" + } + """.formatted(targetUser.getId()); + + mockMvc.perform(post("/api/vets") + .with(authentication(auth(nonAdminVet))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()); + + verify(adminPolicy, times(1)).requireAdmin(any()); + assertEquals(0, vetRepository.count()); + } + + @Test + void VT_P0_03_duplicate_license_id_rejected() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User admin = createAdmin(clinic); + + User firstUser = TestDataFactory.createOwner(userRepository, clinic); + User secondUser = TestDataFactory.createOwner(userRepository, clinic); + + Vet existingVet = new Vet(firstUser, "LIC-DUP-1", "Surgery", "Morning"); + vetRepository.save(existingVet); + + String body = """ + { + "userId": "%s", + "licenseId": "LIC-DUP-1", + "specialization": "Dentistry", + "bookingInfo": "Afternoon" + } + """.formatted(secondUser.getId()); + + mockMvc.perform(post("/api/vets") + .with(authentication(auth(admin))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + + assertEquals(1, vetRepository.count()); + assertTrue(vetRepository.existsByLicenseId("LIC-DUP-1")); + } + + @Test + void VT_P0_04_missing_target_user_rejected() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User admin = createAdmin(clinic); + + String body = """ + { + "userId": "%s", + "licenseId": "LIC-404", + "specialization": "Neurology", + "bookingInfo": "By appointment" + } + """.formatted(UUID.randomUUID()); + + mockMvc.perform(post("/api/vets") + .with(authentication(auth(admin))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()); + + assertEquals(0, vetRepository.count()); + } + + @Test + void VT_P0_05_get_all_vets_includes_clinic_name() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User user = createVetUser(clinic); + + Vet vet = new Vet(user, "LIC-GET-ALL", "Orthopedics", "Mon-Wed"); + vetRepository.save(vet); + + mockMvc.perform(get("/api/vets") + .with(authentication(auth(user)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId").value(user.getId().toString())) + .andExpect(jsonPath("$[0].licenseId").value("LIC-GET-ALL")) + .andExpect(jsonPath("$[0].clinicName").value(clinic.getName())); + } + + @Test + void VT_P0_06_get_vet_by_id_returns_expected_vet() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User user = createVetUser(clinic); + + Vet vet = new Vet(user, "LIC-BY-ID", "Internal medicine", "Fri"); + vetRepository.save(vet); + + mockMvc.perform(get("/api/vets/" + user.getId()) + .with(authentication(auth(user)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(user.getId().toString())) + .andExpect(jsonPath("$.licenseId").value("LIC-BY-ID")) + .andExpect(jsonPath("$.clinicName").value(clinic.getName())); + } + + @Test + void VT_P0_06_get_vet_by_id_returns_404_for_unknown_id() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User user = createVetUser(clinic); + + mockMvc.perform(get("/api/vets/" + UUID.randomUUID()) + .with(authentication(auth(user)))) + .andExpect(status().isNotFound()); + } + + @Test + void VT_P1_01_validation_error_for_invalid_vet_request() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User admin = createAdmin(clinic); + + String body = """ + { + "userId": null, + "licenseId": "", + "specialization": "Valid specialization", + "bookingInfo": "Valid booking info" + } + """; + + mockMvc.perform(post("/api/vets") + .with(authentication(auth(admin))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + + assertEquals(0, vetRepository.count()); + } + + @Test + void VT_P1_02_existing_vet_role_remains_stable() throws Exception { + Clinic clinic = TestDataFactory.createClinic(clinicRepository); + User admin = createAdmin(clinic); + User targetUser = createVetUser(clinic); // redan VET, men ännu ingen vet_details-rad + + String body = """ + { + "userId": "%s", + "licenseId": "LIC-STABLE", + "specialization": "Exotics", + "bookingInfo": "Weekends" + } + """.formatted(targetUser.getId()); + + mockMvc.perform(post("/api/vets") + .with(authentication(auth(admin))) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userId").value(targetUser.getId().toString())) + .andExpect(jsonPath("$.licenseId").value("LIC-STABLE")); + + User reloadedUser = userRepository.findById(targetUser.getId()).orElseThrow(); + assertEquals(Role.VET, reloadedUser.getRole()); + assertEquals(1, vetRepository.count()); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/vet1177/services/UserServiceTest.java b/src/test/java/org/example/vet1177/services/UserServiceTest.java new file mode 100644 index 00000000..46a64088 --- /dev/null +++ b/src/test/java/org/example/vet1177/services/UserServiceTest.java @@ -0,0 +1,492 @@ +package org.example.vet1177.services; + +import org.example.vet1177.dto.request.user.UserRequest; +import org.example.vet1177.dto.request.user.UserUpdateRequest; +import org.example.vet1177.dto.response.user.UserResponse; +import org.example.vet1177.entities.Clinic; +import org.example.vet1177.entities.Role; +import org.example.vet1177.entities.User; +import org.example.vet1177.exception.BusinessRuleException; +import org.example.vet1177.exception.ResourceNotFoundException; +import org.example.vet1177.repository.ClinicRepository; +import org.example.vet1177.repository.MedicalRecordRepository; +import org.example.vet1177.repository.PetRepository; +import org.example.vet1177.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private ClinicRepository clinicRepository; + + @Mock + private PetRepository petRepository; + + @Mock + private MedicalRecordRepository medicalRecordRepository; + + @InjectMocks + private UserService userService; + + private UUID userId; + private UUID clinicId; + private User ownerUser; + private User vetUser; + private User adminUser; + private Clinic clinic; + + @BeforeEach + void setUp() throws Exception { + userId = UUID.randomUUID(); + clinicId = UUID.randomUUID(); + + clinic = new Clinic("Djurkliniken", "Storgatan 1", "+4670123456"); + setPrivateField(clinic, "id", clinicId); + + ownerUser = new User("Anna Ägare", "anna@example.se", "hashedPw", Role.OWNER); + setPrivateField(ownerUser, "id", userId); + + vetUser = new User("Dr. Vet", "vet@klinik.se", "hashedPw", Role.VET, clinic); + setPrivateField(vetUser, "id", UUID.randomUUID()); + + adminUser = new User("Admin Adminsson", "admin@example.se", "hashedPw", Role.ADMIN); + setPrivateField(adminUser, "id", UUID.randomUUID()); + } + + // createUser + // ───────────────────────────────────────────────────────────────────────── + + @Test + void createUser_owner_withoutClinic_returnsResponse() { + UserRequest request = ownerRequest("anna@example.se", null); + + when(userRepository.existsByEmail("anna@example.se")).thenReturn(false); + when(userRepository.save(any(User.class))).thenReturn(ownerUser); + + UserResponse response = userService.createUser(request); + + assertThat(response.getEmail()).isEqualTo("anna@example.se"); + assertThat(response.getRole()).isEqualTo(Role.OWNER); + assertThat(response.getClinicId()).isNull(); + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getPassword()) + .as("lösenordet ska vara hashat innan persist") + .isNotEqualTo(request.getPassword()); + } + + @Test + void createUser_vet_withClinic_returnsResponse() { + UserRequest request = vetRequest("vet@klinik.se", clinicId); + + when(userRepository.existsByEmail("vet@klinik.se")).thenReturn(false); + when(clinicRepository.findById(clinicId)).thenReturn(Optional.of(clinic)); + when(userRepository.save(any(User.class))).thenReturn(vetUser); + + UserResponse response = userService.createUser(request); + + assertThat(response.getRole()).isEqualTo(Role.VET); + assertThat(response.getClinicId()).isEqualTo(clinicId); + } + + @Test + void createUser_emailAlreadyExists_throwsBusinessRuleException() { + UserRequest request = ownerRequest("anna@example.se", null); + when(userRepository.existsByEmail("anna@example.se")).thenReturn(true); + + assertThatThrownBy(() -> userService.createUser(request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Email används redan"); + + verify(userRepository, never()).save(any()); + } + + @Test + void createUser_vet_withoutClinic_throwsBusinessRuleException() { + UserRequest request = vetRequest("vet@klinik.se", null); + when(userRepository.existsByEmail("vet@klinik.se")).thenReturn(false); + + assertThatThrownBy(() -> userService.createUser(request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Veterinär måste vara kopplad till en klinik"); + } + + @Test + void createUser_owner_withClinic_throwsBusinessRuleException() { + UserRequest request = ownerRequest("anna@example.se", clinicId); + when(userRepository.existsByEmail("anna@example.se")).thenReturn(false); + + assertThatThrownBy(() -> userService.createUser(request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Endast veterinärer kan kopplas till en klinik"); + } + + @Test + void createUser_vet_clinicNotFound_throwsResourceNotFoundException() { + UUID unknownClinicId = UUID.randomUUID(); + UserRequest request = vetRequest("vet@klinik.se", unknownClinicId); + + when(userRepository.existsByEmail("vet@klinik.se")).thenReturn(false); + when(clinicRepository.findById(unknownClinicId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.createUser(request)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void createUser_dataIntegrityViolation_throwsBusinessRuleException() { + UserRequest request = ownerRequest("anna@example.se", null); + + when(userRepository.existsByEmail("anna@example.se")).thenReturn(false); + when(userRepository.save(any(User.class))).thenThrow(new DataIntegrityViolationException("dup")); + + assertThatThrownBy(() -> userService.createUser(request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Email används redan"); + } + + //getByEmail + + + @Test + void getByEmail_existingEmail_returnsUser() { + when(userRepository.findByEmail("anna@example.se")).thenReturn(Optional.of(ownerUser)); + + User result = userService.getByEmail("anna@example.se"); + + assertThat(result).isEqualTo(ownerUser); + } + + @Test + void getByEmail_unknownEmail_throwsResourceNotFoundException() { + when(userRepository.findByEmail("okänd@example.se")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getByEmail("okänd@example.se")) + .isInstanceOf(ResourceNotFoundException.class); + } + + + // getUserEntityById + + @Test + void getUserEntityById_existingId_returnsUserEntity() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + + User result = userService.getUserEntityById(userId); + + assertThat(result).isEqualTo(ownerUser); + } + + @Test + void getUserEntityById_unknownId_throwsResourceNotFoundException() { + UUID unknownId = UUID.randomUUID(); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserEntityById(unknownId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + + // getById + + @Test + void getById_existingId_returnsUserResponse() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + + UserResponse response = userService.getById(userId); + + assertThat(response.getId()).isEqualTo(ownerUser.getId()); + assertThat(response.getEmail()).isEqualTo("anna@example.se"); + assertThat(response.getRole()).isEqualTo(Role.OWNER); + } + + @Test + void getById_unknownId_throwsResourceNotFoundException() { + UUID unknownId = UUID.randomUUID(); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getById(unknownId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // getAllUsers + + @Test + void getAllUsers_returnsAllMappedResponses() { + when(userRepository.findAll()).thenReturn(List.of(ownerUser, vetUser, adminUser)); + + List responses = userService.getAllUsers(); + + assertThat(responses).hasSize(3); + assertThat(responses).extracting(UserResponse::getRole) + .containsExactlyInAnyOrder(Role.OWNER, Role.VET, Role.ADMIN); + } + + @Test + void getAllUsers_emptyRepo_returnsEmptyList() { + when(userRepository.findAll()).thenReturn(List.of()); + + List responses = userService.getAllUsers(); + + assertThat(responses).isEmpty(); + } + + + //updateUser + + @Test + void updateUser_changeName_updatesAndReturns() { + UserUpdateRequest request = new UserUpdateRequest(); + request.setName("Nytt Namn"); + + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(userRepository.save(ownerUser)).thenReturn(ownerUser); + + UserResponse response = userService.updateUser(userId, request); + + assertThat(ownerUser.getName()).isEqualTo("Nytt Namn"); + verify(userRepository).save(ownerUser); + } + + @Test + void updateUser_changeEmail_uniqueEmail_updatesAndReturns() { + UserUpdateRequest request = new UserUpdateRequest(); + request.setEmail("ny@example.se"); + + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(userRepository.existsByEmailAndIdNot("ny@example.se", userId)).thenReturn(false); + when(userRepository.save(ownerUser)).thenReturn(ownerUser); + + userService.updateUser(userId, request); + + assertThat(ownerUser.getEmail()).isEqualTo("ny@example.se"); + } + + @Test + void updateUser_changeEmail_duplicateEmail_throwsBusinessRuleException() { + UserUpdateRequest request = new UserUpdateRequest(); + request.setEmail("tagen@example.se"); + + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(userRepository.existsByEmailAndIdNot("tagen@example.se", userId)).thenReturn(true); + + assertThatThrownBy(() -> userService.updateUser(userId, request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Email används redan"); + + verify(userRepository, never()).save(any()); + } + + @Test + void updateUser_vet_changeClinic_updatesClinic() { + UUID newClinicId = UUID.randomUUID(); + Clinic newClinic = new Clinic("Ny Klinik", "Nya gatan 2", "+4670000000"); + + UserUpdateRequest request = new UserUpdateRequest(); + request.setClinicId(newClinicId); + + when(userRepository.findById(vetUser.getId())).thenReturn(Optional.of(vetUser)); + when(clinicRepository.findById(newClinicId)).thenReturn(Optional.of(newClinic)); + when(userRepository.save(vetUser)).thenReturn(vetUser); + + userService.updateUser(vetUser.getId(), request); + + assertThat(vetUser.getClinic()).isEqualTo(newClinic); + } + + @Test + void updateUser_owner_setClinic_throwsBusinessRuleException() { + UserUpdateRequest request = new UserUpdateRequest(); + request.setClinicId(clinicId); + + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + + assertThatThrownBy(() -> userService.updateUser(userId, request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Endast veterinärer kan kopplas till en klinik"); + } + + @Test + void updateUser_unknownId_throwsResourceNotFoundException() { + UUID unknownId = UUID.randomUUID(); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.updateUser(unknownId, new UserUpdateRequest())) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void updateUser_dataIntegrityViolation_throwsBusinessRuleException() { + UserUpdateRequest request = new UserUpdateRequest(); + request.setEmail("ny@example.se"); + + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(userRepository.existsByEmailAndIdNot("ny@example.se", userId)).thenReturn(false); + when(userRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); + + assertThatThrownBy(() -> userService.updateUser(userId, request)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("Email används redan"); + } + + //DeleteUser + + @Test + void deleteUser_noConstraints_deletesSuccessfully() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(false); + when(medicalRecordRepository.existsByOwnerId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByAssignedVetId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByCreatedById(userId)).thenReturn(false); + when(medicalRecordRepository.existsByUpdatedById(userId)).thenReturn(false); + + userService.deleteUser(userId); + + verify(userRepository).delete(ownerUser); + } + + @Test + void deleteUser_unknownId_throwsResourceNotFoundException() { + UUID unknownId = UUID.randomUUID(); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUser(unknownId)) + .isInstanceOf(ResourceNotFoundException.class); + + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUser_userHasPets_throwsBusinessRuleException() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(true); + + assertThatThrownBy(() -> userService.deleteUser(userId)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("kopplade djur"); + + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUser_userIsRecordOwner_throwsBusinessRuleException() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(false); + when(medicalRecordRepository.existsByOwnerId(userId)).thenReturn(true); + + assertThatThrownBy(() -> userService.deleteUser(userId)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("ägare på journalposter"); + + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUser_userIsAssignedVet_throwsBusinessRuleException() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(false); + when(medicalRecordRepository.existsByOwnerId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByAssignedVetId(userId)).thenReturn(true); + + assertThatThrownBy(() -> userService.deleteUser(userId)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("tilldelad veterinär"); + + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUser_userCreatedRecords_throwsBusinessRuleException() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(false); + when(medicalRecordRepository.existsByOwnerId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByAssignedVetId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByCreatedById(userId)).thenReturn(true); + + assertThatThrownBy(() -> userService.deleteUser(userId)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("skapat journalposter"); + + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUser_userUpdatedRecords_throwsBusinessRuleException() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(false); + when(medicalRecordRepository.existsByOwnerId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByAssignedVetId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByCreatedById(userId)).thenReturn(false); + when(medicalRecordRepository.existsByUpdatedById(userId)).thenReturn(true); + + assertThatThrownBy(() -> userService.deleteUser(userId)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("uppdaterat journalposter"); + + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUser_dataIntegrityViolation_throwsBusinessRuleException() { + when(userRepository.findById(userId)).thenReturn(Optional.of(ownerUser)); + when(petRepository.existsByOwner_Id(userId)).thenReturn(false); + when(medicalRecordRepository.existsByOwnerId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByAssignedVetId(userId)).thenReturn(false); + when(medicalRecordRepository.existsByCreatedById(userId)).thenReturn(false); + when(medicalRecordRepository.existsByUpdatedById(userId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("fk")).when(userRepository).delete(ownerUser); + + assertThatThrownBy(() -> userService.deleteUser(userId)) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("kopplade poster"); + } + + //Helper + private void setPrivateField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private UserRequest vetRequest(String email, UUID clinicId) { + UserRequest req = new UserRequest(); + req.setName("Dr. Vet"); + req.setEmail(email); + req.setPassword("veterinär123"); + req.setRole(Role.VET); + req.setClinicId(clinicId); + return req; + } + + private UserRequest ownerRequest(String email, UUID clinicId) { + UserRequest req = new UserRequest(); + req.setName("Anna Ägare"); + req.setEmail(email); + req.setPassword("lösenord123"); + req.setRole(Role.OWNER); + req.setClinicId(clinicId); + return req; + } + +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 88e85bcc..0fcfdc88 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,9 +1,8 @@ # ============================================ -# TEST-profil ? krs av @ActiveProfiles("test") -# Anvnds av integrationstester med @SpringBootTest + # ============================================ -# Separat testdatabas ? aldrig dev-databasen + spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa @@ -13,14 +12,14 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.datasource.hikari.driver-class-name=org.h2.Driver -# Skapa schema frn scratch och rensa efter tester + spring.jpa.hibernate.ddl-auto=create-drop # Hibernate creates schema automatically (schema.sql is NOT used in tests) spring.sql.init.mode=never spring.sql.init.data-locations= -# Stng av Docker Compose i tester + # CI hanterar databasen som service container spring.docker.compose.lifecycle-management=none @@ -31,8 +30,8 @@ aws.s3.secret-key=minioadmin aws.s3.bucket-name=vet1177-test-attachments aws.s3.region=eu-north-1 -# JWT ? dummyvrden fr tester -jwt.secret=test-secret-key-minst-32-tecken-lang-for-tester + +jwt.secret-key=test-secret-key-minst-32-tecken-lang-for-tester jwt.expiration-ms=86400000 # Mindre output i tester