refactor: extrage quiet hours in functie pura si o acopera cu teste

Aritmetica ferestrei care traverseaza miezul noptii era netestata si prinsa
intr-o metoda privata. Extrasa in quiet-hours.ts cu 10 teste care acopera
fereastra simpla, traversarea miezului noptii si politica de bypass CRITICAL.

Documenteaza explicit ca agregarea nu reevalueaza quiet hours: incrementarea
unui contor pe o notificare deja vizibila nu e o intrerupere noua.
This commit is contained in:
valentinbvro 2026-07-29 14:48:04 +02:00
parent 80e9ede875
commit c8b9be585e
3 changed files with 118 additions and 28 deletions

View file

@ -17,6 +17,7 @@ import {
type NotificationRule,
type Severity,
} from './notification-rules';
import { quietHoursDelay } from './quiet-hours';
const BATCH_SIZE = 200;
const SAFETY_LAG_MS = 60_000;
@ -96,6 +97,11 @@ export class NotificationRunnerService {
// Agregare: daca exista deja o notificare necitita cu aceeasi cheie in
// fereastra, o incrementam in loc sa cream a doua (spec §12).
//
// Nota deliberata: agregarea NU reevalueaza quiet hours. Incrementarea
// unui contor pe o notificare deja vizibila nu e o intrerupere noua, deci
// decision.scheduledAt se aplica doar randurilor nou create. Daca
// notificarea existenta e SCHEDULED, ramane SCHEDULED.
if (rule.aggregationWindowMinutes) {
const windowStart = new Date(Date.now() - rule.aggregationWindowMinutes * 60_000);
const existing = await db.query.notifications.findFirst({
@ -210,37 +216,15 @@ export class NotificationRunnerService {
return { deliver: false, scheduledAt: null };
}
const scheduledAt = this.quietHoursDelay(
prefs.quietHoursStart,
prefs.quietHoursEnd,
rule.severity,
prefs.criticalBypassesQuietHours === 1,
);
const scheduledAt = quietHoursDelay({
start: prefs.quietHoursStart,
end: prefs.quietHoursEnd,
severity: rule.severity,
criticalBypasses: prefs.criticalBypassesQuietHours === 1,
});
return { deliver: true, scheduledAt };
}
private quietHoursDelay(
start: number | null,
end: number | null,
severity: Severity,
criticalBypasses: boolean,
): Date | null {
if (start === null || end === null || start === end) return null;
if (severity === 'CRITICAL' && criticalBypasses) return null;
const now = new Date();
const hour = now.getUTCHours();
// Fereastra poate traversa miezul noptii (ex. 22 -> 7).
const inQuiet = start < end ? hour >= start && hour < end : hour >= start || hour < end;
if (!inQuiet) return null;
const deliverAt = new Date(now);
deliverAt.setUTCMinutes(0, 0, 0);
if (hour >= end) deliverAt.setUTCDate(deliverAt.getUTCDate() + 1);
deliverAt.setUTCHours(end);
return deliverAt;
}
private deduplicationKey(
rule: NotificationRule,
event: NotificationEvent,

View file

@ -0,0 +1,68 @@
import { quietHoursDelay } from './quiet-hours';
const at = (hourUtc: number) => new Date(Date.UTC(2026, 6, 29, hourUtc, 30, 0));
describe('quietHoursDelay', () => {
it('livreaza imediat cand userul n-a configurat quiet hours', () => {
expect(
quietHoursDelay({ start: null, end: null, severity: 'INFORMATION', criticalBypasses: true, now: at(3) }),
).toBeNull();
});
it('trateaza start === end ca fiind fara quiet hours, nu ca 24h de liniste', () => {
expect(
quietHoursDelay({ start: 9, end: 9, severity: 'INFORMATION', criticalBypasses: true, now: at(9) }),
).toBeNull();
});
describe('fereastra simpla, in aceeasi zi (9 -> 17)', () => {
it('amana pana la sfarsitul ferestrei cand suntem inauntru', () => {
const result = quietHoursDelay({ start: 9, end: 17, severity: 'INFORMATION', criticalBypasses: true, now: at(13) });
expect(result?.toISOString()).toBe('2026-07-29T17:00:00.000Z');
});
it('livreaza imediat inainte de inceputul ferestrei', () => {
expect(
quietHoursDelay({ start: 9, end: 17, severity: 'INFORMATION', criticalBypasses: true, now: at(8) }),
).toBeNull();
});
it('livreaza imediat exact la ora de sfarsit -- capatul e exclusiv', () => {
expect(
quietHoursDelay({ start: 9, end: 17, severity: 'INFORMATION', criticalBypasses: true, now: at(17) }),
).toBeNull();
});
});
describe('fereastra care traverseaza miezul noptii (22 -> 7)', () => {
it('seara amana pana dimineata ZILEI URMATOARE', () => {
const result = quietHoursDelay({ start: 22, end: 7, severity: 'INFORMATION', criticalBypasses: true, now: at(23) });
expect(result?.toISOString()).toBe('2026-07-30T07:00:00.000Z');
});
it('noaptea tarziu amana pana dimineata ACELEIASI zi', () => {
const result = quietHoursDelay({ start: 22, end: 7, severity: 'INFORMATION', criticalBypasses: true, now: at(3) });
expect(result?.toISOString()).toBe('2026-07-29T07:00:00.000Z');
});
it('la pranz livreaza imediat -- suntem in afara ferestrei', () => {
expect(
quietHoursDelay({ start: 22, end: 7, severity: 'INFORMATION', criticalBypasses: true, now: at(12) }),
).toBeNull();
});
});
describe('CRITICAL', () => {
it('depaseste quiet hours cand politica userului permite', () => {
expect(
quietHoursDelay({ start: 22, end: 7, severity: 'CRITICAL', criticalBypasses: true, now: at(3) }),
).toBeNull();
});
it('respecta quiet hours cand userul a dezactivat bypass-ul', () => {
// Nu presupunem ca "critical" invinge mereu preferinta explicita a userului.
const result = quietHoursDelay({ start: 22, end: 7, severity: 'CRITICAL', criticalBypasses: false, now: at(3) });
expect(result?.toISOString()).toBe('2026-07-29T07:00:00.000Z');
});
});
});

View file

@ -0,0 +1,38 @@
import type { Severity } from './notification-rules';
/**
* Calculeaza cand se poate livra o notificare data fiind fereastra de liniste
* a userului. Intoarce null cand se poate livra imediat.
*
* Functie pura, separata de runner, ca sa fie testabila: aritmetica de ore cu
* fereastra care traverseaza miezul noptii e locul unde apar erorile.
*/
export function quietHoursDelay(params: {
start: number | null;
end: number | null;
severity: Severity;
criticalBypasses: boolean;
now?: Date;
}): Date | null {
const { start, end, severity, criticalBypasses } = params;
const now = params.now ?? new Date();
// start === end ar insemna fereastra de 24h sau de 0h -- ambiguu, deci o
// tratam ca "fara quiet hours" in loc sa ghicim.
if (start === null || end === null || start === end) return null;
if (severity === 'CRITICAL' && criticalBypasses) return null;
const hour = now.getUTCHours();
const crossesMidnight = start > end;
const inQuiet = crossesMidnight ? hour >= start || hour < end : hour >= start && hour < end;
if (!inQuiet) return null;
const deliverAt = new Date(now);
deliverAt.setUTCMinutes(0, 0, 0);
// Daca ora curenta e deja peste ora de sfarsit, sfarsitul ferestrei e maine.
if (hour >= end) {
deliverAt.setUTCDate(deliverAt.getUTCDate() + 1);
}
deliverAt.setUTCHours(end);
return deliverAt;
}