Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/documents/document-expiration.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
EXPIRY_NOTIFICATION_DAYS,
daysUntilExpiry,
isExpired,
dueNotificationMilestone,
shouldMarkExpired,
} from './document-expiration.util';

describe('document-expiration.util', () => {
const now = new Date('2026-01-01T00:00:00.000Z');

const inDays = (days: number): Date => new Date(now.getTime() + days * 24 * 60 * 60 * 1000);

it('exposes the expected notification milestones', () => {
expect(EXPIRY_NOTIFICATION_DAYS).toEqual([30, 14, 7, 1]);
});

describe('daysUntilExpiry', () => {
it('rounds up remaining days', () => {
expect(daysUntilExpiry(inDays(7), now)).toBe(7);
});

it('is negative once the expiry date has passed', () => {
expect(daysUntilExpiry(inDays(-3), now)).toBe(-3);
});
});

describe('isExpired', () => {
it('returns false when there is no expiry date', () => {
expect(isExpired(null, now)).toBe(false);
});

it('returns false before expiry', () => {
expect(isExpired(inDays(1), now)).toBe(false);
});

it('returns true at or after expiry', () => {
expect(isExpired(now, now)).toBe(true);
expect(isExpired(inDays(-1), now)).toBe(true);
});
});

describe('dueNotificationMilestone', () => {
it('returns null when there is no expiry date', () => {
expect(dueNotificationMilestone(null, now)).toBeNull();
});

it('returns the milestone when today is a notification day', () => {
expect(dueNotificationMilestone(inDays(30), now)).toBe(30);
expect(dueNotificationMilestone(inDays(1), now)).toBe(1);
});

it('returns null when today is not a notification day', () => {
expect(dueNotificationMilestone(inDays(10), now)).toBeNull();
});
});

describe('shouldMarkExpired', () => {
it('is true for a past expiry not yet flagged', () => {
expect(shouldMarkExpired({ expiresAt: inDays(-1), isExpired: false }, now)).toBe(true);
});

it('is false when already flagged expired', () => {
expect(shouldMarkExpired({ expiresAt: inDays(-1), isExpired: true }, now)).toBe(false);
});

it('is false when not yet expired', () => {
expect(shouldMarkExpired({ expiresAt: inDays(5), isExpired: false }, now)).toBe(false);
});
});
});
54 changes: 54 additions & 0 deletions src/documents/document-expiration.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Pure helpers for the document expiration workflow (#962).
*
* These are side-effect free so the scheduled job and notification logic can be
* unit tested without a database. The daily cron uses them to decide which
* documents to notify about, which to mark EXPIRED, and how many days remain.
*/

/** Days before expiry at which the owner should be notified. */
export const EXPIRY_NOTIFICATION_DAYS: readonly number[] = [30, 14, 7, 1];

const MS_PER_DAY = 24 * 60 * 60 * 1000;

/**
* Whole days from `now` until `expiresAt`, rounded up. Negative when the
* expiry date is in the past.
*/
export function daysUntilExpiry(expiresAt: Date, now: Date = new Date()): number {
return Math.ceil((expiresAt.getTime() - now.getTime()) / MS_PER_DAY);
}

/** True when the document has reached or passed its expiry date. */
export function isExpired(expiresAt: Date | null, now: Date = new Date()): boolean {
if (!expiresAt) {
return false;
}
return expiresAt.getTime() <= now.getTime();
}

/**
* Returns the notification milestone (30/14/7/1) that falls due today, or
* `null` if today is not a notification day. Used to avoid double-notifying.
*/
export function dueNotificationMilestone(
expiresAt: Date | null,
now: Date = new Date(),
): number | null {
if (!expiresAt) {
return null;
}
const remaining = daysUntilExpiry(expiresAt, now);
return EXPIRY_NOTIFICATION_DAYS.includes(remaining) ? remaining : null;
}

/**
* Whether a document should transition to EXPIRED: it has an expiry date in the
* past and is not already flagged expired.
*/
export function shouldMarkExpired(
document: { expiresAt: Date | null; isExpired: boolean },
now: Date = new Date(),
): boolean {
return !document.isExpired && isExpired(document.expiresAt, now);
}
73 changes: 73 additions & 0 deletions src/reports/report-schedule.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
ReportFrequency,
ReportType,
nextRunAt,
isReportDue,
toCsvField,
buildCsv,
} from './report-schedule.util';

describe('report-schedule.util', () => {
it('defines the required report types', () => {
expect(Object.values(ReportType)).toEqual(['TRANSACTIONS', 'USERS', 'PROPERTIES', 'FRAUD']);
});

describe('nextRunAt', () => {
// 2026-01-07 is a Wednesday.
const wed = new Date('2026-01-07T09:30:00.000Z');

it('daily → next day at midnight UTC', () => {
expect(nextRunAt(ReportFrequency.DAILY, wed).toISOString()).toBe('2026-01-08T00:00:00.000Z');
});

it('weekly → next Monday at midnight UTC', () => {
expect(nextRunAt(ReportFrequency.WEEKLY, wed).toISOString()).toBe('2026-01-12T00:00:00.000Z');
});

it('monthly → first of next month at midnight UTC', () => {
expect(nextRunAt(ReportFrequency.MONTHLY, wed).toISOString()).toBe(
'2026-02-01T00:00:00.000Z',
);
});

it('weekly from a Monday rolls to the following Monday', () => {
const mon = new Date('2026-01-05T00:00:00.000Z');
expect(nextRunAt(ReportFrequency.WEEKLY, mon).toISOString()).toBe('2026-01-12T00:00:00.000Z');
});
});

describe('isReportDue', () => {
it('is due when the next run is in the past', () => {
const past = new Date('2026-01-01T00:00:00.000Z');
const now = new Date('2026-01-02T00:00:00.000Z');
expect(isReportDue(past, now)).toBe(true);
});

it('is not due when the next run is in the future', () => {
const future = new Date('2026-01-03T00:00:00.000Z');
const now = new Date('2026-01-02T00:00:00.000Z');
expect(isReportDue(future, now)).toBe(false);
});
});

describe('CSV helpers', () => {
it('escapes fields containing commas, quotes, or newlines', () => {
expect(toCsvField('plain')).toBe('plain');
expect(toCsvField('a,b')).toBe('"a,b"');
expect(toCsvField('say "hi"')).toBe('"say ""hi"""');
expect(toCsvField(null)).toBe('');
expect(toCsvField(42)).toBe('42');
});

it('builds a CSV document from headers and rows', () => {
const csv = buildCsv(
['id', 'name'],
[
[1, 'Alice'],
[2, 'Bob, Jr'],
],
);
expect(csv).toBe('id,name\n1,Alice\n2,"Bob, Jr"');
});
});
});
69 changes: 69 additions & 0 deletions src/reports/report-schedule.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Pure helpers for scheduled report generation (#966).
*
* Side-effect free so schedule resolution and CSV assembly can be unit tested
* without a scheduler or mailer. The report job uses these to compute the next
* run time for a configured frequency and to build CSV attachments.
*/

export enum ReportFrequency {
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}

export enum ReportType {
TRANSACTIONS = 'TRANSACTIONS',
USERS = 'USERS',
PROPERTIES = 'PROPERTIES',
FRAUD = 'FRAUD',
}

/**
* Compute the next UTC run time (00:00) after `from` for the given frequency.
* - DAILY: next day
* - WEEKLY: next Monday
* - MONTHLY: first day of next month
*/
export function nextRunAt(frequency: ReportFrequency, from: Date = new Date()): Date {
const year = from.getUTCFullYear();
const month = from.getUTCMonth();
const day = from.getUTCDate();

switch (frequency) {
case ReportFrequency.DAILY:
return new Date(Date.UTC(year, month, day + 1));
case ReportFrequency.WEEKLY: {
const dow = from.getUTCDay(); // 0 = Sunday, 1 = Monday
const daysUntilMonday = (8 - dow) % 7 || 7;
return new Date(Date.UTC(year, month, day + daysUntilMonday));
}
case ReportFrequency.MONTHLY:
return new Date(Date.UTC(year, month + 1, 1));
default:
return new Date(Date.UTC(year, month, day + 1));
}
}

/** True when a scheduled report is due (its next run time is at or before now). */
export function isReportDue(nextRun: Date, now: Date = new Date()): boolean {
return nextRun.getTime() <= now.getTime();
}

/** Escape a single CSV field per RFC 4180. */
export function toCsvField(value: unknown): string {
const str = value === null || value === undefined ? '' : String(value);
if (/[",\n]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}

/** Build a CSV document from a header row and data rows. */
export function buildCsv(headers: string[], rows: unknown[][]): string {
const lines = [headers.map(toCsvField).join(',')];
for (const row of rows) {
lines.push(row.map(toCsvField).join(','));
}
return lines.join('\n');
}
51 changes: 51 additions & 0 deletions src/users/activity-analytics.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
ActivityRecord,
DEFAULT_RETENTION_DAYS,
aggregateByType,
activeUsers,
dailyActiveUsers,
weeklyActiveUsers,
retentionCutoff,
partitionForRetention,
} from './activity-analytics.util';

describe('activity-analytics.util', () => {
const now = new Date('2026-06-01T00:00:00.000Z');
const daysAgo = (n: number): Date => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);

const records: ActivityRecord[] = [
{ userId: 'u1', type: 'LOGIN', createdAt: daysAgo(0) },
{ userId: 'u1', type: 'LOGIN', createdAt: daysAgo(3) },
{ userId: 'u2', type: 'UPLOAD', createdAt: daysAgo(5) },
{ userId: 'u3', type: 'LOGIN', createdAt: daysAgo(120) },
];

it('aggregates counts by type', () => {
expect(aggregateByType(records)).toEqual({ LOGIN: 3, UPLOAD: 1 });
});

it('counts distinct active users within a window', () => {
expect(activeUsers(records, 7, now)).toBe(2); // u1, u2
});

it('computes daily active users', () => {
expect(dailyActiveUsers(records, now)).toBe(1); // only u1 today
});

it('computes weekly active users', () => {
expect(weeklyActiveUsers(records, now)).toBe(2); // u1, u2
});

it('derives the retention cutoff from the default window', () => {
const cutoff = retentionCutoff(now);
expect(cutoff.toISOString()).toBe('2026-03-03T00:00:00.000Z');
expect(DEFAULT_RETENTION_DAYS).toBe(90);
});

it('partitions records into keep and prune by retention window', () => {
const { keep, prune } = partitionForRetention(records, now);
expect(keep).toHaveLength(3);
expect(prune).toHaveLength(1);
expect(prune[0].userId).toBe('u3');
});
});
Loading
Loading