Semaine 1 — fuites et accès : - /archive rendu accessible via sidebar (était invisible) - Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions - Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF - 15 locales mises à jour Semaine 2 — garde-fous : - 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe) - reactStrictMode: true (dev-only, zero impact prod) - reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow - auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse() Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
billingOwnerFromSession,
|
|
getBillingOwner,
|
|
} from '@/lib/brainstorm-collab'
|
|
import {
|
|
checkSessionEntitlementOrThrow,
|
|
incrementUsageAsync,
|
|
QuotaExceededError,
|
|
} from '@/lib/entitlements'
|
|
|
|
const { prismaMock } = vi.hoisted(() => ({
|
|
prismaMock: {
|
|
subscription: { findUnique: vi.fn() },
|
|
brainstormSession: { findUnique: vi.fn() },
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/prisma', () => ({
|
|
prisma: prismaMock,
|
|
default: prismaMock,
|
|
}))
|
|
|
|
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/config', () => ({
|
|
getSystemConfig: vi.fn().mockResolvedValue({
|
|
AI_PROVIDER_CHAT: 'openai',
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/ai/provider-for-user', () => ({
|
|
willUseByokForLane: vi.fn().mockResolvedValue({
|
|
providerType: 'openai',
|
|
usedByok: false,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/credits', () => ({
|
|
reserveCreditsOrThrow: vi.fn(),
|
|
resolveCreditCost: vi.fn().mockReturnValue(1),
|
|
releaseCredits: vi.fn(),
|
|
}))
|
|
|
|
import { redis } from '@/lib/redis'
|
|
import { willUseByokForLane } from '@/lib/ai/provider-for-user'
|
|
import { reserveCreditsOrThrow } from '@/lib/credits'
|
|
|
|
function mockActiveSubscription(tier: string) {
|
|
prismaMock.subscription.findUnique.mockResolvedValue({
|
|
userId: 'host-1',
|
|
tier: tier as any,
|
|
status: 'ACTIVE' as any,
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: new Date(),
|
|
} as any)
|
|
}
|
|
|
|
describe('brainstorm host-pays billing (Story 3.4)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(willUseByokForLane).mockResolvedValue({
|
|
providerType: 'openai',
|
|
usedByok: false,
|
|
})
|
|
vi.mocked(reserveCreditsOrThrow).mockResolvedValue({
|
|
cost: 1,
|
|
unlimited: false,
|
|
})
|
|
})
|
|
|
|
describe('billingOwnerFromSession', () => {
|
|
it('returns host id for host actor', () => {
|
|
expect(billingOwnerFromSession('host-1', 'host-1')).toEqual({
|
|
billingOwnerId: 'host-1',
|
|
isGuestActor: false,
|
|
})
|
|
})
|
|
|
|
it('returns host id for guest actor', () => {
|
|
expect(billingOwnerFromSession('host-1', 'guest-9')).toEqual({
|
|
billingOwnerId: 'host-1',
|
|
isGuestActor: true,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getBillingOwner', () => {
|
|
it('loads session and resolves billing owner', async () => {
|
|
prismaMock.brainstormSession.findUnique.mockResolvedValue({ userId: 'host-1' })
|
|
|
|
const result = await getBillingOwner('session-abc', 'guest-9')
|
|
expect(result).toEqual({ billingOwnerId: 'host-1', isGuestActor: true })
|
|
})
|
|
})
|
|
|
|
describe('checkSessionEntitlementOrThrow', () => {
|
|
it('throws QuotaExceededError with guest metadata when host quota exhausted', async () => {
|
|
mockActiveSubscription('BASIC')
|
|
vi.mocked(reserveCreditsOrThrow).mockRejectedValue(
|
|
new QuotaExceededError('PRO', 'brainstorm_expand', 10, 10, false, {
|
|
currentTier: 'BASIC',
|
|
}),
|
|
)
|
|
|
|
await expect(
|
|
checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'),
|
|
).rejects.toMatchObject({
|
|
billingOwnerId: 'host-1',
|
|
triggeredByUserId: 'guest-9',
|
|
isGuestActor: true,
|
|
})
|
|
})
|
|
|
|
it('increments host credits when guest action bills host', async () => {
|
|
mockActiveSubscription('PRO')
|
|
|
|
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
|
|
|
|
expect(reserveCreditsOrThrow).toHaveBeenCalledTimes(1)
|
|
const [billedUserId, , options] = vi.mocked(reserveCreditsOrThrow).mock.calls[0]
|
|
expect(billedUserId).toBe('host-1')
|
|
expect(options).toMatchObject({ feature: 'brainstorm_expand' })
|
|
})
|
|
})
|
|
|
|
describe('host BYOK bypass (Story 3.5)', () => {
|
|
it('does not debit credits when host has active BYOK', async () => {
|
|
vi.mocked(willUseByokForLane).mockResolvedValue({
|
|
providerType: 'openai',
|
|
usedByok: true,
|
|
})
|
|
mockActiveSubscription('BASIC')
|
|
vi.mocked(reserveCreditsOrThrow).mockRejectedValue(
|
|
new Error('reserveCreditsOrThrow should not be called when BYOK is active'),
|
|
)
|
|
|
|
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
|
|
|
|
expect(reserveCreditsOrThrow).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('AC10: guest action bills host, not guest', async () => {
|
|
mockActiveSubscription('PRO')
|
|
|
|
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
|
|
|
|
expect(reserveCreditsOrThrow).toHaveBeenCalledTimes(1)
|
|
const [billedUserId] = vi.mocked(reserveCreditsOrThrow).mock.calls[0]
|
|
expect(billedUserId).toBe('host-1')
|
|
expect(billedUserId).not.toBe('guest-9')
|
|
})
|
|
})
|
|
|
|
describe('QuotaExceededError.toJSON', () => {
|
|
it('includes isGuestActor in toJSON for 402 responses', () => {
|
|
const err = new QuotaExceededError('PRO', 'brainstorm_expand', 10, 10, false, {
|
|
billingOwnerId: 'host-1',
|
|
triggeredByUserId: 'guest-9',
|
|
isGuestActor: true,
|
|
})
|
|
expect(err.toJSON()).toEqual({
|
|
error: 'QUOTA_EXCEEDED',
|
|
feature: 'brainstorm_expand',
|
|
upgradeTier: 'PRO',
|
|
byokConfigured: false,
|
|
isGuestActor: true,
|
|
})
|
|
expect(err.billingOwnerId).toBe('host-1')
|
|
expect(err.triggeredByUserId).toBe('guest-9')
|
|
})
|
|
})
|
|
})
|