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:
parent
80e9ede875
commit
c8b9be585e
3 changed files with 118 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
68
src/notifications/quiet-hours.spec.ts
Normal file
68
src/notifications/quiet-hours.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
38
src/notifications/quiet-hours.ts
Normal file
38
src/notifications/quiet-hours.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue