generated from bastian/boilerplate
meldungen
This commit is contained in:
@@ -9,6 +9,7 @@ import { routes } from './app.routes';
|
||||
import { csrfInterceptor } from './core/csrf.interceptor';
|
||||
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
|
||||
import { titleStrategyProvider } from './core/title.strategy';
|
||||
import { backendErrorToastInterceptor } from './core/backend-error-toast.interceptor';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -16,7 +17,9 @@ export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
||||
provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])),
|
||||
provideHttpClient(
|
||||
withInterceptors([csrfInterceptor, sessionExpiryInterceptor, backendErrorToastInterceptor]),
|
||||
),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
titleStrategyProvider,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ToastService } from '../shared/ui';
|
||||
import { backendErrorToastInterceptor } from './backend-error-toast.interceptor';
|
||||
|
||||
describe('backendErrorToastInterceptor', () => {
|
||||
it('shows the backend message, validation details and request ID', () => {
|
||||
const show = vi.fn();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([backendErrorToastInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: ToastService, useValue: { show } },
|
||||
],
|
||||
});
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
http.post('/api/projects/project-1/rooms', {}).subscribe({ error: () => undefined });
|
||||
controller.expectOne('/api/projects/project-1/rooms').flush(
|
||||
{
|
||||
status: 400,
|
||||
code: 'VALIDATION_FAILED',
|
||||
message: 'Die Eingaben sind ungültig.',
|
||||
requestId: 'request-123',
|
||||
validation: [{ field: 'name', messages: ['Name fehlt.'] }],
|
||||
},
|
||||
{ status: 400, statusText: 'Bad Request' },
|
||||
);
|
||||
|
||||
expect(show).toHaveBeenCalledWith({
|
||||
tone: 'danger',
|
||||
title: 'Anfrage fehlgeschlagen',
|
||||
message: 'Die Eingaben sind ungültig. name: Name fehlt.',
|
||||
requestId: 'request-123',
|
||||
});
|
||||
controller.verify();
|
||||
});
|
||||
|
||||
it('does not show an error for the expected anonymous session check', () => {
|
||||
const show = vi.fn();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([backendErrorToastInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: ToastService, useValue: { show } },
|
||||
],
|
||||
});
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
http.get('/api/auth/me').subscribe({ error: () => undefined });
|
||||
controller.expectOne('/api/auth/me').flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
controller.verify();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { HttpErrorResponse, type HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import type { ApiErrorBody } from '@boilerplate/api-client';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { ToastService } from '../shared/ui';
|
||||
|
||||
export const backendErrorToastInterceptor: HttpInterceptorFn = (request, next) => {
|
||||
const toasts = inject(ToastService);
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof HttpErrorResponse && isBackendRequest(request.url)) {
|
||||
const body = apiErrorBody(error.error);
|
||||
if (!isExpectedAnonymousCheck(request.method, request.url, error.status)) {
|
||||
toasts.show({
|
||||
tone: 'danger',
|
||||
title: error.status >= 500 ? 'Serverfehler' : 'Anfrage fehlgeschlagen',
|
||||
message: messageFor(error, body),
|
||||
...(body?.requestId ? { requestId: body.requestId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function isBackendRequest(url: string): boolean {
|
||||
return new URL(url, document.baseURI).pathname.startsWith('/api/');
|
||||
}
|
||||
|
||||
function isExpectedAnonymousCheck(method: string, url: string, status: number): boolean {
|
||||
return (
|
||||
method === 'GET' && status === 401 && new URL(url, document.baseURI).pathname === '/api/auth/me'
|
||||
);
|
||||
}
|
||||
|
||||
function apiErrorBody(value: unknown): ApiErrorBody | undefined {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('message' in value) ||
|
||||
typeof value.message !== 'string' ||
|
||||
!('requestId' in value) ||
|
||||
typeof value.requestId !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return value as ApiErrorBody;
|
||||
}
|
||||
|
||||
function messageFor(error: HttpErrorResponse, body: ApiErrorBody | undefined): string {
|
||||
if (error.status === 0) return 'Das Backend ist derzeit nicht erreichbar.';
|
||||
const details = body?.validation
|
||||
?.flatMap((entry) => entry.messages.map((message) => `${entry.field}: ${message}`))
|
||||
.join(' · ');
|
||||
if (body && details) return `${body.message} ${details}`;
|
||||
return body?.message ?? 'Die Anfrage konnte nicht verarbeitet werden.';
|
||||
}
|
||||
Reference in New Issue
Block a user