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:
autocommit 2026-06-03 23:57:19 -07:00
parent 5eb466f325
commit 8cf731ce7a
2 changed files with 157 additions and 1 deletions

View file

@ -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');
});
});

View file

@ -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':