From 9669b2b5b405ff48c224f992c70487507858c1fc Mon Sep 17 00:00:00 2001 From: styve Lioumba Date: Mon, 17 Nov 2025 17:59:45 +0100 Subject: [PATCH] refacto et TU user --- .../user-avatar-form.component.html | 136 +++++------ .../user-avatar-form.component.ts | 18 +- .../user-form/user-form.component.html | 214 +++++++++--------- .../user-form/user-form.component.ts | 16 +- .../domain/users/fake-user.repository.ts | 16 ++ .../users/pb-user.repository.spec.ts | 58 +++++ src/app/testing/ui/users/user.facade.spec.ts | 37 +++ .../usecase/users/get-user.usecase.spec.ts | 19 ++ .../usecase/users/update-user.usecase.spec.ts | 22 ++ src/app/testing/user.mock.ts | 15 ++ src/app/ui/users/user.facade.ts | 11 +- 11 files changed, 377 insertions(+), 185 deletions(-) create mode 100644 src/app/testing/domain/users/fake-user.repository.ts create mode 100644 src/app/testing/infrastructure/users/pb-user.repository.spec.ts create mode 100644 src/app/testing/ui/users/user.facade.spec.ts create mode 100644 src/app/testing/usecase/users/get-user.usecase.spec.ts create mode 100644 src/app/testing/usecase/users/update-user.usecase.spec.ts create mode 100644 src/app/testing/user.mock.ts diff --git a/src/app/shared/components/user-avatar-form/user-avatar-form.component.html b/src/app/shared/components/user-avatar-form/user-avatar-form.component.html index eebcc10..a5f4004 100644 --- a/src/app/shared/components/user-avatar-form/user-avatar-form.component.html +++ b/src/app/shared/components/user-avatar-form/user-avatar-form.component.html @@ -1,69 +1,75 @@ -@if (user) { -

Ma photo de profil

- -
- @if (imagePreviewUrl != null) { - {{ user!.username }} - } @else if (user.avatar) { - {{ user!.username }} - } @else { - {{ user!.username }} - } -
+@if (loading().action === ActionType.UPDATE && loading().isLoading && onSubmitted()) { + +} @else { + } - + + @if (user) { +
+ @if (imagePreviewUrl != null) { + {{ user!.username }} + } @else if (user.avatar) { + {{ user!.username }} + } @else { + {{ user!.username }} + } +
+ } -@if (file != null || imagePreviewUrl != null) { - -} + + + + + Selectionner une image + + + + @if (file != null || imagePreviewUrl != null) { + + } +
diff --git a/src/app/shared/components/user-avatar-form/user-avatar-form.component.ts b/src/app/shared/components/user-avatar-form/user-avatar-form.component.ts index 36dc341..ae14588 100644 --- a/src/app/shared/components/user-avatar-form/user-avatar-form.component.ts +++ b/src/app/shared/components/user-avatar-form/user-avatar-form.component.ts @@ -1,17 +1,17 @@ -import { Component, effect, inject, Input, output } from '@angular/core'; +import { Component, effect, inject, Input, output, signal } from '@angular/core'; import { User } from '@app/domain/users/user.model'; import { ReactiveFormsModule } from '@angular/forms'; -import { AuthService } from '@app/core/services/authentication/auth.service'; import { environment } from '@env/environment'; -import { NgClass } from '@angular/common'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; import { ToastrService } from 'ngx-toastr'; import { UserFacade } from '@app/ui/users/user.facade'; import { ActionType } from '@app/domain/action-type.util'; +import { LoadingComponent } from '@app/shared/components/loading/loading.component'; @Component({ selector: 'app-user-avatar-form', standalone: true, - imports: [ReactiveFormsModule, NgClass], + imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, LoadingComponent], templateUrl: './user-avatar-form.component.html', styleUrl: './user-avatar-form.component.scss', }) @@ -23,10 +23,9 @@ export class UserAvatarFormComponent { onFormSubmitted = output(); - private authService = inject(AuthService); - file: File | null = null; // Variable to store file imagePreviewUrl: string | null = null; // URL for image preview + protected onSubmitted = signal(false); protected readonly loading = this.facade.loading; protected readonly error = this.facade.error; @@ -35,12 +34,12 @@ export class UserAvatarFormComponent { let message = ''; effect(() => { - if (!this.loading().isLoading) { + if (!this.loading().isLoading && this.onSubmitted()) { switch (this.loading().action) { case ActionType.UPDATE: - this.authService.updateUser(); message = `Votre photo de profile a bien été modifier !`; this.customToast(ActionType.UPDATE, message); + this.onSubmitted.set(false); break; } } @@ -55,6 +54,7 @@ export class UserAvatarFormComponent { this.facade.update(this.user?.id!, formData as Partial); this.onFormSubmitted.emit(''); + this.onSubmitted.set(true); } } @@ -98,4 +98,6 @@ export class UserAvatarFormComponent { } ); } + + protected readonly ActionType = ActionType; } diff --git a/src/app/shared/components/user-form/user-form.component.html b/src/app/shared/components/user-form/user-form.component.html index 37c4b80..c235072 100644 --- a/src/app/shared/components/user-form/user-form.component.html +++ b/src/app/shared/components/user-form/user-form.component.html @@ -1,107 +1,115 @@ -
- -
-
- - - -
-

Mon identité

-
+@if (loading().action === ActionType.UPDATE && loading().isLoading && onSubmitted()) { + +} @else { + +} - -
- -
-
- - - -
- -
-
- - -
- -
-
- - - -
- -
-
- - - - + + + + +
+ +
+
+ + + +
+ +
+
+ + + + + diff --git a/src/app/shared/components/user-form/user-form.component.ts b/src/app/shared/components/user-form/user-form.component.ts index 9b1f2e3..05ca737 100644 --- a/src/app/shared/components/user-form/user-form.component.ts +++ b/src/app/shared/components/user-form/user-form.component.ts @@ -1,4 +1,4 @@ -import { Component, effect, inject, Input, OnInit, output } from '@angular/core'; +import { Component, effect, inject, Input, OnInit, output, signal } from '@angular/core'; import { FormBuilder, FormControl, @@ -7,16 +7,17 @@ import { Validators, } from '@angular/forms'; import { User } from '@app/domain/users/user.model'; -import { AuthService } from '@app/core/services/authentication/auth.service'; import { UntilDestroy } from '@ngneat/until-destroy'; import { ToastrService } from 'ngx-toastr'; import { UserFacade } from '@app/ui/users/user.facade'; import { ActionType } from '@app/domain/action-type.util'; +import { LoadingComponent } from '@app/shared/components/loading/loading.component'; +import { NgTemplateOutlet } from '@angular/common'; @Component({ selector: 'app-user-form', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, LoadingComponent, NgTemplateOutlet], templateUrl: './user-form.component.html', styleUrl: './user-form.component.scss', }) @@ -24,28 +25,28 @@ import { ActionType } from '@app/domain/action-type.util'; export class UserFormComponent implements OnInit { private readonly toastrService = inject(ToastrService); private readonly facade = inject(UserFacade); + protected readonly ActionType = ActionType; @Input({ required: true }) user: User | undefined = undefined; onFormSubmitted = output(); - private authService = inject(AuthService); - private fb = inject(FormBuilder); protected userForm!: FormGroup; protected readonly loading = this.facade.loading; protected readonly error = this.facade.error; + protected onSubmitted = signal(false); constructor() { let message = ''; effect(() => { - if (!this.loading().isLoading) { + if (!this.loading().isLoading && this.onSubmitted()) { switch (this.loading().action) { case ActionType.UPDATE: - this.authService.updateUser(); message = `Vos informations personnelles ont bien été modifier !`; this.customToast(ActionType.UPDATE, message); + this.onSubmitted.set(false); break; } } @@ -74,6 +75,7 @@ export class UserFormComponent implements OnInit { this.facade.update(this.user?.id!, data); this.onFormSubmitted.emit(data); + this.onSubmitted.set(true); } private customToast(action: ActionType, message: string): void { diff --git a/src/app/testing/domain/users/fake-user.repository.ts b/src/app/testing/domain/users/fake-user.repository.ts new file mode 100644 index 0000000..d6c5b5c --- /dev/null +++ b/src/app/testing/domain/users/fake-user.repository.ts @@ -0,0 +1,16 @@ +import { UserRepository } from '@app/domain/users/user.repository'; +import { Observable, of } from 'rxjs'; +import { User } from '@app/domain/users/user.model'; +import { fakeUsers } from '@app/testing/user.mock'; + +export class FakeUserRepository implements UserRepository { + getUserById(userId: string): Observable { + const user = fakeUsers.find((u) => u.id === userId) ?? ({} as User); + return of(user); + } + + update(userId: string, user: Partial | User): Observable { + const existingUser = fakeUsers.find((u) => u.id === userId) ?? fakeUsers[0]; + return of({ ...existingUser, ...user }); + } +} diff --git a/src/app/testing/infrastructure/users/pb-user.repository.spec.ts b/src/app/testing/infrastructure/users/pb-user.repository.spec.ts new file mode 100644 index 0000000..5d49c9e --- /dev/null +++ b/src/app/testing/infrastructure/users/pb-user.repository.spec.ts @@ -0,0 +1,58 @@ +import { FakeUserRepository } from '@app/testing/domain/users/fake-user.repository'; +import { PbUserRepository } from '@app/infrastructure/users/pb-user.repository'; +import { fakeUsers } from '@app/testing/user.mock'; +import PocketBase from 'pocketbase'; + +jest.mock('pocketbase'); + +describe('UserRepository', () => { + let userRepo: FakeUserRepository; + let mockCollection: any; + let mockPocketBase: any; + + beforeEach(() => { + mockPocketBase = { + collection: jest.fn().mockReturnValue({ + update: jest.fn(), + getOne: jest.fn(), + }), + }; + + // 👇 On remplace la classe importée par notre version mockée + (PocketBase as jest.Mock).mockImplementation(() => mockPocketBase); + + // on récupère une "collection" simulée + mockCollection = mockPocketBase.collection('users'); + + // on instancie le repository à tester + userRepo = new PbUserRepository(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // TODO: revoir la logique de test + it('devrait appeler pb.collection("users").getFirstListItem() ', () => { + const userId = 'user_001'; + + mockCollection.getOne.mockResolvedValue(fakeUsers.find((u) => u.id === userId)); + + userRepo.getUserById(userId).subscribe((result) => { + expect(mockCollection.getOne).toHaveBeenCalledWith(userId); + expect(result).toEqual(fakeUsers[0]); + }); + }); + + it('devrait appeler pb.collection("users").update() avec ID et data ', () => { + const userId = 'user_001'; + const data = { email: 'bar@foo.com' }; + const updatedUser = { ...fakeUsers[0], email: data.email }; + mockCollection.update.mockResolvedValue(updatedUser); + + userRepo.update(userId, data).subscribe((result) => { + expect(mockCollection.update).toHaveBeenCalledWith(userId, data); + expect(result.email).toBe(data.email); + }); + }); +}); diff --git a/src/app/testing/ui/users/user.facade.spec.ts b/src/app/testing/ui/users/user.facade.spec.ts new file mode 100644 index 0000000..3160127 --- /dev/null +++ b/src/app/testing/ui/users/user.facade.spec.ts @@ -0,0 +1,37 @@ +import { UserFacade } from '@app/ui/users/user.facade'; +import { TestBed } from '@angular/core/testing'; +import { USER_REPOSITORY_TOKEN } from '@app/infrastructure/users/user-repository.token'; +import { FakeUserRepository } from '@app/testing/domain/users/fake-user.repository'; + +describe('UserFacade', () => { + let facade: UserFacade; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [UserFacade, { provide: USER_REPOSITORY_TOKEN, useClass: FakeUserRepository }], + }); + facade = TestBed.inject(UserFacade); + }); + + it('devrait charger un user par son id ', () => { + const userId = 'user_001'; + facade.loadOne(userId); + + setTimeout(() => { + const user = facade.user(); + expect(user?.id).toBe(userId); + expect(facade.error().hasError).toBe(false); + }, 0); + }); + + it('devrait mettre a jour un user existant ', () => { + const userId = 'user_001'; + const newData = { email: 'bar@foo.com' }; + facade.update(userId, newData); + + setTimeout(() => { + const updatedUser = facade.user(); + expect(updatedUser?.email).toBe(newData.email); + expect(facade.error().hasError).toBe(false); + }, 0); + }); +}); diff --git a/src/app/testing/usecase/users/get-user.usecase.spec.ts b/src/app/testing/usecase/users/get-user.usecase.spec.ts new file mode 100644 index 0000000..85a6871 --- /dev/null +++ b/src/app/testing/usecase/users/get-user.usecase.spec.ts @@ -0,0 +1,19 @@ +import { FakeUserRepository } from '@app/testing/domain/users/fake-user.repository'; +import { GetUserUseCase } from '@app/usecase/users/get-user.usecase'; + +describe('GetUserUseCase', () => { + let useCase: GetUserUseCase; + let repo: FakeUserRepository; + + beforeEach(() => { + repo = new FakeUserRepository(); + useCase = new GetUserUseCase(repo); + }); + + it("devrait retourné un user en fonction de l'id ", () => { + const userId = 'user_001'; + useCase.execute(userId).subscribe((user) => { + expect(user.id).toBe(userId); + }); + }); +}); diff --git a/src/app/testing/usecase/users/update-user.usecase.spec.ts b/src/app/testing/usecase/users/update-user.usecase.spec.ts new file mode 100644 index 0000000..adc7bee --- /dev/null +++ b/src/app/testing/usecase/users/update-user.usecase.spec.ts @@ -0,0 +1,22 @@ +import { UpdateUserUseCase } from '@app/usecase/users/update-user.usecase'; +import { FakeUserRepository } from '@app/testing/domain/users/fake-user.repository'; + +describe('UpdateUserUsecase', () => { + let useCase: UpdateUserUseCase; + let repo: FakeUserRepository; + + beforeEach(() => { + repo = new FakeUserRepository(); + useCase = new UpdateUserUseCase(repo); + }); + + it("devrait modifier un user en fonction de l'id ", () => { + const userId = 'user_001'; + const newData = { email: 'bar@foo.com' }; + + useCase.execute(userId, newData).subscribe((updated) => { + expect(updated.email).toBe(newData.email); + expect(updated.updated).toBeDefined(); + }); + }); +}); diff --git a/src/app/testing/user.mock.ts b/src/app/testing/user.mock.ts new file mode 100644 index 0000000..288de1f --- /dev/null +++ b/src/app/testing/user.mock.ts @@ -0,0 +1,15 @@ +import { User } from '@app/domain/users/user.model'; + +export const fakeUsers: User[] = [ + { + id: 'user_001', + created: '2025-01-01T10:00:00Z', + updated: '2025-01-05T14:00:00Z', + name: 'foo', + avatar: 'foo.png', + email: 'foo@bar.com', + username: 'foo', + emailVisibility: false, + verified: false, + }, +]; diff --git a/src/app/ui/users/user.facade.ts b/src/app/ui/users/user.facade.ts index 1a19096..4a8294e 100644 --- a/src/app/ui/users/user.facade.ts +++ b/src/app/ui/users/user.facade.ts @@ -6,6 +6,8 @@ import { LoaderAction } from '@app/domain/loader-action.util'; import { ActionType } from '@app/domain/action-type.util'; import { ErrorResponse } from '@app/domain/error-response.util'; import { UserViewModel } from '@app/ui/users/user.presenter.model'; +import { UserPresenter } from '@app/ui/users/user.presenter'; +import { AuthService } from '@app/core/services/authentication/auth.service'; @Injectable({ providedIn: 'root', @@ -13,6 +15,8 @@ import { UserViewModel } from '@app/ui/users/user.presenter.model'; export class UserFacade { private readonly userRepository = inject(USER_REPOSITORY_TOKEN); + private readonly authService = inject(AuthService); + private readonly getUseCase = new GetUserUseCase(this.userRepository); private readonly updateUseCase = new UpdateUserUseCase(this.userRepository); @@ -25,11 +29,13 @@ export class UserFacade { message: null, }); + private readonly userPresenter = new UserPresenter(); + loadOne(userId: string) { this.handleError(ActionType.READ, false, null, true); this.getUseCase.execute(userId).subscribe({ next: (user) => { - this.user.set(user); + this.user.set(this.userPresenter.toViewModel(user)); this.handleError(ActionType.READ, false, null, false); }, error: (err) => { @@ -42,7 +48,8 @@ export class UserFacade { this.handleError(ActionType.UPDATE, false, null, true); this.updateUseCase.execute(userId, user).subscribe({ next: (user) => { - this.user.set(user); + this.user.set(this.userPresenter.toViewModel(user)); + this.authService.updateUser(); this.handleError(ActionType.UPDATE, false, null, false); }, error: (err) => {