feat: Challenges Page and Challenge Solve Modal
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user