fix(logout): enhance logout handling with success and error toasts

This commit is contained in:
2026-09-07 10:14:11 -03:00
parent 8ba34c6c7a
commit f859eba8a8
6 changed files with 215 additions and 33 deletions

View File

@@ -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', () => {

View File

@@ -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<void> {
if (this.isCreatingPurchase()) {
return;

View File

@@ -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();
});
});

View File

@@ -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<Toast[]>([]);
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<Toast>;
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.
}
}
}

View File

@@ -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<typeof vi.fn>;
showAfterReload: ReturnType<typeof vi.fn>;
};
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.',
);
});
});

View File

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