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 extends GrantedAuthority> 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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