Files
rengine/frontend/src/lib/utilities/dates.test.ts
T
Yogesh Ojha a548afffa2 feat(settings): tables that save on change, Users, sheets on their pages
Settings has six tabs: General, API keys, Proxies, Notifications, AI and
Users. Each tab is one card whose controls save on change; list tabs are
tables with a row menu and their Add action in the tab row. A non-admin
sees General and AI, read-only.

Out-of-band testing is the Callback server sheet on the Arsenal Nuclei
tab, with a Need a callback count that filters the library to the same
rows. Bounty Hub settings are a sheet on the Bounty Hub page. The old
settings routes redirect to both.

- API keys store the last test result on the row and clear it when the
  key changes. Providers report whether a test exists. The usage count
  is gone: only ViewDNS ever incremented it.
- Notification channels store the last delivery, written by the
  notifier on its own connection. A channel offers the seven event
  types the product emits, program changes on by default, and levels
  named All events, Warning and critical, Critical only.
- A default proxy applies to a scan launched without a context and is
  preselected for new contexts. A pool hands each scan one endpoint,
  proxy URLs bracket IPv6 hosts, and deleting a proxy clears it from
  its contexts.
- Users can be added with a role, disabled and promoted. An account
  cannot change itself, and one that owns records answers 409.
- Retention windows are validated and include keep forever. The
  instance name cannot be blank and names every page title.
- The AI list drops the two switches nothing read; each feature states
  what it sends. The out-of-band Off help says the checks are skipped.

Connectors, Bounty Hub and Remote control lose their extra page
padding. Migration 5d2e9a8c1b47 adds the test and delivery columns and
drops proxies.mode.

Claude-Session: https://claude.ai/code/session_01RVAHQxZ7JAkoEiTCDiHYue
2026-09-27 10:01:19 +05:30

124 lines
3.6 KiB
TypeScript

import { describe, expect, it, afterEach, beforeEach, vi } from 'vitest';
import { relativeTime, relativeTimeLong, untilTime, uptime } from './dates';
const NOW = new Date('2026-09-11T12:00:00Z');
const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000).toISOString();
const MIN = 1;
const HOUR = 60;
const DAY = 60 * 24;
const MONTH = DAY * 30;
const YEAR = DAY * 365;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => vi.useRealTimers());
describe('relativeTime', () => {
it.each([
[0.5 * MIN, 'just now'],
[1 * MIN, '1m ago'],
[59 * MIN, '59m ago'],
[1 * HOUR, '1h ago'],
[23 * HOUR, '23h ago'],
[1 * DAY, '1d ago'],
[29 * DAY, '29d ago'],
[1 * MONTH, '1mo ago'],
[11 * MONTH, '11mo ago'],
[1 * YEAR, '1y ago'],
[3 * YEAR, '3y ago']
])('reads %i minutes back as %s', (minutes, expected) => {
expect(relativeTime(ago(minutes))).toBe(expected);
});
it('reaches years, where a months-only helper said 36mo ago', () => {
expect(relativeTime(ago(3 * YEAR))).toBe('3y ago');
});
});
describe('relativeTimeLong', () => {
it.each([
[0.5 * MIN, 'just now'],
[1 * MIN, '1 minute ago'],
[2 * MIN, '2 minutes ago'],
[1 * HOUR, '1 hour ago'],
[5 * HOUR, '5 hours ago'],
[1 * DAY, '1 day ago'],
[1 * MONTH, '1 month ago'],
[1 * YEAR, '1 year ago'],
[3 * YEAR, '3 years ago']
])('reads %i minutes back as %s', (minutes, expected) => {
expect(relativeTimeLong(ago(minutes))).toBe(expected);
});
it('always says ago, because three call sites printed "used 3 hours"', () => {
expect(relativeTimeLong(ago(3 * HOUR))).toBe('3 hours ago');
});
});
describe('what neither helper may do', () => {
it.each([null, undefined, '', 'not a date'])('reports %s as never', (value) => {
expect(relativeTime(value)).toBe('never');
expect(relativeTimeLong(value)).toBe('never');
});
it('never reads a missing date as the epoch', () => {
expect(relativeTimeLong(null)).not.toContain('year');
});
it('reads a clock running ahead of ours as now, not a negative count', () => {
const future = new Date(NOW.getTime() + 60 * 60_000).toISOString();
expect(relativeTime(future)).toBe('just now');
expect(relativeTimeLong(future)).toBe('just now');
});
it('takes a Date as readily as a string', () => {
expect(relativeTime(new Date(NOW.getTime() - 2 * HOUR * 60_000))).toBe('2h ago');
});
});
describe('the two renderings agree on the bucket', () => {
it.each([1 * MIN, 90 * MIN, 5 * DAY, 7 * MONTH, 2 * YEAR])('at %i minutes back', (minutes) => {
const short = relativeTime(ago(minutes));
const long = relativeTimeLong(ago(minutes));
expect(long.split(' ')[0]).toBe(short.match(/^\d+/)?.[0]);
});
});
describe('uptime', () => {
it.each([
[0.5 * MIN, 'under a minute'],
[1 * MIN, '1m'],
[3 * HOUR, '3h'],
[2 * DAY, '2d']
])('reads %i minutes back as %s', (minutes, expected) => {
expect(uptime(ago(minutes))).toBe(expected);
});
it('reads a missing start as empty, never "never"', () => {
expect(uptime(null)).toBe('');
});
it('shares the bucket relativeTime uses', () => {
expect(relativeTime(ago(3 * HOUR))).toBe(`${uptime(ago(3 * HOUR))} ago`);
});
});
describe('untilTime', () => {
it('reads a future moment in the same buckets', () => {
expect(untilTime(ago(-5 * HOUR))).toBe('in 5h');
expect(untilTime(ago(-2 * DAY))).toBe('in 2d');
});
it('reads a moment under a minute away', () => {
expect(untilTime(new Date(NOW.getTime() + 20_000).toISOString())).toBe('in under a minute');
});
it('returns null once the moment has passed', () => {
expect(untilTime(ago(35))).toBeNull();
expect(untilTime(null)).toBeNull();
});
});