From 8cf731ce7a3ec1aa1c940c5fc863efce29d9b0e5 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 23:57:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(policy):=20=E2=9C=A8=20Add=20plus=20varian?= =?UTF-8?q?t=20tier=20handling=20to=20normalize=20premium/bookings-plus=20?= =?UTF-8?q?tiers=20in=20BumpPolicyService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/policy/bump-policy.service.spec.ts | 148 ++++++++++++++++++ .../ai-core/src/policy/bump-policy.service.ts | 10 +- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.spec.ts diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.spec.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.spec.ts new file mode 100644 index 0000000..349b9ae --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.ts index 1937a57..fecccc3 100644 --- a/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.ts +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/policy/bump-policy.service.ts @@ -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':