fix(logout): enhance logout handling with success and error toasts
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user