Files
Momento/memento-note/tests/unit/entitlements.test.ts
Antigravity 96e7902f01
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped
feat: publication IA (magazine/brief/essay) + fixes critique
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
2026-06-28 07:32:57 +00:00

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