feat: Challenges Page and Challenge Solve Modal

This commit is contained in:
OpenVelo Agent
2026-07-23 00:13:57 +00:00
parent 77d31e0ee1
commit cb88b21462
32 changed files with 4134 additions and 119 deletions
@@ -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);
});
});