feat(policy): ✨ Add plus variant tier handling to normalize premium/bookings-plus tiers in BumpPolicyService
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5eb466f325
commit
8cf731ce7a
2 changed files with 157 additions and 1 deletions
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PlatformApiClient } from '@cocottetech/surface-adapter-contracts';
|
||||
|
||||
import {
|
||||
BumpPolicyService,
|
||||
TRYST_HARD_FLOOR_MINUTES,
|
||||
type SurfaceBumpPolicyRow,
|
||||
} from './bump-policy.service.js';
|
||||
|
||||
function policy(overrides: Partial<SurfaceBumpPolicyRow> = {}): SurfaceBumpPolicyRow {
|
||||
return {
|
||||
surface: 'tryst',
|
||||
enabled: true,
|
||||
cadence_minutes: 180,
|
||||
active_start: '00:00',
|
||||
active_end: '00:00',
|
||||
active_days: [1, 2, 3, 4, 5, 6, 7],
|
||||
paused_until: null,
|
||||
last_bump_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function svc(): BumpPolicyService {
|
||||
const api: PlatformApiClient = { get: vi.fn(), post: vi.fn() };
|
||||
return new BumpPolicyService(api);
|
||||
}
|
||||
|
||||
describe('BumpPolicyService.effectiveCadenceMinutes', () => {
|
||||
it('clamps a sub-floor policy cadence up to the Basic/Standard 3h floor', () => {
|
||||
expect(svc().effectiveCadenceMinutes(policy({ cadence_minutes: 60 }), 'basic')).toBe(180);
|
||||
expect(svc().effectiveCadenceMinutes(policy({ cadence_minutes: 60 }), 'standard')).toBe(180);
|
||||
});
|
||||
|
||||
it('uses the Premium/Premium+ 2h floor', () => {
|
||||
expect(svc().effectiveCadenceMinutes(policy({ cadence_minutes: 30 }), 'premium')).toBe(120);
|
||||
expect(svc().effectiveCadenceMinutes(policy({ cadence_minutes: 30 }), 'premium_plus')).toBe(120);
|
||||
});
|
||||
|
||||
it('honours a longer-than-floor policy cadence', () => {
|
||||
expect(svc().effectiveCadenceMinutes(policy({ cadence_minutes: 360 }), 'premium')).toBe(360);
|
||||
});
|
||||
|
||||
it('never goes below the absolute hard floor', () => {
|
||||
// A pathological tier floor below the hard floor would still be clamped up.
|
||||
expect(svc().effectiveCadenceMinutes(policy({ cadence_minutes: 1 }), 'premium')).toBeGreaterThanOrEqual(
|
||||
TRYST_HARD_FLOOR_MINUTES,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BumpPolicyService.decide — active window (wrap-around)', () => {
|
||||
// 10:00 → 02:00 window (wraps midnight). 600 = 10:00, 1380 = 23:00, 60 = 01:00, 300 = 05:00.
|
||||
const wrap = policy({ active_start: '10:00', active_end: '02:00' });
|
||||
|
||||
it('is eligible at 23:00 (inside the pre-midnight half)', () => {
|
||||
const d = svc().decide(wrap, 'basic', { localMinutesOfDay: 1380, localIsoWeekday: 1 });
|
||||
expect(d.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('is eligible at 01:00 (inside the post-midnight half)', () => {
|
||||
const d = svc().decide(wrap, 'basic', { localMinutesOfDay: 60, localIsoWeekday: 1 });
|
||||
expect(d.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('is blocked at 05:00 (outside the wrap window)', () => {
|
||||
const d = svc().decide(wrap, 'basic', { localMinutesOfDay: 300, localIsoWeekday: 1 });
|
||||
expect(d.eligible).toBe(false);
|
||||
expect(d.blockReason).toBe('outside-active-hours');
|
||||
});
|
||||
|
||||
it('treats start == end as a 24h always-open window', () => {
|
||||
const allDay = policy({ active_start: '00:00', active_end: '00:00' });
|
||||
const d = svc().decide(allDay, 'basic', { localMinutesOfDay: 720, localIsoWeekday: 3 });
|
||||
expect(d.eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BumpPolicyService.decide — gates', () => {
|
||||
it('blocks when disabled', () => {
|
||||
const d = svc().decide(policy({ enabled: false }), 'basic', { localMinutesOfDay: 720, localIsoWeekday: 1 });
|
||||
expect(d.blockReason).toBe('disabled');
|
||||
});
|
||||
|
||||
it('blocks when paused_until is in the future', () => {
|
||||
const future = new Date(Date.now() + 3_600_000).toISOString();
|
||||
const d = svc().decide(policy({ paused_until: future }), 'basic', {
|
||||
localMinutesOfDay: 720,
|
||||
localIsoWeekday: 1,
|
||||
});
|
||||
expect(d.blockReason).toBe('paused');
|
||||
});
|
||||
|
||||
it('does not block when paused_until is in the past', () => {
|
||||
const past = new Date(Date.now() - 3_600_000).toISOString();
|
||||
const d = svc().decide(policy({ paused_until: past }), 'basic', {
|
||||
localMinutesOfDay: 720,
|
||||
localIsoWeekday: 1,
|
||||
});
|
||||
expect(d.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks on an inactive weekday', () => {
|
||||
const d = svc().decide(policy({ active_days: [1, 2, 3, 4, 5] }), 'basic', {
|
||||
localMinutesOfDay: 720,
|
||||
localIsoWeekday: 6, // Saturday
|
||||
});
|
||||
expect(d.blockReason).toBe('inactive-day');
|
||||
});
|
||||
|
||||
it('blocks when the last bump is newer than the effective cadence', () => {
|
||||
const now = new Date('2026-06-03T12:00:00.000Z');
|
||||
const lastBumpAt = new Date('2026-06-03T11:00:00.000Z').toISOString(); // 60 min ago
|
||||
const d = svc().decide(policy(), 'basic', {
|
||||
now,
|
||||
lastBumpAt,
|
||||
localMinutesOfDay: 720,
|
||||
localIsoWeekday: 3,
|
||||
});
|
||||
expect(d.blockReason).toBe('too-soon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BumpPolicyService.loadPolicy / loadTier', () => {
|
||||
it('returns null when platform.api has no policy row', async () => {
|
||||
const api: PlatformApiClient = { get: vi.fn().mockResolvedValue(null), post: vi.fn() };
|
||||
const service = new BumpPolicyService(api);
|
||||
expect(await service.loadPolicy('user_1')).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes an unknown tier to basic (most conservative)', async () => {
|
||||
const api: PlatformApiClient = {
|
||||
get: vi.fn().mockResolvedValue({ tier: 'mystery' }),
|
||||
post: vi.fn(),
|
||||
};
|
||||
const service = new BumpPolicyService(api);
|
||||
expect(await service.loadTier('user_1')).toBe('basic');
|
||||
});
|
||||
|
||||
it('normalizes "Premium+" to premium_plus', async () => {
|
||||
const api: PlatformApiClient = {
|
||||
get: vi.fn().mockResolvedValue({ tier: 'Premium+' }),
|
||||
post: vi.fn(),
|
||||
};
|
||||
const service = new BumpPolicyService(api);
|
||||
expect(await service.loadTier('user_1')).toBe('premium_plus');
|
||||
});
|
||||
});
|
||||
|
|
@ -252,7 +252,15 @@ function localMinutesOfDay(date: Date): number {
|
|||
|
||||
/** Normalize a free-form tier label to a {@link TrystTier}; unknown ⇒ basic. */
|
||||
function normalizeTier(raw: string | undefined): TrystTier {
|
||||
switch ((raw ?? '').trim().toLowerCase().replace(/[\s+]+/g, '_')) {
|
||||
// Map a trailing '+' to '_plus' first (Premium+ → premium_plus), then collapse
|
||||
// any remaining whitespace/separators to underscores.
|
||||
const key = (raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\+/g, '_plus')
|
||||
.replace(/[\s_]+/g, '_')
|
||||
.replace(/^_|_$/g, '');
|
||||
switch (key) {
|
||||
case 'standard':
|
||||
return 'standard';
|
||||
case 'premium':
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue