Files
Momento/memento-note/tests/unit/entitlements.test.ts
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
2026-05-16 12:59:30 +00:00

252 lines
7.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
canUseFeature,
checkEntitlementOrThrow,
QuotaExceededError,
} from '@/lib/entitlements';
vi.mock('@/lib/redis', () => ({
redis: {
get: vi.fn(),
mget: vi.fn(),
eval: vi.fn(),
},
}));
vi.mock('@/lib/byok', () => ({
hasAnyActiveByok: vi.fn().mockResolvedValue(false),
}));
vi.mock('@/lib/prisma', () => ({
prisma: {
subscription: {
findUnique: vi.fn(),
},
},
}));
import { redis } from '@/lib/redis';
import { prisma } from '@/lib/prisma';
function mockActiveSubscription(tier: string) {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'user1',
tier: tier as any,
status: 'ACTIVE' as any,
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
} as any);
}
describe('entitlements', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('canUseFeature', () => {
it('should allow BASIC user when under limit (30)', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('5');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(25);
expect(result.limit).toBe(30);
});
it('should deny BASIC user when at limit (30)', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('30');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it('should allow PRO user with higher limits', async () => {
mockActiveSubscription('PRO');
vi.mocked(redis.get).mockResolvedValue('50');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(true);
expect(result.limit).toBe(100);
expect(result.remaining).toBe(50);
});
it('should allow ENTERPRISE user unlimited features', async () => {
mockActiveSubscription('ENTERPRISE');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(true);
expect(result.limit).toBe(Infinity);
expect(result.remaining).toBe(Infinity);
});
it('should default to BASIC tier when no subscription exists', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
vi.mocked(redis.get).mockResolvedValue('10');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(true);
expect(result.limit).toBe(30);
});
it('should return 0 remaining when usage exceeds limit', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('35');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it('should deny unknown features', async () => {
mockActiveSubscription('BASIC');
const result = await canUseFeature('user1', 'unknownFeature');
expect(result.allowed).toBe(false);
expect(result.limit).toBe(0);
});
it('should fallback to BASIC for INACTIVE subscription', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'user1',
tier: 'PRO' as any,
status: 'INACTIVE' as any,
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
} as any);
vi.mocked(redis.get).mockResolvedValue('5');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.limit).toBe(30);
});
it('should return FEATURE_NOT_AVAILABLE for BASIC user requesting chat', async () => {
mockActiveSubscription('BASIC');
const result = await canUseFeature('user1', 'chat');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('FEATURE_NOT_AVAILABLE');
});
it('should fail-open when Redis is down', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockRejectedValue(new Error('Connection refused'));
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(true);
});
});
describe('checkEntitlementOrThrow', () => {
it('should throw QuotaExceededError when at limit', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('30');
await expect(
checkEntitlementOrThrow('user1', 'semantic_search'),
).rejects.toThrow(QuotaExceededError);
});
it('should not throw when under limit', async () => {
mockActiveSubscription('BASIC');
vi.mocked(redis.get).mockResolvedValue('5');
await expect(
checkEntitlementOrThrow('user1', 'semantic_search'),
).resolves.toBeUndefined();
});
it('should return 402-compatible JSON from QuotaExceededError', () => {
const error = new QuotaExceededError('PRO', 'semantic_search', 30, 30, false);
const json = error.toJSON();
expect(json).toEqual({
error: 'QUOTA_EXCEEDED',
feature: 'semantic_search',
upgradeTier: 'PRO',
byokConfigured: false,
isGuestActor: false,
});
});
});
describe('getEffectiveTier — grace period', () => {
it('should retain PRO tier for PAST_DUE within billing period', async () => {
const futureEnd = new Date();
futureEnd.setDate(futureEnd.getDate() + 10);
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'user1',
tier: 'PRO' as any,
status: 'PAST_DUE' as any,
currentPeriodStart: new Date(),
currentPeriodEnd: futureEnd,
} as any);
vi.mocked(redis.get).mockResolvedValue('5');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.limit).toBe(100);
});
it('should drop to BASIC for PAST_DUE past billing period', async () => {
const pastEnd = new Date();
pastEnd.setDate(pastEnd.getDate() - 5);
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'user1',
tier: 'PRO' as any,
status: 'PAST_DUE' as any,
currentPeriodStart: new Date(),
currentPeriodEnd: pastEnd,
} as any);
vi.mocked(redis.get).mockResolvedValue('5');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.limit).toBe(30);
});
it('should retain tier for CANCELED with cancelAtPeriodEnd still in period', async () => {
const futureEnd = new Date();
futureEnd.setDate(futureEnd.getDate() + 15);
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'user1',
tier: 'BUSINESS' as any,
status: 'CANCELED' as any,
currentPeriodStart: new Date(),
currentPeriodEnd: futureEnd,
} as any);
vi.mocked(redis.get).mockResolvedValue('50');
const result = await canUseFeature('user1', 'semantic_search');
expect(result.limit).toBe(1000);
});
});
describe('QuotaExceededError', () => {
it('should have correct properties', () => {
const error = new QuotaExceededError('PRO', 'semantic_search', 30, 30, false);
expect(error.code).toBe('QUOTA_EXCEEDED');
expect(error.upgradeTier).toBe('PRO');
expect(error.feature).toBe('semantic_search');
expect(error.currentQuota).toBe(30);
expect(error.usedQuota).toBe(30);
expect(error.byokConfigured).toBe(false);
});
});
});