Implement Landlord and Apartment CRUD features with secure endpoints, coverage tests, and responsive UI
This commit is contained in:
1 parent
60f0377f97
commit
62a0bd5db3
18 files changed
+1419
-31
No files matched your search
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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<Apartment> getAllApartments() {
|
||||
return apartmentRepository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Apartment> getApartmentById(@PathVariable String id) {
|
||||
return apartmentRepository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Apartment> 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<Apartment> 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<Void> deleteApartment(@PathVariable String id) {
|
||||
if (apartmentRepository.existsById(id)) {
|
||||
apartmentRepository.deleteById(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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<Apartment, String> {
|
||||
List<Apartment> findByBailleurId(String bailleurId);
|
||||
}
|
||||
+9
@@ -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<Landlord, String> {
|
||||
}
|
||||
+133
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<Apartment[]> {
|
||||
return this.http.get<Apartment[]>('/api/apartments', { headers: this.getHeaders() });
|
||||
}
|
||||
|
||||
getApartment(id: string): Observable<Apartment> {
|
||||
return this.http.get<Apartment>(`/api/apartments/${id}`, { headers: this.getHeaders() });
|
||||
}
|
||||
|
||||
createApartment(apartment: Apartment): Observable<Apartment> {
|
||||
return this.http.post<Apartment>('/api/apartments', apartment, { headers: this.getHeaders() });
|
||||
}
|
||||
|
||||
updateApartment(id: string, apartment: Apartment): Observable<Apartment> {
|
||||
return this.http.put<Apartment>(`/api/apartments/${id}`, apartment, { headers: this.getHeaders() });
|
||||
}
|
||||
|
||||
deleteApartment(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/apartments/${id}`, { headers: this.getHeaders() });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+155
-26
@@ -37,35 +37,164 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="auth-state unlocked">
|
||||
<div class="user-profile">
|
||||
<div class="avatar">
|
||||
{{ username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<h3>{{ username }}</h3>
|
||||
<p class="user-email">{{ email }}</p>
|
||||
<span class="badge badge-success">Connecté</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Multi-Tab Navigation Menu -->
|
||||
<nav class="tab-nav">
|
||||
<button (click)="activeTab.set('dashboard')" [class.active]="activeTab() === 'dashboard'" class="tab-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon">
|
||||
<rect width="7" height="9" x="3" y="3" rx="1"/>
|
||||
<rect width="7" height="5" x="14" y="3" rx="1"/>
|
||||
<rect width="7" height="9" x="14" y="12" rx="1"/>
|
||||
<rect width="7" height="5" x="3" y="16" rx="1"/>
|
||||
</svg>
|
||||
<span>Tableau de Bord</span>
|
||||
</button>
|
||||
<button (click)="activeTab.set('apartments')" [class.active]="activeTab() === 'apartments'" class="tab-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon">
|
||||
<path d="M2 22h20"/>
|
||||
<path d="M4 22V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v18"/>
|
||||
<path d="M12 18h.01"/>
|
||||
<path d="M8 6h2"/>
|
||||
<path d="M14 6h2"/>
|
||||
<path d="M8 10h2"/>
|
||||
<path d="M14 10h2"/>
|
||||
<path d="M8 14h2"/>
|
||||
<path d="M14 14h2"/>
|
||||
</svg>
|
||||
<span>Appartements</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="api-demo-section">
|
||||
<h4>Vérification API Sécurisée</h4>
|
||||
<p>Testez la transmission et la validation du jeton JWT auprès du backend Java 25.</p>
|
||||
<div class="api-actions">
|
||||
<button (click)="callHelloApi()" class="btn btn-secondary">
|
||||
Appeler /api/hello
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (helloMessage()) {
|
||||
<div class="api-response-wrapper">
|
||||
<span class="response-label">Réponse du backend :</span>
|
||||
<code class="api-response-code">{{ helloMessage() }}</code>
|
||||
<!-- Tab Content: Dashboard -->
|
||||
@if (activeTab() === 'dashboard') {
|
||||
<div class="tab-pane">
|
||||
<div class="user-profile">
|
||||
<div class="avatar">
|
||||
{{ username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<h3>{{ username }}</h3>
|
||||
<p class="user-email">{{ email }}</p>
|
||||
<span class="badge badge-success">Bailleur Connecté</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button (click)="logout()" class="btn btn-outline">
|
||||
<div class="api-demo-section">
|
||||
<h4>Vérification API Sécurisée</h4>
|
||||
<p>Testez la transmission et la validation du jeton JWT auprès du backend Java 25.</p>
|
||||
<div class="api-actions">
|
||||
<button (click)="callHelloApi()" class="btn btn-secondary">
|
||||
Appeler /api/hello
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (helloMessage()) {
|
||||
<div class="api-response-wrapper">
|
||||
<span class="response-label">Réponse du backend :</span>
|
||||
<code class="api-response-code">{{ helloMessage() }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tab Content: Apartments -->
|
||||
@if (activeTab() === 'apartments') {
|
||||
<div class="tab-pane">
|
||||
@if (!showForm()) {
|
||||
<div class="section-header">
|
||||
<h2>Mon Portefeuille de Biens</h2>
|
||||
<button (click)="openAddForm()" class="btn btn-primary btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="btn-icon">
|
||||
<path d="M5 12h14"/>
|
||||
<path d="M12 5v14"/>
|
||||
</svg>
|
||||
<span>Ajouter un appartement</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (apartments().length === 0) {
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<h3>Aucun appartement enregistré</h3>
|
||||
<p>Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="apartments-grid">
|
||||
@for (apt of apartments(); track apt.id) {
|
||||
<div class="apartment-card">
|
||||
<div class="apt-info">
|
||||
<h3>{{ apt.name }}</h3>
|
||||
<p class="apt-address">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-icon">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{{ apt.address }}
|
||||
</p>
|
||||
<p class="apt-price">{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</p>
|
||||
@if (apt.description) {
|
||||
<p class="apt-desc">{{ apt.description }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="apt-actions">
|
||||
<button (click)="openEditForm(apt)" class="btn btn-outline btn-xs btn-warning">
|
||||
Modifier
|
||||
</button>
|
||||
@if (apt.id) {
|
||||
<button (click)="deleteApartment(apt.id)" class="btn btn-outline btn-xs btn-danger">
|
||||
Supprimer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="form-container">
|
||||
<h2>{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}</h2>
|
||||
|
||||
@if (formError) {
|
||||
<div class="alert alert-error">
|
||||
{{ formError }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (submit)="saveApartment(); $event.preventDefault()" class="crud-form">
|
||||
<div class="form-group">
|
||||
<label for="aptName">Nom de l'appartement *</label>
|
||||
<input type="text" id="aptName" name="name" [(ngModel)]="formName" placeholder="Ex. T2 Cosy Paris 15" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aptAddress">Adresse complète *</label>
|
||||
<input type="text" id="aptAddress" name="address" [(ngModel)]="formAddress" placeholder="Ex. 45 rue des Entrepreneurs, 75015 Paris" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aptPrice">Prix d'acquisition (€) *</label>
|
||||
<input type="number" id="aptPrice" name="purchasePrice" [(ngModel)]="formPurchasePrice" min="1" placeholder="Ex. 180000" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="aptDesc">Description / Notes</label>
|
||||
<textarea id="aptDesc" name="description" [(ngModel)]="formDescription" rows="3" placeholder="Ex. T2 en rez-de-chaussée, meublé, cuisine équipée."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" (click)="closeForm()" class="btn btn-outline">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button (click)="logout()" class="btn btn-outline btn-logout">
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+114
-3
@@ -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<string>('');
|
||||
|
||||
// Tab & CRUD States
|
||||
readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard');
|
||||
readonly apartments = signal<Apartment[]>([]);
|
||||
readonly showForm = signal<boolean>(false);
|
||||
readonly editingApartment = signal<Apartment | null>(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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user