AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password (#14)
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('POST /api/v1/auth/change-password', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function buildLoggedInAgent(): Promise<any> {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
const csrf = cookies.find((c: any) => c.name === 'csrf');
|
||||
const res = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
(agent as any).accessToken = res.body.accessToken as string;
|
||||
return agent;
|
||||
}
|
||||
|
||||
it('rejects unauthenticated requests (CSRF or auth guard)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/change-password')
|
||||
.send({ oldPassword: 'x', newPassword: 'Abcdefg1!Xyz', confirmNewPassword: 'Abcdefg1!Xyz' });
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('rejects when new and confirm do not match (VALIDATION_FAILED or PASSWORDS_DO_NOT_MATCH)', async () => {
|
||||
const agent = await buildLoggedInAgent();
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
const res = await agent
|
||||
.post('/api/v1/auth/change-password')
|
||||
.set('Authorization', `Bearer ${agent.accessToken}`)
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ oldPassword: 'Sup3rSecret!Pass', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'Different1!Pass' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(['PASSWORDS_DO_NOT_MATCH', 'VALIDATION_FAILED']).toContain(res.body.code);
|
||||
});
|
||||
|
||||
it('rejects when old password is wrong (INVALID_OLD_PASSWORD)', async () => {
|
||||
const agent = await buildLoggedInAgent();
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
await agent
|
||||
.post('/api/v1/auth/change-password')
|
||||
.set('Authorization', `Bearer ${agent.accessToken}`)
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ oldPassword: 'WrongOld!Pass1', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'NewSecret!Pass1' })
|
||||
.expect(401)
|
||||
.expect((r) => {
|
||||
expect(r.body.code).toBe('INVALID_OLD_PASSWORD');
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when new password is too weak (PASSWORD_POLICY)', async () => {
|
||||
const agent = await buildLoggedInAgent();
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
await agent
|
||||
.post('/api/v1/auth/change-password')
|
||||
.set('Authorization', `Bearer ${agent.accessToken}`)
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ oldPassword: 'Sup3rSecret!Pass', newPassword: 'short', confirmNewPassword: 'short' })
|
||||
.expect(400)
|
||||
.expect((r) => {
|
||||
expect(r.body.code).toBe('PASSWORD_POLICY');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EventStatusService } from '../../backend/src/common/services/event-status.service';
|
||||
|
||||
describe('EventStatusService.getState', () => {
|
||||
const settings = { get: jest.fn() } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
settings.get.mockReset();
|
||||
});
|
||||
|
||||
it('returns countdown with positive secondsToStart before start', async () => {
|
||||
const start = new Date(Date.now() + 60_000).toISOString();
|
||||
const end = new Date(Date.now() + 120_000).toISOString();
|
||||
settings.get.mockImplementation(async (k: string) => (k === 'eventStartUtc' ? start : end));
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('countdown');
|
||||
expect(s.secondsToStart).not.toBeNull();
|
||||
expect(s.secondsToStart!).toBeGreaterThan(0);
|
||||
expect(s.secondsToEnd).toBeNull();
|
||||
});
|
||||
|
||||
it('returns running with secondsToEnd inside window', async () => {
|
||||
const start = new Date(Date.now() - 30_000).toISOString();
|
||||
const end = new Date(Date.now() + 60_000).toISOString();
|
||||
settings.get.mockImplementation(async (k: string) => (k === 'eventStartUtc' ? start : end));
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('running');
|
||||
expect(s.secondsToEnd).not.toBeNull();
|
||||
expect(s.secondsToStart).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stopped after end', async () => {
|
||||
const start = new Date(Date.now() - 120_000).toISOString();
|
||||
const end = new Date(Date.now() - 60_000).toISOString();
|
||||
settings.get.mockImplementation(async (k: string) => (k === 'eventStartUtc' ? start : end));
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('stopped');
|
||||
expect(s.secondsToStart).toBeNull();
|
||||
expect(s.secondsToEnd).toBeNull();
|
||||
});
|
||||
|
||||
it('returns unconfigured when settings are missing', async () => {
|
||||
settings.get.mockResolvedValue('');
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('unconfigured');
|
||||
expect(s.secondsToStart).toBeNull();
|
||||
expect(s.secondsToEnd).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('GET /api/v1/events/status (authenticated SSE)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated SSE with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/events/status').expect(401);
|
||||
});
|
||||
|
||||
it('emits initial status with state + serverNowUtc + eventStartUtc + eventEndUtc when authenticated', (done) => {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
(async () => {
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
const login = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const token = login.body.accessToken as string;
|
||||
|
||||
const req = request(server)
|
||||
.get('/api/v1/events/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
let received = false;
|
||||
req
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
if (received) return;
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const match = /data: ({.*?})\n/.exec(text);
|
||||
if (match) {
|
||||
try {
|
||||
const payload = JSON.parse(match[1]);
|
||||
expect(payload).toHaveProperty('state');
|
||||
expect(payload).toHaveProperty('serverNowUtc');
|
||||
expect(payload).toHaveProperty('eventStartUtc');
|
||||
expect(payload).toHaveProperty('eventEndUtc');
|
||||
expect(['countdown', 'running', 'stopped', 'unconfigured']).toContain(payload.state);
|
||||
received = true;
|
||||
(res as any).destroy();
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
res.on('error', (err) => cb(err, null));
|
||||
})
|
||||
.end(() => {});
|
||||
})().catch(done);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('GET /api/v1/auth/me', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/auth/me').expect(401);
|
||||
});
|
||||
|
||||
it('returns id, username, role, rank, points when authenticated', async () => {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
const login = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const token = login.body.accessToken as string;
|
||||
|
||||
const res = await agent
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(res.body.username).toBe('admin');
|
||||
expect(res.body.role).toBe('admin');
|
||||
expect(typeof res.body.id).toBe('string');
|
||||
expect(res.body.points).toBe(0);
|
||||
expect(res.body.rank === null || typeof res.body.rank === 'number').toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { UsersRankService } from '../../backend/src/modules/users/users-rank.service';
|
||||
|
||||
function buildMockManager(opts: { pointsByUser?: Map<string, number>; usersAhead?: number }): any {
|
||||
const pointsByUser = opts.pointsByUser ?? new Map();
|
||||
const usersAhead = opts.usersAhead ?? 0;
|
||||
const repo: any = {
|
||||
createQueryBuilder: jest.fn().mockImplementation((alias: string) => {
|
||||
const builder: any = {};
|
||||
builder.select = jest.fn().mockReturnThis();
|
||||
builder.addSelect = jest.fn().mockReturnThis();
|
||||
builder.where = jest.fn().mockReturnThis();
|
||||
builder.groupBy = jest.fn().mockReturnThis();
|
||||
builder.having = jest.fn().mockReturnThis();
|
||||
builder.getRawOne = jest.fn().mockImplementation(async () => {
|
||||
return { total: pointsByUser.get(alias === 's' ? '_self_' : '_self_') ?? 0 };
|
||||
});
|
||||
builder.getRawOneForUser = jest.fn().mockImplementation(async (userId: string) => {
|
||||
return { total: pointsByUser.get(userId) ?? 0 };
|
||||
});
|
||||
builder.getRawMany = jest.fn().mockImplementation(async () => {
|
||||
return Array.from({ length: usersAhead }, (_, i) => ({ userId: 'other_' + i }));
|
||||
});
|
||||
return builder;
|
||||
}),
|
||||
};
|
||||
// Wire the user's id into the "self" lookup.
|
||||
return {
|
||||
getRepository: () => ({
|
||||
createQueryBuilder: () => {
|
||||
const b: any = {};
|
||||
b.select = jest.fn().mockReturnThis();
|
||||
b.addSelect = jest.fn().mockReturnThis();
|
||||
b.where = jest.fn((q: string, p: any) => {
|
||||
b._userId = p?.userId;
|
||||
return b;
|
||||
});
|
||||
b.groupBy = jest.fn().mockReturnThis();
|
||||
b.having = jest.fn().mockReturnThis();
|
||||
b.getRawOne = jest.fn().mockImplementation(async () => {
|
||||
return { total: pointsByUser.get(b._userId) ?? 0 };
|
||||
});
|
||||
b.getRawMany = jest.fn().mockImplementation(async () => {
|
||||
return Array.from({ length: usersAhead }, (_, i) => ({ userId: 'other_' + i }));
|
||||
});
|
||||
return b;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('UsersRankService.rankOfUser', () => {
|
||||
it('returns null rank and 0 points for a user with no solves', async () => {
|
||||
const svc = new UsersRankService();
|
||||
const r = await svc.rankOfUser(buildMockManager({}) as any, 'u1');
|
||||
expect(r.rank).toBeNull();
|
||||
expect(r.points).toBe(0);
|
||||
});
|
||||
|
||||
it('returns rank 1 when no users are ahead', async () => {
|
||||
const svc = new UsersRankService();
|
||||
const r = await svc.rankOfUser(buildMockManager({ pointsByUser: new Map([['u1', 100]]), usersAhead: 0 }) as any, 'u1');
|
||||
expect(r.rank).toBe(1);
|
||||
expect(r.points).toBe(100);
|
||||
});
|
||||
|
||||
it('returns rank = ahead+1', async () => {
|
||||
const svc = new UsersRankService();
|
||||
const r = await svc.rankOfUser(buildMockManager({ pointsByUser: new Map([['u1', 100]]), usersAhead: 2 }) as any, 'u1');
|
||||
expect(r.rank).toBe(3);
|
||||
expect(r.points).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
passwordMatchValidator,
|
||||
buildPasswordPolicyMessage,
|
||||
} from '../../frontend/src/app/features/shell/change-password/change-password-modal.validators';
|
||||
import { formatChangePasswordError } from '../../frontend/src/app/features/shell/change-password/password-feedback';
|
||||
|
||||
describe('passwordMatchValidator', () => {
|
||||
const grp = (newP: string, conf: string) => ({
|
||||
get: (k: string) =>
|
||||
k === 'newPassword' ? { value: newP } : k === 'confirmNewPassword' ? { value: conf } : { value: '' },
|
||||
});
|
||||
|
||||
it('returns null when both inputs are equal', () => {
|
||||
expect(passwordMatchValidator(grp('Abc123!Xyz', 'Abc123!Xyz'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns {passwordMismatch:true} when inputs differ', () => {
|
||||
expect(passwordMatchValidator(grp('Abc123!Xyz', 'Other1!Pass'))).toEqual({ passwordMismatch: true });
|
||||
});
|
||||
|
||||
it('returns null when either input is empty (handled by required validators)', () => {
|
||||
expect(passwordMatchValidator(grp('', ''))).toBeNull();
|
||||
expect(passwordMatchValidator(grp('Abc', ''))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPasswordPolicyMessage', () => {
|
||||
it('returns the policy description verbatim', () => {
|
||||
expect(buildPasswordPolicyMessage({ minLength: 12, requireMixed: true, description: 'foo bar' })).toBe('foo bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatChangePasswordError', () => {
|
||||
it('maps INVALID_OLD_PASSWORD to a clear message', () => {
|
||||
expect(formatChangePasswordError({ code: 'INVALID_OLD_PASSWORD' })).toBe('Old password is incorrect');
|
||||
});
|
||||
|
||||
it('maps PASSWORDS_DO_NOT_MATCH', () => {
|
||||
expect(formatChangePasswordError({ code: 'PASSWORDS_DO_NOT_MATCH' })).toBe('New password and confirmation do not match');
|
||||
});
|
||||
|
||||
it('maps PASSWORD_POLICY and surfaces server message if present', () => {
|
||||
expect(formatChangePasswordError({ code: 'PASSWORD_POLICY', message: 'too short' })).toBe('too short');
|
||||
expect(formatChangePasswordError({ code: 'PASSWORD_POLICY' })).toBe('New password does not meet the policy requirements');
|
||||
});
|
||||
|
||||
it('maps UNAUTHORIZED', () => {
|
||||
expect(formatChangePasswordError({ code: 'UNAUTHORIZED' })).toBe('You must be signed in to change your password');
|
||||
});
|
||||
|
||||
it('falls back to message or default for unknown codes', () => {
|
||||
expect(formatChangePasswordError({ code: 'EXOTIC', message: 'Quux' })).toBe('Quux');
|
||||
expect(formatChangePasswordError({ code: 'EXOTIC' })).toBe('Failed to change password');
|
||||
});
|
||||
|
||||
it('handles null/undefined code and message', () => {
|
||||
expect(formatChangePasswordError({ code: null, message: null })).toBe('Failed to change password');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
formatDdHhMm,
|
||||
deriveCountdownText,
|
||||
EventState,
|
||||
} from '../../frontend/src/app/core/services/event-status.pure';
|
||||
|
||||
describe('formatDdHhMm', () => {
|
||||
it('formats 90_061 seconds as 01:01:01', () => {
|
||||
expect(formatDdHhMm(90_061)).toBe('01:01:01');
|
||||
});
|
||||
|
||||
it('formats 800 seconds as 00:00:13', () => {
|
||||
expect(formatDdHhMm(800)).toBe('00:00:13');
|
||||
});
|
||||
|
||||
it('formats 0 seconds as 00:00:00', () => {
|
||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
it('returns 00:00:00 for negative or non-finite values', () => {
|
||||
expect(formatDdHhMm(-5)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveCountdownText', () => {
|
||||
const states: EventState[] = ['countdown', 'running', 'stopped', 'unconfigured'];
|
||||
states.forEach((state) => {
|
||||
it(`returns empty string for ${state} when respective seconds are null`, () => {
|
||||
if (state === 'stopped' || state === 'unconfigured') {
|
||||
expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended');
|
||||
} else {
|
||||
expect(deriveCountdownText(state, null, null)).toBe('00:00:00');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns DD:HH:mm to start when countdown and secondsToStart provided', () => {
|
||||
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00');
|
||||
});
|
||||
|
||||
it('returns DD:HH:mm to end when running and secondsToEnd provided', () => {
|
||||
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13');
|
||||
});
|
||||
|
||||
it('returns "Event ended" when stopped', () => {
|
||||
expect(deriveCountdownText('stopped', 0, 0)).toBe('Event ended');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { deriveActiveSectionFromUrl } from '../../frontend/src/app/features/home/home.shell';
|
||||
|
||||
describe('deriveActiveSectionFromUrl', () => {
|
||||
it('returns Scoreboard for /scoreboard', () => {
|
||||
expect(deriveActiveSectionFromUrl('/scoreboard')).toBe('Scoreboard');
|
||||
});
|
||||
|
||||
it('returns Blog for /blog', () => {
|
||||
expect(deriveActiveSectionFromUrl('/blog')).toBe('Blog');
|
||||
});
|
||||
|
||||
it('returns Admin for /admin', () => {
|
||||
expect(deriveActiveSectionFromUrl('/admin')).toBe('Admin');
|
||||
});
|
||||
|
||||
it('returns Challenges for / or /challenges', () => {
|
||||
expect(deriveActiveSectionFromUrl('/')).toBe('Challenges');
|
||||
expect(deriveActiveSectionFromUrl('/challenges')).toBe('Challenges');
|
||||
});
|
||||
|
||||
it('ignores query string and hash', () => {
|
||||
expect(deriveActiveSectionFromUrl('/scoreboard?x=1')).toBe('Scoreboard');
|
||||
expect(deriveActiveSectionFromUrl('/blog#top')).toBe('Blog');
|
||||
});
|
||||
|
||||
it('falls back to Challenges for empty url', () => {
|
||||
expect(deriveActiveSectionFromUrl('')).toBe('Challenges');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user