Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
271 lines
8.1 KiB
TypeScript
271 lines
8.1 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(),
|
|
},
|
|
planEntitlement: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
usageLog: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
},
|
|
},
|
|
}));
|
|
|
|
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();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
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(200);
|
|
expect(result.remaining).toBe(150);
|
|
});
|
|
|
|
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 deny BASIC user to use chat by default (strict lock)', 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 in non-production', async () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
mockActiveSubscription('BASIC');
|
|
vi.mocked(redis.get).mockRejectedValue(new Error('Connection refused'));
|
|
|
|
const result = await canUseFeature('user1', 'semantic_search');
|
|
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
it('should fail-closed when Redis is down in production', async () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
mockActiveSubscription('BASIC');
|
|
vi.mocked(redis.get).mockRejectedValue(new Error('Connection refused'));
|
|
|
|
const result = await canUseFeature('user1', 'semantic_search');
|
|
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason).toBe('SERVICE_UNAVAILABLE');
|
|
});
|
|
});
|
|
|
|
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(200);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|