From f859eba8a860c3364698bb8553b8fb2e32e62881 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 7 Sep 2026 10:14:11 -0300 Subject: [PATCH] fix(logout): enhance logout handling with success and error toasts --- .../store-layout.component.spec.ts | 22 ++++- .../store-layout/store-layout.component.ts | 19 +++- src/app/core/services/toast.service.spec.ts | 24 ++++- src/app/core/services/toast.service.ts | 54 ++++++++++- .../login-page/login-page.component.spec.ts | 97 ++++++++++++++++--- .../pages/login-page/login-page.component.ts | 32 +++--- 6 files changed, 215 insertions(+), 33 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 28be962..cfed07f 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -12,7 +12,7 @@ import { UrlSerializer, } from '@angular/router'; -import { BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject, of, throwError } from 'rxjs'; import { Tenant } from '../../services/tenant.interface'; import { TenantService } from '../../services/tenant.service'; @@ -211,7 +211,7 @@ describe('StoreLayoutComponent', () => { useValue: { user: authUserState, isAuthenticated: isAuthenticatedState, - logout: vi.fn().mockReturnValue(of(void 0)), + logout: vi.fn().mockReturnValue(of({ message: 'Sesión cerrada correctamente.' })), }, }, { @@ -574,6 +574,24 @@ describe('StoreLayoutComponent', () => { expect(authService.logout).toHaveBeenCalled(); expect(cartService.clearCart).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/']); + expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith( + 'Sesión cerrada correctamente.', + ); + }); + + it('shows a danger toast when logout fails', async () => { + const authService = TestBed.inject(AuthService); + const message = 'La sesión no pudo cerrarse en el servidor.'; + (authService.logout as any).mockReturnValue( + throwError(() => ({ error: { message } })), + ); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + await (fixture.componentInstance as any).onLogoutClick(); + + expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message); }); it('provides account actions from the footer', () => { diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 01844c0..9a40985 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -236,6 +236,7 @@ export class StoreLayoutComponent implements OnInit { const navigationSucceeded = await this.router.navigate(['/']); if (!navigationSucceeded) { + this.toastService.danger('No se pudo cerrar la sesión. Intentá nuevamente.'); return; } } @@ -244,16 +245,30 @@ export class StoreLayoutComponent implements OnInit { next: ({ message }) => { this.cartService.clearCart(); this.isCartOpen.set(false); - this.toastService.success(message || 'Sesión cerrada correctamente.'); + this.toastService.info(message || 'Sesión cerrada correctamente.'); if (!isLeavingCheckout) { void this.router.navigate(['/']); } }, - error: (err) => console.error('Error logging out', err), + error: (error: unknown) => { + this.toastService.danger(this.resolveLogoutErrorMessage(error)); + console.error('Error logging out', error); + }, }); } + private resolveLogoutErrorMessage(error: unknown): string { + const payload = + typeof error === 'object' && error !== null && 'error' in error + ? (error as { error?: { message?: unknown } }).error + : undefined; + + return typeof payload?.message === 'string' && payload.message.trim() + ? payload.message + : 'No se pudo cerrar la sesión. Intentá nuevamente.'; + } + protected async onCheckoutClick(): Promise { if (this.isCreatingPurchase()) { return; diff --git a/src/app/core/services/toast.service.spec.ts b/src/app/core/services/toast.service.spec.ts index cb28a55..8d4fe1b 100644 --- a/src/app/core/services/toast.service.spec.ts +++ b/src/app/core/services/toast.service.spec.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from '@angular/common'; import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { ToastService } from './toast.service'; @@ -6,14 +7,16 @@ describe('ToastService', () => { let service: ToastService; beforeEach(() => { + vi.useFakeTimers(); TestBed.configureTestingModule({ providers: [ToastService] }); + TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast'); service = TestBed.inject(ToastService); - vi.useFakeTimers(); }); afterEach(() => { + TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast'); vi.useRealTimers(); }); @@ -98,4 +101,23 @@ describe('ToastService', () => { expect(service.toasts().length).toBe(1); }); + + it('should restore a toast queued for after a full page reload', () => { + service.showAfterReload('Sesión iniciada correctamente.', 'success', 0); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [ToastService] }); + service = TestBed.inject(ToastService); + + expect(service.toasts()).toEqual([ + expect.objectContaining({ + message: 'Sesión iniciada correctamente.', + type: 'success', + duration: 0, + }), + ]); + expect( + TestBed.inject(DOCUMENT).defaultView?.sessionStorage.getItem('shopit.pending-toast'), + ).toBeNull(); + }); }); diff --git a/src/app/core/services/toast.service.ts b/src/app/core/services/toast.service.ts index bcdd4e4..b5c3666 100644 --- a/src/app/core/services/toast.service.ts +++ b/src/app/core/services/toast.service.ts @@ -1,4 +1,7 @@ -import { Injectable, signal } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; + +const PENDING_TOAST_STORAGE_KEY = 'shopit.pending-toast'; export interface Toast { id: string; @@ -11,9 +14,15 @@ export interface Toast { providedIn: 'root' }) export class ToastService { + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); private readonly toastsSignal = signal([]); readonly toasts = this.toastsSignal.asReadonly(); + constructor() { + this.restorePendingToast(); + } + show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string { const id = Math.random().toString(36).substring(2, 9); const newToast: Toast = { id, message, type, duration }; @@ -41,7 +50,50 @@ export class ToastService { return this.show(message, 'success', duration); } + showAfterReload( + message: string, + type: Toast['type'] = 'info', + duration = 3000, + ): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + this.document.defaultView?.sessionStorage.setItem( + PENDING_TOAST_STORAGE_KEY, + JSON.stringify({ message, type, duration }), + ); + } + dismiss(id: string): void { this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id)); } + + private restorePendingToast(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const storage = this.document.defaultView?.sessionStorage; + const pendingToast = storage?.getItem(PENDING_TOAST_STORAGE_KEY); + + if (!pendingToast) { + return; + } + + storage?.removeItem(PENDING_TOAST_STORAGE_KEY); + + try { + const parsed = JSON.parse(pendingToast) as Partial; + + if ( + typeof parsed.message === 'string' && + (parsed.type === 'success' || parsed.type === 'danger' || parsed.type === 'info') + ) { + this.show(parsed.message, parsed.type, parsed.duration); + } + } catch { + // Ignore malformed session data left by an older or interrupted client. + } + } } diff --git a/src/app/features/store/pages/login-page/login-page.component.spec.ts b/src/app/features/store/pages/login-page/login-page.component.spec.ts index f08aaea..0e93561 100644 --- a/src/app/features/store/pages/login-page/login-page.component.spec.ts +++ b/src/app/features/store/pages/login-page/login-page.component.spec.ts @@ -3,11 +3,21 @@ import { provideRouter, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; import { LoginPageComponent } from './login-page.component'; describe('LoginPageComponent', () => { + let toastService: { + danger: ReturnType; + showAfterReload: ReturnType; + }; + beforeEach(() => { TestBed.resetTestingModule(); + toastService = { + danger: vi.fn(), + showAfterReload: vi.fn(), + }; }); it('submits credentials and redirects to home with a full page reload on success', async () => { @@ -16,14 +26,18 @@ describe('LoginPageComponent', () => { of({ id: 1, nombre_apellido: 'Ada Lovelace', - email: 'ada@example.com' - }) - ) + email: 'ada@example.com', + }), + ), }; await TestBed.configureTestingModule({ imports: [LoginPageComponent], - providers: [provideRouter([]), { provide: AuthService, useValue: authService }] + providers: [ + provideRouter([]), + { provide: AuthService, useValue: authService }, + { provide: ToastService, useValue: toastService }, + ], }).compileComponents(); const fixture = TestBed.createComponent(LoginPageComponent); @@ -34,17 +48,56 @@ describe('LoginPageComponent', () => { component.form.setValue({ email: 'ada@example.com', - password: 'secret123' + password: 'secret123', }); component.onSubmit(); expect(authService.login).toHaveBeenCalledWith({ email: 'ada@example.com', - password: 'secret123' + password: 'secret123', }); expect(redirectSpy).toHaveBeenCalled(); expect(navigateSpy).not.toHaveBeenCalled(); + expect(toastService.showAfterReload).toHaveBeenCalledWith( + 'Sesión iniciada correctamente.', + 'success', + ); + }); + + it('serializes a stored return URL before reloading so tenant base paths are restored', async () => { + await TestBed.configureTestingModule({ + imports: [LoginPageComponent], + providers: [ + provideRouter([]), + { provide: AuthService, useValue: {} }, + { provide: ToastService, useValue: toastService }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(LoginPageComponent); + const component = fixture.componentInstance as any; + const router = TestBed.inject(Router); + const assign = vi.fn(); + const parsedUrl = router.parseUrl('/'); + + component.document = { + defaultView: { + sessionStorage: { + getItem: vi.fn().mockReturnValue('/'), + removeItem: vi.fn(), + }, + }, + location: { assign }, + }; + const parseUrlSpy = vi.spyOn(router, 'parseUrl').mockReturnValue(parsedUrl); + const serializeUrlSpy = vi.spyOn(router, 'serializeUrl').mockReturnValue('/sonder'); + + component.redirectToHome(); + + expect(parseUrlSpy).toHaveBeenCalledWith('/'); + expect(serializeUrlSpy).toHaveBeenCalledWith(parsedUrl); + expect(assign).toHaveBeenCalledWith('/sonder'); }); it('surfaces backend login errors', async () => { @@ -53,16 +106,20 @@ describe('LoginPageComponent', () => { throwError(() => ({ error: { errors: { - email: ['Las credenciales son invalidas.'] - } - } - })) - ) + email: ['Las credenciales son invalidas.'], + }, + }, + })), + ), }; await TestBed.configureTestingModule({ imports: [LoginPageComponent], - providers: [provideRouter([]), { provide: AuthService, useValue: authService }] + providers: [ + provideRouter([]), + { provide: AuthService, useValue: authService }, + { provide: ToastService, useValue: toastService }, + ], }).compileComponents(); const fixture = TestBed.createComponent(LoginPageComponent); @@ -70,22 +127,27 @@ describe('LoginPageComponent', () => { component.form.setValue({ email: 'ada@example.com', - password: 'wrong-password' + password: 'wrong-password', }); component.onSubmit(); expect(component.serverError()).toBe('Las credenciales son invalidas.'); + expect(toastService.danger).toHaveBeenCalledWith('Las credenciales son invalidas.'); }); it('validates email length and password minimum length before submit', async () => { const authService = { - login: vi.fn() + login: vi.fn(), }; await TestBed.configureTestingModule({ imports: [LoginPageComponent], - providers: [provideRouter([]), { provide: AuthService, useValue: authService }] + providers: [ + provideRouter([]), + { provide: AuthService, useValue: authService }, + { provide: ToastService, useValue: toastService }, + ], }).compileComponents(); const fixture = TestBed.createComponent(LoginPageComponent); @@ -93,12 +155,15 @@ describe('LoginPageComponent', () => { component.form.setValue({ email: `${'a'.repeat(250)}@example.com`, - password: '1234567' + password: '1234567', }); component.onSubmit(); expect(authService.login).not.toHaveBeenCalled(); expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.'); expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.'); + expect(toastService.danger).toHaveBeenCalledWith( + 'Revisá los datos ingresados para iniciar sesión.', + ); }); }); diff --git a/src/app/features/store/pages/login-page/login-page.component.ts b/src/app/features/store/pages/login-page/login-page.component.ts index d573af4..04f2bf6 100644 --- a/src/app/features/store/pages/login-page/login-page.component.ts +++ b/src/app/features/store/pages/login-page/login-page.component.ts @@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { AuthService } from '../../../../core/services/auth/auth.service'; +import { ToastService } from '../../../../core/services/toast.service'; import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { InputComponent } from '../../../../shared/components/input/input.component'; @@ -16,13 +17,14 @@ const POST_LOGIN_RETURN_URL_KEY = 'shopit.auth.return-url'; imports: [ReactiveFormsModule, InputComponent, ButtonComponent], templateUrl: './login-page.component.html', styleUrl: './login-page.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class LoginPageComponent { private readonly formBuilder = inject(FormBuilder); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly authService = inject(AuthService); + private readonly toastService = inject(ToastService); private readonly document = inject(DOCUMENT); private readonly platformId = inject(PLATFORM_ID); @@ -32,7 +34,7 @@ export class LoginPageComponent { protected readonly form = this.formBuilder.nonNullable.group({ email: ['', [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)]], - password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]] + password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]], }); protected readonly submitted = this.submittedState.asReadonly(); protected readonly serverError = this.serverErrorState.asReadonly(); @@ -64,6 +66,7 @@ export class LoginPageComponent { if (this.form.invalid) { this.form.markAllAsTouched(); + this.toastService.danger('Revisá los datos ingresados para iniciar sesión.'); return; } @@ -72,12 +75,13 @@ export class LoginPageComponent { this.authService.login(this.form.getRawValue()).subscribe({ next: () => { this.isSubmittingState.set(false); + this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success'); this.redirectToHome(); }, error: (error: unknown) => { this.isSubmittingState.set(false); - this.serverErrorState.set(this.resolveErrorMessage(error)); - } + this.showLoginError(error); + }, }); } @@ -91,7 +95,7 @@ export class LoginPageComponent { } this.authService.loginWithGoogle(); } catch (error: unknown) { - this.serverErrorState.set(this.resolveErrorMessage(error)); + this.showLoginError(error); } } @@ -139,10 +143,9 @@ export class LoginPageComponent { const requestedUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY); - const destination = - requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') - ? requestedUrl - : this.router.serializeUrl(this.router.createUrlTree(['/'])); + const internalDestination = + requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') ? requestedUrl : '/'; + const destination = this.router.serializeUrl(this.router.parseUrl(internalDestination)); this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY); this.document.location.assign(destination); @@ -155,15 +158,22 @@ export class LoginPageComponent { this.authService.completeGoogleLogin(oauthCode).subscribe({ next: () => { this.isSubmittingState.set(false); + this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success'); this.redirectToHome(); }, error: (error: unknown) => { this.isSubmittingState.set(false); - this.serverErrorState.set(this.resolveErrorMessage(error)); - } + this.showLoginError(error); + }, }); } + private showLoginError(error: unknown): void { + const message = this.resolveErrorMessage(error); + this.serverErrorState.set(message); + this.toastService.danger(message); + } + private resolveErrorMessage(error: unknown): string { const errorPayload = typeof error === 'object' && error !== null && 'error' in error