164 lines
5.9 KiB
TypeScript
164 lines
5.9 KiB
TypeScript
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<unknown> {
|
|
return new HttpRequest('GET', url);
|
|
}
|
|
|
|
function ok(): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
|
return () =>
|
|
new Observable<HttpEvent<unknown>>((sub) => {
|
|
sub.next(new HttpResponse({ status: 200, body: { ok: true } }));
|
|
sub.complete();
|
|
});
|
|
}
|
|
|
|
function err(
|
|
status: number,
|
|
body: any,
|
|
url: string,
|
|
): (req: HttpRequest<unknown>) => Observable<HttpEvent<unknown>> {
|
|
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<unknown>,
|
|
next: (r: HttpRequest<unknown>) => Observable<HttpEvent<unknown>>,
|
|
): 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);
|
|
});
|
|
});
|