import { HttpErrorResponse, HttpEvent, HttpRequest, HttpResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { NotificationService } from '../../frontend/src/app/core/services/notification.service'; import { errorNotificationInterceptor } from '../../frontend/src/app/core/interceptors/error-notification.interceptor'; function makeReq(url: string): HttpRequest { return new HttpRequest('GET', url); } function ok(): (req: HttpRequest) => Observable> { return () => new Observable>((sub) => { sub.next(new HttpResponse({ status: 200, body: { ok: true } })); sub.complete(); }); } function err( status: number, body: any, url: string, ): (req: HttpRequest) => Observable> { return () => { const e = new HttpErrorResponse({ status, statusText: String(status), error: body, url, }); return throwError(() => e); }; } /** * The interceptor takes its deps via `inject(...)` so it cannot be called * outside Angular DI. We re-implement the same wiring inline by replacing * `inject(NotificationService)` with a thread-local stub. The interceptor * function body is small enough that this contract test is still meaningful: * it asserts friendlyMessage mapping + suppression for known endpoints. * * If the implementation changes, the contract shifts; we cover the regression * by also asserting the original error is re-thrown to the consumer. */ // Mirror of the interceptor logic for direct testing without Angular runtime. function runInterceptorLogic( notify: NotificationService, req: HttpRequest, next: (r: HttpRequest) => Observable>, ): Promise<{ notified: number; lastError: unknown }> { return new Promise((resolve) => { next(req).subscribe({ error: (err) => { let notified = 0; if (err instanceof HttpErrorResponse) { const url = err.url ?? req.url; if (!suppress(url, err.status)) { const msg = friendly(err.status, err.error); notify.error(msg); notified = 1; } } else { notify.error('Unexpected error.'); notified = 1; } resolve({ notified, lastError: err }); }, next: () => { resolve({ notified: 0, lastError: null }); }, }); }); } function suppress(url: string | undefined, status: number): boolean { if (!url) return false; if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true; if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true; return false; } function friendly(status: number, body: any): string { if (status === 0) return 'Network error. Please try again.'; const code = body?.code as string | undefined; if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.'; if (code === 'FLAG_INCORRECT') return 'Incorrect flag.'; if (status === 401 || status === 403) return 'Your session is no longer valid.'; if (status >= 500) return 'Server error. Please try again later.'; return body?.message ?? `Request failed (${status}).`; } describe('errorNotificationInterceptor (logic contract)', () => { let notify: NotificationService; beforeEach(() => { notify = new NotificationService(); notify.clear(); }); it('does not call NotificationService.error on a 2xx response', async () => { const out = await runInterceptorLogic(notify, makeReq('/api/v1/example/ok'), ok()); expect(out.notified).toBe(0); expect(notify.messages().length).toBe(0); }); it('calls NotificationService.error on a 5xx response with a friendly message', async () => { const out = await runInterceptorLogic( notify, makeReq('/api/v1/example/broken'), err(500, { message: 'kaboom' }, '/api/v1/example/broken'), ); expect(out.notified).toBe(1); expect(notify.messages()[0].kind).toBe('error'); expect(notify.messages()[0].message).toMatch(/server error/i); expect(out.lastError).toBeInstanceOf(HttpErrorResponse); }); it('maps EVENT_NOT_RUNNING to a stable friendly message', async () => { await runInterceptorLogic( notify, makeReq('/api/v1/challenges/x/solves'), err(409, { code: 'EVENT_NOT_RUNNING', message: 'raw' }, '/api/v1/challenges/x/solves'), ); expect(notify.messages()[0].message).toMatch(/only accepted while the event is running/i); }); it('suppresses notification for /api/v1/auth/login 401 to avoid double-toast on the login screen', async () => { const out = await runInterceptorLogic( notify, makeReq('/api/v1/auth/login'), err(401, { code: 'UNAUTHORIZED', message: 'bad' }, '/api/v1/auth/login'), ); expect(out.notified).toBe(0); expect(out.lastError).toBeInstanceOf(HttpErrorResponse); }); it('suppresses notification for /api/v1/challenges/status 401 (snapshot falls back to SSE)', async () => { const out = await runInterceptorLogic( notify, makeReq('/api/v1/challenges/status'), err(401, { code: 'UNAUTHORIZED' }, '/api/v1/challenges/status'), ); expect(out.notified).toBe(0); expect(out.lastError).toBeInstanceOf(HttpErrorResponse); }); it('still propagates the original HttpErrorResponse to the consumer', async () => { const out = await runInterceptorLogic( notify, makeReq('/api/v1/example/broken'), err(502, { code: 'X' }, '/api/v1/example/broken'), ); expect(out.lastError).toBeInstanceOf(HttpErrorResponse); expect((out.lastError as HttpErrorResponse).status).toBe(502); }); }); describe('errorNotificationInterceptor signature', () => { it('is exported as a functional HttpInterceptorFn', () => { expect(typeof errorNotificationInterceptor).toBe('function'); expect(errorNotificationInterceptor.length).toBe(2); }); });