Implement Landlord and Apartment CRUD features with secure endpoints, coverage tests, and responsive UI

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent 60f0377f97
commit 62a0bd5db3
18 files changed
+1419 -31

No files matched your search

+3
View File
@@ -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.");
}
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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> {
}
@@ -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());
}
}
+8 -1
View File
@@ -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));
});
});
+47
View File
@@ -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() });
}
}
+290
View File
@@ -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
View File
@@ -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') }} &euro;</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 (&euro;) *</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>
+188 -1
View File
@@ -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
View File
@@ -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)
});
}
}
}
+48
View File
@@ -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();
});
});