feat(shared): gate paid features from one entitlement source
Desktop, web, mobile, and the API each decided locally what a tier could do, so a plan change could unlock a feature on one surface and not another. Entitlement checks now live in `@d3ro/core` and are exercised by tests. The shared theme and design-system packages also gain the tokens the new surfaces consume, and the api-client exposes the dictionary and team types the clients now send.
This commit is contained in:
parent
6ba25f53b7
commit
f6a29db95a
13 changed files with 576 additions and 11 deletions
98
apps/desktop/tests/shared/entitlement.test.ts
Normal file
98
apps/desktop/tests/shared/entitlement.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
adminRoleAtLeast,
|
||||
entitlementFeatures,
|
||||
maxEntitlementTier,
|
||||
normalizeAdminRole,
|
||||
normalizeEntitlementTier,
|
||||
resolveEntitlement,
|
||||
} from '@d3ro/core/entitlement'
|
||||
import { Feature } from '@d3ro/core/types'
|
||||
|
||||
describe('entitlement tier normalization', () => {
|
||||
it('maps cloud and desktop tiers onto the canonical set', () => {
|
||||
expect(normalizeEntitlementTier('free')).toBe('free')
|
||||
expect(normalizeEntitlementTier('pro')).toBe('pro')
|
||||
expect(normalizeEntitlementTier('pro_plus')).toBe('pro_plus')
|
||||
expect(normalizeEntitlementTier('team')).toBe('pro_plus')
|
||||
expect(normalizeEntitlementTier('enterprise')).toBe('pro_plus')
|
||||
})
|
||||
|
||||
it('falls back to free for unknown values', () => {
|
||||
expect(normalizeEntitlementTier(undefined)).toBe('free')
|
||||
expect(normalizeEntitlementTier(42)).toBe('free')
|
||||
expect(normalizeEntitlementTier('vip')).toBe('free')
|
||||
})
|
||||
|
||||
it('picks the higher tier', () => {
|
||||
expect(maxEntitlementTier('free', 'pro')).toBe('pro')
|
||||
expect(maxEntitlementTier('pro_plus', 'pro')).toBe('pro_plus')
|
||||
expect(maxEntitlementTier('free', 'free')).toBe('free')
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin role normalization', () => {
|
||||
it('normalizes .NET and Supabase spellings', () => {
|
||||
expect(normalizeAdminRole('SuperAdmin')).toBe('superadmin')
|
||||
expect(normalizeAdminRole('super_admin')).toBe('superadmin')
|
||||
expect(normalizeAdminRole('admin')).toBe('admin')
|
||||
expect(normalizeAdminRole('Manager')).toBe('manager')
|
||||
expect(normalizeAdminRole('LegacyDisabled')).toBe('user')
|
||||
expect(normalizeAdminRole(undefined)).toBe('user')
|
||||
})
|
||||
|
||||
it('compares roles by rank', () => {
|
||||
expect(adminRoleAtLeast('admin', 'manager')).toBe(true)
|
||||
expect(adminRoleAtLeast('manager', 'admin')).toBe(false)
|
||||
expect(adminRoleAtLeast('superadmin', 'superadmin')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEntitlement', () => {
|
||||
it('takes the highest paid tier across sources', () => {
|
||||
const snapshot = resolveEntitlement({ subscriptionTier: 'free', licenseTier: 'pro_plus' })
|
||||
expect(snapshot.tier).toBe('pro_plus')
|
||||
expect(snapshot.licenseTier).toBe('pro_plus')
|
||||
expect(snapshot.adFree).toBe(true)
|
||||
expect(snapshot.features).toContain(Feature.TEAM_WORKSPACE)
|
||||
})
|
||||
|
||||
it('does not downgrade a paid subscription with a stale free license', () => {
|
||||
const snapshot = resolveEntitlement({ subscriptionTier: 'pro', licenseTier: 'free' })
|
||||
expect(snapshot.tier).toBe('pro')
|
||||
expect(snapshot.source).toBe('supabase')
|
||||
})
|
||||
|
||||
it('collapses an expired period to free', () => {
|
||||
const snapshot = resolveEntitlement({
|
||||
subscriptionTier: 'pro_plus',
|
||||
expiresAt: Date.now() - 1000,
|
||||
})
|
||||
expect(snapshot.tier).toBe('free')
|
||||
expect(snapshot.adFree).toBe(false)
|
||||
expect(snapshot.features).not.toContain(Feature.TEAM_WORKSPACE)
|
||||
})
|
||||
|
||||
it('prefers the Supabase role over the .NET role for admin capabilities', () => {
|
||||
const snapshot = resolveEntitlement({ dotnetRole: 'SuperAdmin', supabaseRole: 'manager' })
|
||||
expect(snapshot.adminRole).toBe('manager')
|
||||
expect(snapshot.adminCapabilities).toEqual({
|
||||
canManage: true,
|
||||
canAdmin: false,
|
||||
canSuperAdmin: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no source when nothing is provided', () => {
|
||||
const snapshot = resolveEntitlement()
|
||||
expect(snapshot.tier).toBe('free')
|
||||
expect(snapshot.adminRole).toBe('user')
|
||||
expect(snapshot.source).toBe('none')
|
||||
})
|
||||
|
||||
it('grants the team feature only to paid tiers', () => {
|
||||
expect(entitlementFeatures('free')).not.toContain(Feature.TEAM_WORKSPACE)
|
||||
expect(entitlementFeatures('pro')).toContain(Feature.TEAM_WORKSPACE)
|
||||
expect(entitlementFeatures('free')).toContain(Feature.DICTATION)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue