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
@@ -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