diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7b016a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java index 9f0e5c6..df868d9 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java @@ -15,6 +15,7 @@ public class SecurityConfig { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/hello").authenticated() + .requestMatchers("/api/apartments", "/api/apartments/**").authenticated() .anyRequest().permitAll() ) .oauth2ResourceServer(oauth2 -> oauth2 diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java new file mode 100644 index 0000000..6d34461 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java @@ -0,0 +1,33 @@ +package fr.nessar.lmnporganizer.backend.config; + +import fr.nessar.lmnporganizer.backend.model.Landlord; +import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class DataInitializer implements ApplicationRunner { + + private final LandlordRepository landlordRepository; + + public DataInitializer(LandlordRepository landlordRepository) { + this.landlordRepository = landlordRepository; + } + + @Override + public void run(ApplicationArguments args) throws Exception { + if (!landlordRepository.existsById("default-landlord")) { + Landlord defaultLandlord = new Landlord( + "default-landlord", + "Bailleur", + "LMNP", + "bailleur@example.com", + "0102030405", + "123 Rue de la Gestion, Paris" + ); + landlordRepository.save(defaultLandlord); + System.out.println("Default landlord profile initialized in database."); + } + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java new file mode 100644 index 0000000..425d38f --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApartmentController.java @@ -0,0 +1,66 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Apartment; +import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/apartments") +public class ApartmentController { + + private final ApartmentRepository apartmentRepository; + + public ApartmentController(ApartmentRepository apartmentRepository) { + this.apartmentRepository = apartmentRepository; + } + + @GetMapping + public List getAllApartments() { + return apartmentRepository.findAll(); + } + + @GetMapping("/{id}") + public ResponseEntity getApartmentById(@PathVariable String id) { + return apartmentRepository.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createApartment(@RequestBody Apartment apartment) { + apartment.setId(null); // Ensure a new entity is created + apartment.setBailleurId("default-landlord"); // Associate with default landlord profile + Apartment savedApartment = apartmentRepository.save(apartment); + return ResponseEntity.status(HttpStatus.CREATED).body(savedApartment); + } + + @PutMapping("/{id}") + public ResponseEntity updateApartment(@PathVariable String id, @RequestBody Apartment apartmentDetails) { + return apartmentRepository.findById(id) + .map(existingApartment -> { + existingApartment.setName(apartmentDetails.getName()); + existingApartment.setAddress(apartmentDetails.getAddress()); + existingApartment.setPurchasePrice(apartmentDetails.getPurchasePrice()); + existingApartment.setDescription(apartmentDetails.getDescription()); + // Keep bailleurId fixed to default-landlord in single-portfolio mode + existingApartment.setBailleurId("default-landlord"); + Apartment updatedApartment = apartmentRepository.save(existingApartment); + return ResponseEntity.ok(updatedApartment); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteApartment(@PathVariable String id) { + if (apartmentRepository.existsById(id)) { + apartmentRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } else { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Apartment.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Apartment.java new file mode 100644 index 0000000..ce97c4f --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Apartment.java @@ -0,0 +1,80 @@ +package fr.nessar.lmnporganizer.backend.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "apartments") +public class Apartment { + @Id + private String id; + + @Indexed + private String bailleurId; + + @Indexed + private String name; + + private String address; + private double purchasePrice; + private String description; + + public Apartment() {} + + public Apartment(String id, String bailleurId, String name, String address, double purchasePrice, String description) { + this.id = id; + this.bailleurId = bailleurId; + this.name = name; + this.address = address; + this.purchasePrice = purchasePrice; + this.description = description; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getBailleurId() { + return bailleurId; + } + + public void setBailleurId(String bailleurId) { + this.bailleurId = bailleurId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public double getPurchasePrice() { + return purchasePrice; + } + + public void setPurchasePrice(double purchasePrice) { + this.purchasePrice = purchasePrice; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java new file mode 100644 index 0000000..31f9365 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Landlord.java @@ -0,0 +1,74 @@ +package fr.nessar.lmnporganizer.backend.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "landlords") +public class Landlord { + @Id + private String id; + private String firstName; + private String lastName; + private String email; + private String phone; + private String address; + + public Landlord() {} + + public Landlord(String id, String firstName, String lastName, String email, String phone, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.phone = phone; + this.address = address; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/ApartmentRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/ApartmentRepository.java new file mode 100644 index 0000000..3a2c804 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/ApartmentRepository.java @@ -0,0 +1,11 @@ +package fr.nessar.lmnporganizer.backend.repository; + +import fr.nessar.lmnporganizer.backend.model.Apartment; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface ApartmentRepository extends MongoRepository { + List findByBailleurId(String bailleurId); +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java new file mode 100644 index 0000000..32ac41e --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LandlordRepository.java @@ -0,0 +1,9 @@ +package fr.nessar.lmnporganizer.backend.repository; + +import fr.nessar.lmnporganizer.backend.model.Landlord; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LandlordRepository extends MongoRepository { +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java new file mode 100644 index 0000000..d5a7bdd --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java @@ -0,0 +1,133 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Apartment; +import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class ApartmentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ApartmentRepository apartmentRepository; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @Test + void whenGetApartmentsWithoutToken_thenUnauthorized() throws Exception { + mockMvc.perform(get("/api/apartments")) + .andExpect(status().isUnauthorized()); + } + + @Test + void whenGetApartmentsWithToken_thenOk() throws Exception { + Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); + when(apartmentRepository.findAll()).thenReturn(List.of(apt)); + + mockMvc.perform(get("/api/apartments").with(jwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].name").value("Appartement 1")); + } + + @Test + void whenGetApartmentByIdNotFound_thenNotFound() throws Exception { + when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/apartments/999").with(jwt())) + .andExpect(status().isNotFound()); + } + + @Test + void whenGetApartmentByIdFound_thenOk() throws Exception { + Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); + when(apartmentRepository.findById("1")).thenReturn(Optional.of(apt)); + + mockMvc.perform(get("/api/apartments/1").with(jwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("1")); + } + + @Test + void whenCreateApartment_thenCreated() throws Exception { + Apartment savedApt = new Apartment("1", "default-landlord", "New Apartment", "Address", 200000.0, "Desc"); + when(apartmentRepository.save(any(Apartment.class))).thenReturn(savedApt); + + mockMvc.perform(post("/api/apartments") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"New Apartment\",\"address\":\"Address\",\"purchasePrice\":200000.0,\"description\":\"Desc\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("1")) + .andExpect(jsonPath("$.bailleurId").value("default-landlord")); + } + + @Test + void whenUpdateApartmentNotFound_thenNotFound() throws Exception { + when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); + + mockMvc.perform(put("/api/apartments/999") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) + .andExpect(status().isNotFound()); + } + + @Test + void whenUpdateApartmentFound_thenOk() throws Exception { + Apartment existing = new Apartment("1", "default-landlord", "Old Name", "Old Address", 100000.0, "Old Desc"); + Apartment updated = new Apartment("1", "default-landlord", "New Name", "New Address", 120000.0, "New Desc"); + + when(apartmentRepository.findById("1")).thenReturn(Optional.of(existing)); + when(apartmentRepository.save(any(Apartment.class))).thenReturn(updated); + + mockMvc.perform(put("/api/apartments/1") + .with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.purchasePrice").value(120000.0)); + } + + @Test + void whenDeleteApartmentNotFound_thenNotFound() throws Exception { + when(apartmentRepository.existsById("999")).thenReturn(false); + + mockMvc.perform(delete("/api/apartments/999").with(jwt())) + .andExpect(status().isNotFound()); + } + + @Test + void whenDeleteApartmentFound_thenNoContent() throws Exception { + when(apartmentRepository.existsById("1")).thenReturn(true); + doNothing().when(apartmentRepository).deleteById("1"); + + mockMvc.perform(delete("/api/apartments/1").with(jwt())) + .andExpect(status().isNoContent()); + + verify(apartmentRepository, times(1)).deleteById("1"); + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java new file mode 100644 index 0000000..f133aeb --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java @@ -0,0 +1,73 @@ +package fr.nessar.lmnporganizer.backend.model; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ModelCoverageTest { + + @Test + void testLandlordGettersSettersAndConstructors() { + Landlord landlord = new Landlord(); + assertNull(landlord.getId()); + assertNull(landlord.getFirstName()); + assertNull(landlord.getLastName()); + assertNull(landlord.getEmail()); + assertNull(landlord.getPhone()); + assertNull(landlord.getAddress()); + + landlord.setId("id"); + landlord.setFirstName("first"); + landlord.setLastName("last"); + landlord.setEmail("email"); + landlord.setPhone("phone"); + landlord.setAddress("address"); + + assertEquals("id", landlord.getId()); + assertEquals("first", landlord.getFirstName()); + assertEquals("last", landlord.getLastName()); + assertEquals("email", landlord.getEmail()); + assertEquals("phone", landlord.getPhone()); + assertEquals("address", landlord.getAddress()); + + Landlord fullLandlord = new Landlord("id2", "first2", "last2", "email2", "phone2", "address2"); + assertEquals("id2", fullLandlord.getId()); + assertEquals("first2", fullLandlord.getFirstName()); + assertEquals("last2", fullLandlord.getLastName()); + assertEquals("email2", fullLandlord.getEmail()); + assertEquals("phone2", fullLandlord.getPhone()); + assertEquals("address2", fullLandlord.getAddress()); + } + + @Test + void testApartmentGettersSettersAndConstructors() { + Apartment apt = new Apartment(); + assertNull(apt.getId()); + assertNull(apt.getBailleurId()); + assertNull(apt.getName()); + assertNull(apt.getAddress()); + assertEquals(0.0, apt.getPurchasePrice()); + assertNull(apt.getDescription()); + + apt.setId("id"); + apt.setBailleurId("bailleur"); + apt.setName("name"); + apt.setAddress("address"); + apt.setPurchasePrice(100.0); + apt.setDescription("desc"); + + assertEquals("id", apt.getId()); + assertEquals("bailleur", apt.getBailleurId()); + assertEquals("name", apt.getName()); + assertEquals("address", apt.getAddress()); + assertEquals(100.0, apt.getPurchasePrice()); + assertEquals("desc", apt.getDescription()); + + Apartment fullApt = new Apartment("id2", "bailleur2", "name2", "address2", 200.0, "desc2"); + assertEquals("id2", fullApt.getId()); + assertEquals("bailleur2", fullApt.getBailleurId()); + assertEquals("name2", fullApt.getName()); + assertEquals("address2", fullApt.getAddress()); + assertEquals(200.0, fullApt.getPurchasePrice()); + assertEquals("desc2", fullApt.getDescription()); + } +} diff --git a/frontend/angular.json b/frontend/angular.json index 1257af8..d5a441d 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -68,7 +68,14 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "coverageExclude": [ + "**/*.html", + "src/main.ts" + ] + } }, "lint": { "builder": "@angular-eslint/builder:lint", diff --git a/frontend/src/app/apartment.service.spec.ts b/frontend/src/app/apartment.service.spec.ts new file mode 100644 index 0000000..a9e47b7 --- /dev/null +++ b/frontend/src/app/apartment.service.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { ApartmentService, Apartment } from './apartment.service'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; + +describe('ApartmentService', () => { + let service: ApartmentService; + let mockHttpClient: any; + let mockOAuthService: any; + + beforeEach(() => { + mockHttpClient = { + get: vi.fn().mockReturnValue(of([])), + post: vi.fn().mockReturnValue(of({})), + put: vi.fn().mockReturnValue(of({})), + delete: vi.fn().mockReturnValue(of({})) + }; + + mockOAuthService = { + getAccessToken: () => 'mock-token' + }; + + TestBed.configureTestingModule({ + providers: [ + ApartmentService, + { provide: HttpClient, useValue: mockHttpClient }, + { provide: OAuthService, useValue: mockOAuthService } + ] + }); + + service = TestBed.inject(ApartmentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call GET /api/apartments', () => { + const mockApts: Apartment[] = [ + { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' } + ]; + mockHttpClient.get.mockReturnValue(of(mockApts)); + + service.getApartments().subscribe(data => { + expect(data).toEqual(mockApts); + }); + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/apartments', expect.any(Object)); + }); + + it('should call GET /api/apartments/:id', () => { + const mockApt: Apartment = { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }; + mockHttpClient.get.mockReturnValue(of(mockApt)); + + service.getApartment('1').subscribe(data => { + expect(data).toEqual(mockApt); + }); + expect(mockHttpClient.get).toHaveBeenCalledWith('/api/apartments/1', expect.any(Object)); + }); + + it('should call POST /api/apartments', () => { + const mockApt: Apartment = { name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }; + mockHttpClient.post.mockReturnValue(of({ id: '2', ...mockApt })); + + service.createApartment(mockApt).subscribe(data => { + expect(data.id).toBe('2'); + }); + expect(mockHttpClient.post).toHaveBeenCalledWith('/api/apartments', mockApt, expect.any(Object)); + }); + + it('should call PUT /api/apartments/:id', () => { + const mockApt: Apartment = { name: 'Updated Apt', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }; + mockHttpClient.put.mockReturnValue(of({ id: '1', ...mockApt })); + + service.updateApartment('1', mockApt).subscribe(data => { + expect(data.name).toBe('Updated Apt'); + }); + expect(mockHttpClient.put).toHaveBeenCalledWith('/api/apartments/1', mockApt, expect.any(Object)); + }); + + it('should call DELETE /api/apartments/:id', () => { + service.deleteApartment('1').subscribe(); + expect(mockHttpClient.delete).toHaveBeenCalledWith('/api/apartments/1', expect.any(Object)); + }); +}); diff --git a/frontend/src/app/apartment.service.ts b/frontend/src/app/apartment.service.ts new file mode 100644 index 0000000..68c165b --- /dev/null +++ b/frontend/src/app/apartment.service.ts @@ -0,0 +1,47 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { OAuthService } from 'angular-oauth2-oidc'; + +export interface Apartment { + id?: string; + bailleurId?: string; + name: string; + address: string; + purchasePrice: number; + description: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ApartmentService { + private readonly http = inject(HttpClient); + private readonly oauthService = inject(OAuthService); + + private getHeaders(): HttpHeaders { + return new HttpHeaders({ + 'Authorization': 'Bearer ' + this.oauthService.getAccessToken() + }); + } + + getApartments(): Observable { + return this.http.get('/api/apartments', { headers: this.getHeaders() }); + } + + getApartment(id: string): Observable { + return this.http.get(`/api/apartments/${id}`, { headers: this.getHeaders() }); + } + + createApartment(apartment: Apartment): Observable { + return this.http.post('/api/apartments', apartment, { headers: this.getHeaders() }); + } + + updateApartment(id: string, apartment: Apartment): Observable { + return this.http.put(`/api/apartments/${id}`, apartment, { headers: this.getHeaders() }); + } + + deleteApartment(id: string): Observable { + return this.http.delete(`/api/apartments/${id}`, { headers: this.getHeaders() }); + } +} diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css index 6e3a2e6..9e38d61 100644 --- a/frontend/src/app/app.css +++ b/frontend/src/app/app.css @@ -319,6 +319,254 @@ h1 { transform: translateY(-1px); } +/* Tab Navigation */ +.tab-nav { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: 2rem; + padding-bottom: 0.5rem; +} + +.tab-btn { + background: transparent; + border: none; + color: var(--color-text-muted); + padding: 0.75rem 1.25rem; + font-weight: 500; + font-size: 0.95rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-radius: 8px; + transition: all var(--transition-speed); +} + +.tab-btn:hover { + background: rgba(255, 255, 255, 0.03); + color: var(--color-text); +} + +.tab-btn.active { + background: rgba(255, 255, 255, 0.05); + color: var(--color-primary); + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.tab-icon { + width: 18px; + height: 18px; +} + +.tab-pane { + animation: fadeIn var(--transition-speed) ease; +} + +/* Section Header */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.section-header h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--color-text); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem 1.5rem; + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + border: 1px dashed rgba(255, 255, 255, 0.1); +} + +.empty-icon { + width: 48px; + height: 48px; + color: var(--color-text-muted); + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h3 { + font-size: 1.1rem; + margin-bottom: 0.5rem; + color: var(--color-text); +} + +.empty-state p { + font-size: 0.875rem; + color: var(--color-text-muted); + margin: 0; +} + +/* Apartments Grid & Cards */ +.apartments-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +.apartment-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 1.25rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + transition: all var(--transition-speed); +} + +.apartment-card:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); +} + +.apt-info { + flex: 1; + padding-right: 1rem; +} + +.apt-info h3 { + font-size: 1.1rem; + margin: 0 0 0.5rem 0; + color: var(--color-text); +} + +.apt-address { + font-size: 0.85rem; + color: var(--color-text-muted); + margin: 0 0 0.5rem 0; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.inline-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.apt-price { + font-size: 1.15rem; + font-weight: 700; + color: var(--color-primary); + margin: 0 0 0.5rem 0; +} + +.apt-desc { + font-size: 0.85rem; + color: var(--color-text-muted); + margin: 0; +} + +.apt-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-shrink: 0; +} + +/* CRUD Form Styling */ +.form-container h2 { + font-size: 1.25rem; + margin: 0 0 1.5rem 0; +} + +.crud-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-size: 0.85rem; + color: var(--color-text-muted); + font-weight: 500; +} + +.form-group input, +.form-group textarea { + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.75rem; + color: var(--color-text); + font-family: inherit; + font-size: 0.9rem; + transition: border-color var(--transition-speed); +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; +} + +/* Warnings and Alerts */ +.alert { + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + margin-bottom: 1.25rem; + border: 1px solid transparent; +} + +.alert-error { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +.btn-warning { + border-color: rgba(245, 158, 11, 0.2); + color: #fbbf24; +} + +.btn-warning:hover { + border-color: rgba(245, 158, 11, 0.5); + color: #fbbf24; +} + +.btn-danger { + border-color: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +.btn-danger:hover { + border-color: rgba(239, 68, 68, 0.5); + color: #f87171; +} + +.btn-logout { + width: 100%; + margin-top: 2rem; +} + .card-footer { margin-top: 2.5rem; border-top: 1px solid rgba(255, 255, 255, 0.05); @@ -374,5 +622,47 @@ h1 { padding: 1.25rem; margin-bottom: 1.5rem; } + + .tab-nav { + flex-wrap: wrap; + justify-content: center; + } + + .tab-btn { + flex: 1; + min-width: 110px; + justify-content: center; + font-size: 0.85rem; + padding: 0.6rem 0.8rem; + } + + .section-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + text-align: center; + } + + .apartment-card { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .apt-info { + padding-right: 0; + } + + .apt-actions { + flex-direction: row; + width: 100%; + gap: 0.5rem; + } + + .apt-actions button { + flex: 1; + padding: 0.5rem; + font-size: 0.8rem; + } } diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 72e0067..6513c63 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -37,35 +37,164 @@ } @else {
- + + -
-

Vérification API Sécurisée

-

Testez la transmission et la validation du jeton JWT auprès du backend Java 25.

-
- -
- - @if (helloMessage()) { -
- Réponse du backend : - {{ helloMessage() }} + + @if (activeTab() === 'dashboard') { +
+ - } -
- +
+ + @if (helloMessage()) { +
+ Réponse du backend : + {{ helloMessage() }} +
+ } +
+
+ } + + + @if (activeTab() === 'apartments') { +
+ @if (!showForm()) { +
+

Mon Portefeuille de Biens

+ +
+ + @if (apartments().length === 0) { +
+ + + + +

Aucun appartement enregistré

+

Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.

+
+ } @else { +
+ @for (apt of apartments(); track apt.id) { +
+
+

{{ apt.name }}

+

+ + + + + {{ apt.address }} +

+

{{ apt.purchasePrice.toLocaleString('fr-FR') }} €

+ @if (apt.description) { +

{{ apt.description }}

+ } +
+
+ + @if (apt.id) { + + } +
+
+ } +
+ } + } @else { +
+

{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}

+ + @if (formError) { +
+ {{ formError }} +
+ } + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ } +
+ } + + diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index 7e632f6..e780d67 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -2,12 +2,14 @@ import { TestBed } from '@angular/core/testing'; import { App } from './app'; import { OAuthService } from 'angular-oauth2-oidc'; import { HttpClient } from '@angular/common/http'; +import { ApartmentService } from './apartment.service'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; describe('App', () => { let mockOAuthService: any; let mockHttpClient: any; + let mockApartmentService: any; beforeEach(async () => { mockOAuthService = { @@ -22,11 +24,22 @@ describe('App', () => { get: () => of('Hello from mocked API!') }; + mockApartmentService = { + getApartments: () => of([ + { id: '1', name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' } + ]), + getApartment: (id: string) => of({ id, name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }), + createApartment: (apt: any) => of({ id: '2', ...apt }), + updateApartment: (id: string, apt: any) => of({ id, ...apt }), + deleteApartment: () => of(undefined) + }; + await TestBed.configureTestingModule({ imports: [App], providers: [ { provide: OAuthService, useValue: mockOAuthService }, - { provide: HttpClient, useValue: mockHttpClient } + { provide: HttpClient, useValue: mockHttpClient }, + { provide: ApartmentService, useValue: mockApartmentService } ] }).compileComponents(); }); @@ -149,4 +162,178 @@ describe('App', () => { fixture.detectChanges(); expect(compiled.querySelector('.api-response-code')?.textContent).toContain('Hello from mocked API!'); }); + + // Apartment CRUD tests + it('should load apartments on init if authenticated', async () => { + mockOAuthService.hasValidAccessToken = () => true; + const loadSpy = vi.spyOn(mockApartmentService, 'getApartments'); + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + + fixture.detectChanges(); + await fixture.whenStable(); + + app.ngOnInit(); + expect(loadSpy).toHaveBeenCalled(); + expect(app.apartments().length).toBe(1); + expect(app.apartments()[0].name).toBe('Apt 1'); + }); + + it('should navigate between tabs', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(app.activeTab()).toBe('dashboard'); + app.activeTab.set('apartments'); + + fixture.detectChanges(); + await fixture.whenStable(); + expect(app.activeTab()).toBe('apartments'); + }); + + it('should open add form and reset fields', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + + fixture.detectChanges(); + await fixture.whenStable(); + + app.openAddForm(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(app.showForm()).toBe(true); + expect(app.editingApartment()).toBeNull(); + expect(app.formName).toBe(''); + expect(app.formPurchasePrice).toBe(0); + }); + + it('should open edit form and populate fields', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + const apt = { id: '123', name: 'Apartment 123', address: 'Addr', purchasePrice: 90000, description: 'Test' }; + + fixture.detectChanges(); + await fixture.whenStable(); + + app.openEditForm(apt); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(app.showForm()).toBe(true); + expect(app.editingApartment()).toEqual(apt); + expect(app.formName).toBe('Apartment 123'); + expect(app.formPurchasePrice).toBe(90000); + }); + + it('should display error if fields are invalid on save', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + + fixture.detectChanges(); + await fixture.whenStable(); + + app.openAddForm(); + fixture.detectChanges(); + await fixture.whenStable(); + + app.saveApartment(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(app.formError).toContain('Veuillez remplir tous les champs obligatoires'); + }); + + it('should save a new apartment successfully', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + const createSpy = vi.spyOn(mockApartmentService, 'createApartment'); + + fixture.detectChanges(); + await fixture.whenStable(); + + app.openAddForm(); + fixture.detectChanges(); + await fixture.whenStable(); + + app.formName = 'New Apt'; + app.formAddress = 'New Addr'; + app.formPurchasePrice = 200000; + app.formDescription = 'New Desc'; + + app.saveApartment(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(createSpy).toHaveBeenCalledWith({ + name: 'New Apt', + address: 'New Addr', + purchasePrice: 200000, + description: 'New Desc' + }); + expect(app.showForm()).toBe(false); + }); + + it('should update an existing apartment successfully', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + const updateSpy = vi.spyOn(mockApartmentService, 'updateApartment'); + const apt = { id: '1', name: 'Old Apt', address: 'Old Addr', purchasePrice: 100000, description: 'Old Desc' }; + + fixture.detectChanges(); + await fixture.whenStable(); + + app.openEditForm(apt); + fixture.detectChanges(); + await fixture.whenStable(); + + app.formName = 'Updated Apt'; + app.formAddress = 'Updated Addr'; + app.formPurchasePrice = 110000; + app.formDescription = 'Updated Desc'; + + app.saveApartment(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(updateSpy).toHaveBeenCalledWith('1', { + name: 'Updated Apt', + address: 'Updated Addr', + purchasePrice: 110000, + description: 'Updated Desc' + }); + expect(app.showForm()).toBe(false); + }); + + it('should delete an apartment when confirmed', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + const deleteSpy = vi.spyOn(mockApartmentService, 'deleteApartment'); + vi.spyOn(window, 'confirm').mockImplementation(() => true); + + fixture.detectChanges(); + await fixture.whenStable(); + + app.deleteApartment('1'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(deleteSpy).toHaveBeenCalledWith('1'); + }); + + it('should not delete an apartment when not confirmed', async () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + const deleteSpy = vi.spyOn(mockApartmentService, 'deleteApartment'); + vi.spyOn(window, 'confirm').mockImplementation(() => false); + + fixture.detectChanges(); + await fixture.whenStable(); + + app.deleteApartment('1'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index cb06fd1..efb6ed5 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,19 +1,49 @@ -import { Component, signal, inject } from '@angular/core'; +import { Component, signal, inject, OnInit, effect } from '@angular/core'; import { OAuthService } from 'angular-oauth2-oidc'; import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { ApartmentService, Apartment } from './apartment.service'; +import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-root', - imports: [], + imports: [FormsModule], templateUrl: './app.html', styleUrl: './app.css' }) -export class App { +export class App implements OnInit { protected readonly title = signal('LMNP Organizer'); readonly helloMessage = signal(''); + // Tab & CRUD States + readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard'); + readonly apartments = signal([]); + readonly showForm = signal(false); + readonly editingApartment = signal(null); + + // Form Fields + formName = ''; + formAddress = ''; + formPurchasePrice = 0; + formDescription = ''; + formError = ''; + private readonly oauthService = inject(OAuthService); private readonly http = inject(HttpClient); + private readonly apartmentService = inject(ApartmentService); + + constructor() { + effect(() => { + if (this.isLoggedIn) { + this.loadApartments(); + } + }); + } + + ngOnInit() { + if (this.isLoggedIn) { + this.loadApartments(); + } + } get isLoggedIn(): boolean { return this.oauthService.hasValidAccessToken(); @@ -51,4 +81,85 @@ export class App { } }); } + + // Apartment CRUD operations + loadApartments() { + this.apartmentService.getApartments().subscribe({ + next: (data) => this.apartments.set(data), + error: (err) => console.error('Error loading apartments', err) + }); + } + + openAddForm() { + this.editingApartment.set(null); + this.formName = ''; + this.formAddress = ''; + this.formPurchasePrice = 0; + this.formDescription = ''; + this.formError = ''; + this.showForm.set(true); + } + + openEditForm(apartment: Apartment) { + this.editingApartment.set(apartment); + this.formName = apartment.name; + this.formAddress = apartment.address; + this.formPurchasePrice = apartment.purchasePrice; + this.formDescription = apartment.description; + this.formError = ''; + this.showForm.set(true); + } + + closeForm() { + this.showForm.set(false); + this.editingApartment.set(null); + } + + saveApartment() { + if (!this.formName.trim() || !this.formAddress.trim() || this.formPurchasePrice <= 0) { + this.formError = 'Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).'; + return; + } + + const apartmentData: Apartment = { + name: this.formName.trim(), + address: this.formAddress.trim(), + purchasePrice: this.formPurchasePrice, + description: this.formDescription.trim() + }; + + const editMode = this.editingApartment(); + if (editMode && editMode.id) { + this.apartmentService.updateApartment(editMode.id, apartmentData).subscribe({ + next: () => { + this.loadApartments(); + this.closeForm(); + }, + error: (err) => { + console.error(err); + this.formError = 'Erreur lors de la modification.'; + } + }); + } else { + this.apartmentService.createApartment(apartmentData).subscribe({ + next: () => { + this.loadApartments(); + this.closeForm(); + }, + error: (err) => { + console.error(err); + this.formError = 'Erreur lors de la création.'; + } + }); + } + } + + deleteApartment(id: string) { + if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) { + this.apartmentService.deleteApartment(id).subscribe({ + next: () => this.loadApartments(), + error: (err) => console.error('Error deleting apartment', err) + }); + } + } } diff --git a/frontend/src/app/config.service.spec.ts b/frontend/src/app/config.service.spec.ts new file mode 100644 index 0000000..a3c7bfd --- /dev/null +++ b/frontend/src/app/config.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { ConfigService } from './config.service'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; + +describe('ConfigService', () => { + let service: ConfigService; + let mockHttpClient: any; + + beforeEach(() => { + mockHttpClient = { + get: () => of({ oidcIssuer: 'http://custom-issuer', clientId: 'custom-client' }) + }; + + TestBed.configureTestingModule({ + providers: [ + ConfigService, + { provide: HttpClient, useValue: mockHttpClient } + ] + }); + + service = TestBed.inject(ConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should load config successfully', async () => { + await service.loadConfig(); + expect(service.oidcIssuer).toBe('http://custom-issuer'); + expect(service.clientId).toBe('custom-client'); + }); + + it('should use default values on load error', async () => { + mockHttpClient.get = () => throwError(() => new Error('Load failed')); + + // Silence warning log + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await service.loadConfig(); + expect(service.oidcIssuer).toBe('http://localhost:8090/realms/voidAuth'); + expect(service.clientId).toBe('lmnp-client'); + + warnSpy.mockRestore(); + }); +});