meldungen

This commit is contained in:
Bastian Wagner
2026-07-20 21:20:36 +02:00
parent 8ec1f477d8
commit af5297b335
3 changed files with 122 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ import { routes } from './app.routes';
import { csrfInterceptor } from './core/csrf.interceptor'; import { csrfInterceptor } from './core/csrf.interceptor';
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor'; import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
import { titleStrategyProvider } from './core/title.strategy'; import { titleStrategyProvider } from './core/title.strategy';
import { backendErrorToastInterceptor } from './core/backend-error-toast.interceptor';
registerLocaleData(localeDe); registerLocaleData(localeDe);
@@ -16,7 +17,9 @@ export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
{ provide: LOCALE_ID, useValue: 'de-DE' }, { provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])), provideHttpClient(
withInterceptors([csrfInterceptor, sessionExpiryInterceptor, backendErrorToastInterceptor]),
),
provideRouter(routes, withComponentInputBinding()), provideRouter(routes, withComponentInputBinding()),
titleStrategyProvider, titleStrategyProvider,
], ],

View File

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

View File

@@ -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.';
}