feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- 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
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolvePriceId, priceIdToTier } from '@/lib/billing/stripe-prices';
describe('billing-price-map', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
STRIPE_PRICE_PRO_MONTHLY: 'price_pro_monthly_test',
STRIPE_PRICE_PRO_ANNUAL: 'price_pro_annual_test',
STRIPE_PRICE_BUSINESS_MONTHLY: 'price_business_monthly_test',
STRIPE_PRICE_BUSINESS_ANNUAL: 'price_business_annual_test',
};
});
afterEach(() => {
process.env = originalEnv;
});
describe('resolvePriceId', () => {
it('returns PRO monthly price ID', () => {
expect(resolvePriceId('PRO', 'month')).toBe('price_pro_monthly_test');
});
it('returns PRO annual price ID', () => {
expect(resolvePriceId('PRO', 'year')).toBe('price_pro_annual_test');
});
it('returns BUSINESS monthly price ID', () => {
expect(resolvePriceId('BUSINESS', 'month')).toBe('price_business_monthly_test');
});
it('returns BUSINESS annual price ID', () => {
expect(resolvePriceId('BUSINESS', 'year')).toBe('price_business_annual_test');
});
it('throws if env variable is not set', () => {
delete process.env.STRIPE_PRICE_PRO_MONTHLY;
expect(() => resolvePriceId('PRO', 'month')).toThrow();
});
});
describe('priceIdToTier', () => {
it('resolves PRO monthly price ID to PRO tier', () => {
expect(priceIdToTier('price_pro_monthly_test')).toBe('PRO');
});
it('resolves PRO annual price ID to PRO tier', () => {
expect(priceIdToTier('price_pro_annual_test')).toBe('PRO');
});
it('resolves BUSINESS monthly price ID to BUSINESS tier', () => {
expect(priceIdToTier('price_business_monthly_test')).toBe('BUSINESS');
});
it('resolves BUSINESS annual price ID to BUSINESS tier', () => {
expect(priceIdToTier('price_business_annual_test')).toBe('BUSINESS');
});
it('returns null for unknown price ID', () => {
expect(priceIdToTier('price_unknown_xyz')).toBeNull();
});
it('returns null when env variables are not set', () => {
delete process.env.STRIPE_PRICE_PRO_MONTHLY;
delete process.env.STRIPE_PRICE_PRO_ANNUAL;
delete process.env.STRIPE_PRICE_BUSINESS_MONTHLY;
delete process.env.STRIPE_PRICE_BUSINESS_ANNUAL;
expect(priceIdToTier('price_pro_monthly_test')).toBeNull();
});
});
});

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SubscriptionStatus, SubscriptionTier } from '@prisma/client';
vi.mock('@/lib/prisma', () => ({
prisma: {
subscription: {
upsert: vi.fn().mockResolvedValue({}),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
},
}));
vi.mock('@/lib/billing/stripe-prices', () => ({
priceIdToTier: vi.fn((priceId: string) => {
if (priceId === 'price_pro_monthly_test') return 'PRO';
if (priceId === 'price_business_monthly_test') return 'BUSINESS';
return null;
}),
}));
import { syncSubscriptionFromStripe, handleSubscriptionDeleted } from '@/lib/billing/sync-subscription-from-stripe';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe';
function makeSubscription(overrides: Partial<Stripe.Subscription> = {}): Stripe.Subscription {
return {
id: 'sub_test_123',
object: 'subscription',
status: 'active',
customer: 'cus_test_123',
current_period_start: 1700000000,
current_period_end: 1702678400,
cancel_at_period_end: false,
canceled_at: null,
trial_end: null,
metadata: { userId: 'user_test_123', tier: 'PRO' },
items: {
object: 'list',
data: [
{
id: 'si_test',
object: 'subscription_item',
price: {
id: 'price_pro_monthly_test',
object: 'price',
} as Stripe.Price,
} as Stripe.SubscriptionItem,
],
has_more: false,
url: '',
},
...overrides,
} as unknown as Stripe.Subscription;
}
describe('syncSubscriptionFromStripe', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('upserts subscription with correct PRO tier from price ID', async () => {
const sub = makeSubscription();
await syncSubscriptionFromStripe(sub, 'user_test_123');
expect(prisma.subscription.upsert).toHaveBeenCalledOnce();
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.where).toEqual({ stripeSubscriptionId: 'sub_test_123' });
expect(call.create.tier).toBe(SubscriptionTier.PRO);
expect(call.create.status).toBe(SubscriptionStatus.ACTIVE);
expect(call.create.userId).toBe('user_test_123');
expect(call.create.stripeCustomerId).toBe('cus_test_123');
expect(call.create.stripeSubscriptionId).toBe('sub_test_123');
expect(call.create.stripePriceId).toBe('price_pro_monthly_test');
});
it('maps stripe active status to ACTIVE', async () => {
const sub = makeSubscription({ status: 'active' });
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.status).toBe(SubscriptionStatus.ACTIVE);
});
it('maps stripe past_due status to PAST_DUE', async () => {
const sub = makeSubscription({ status: 'past_due' });
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.status).toBe(SubscriptionStatus.PAST_DUE);
});
it('maps stripe canceled status to CANCELED', async () => {
const sub = makeSubscription({ status: 'canceled', canceled_at: 1700100000 });
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.status).toBe(SubscriptionStatus.CANCELED);
expect(call.create.canceledAt).toBeInstanceOf(Date);
});
it('maps stripe trialing status to TRIALING', async () => {
const sub = makeSubscription({ status: 'trialing', trial_end: 1700500000 });
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.status).toBe(SubscriptionStatus.TRIALING);
expect(call.create.trialEndsAt).toBeInstanceOf(Date);
});
it('maps stripe incomplete_expired to INACTIVE', async () => {
const sub = makeSubscription({ status: 'incomplete_expired' });
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.status).toBe(SubscriptionStatus.INACTIVE);
});
it('falls back to metadata tier when price ID not in map', async () => {
const sub = makeSubscription({
metadata: { userId: 'user_test_123', tier: 'BUSINESS' },
items: {
object: 'list',
data: [{ id: 'si_test', object: 'subscription_item', price: { id: 'price_unknown_abc', object: 'price' } as Stripe.Price } as Stripe.SubscriptionItem],
has_more: false,
url: '',
},
});
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.tier).toBe(SubscriptionTier.BUSINESS);
});
it('sets currentPeriodStart and currentPeriodEnd from Stripe timestamps', async () => {
const sub = makeSubscription({
current_period_start: 1700000000,
current_period_end: 1702678400,
});
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.currentPeriodStart).toEqual(new Date(1700000000 * 1000));
expect(call.create.currentPeriodEnd).toEqual(new Date(1702678400 * 1000));
});
it('sets cancelAtPeriodEnd correctly', async () => {
const sub = makeSubscription({ cancel_at_period_end: true });
await syncSubscriptionFromStripe(sub, 'user_test_123');
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
expect(call.create.cancelAtPeriodEnd).toBe(true);
});
});
describe('handleSubscriptionDeleted', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('marks subscription as CANCELED when found', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
id: 'local_id',
stripeSubscriptionId: 'sub_test_123',
} as any);
const sub = makeSubscription({ status: 'canceled', canceled_at: 1700100000 });
await handleSubscriptionDeleted(sub);
expect(prisma.subscription.update).toHaveBeenCalledOnce();
const call = vi.mocked(prisma.subscription.update).mock.calls[0][0];
expect(call.data.status).toBe(SubscriptionStatus.CANCELED);
expect(call.data.cancelAtPeriodEnd).toBe(false);
});
it('does nothing when subscription not found locally', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
const sub = makeSubscription({ status: 'canceled' });
await handleSubscriptionDeleted(sub);
expect(prisma.subscription.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,140 @@
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),
}))
import { redis } from '@/lib/redis'
import { hasAnyActiveByok } from '@/lib/byok'
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()
})
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(redis.eval).mockResolvedValue(-1)
await expect(
checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'),
).rejects.toMatchObject({
billingOwnerId: 'host-1',
triggeredByUserId: 'guest-9',
isGuestActor: true,
})
})
it('increments host redis key when guest action bills host', async () => {
mockActiveSubscription('PRO')
vi.mocked(redis.eval).mockResolvedValue(1)
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
expect(redis.eval).toHaveBeenCalled()
const keyArg = String(vi.mocked(redis.eval).mock.calls[0]?.[2])
expect(keyArg).toContain('host-1')
expect(keyArg).not.toContain('guest-9')
const allKeyArgs = vi.mocked(redis.eval).mock.calls.map(call => String(call[2]))
const guestKeyUsed = allKeyArgs.some(k => k.includes('guest-9'))
expect(guestKeyUsed).toBe(false)
})
})
describe('host BYOK bypass (Story 3.5)', () => {
it('reserveUsage still runs when host has BYOK (bypass is per-provider in route layer)', async () => {
vi.mocked(hasAnyActiveByok).mockResolvedValue(true)
mockActiveSubscription('BASIC')
vi.mocked(redis.eval).mockResolvedValue(-1)
await expect(
checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'),
).rejects.toThrow()
})
})
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')
})
})
})

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { canUseFeature } from '@/lib/entitlements';
vi.mock('@/lib/redis', () => ({
redis: {
get: vi.fn(),
mget: vi.fn(),
eval: vi.fn(),
},
}));
vi.mock('@/lib/prisma', () => ({
prisma: {
subscription: { findUnique: vi.fn() },
userAPIKey: { count: vi.fn() },
},
}));
import { redis } from '@/lib/redis';
import { prisma } from '@/lib/prisma';
describe('BYOK entitlements', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
userId: 'user1',
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
} as never);
});
it('returns byokConfigured=true when BYOK active but quota exhausted (bypass is per-provider in routes)', async () => {
vi.mocked(redis.get).mockResolvedValue('30');
vi.mocked(prisma.userAPIKey.count).mockResolvedValue(1);
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(false);
expect(result.byokConfigured).toBe(true);
});
it('denies when quota exhausted and no BYOK', async () => {
vi.mocked(redis.get).mockResolvedValue('30');
vi.mocked(prisma.userAPIKey.count).mockResolvedValue(0);
const result = await canUseFeature('user1', 'semantic_search');
expect(result.allowed).toBe(false);
expect(result.byokConfigured).toBe(false);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { applyByokToConfig } from '@/lib/byok';
vi.mock('@/lib/prisma', () => ({
prisma: {
userAPIKey: { findFirst: vi.fn() },
},
}));
vi.mock('@/lib/crypto', () => ({
decryptApiKey: vi.fn(async () => 'user-sk-plaintext'),
encryptApiKey: vi.fn(async () => 'enc'),
hashApiKey: vi.fn(() => 'hash'),
}));
import { prisma } from '@/lib/prisma';
describe('applyByokToConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('injects user API key into config when active row exists', async () => {
vi.mocked(prisma.userAPIKey.findFirst).mockResolvedValue({
id: 'k1',
userId: 'u1',
provider: 'openai',
encryptedKey: 'enc',
keyHash: 'hash',
alias: '',
model: null,
isActive: true,
lastUsedAt: null,
lastUsedFor: null,
createdAt: new Date(),
updatedAt: new Date(),
} as never);
const base = { OPENAI_API_KEY: 'system-key' };
const { config, usedByok } = await applyByokToConfig('u1', 'openai', base);
expect(usedByok).toBe(true);
expect(config.OPENAI_API_KEY).toBe('user-sk-plaintext');
});
it('returns unchanged config when no BYOK row', async () => {
vi.mocked(prisma.userAPIKey.findFirst).mockResolvedValue(null);
const base = { OPENAI_API_KEY: 'system-key' };
const { config, usedByok } = await applyByokToConfig('u1', 'openai', base);
expect(usedByok).toBe(false);
expect(config.OPENAI_API_KEY).toBe('system-key');
});
});

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { decryptApiKey, encryptApiKey, hashApiKey } from '@/lib/crypto';
const TEST_KEY = 'a'.repeat(32);
describe('crypto', () => {
const prev = process.env.MASTER_ENCRYPTION_KEY;
beforeEach(() => {
process.env.MASTER_ENCRYPTION_KEY = TEST_KEY;
});
afterEach(() => {
process.env.MASTER_ENCRYPTION_KEY = prev;
});
it('round-trips encrypt and decrypt', async () => {
const plain = 'sk-test-byok-key-12345';
const payload = await encryptApiKey(plain);
expect(payload).not.toContain(plain);
expect(await decryptApiKey(payload)).toBe(plain);
});
it('hashApiKey is stable', () => {
const h1 = hashApiKey('sk-abc');
const h2 = hashApiKey('sk-abc');
expect(h1).toBe(h2);
expect(h1).not.toBe(hashApiKey('sk-xyz'));
});
it('fails decrypt with wrong master key', async () => {
const payload = await encryptApiKey('secret');
process.env.MASTER_ENCRYPTION_KEY = 'b'.repeat(32);
await expect(decryptApiKey(payload)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,251 @@
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);
});
});
});

View File

@@ -0,0 +1,223 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { QuotaExceededError } from '@/lib/entitlements'
import { APICallError } from 'ai'
import type { AIProvider } from '@/lib/ai/types'
const mockState = vi.hoisted(() => {
const stubProvider = (): AIProvider =>
({
generateTags: async () => [],
getEmbeddings: async () => [],
generateTitles: async () => [],
generateText: async () => '',
chat: async () => ({ text: '' }),
getModel: () => ({}),
}) as AIProvider
const primaryProvider = stubProvider()
const secondaryProvider = stubProvider()
let instanceCall = 0
return {
primaryProvider,
secondaryProvider,
reset: () => {
instanceCall = 0
},
nextProvider: () => {
instanceCall++
return instanceCall === 1 ? primaryProvider : secondaryProvider
},
getCallCount: () => instanceCall,
}
})
vi.mock('@/lib/ai/factory', () => ({
getProviderInstance: vi.fn(() => mockState.nextProvider()),
}))
import {
resolveAiFallbackRoute,
isRetriableProviderError,
extractProviderErrorStatus,
withAiProviderFallback,
FALLBACK_BUDGET_MS,
} from '@/lib/ai/fallback'
import { getProviderInstance } from '@/lib/ai/factory'
describe('resolveAiFallbackRoute', () => {
it('returns null when fallback provider is unset', () => {
expect(resolveAiFallbackRoute('chat', { AI_PROVIDER_CHAT: 'openai' })).toBeNull()
})
it('resolves chat fallback with slash model ids', () => {
const route = resolveAiFallbackRoute('chat', {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_PROVIDER_CHAT_FALLBACK: 'openrouter',
AI_MODEL_CHAT_FALLBACK: 'deepseek/deepseek-chat',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
})
expect(route).not.toBeNull()
expect(route!.providerType).toBe('openrouter')
expect(route!.modelName).toBe('deepseek/deepseek-chat')
})
it('rejects anthropic embedding fallback', () => {
expect(() =>
resolveAiFallbackRoute('embedding', {
AI_PROVIDER_EMBEDDING: 'openai',
AI_MODEL_EMBEDDING: 'x',
AI_MODEL_TAGS: 'y',
AI_PROVIDER_EMBEDDING_FALLBACK: 'anthropic',
})
).toThrow(/AI_PROVIDER_EMBEDDING_FALLBACK cannot use/)
})
it('throws on unknown fallback provider', () => {
expect(() =>
resolveAiFallbackRoute('chat', {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_PROVIDER_CHAT_FALLBACK: 'invalid_provider',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
})
).toThrow(/Unknown fallback provider/)
})
it('returns null when fallback is same provider as primary', () => {
const route = resolveAiFallbackRoute('chat', {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_PROVIDER_CHAT_FALLBACK: 'openai',
AI_MODEL_CHAT_FALLBACK: 'gpt-4.1-mini',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
})
expect(route).toBeNull()
})
})
describe('isRetriableProviderError', () => {
it('treats 429 and 5xx as retriable', () => {
expect(isRetriableProviderError(new APICallError({ message: 'rate', statusCode: 429 }))).toBe(true)
expect(isRetriableProviderError(new APICallError({ message: 'srv', statusCode: 500 }))).toBe(true)
expect(isRetriableProviderError(new APICallError({ message: 'srv', statusCode: 503 }))).toBe(true)
})
it('treats 401, 400, 402, 403 as non-retriable', () => {
expect(isRetriableProviderError(new APICallError({ message: 'auth', statusCode: 401 }))).toBe(false)
expect(isRetriableProviderError(new APICallError({ message: 'bad', statusCode: 400 }))).toBe(false)
expect(isRetriableProviderError(new APICallError({ message: 'forbidden', statusCode: 403 }))).toBe(false)
expect(
isRetriableProviderError(new QuotaExceededError('PRO', 'chat', 10, 10, false))
).toBe(false)
expect(extractProviderErrorStatus(new QuotaExceededError('PRO', 'chat', 10, 10, false))).toBe(402)
})
it('handles circular cause chain without stack overflow', () => {
const err: { cause: unknown } = { cause: null }
err.cause = err
expect(extractProviderErrorStatus(err)).toBeUndefined()
expect(isRetriableProviderError(err)).toBe(false)
})
it('handles nested cause chain', () => {
const deep = { statusCode: 429 }
const mid = { cause: deep }
const top = { cause: mid }
expect(extractProviderErrorStatus(top)).toBe(429)
})
})
describe('withAiProviderFallback', () => {
beforeEach(() => {
mockState.reset()
vi.mocked(getProviderInstance).mockImplementation(() => mockState.nextProvider())
})
it('falls back on retriable primary failure within NFR-R1 budget', async () => {
const cfg = {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_PROVIDER_CHAT_FALLBACK: 'deepseek',
AI_MODEL_CHAT_FALLBACK: 'deepseek-chat',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
}
const t0 = performance.now()
const result = await withAiProviderFallback('chat', cfg, async (provider) => {
if (provider === mockState.primaryProvider) {
throw new APICallError({ message: 'rate limited', statusCode: 429 })
}
return 'secondary-ok'
})
const elapsed = performance.now() - t0
expect(result).toBe('secondary-ok')
expect(mockState.getCallCount()).toBe(2)
expect(elapsed).toBeLessThan(FALLBACK_BUDGET_MS + 250)
})
it('rethrows primary error when no fallback configured', async () => {
const cfg = {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
}
await expect(
withAiProviderFallback('chat', cfg, async () => {
throw new APICallError({ message: 'down', statusCode: 503 })
})
).rejects.toMatchObject({ statusCode: 503 })
expect(mockState.getCallCount()).toBe(1)
})
it('rethrows primary error when fallback is same provider', async () => {
const cfg = {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_PROVIDER_CHAT_FALLBACK: 'openai',
AI_MODEL_CHAT_FALLBACK: 'gpt-4.1-mini',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
}
await expect(
withAiProviderFallback('chat', cfg, async () => {
throw new APICallError({ message: 'rate', statusCode: 429 })
})
).rejects.toMatchObject({ statusCode: 429 })
expect(mockState.getCallCount()).toBe(1)
})
it('skipSystemFallback runs primary only', async () => {
const cfg = {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4o-mini',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
AI_PROVIDER_CHAT_FALLBACK: 'deepseek',
}
await expect(
withAiProviderFallback(
'chat',
cfg,
async () => {
throw new APICallError({ message: 'rate', statusCode: 429 })
},
{ skipSystemFallback: true }
)
).rejects.toMatchObject({ statusCode: 429 })
expect(mockState.getCallCount()).toBe(1)
})
it('rethrows primary error when resolveAiFallbackRoute throws (config error)', async () => {
const cfg = {
AI_PROVIDER_EMBEDDING: 'openai',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
AI_MODEL_TAGS: 'y',
AI_PROVIDER_EMBEDDING_FALLBACK: 'anthropic',
}
await expect(
withAiProviderFallback('embedding', cfg, async () => {
throw new APICallError({ message: 'rate', statusCode: 429 })
})
).rejects.toMatchObject({ statusCode: 429 })
expect(mockState.getCallCount()).toBe(1)
})
})

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, afterEach } from 'vitest';
import { resolveAiRoute, resolveAiRouteWithTiming } from '@/lib/ai/router';
import { getProviderInstance, type ProviderType } from '@/lib/ai/factory';
describe('resolveAiRoute', () => {
afterEach(() => {
delete process.env.AI_PROVIDER_TAGS;
delete process.env.AI_PROVIDER_EMBEDDING;
delete process.env.AI_PROVIDER;
delete process.env.AI_PROVIDER_CHAT;
delete process.env.AI_MODEL_TAGS;
delete process.env.AI_MODEL_EMBEDDING;
delete process.env.AI_MODEL_CHAT;
delete process.env.LMSTUDIO_API_KEY;
});
it('tags lane prefers AI_PROVIDER_TAGS over embedding/generic fallbacks', () => {
const route = resolveAiRoute('tags', {
AI_PROVIDER_TAGS: 'openrouter',
AI_PROVIDER_EMBEDDING: 'deepseek',
AI_PROVIDER: 'openai',
AI_MODEL_TAGS: 'anthropic/claude-3.5-haiku',
AI_MODEL_EMBEDDING: 'openai/text-embedding-3-small',
});
expect(route.lane).toBe('tags');
expect(route.providerType).toBe('openrouter');
expect(route.modelName).toBe('anthropic/claude-3.5-haiku');
expect(route.embeddingModelName).toBe('openai/text-embedding-3-small');
});
it('embedding lane rejects anthropic gateways', () => {
expect(() =>
resolveAiRoute('embedding', {
AI_PROVIDER_EMBEDDING: 'anthropic',
AI_MODEL_TAGS: 'x',
AI_MODEL_EMBEDDING: 'y',
})
).toThrow(/AI_PROVIDER_EMBEDDING cannot use/);
});
it('chat openrouter lane keeps slash-model IDs', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'openrouter',
AI_MODEL_CHAT: 'deepseek/deepseek-v4-flash',
AI_MODEL_EMBEDDING: 'openai/text-embedding-3-small',
});
expect(route.providerType).toBe('openrouter');
expect(route.modelName).toBe('deepseek/deepseek-v4-flash');
});
it('throws on unknown provider string', () => {
expect(() =>
resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'unknown_provider',
})
).toThrow(/Unknown AI provider 'unknown_provider'/);
});
it('throws on provider with typo', () => {
expect(() =>
resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'open ai',
})
).toThrow(/Unknown AI provider 'open ai'/);
});
it('uses provider-specific default for deepseek when no model configured', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'deepseek',
});
expect(route.providerType).toBe('deepseek');
expect(route.modelName).toBe('deepseek-chat');
expect(route.embeddingModelName).toBe('');
});
it('uses provider-specific default for openai when no model configured', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'openai',
});
expect(route.providerType).toBe('openai');
expect(route.modelName).toBe('gpt-4o-mini');
expect(route.embeddingModelName).toBe('text-embedding-3-small');
});
it('uses provider-specific default for google when no model configured', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'google',
});
expect(route.providerType).toBe('google');
expect(route.modelName).toBe('gemini-1.5-flash');
expect(route.embeddingModelName).toBe('text-embedding-004');
});
it('uses provider-specific default for mistral when no model configured', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'mistral',
});
expect(route.providerType).toBe('mistral');
expect(route.modelName).toBe('mistral-small-latest');
expect(route.embeddingModelName).toBe('mistral-embed');
});
it('uses provider-specific default for openrouter when no model configured', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'openrouter',
});
expect(route.providerType).toBe('openrouter');
expect(route.modelName).toBe('openai/gpt-4o-mini');
expect(route.embeddingModelName).toBe('openai/text-embedding-3-small');
});
it('explicit model overrides provider default', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-5.4',
});
expect(route.modelName).toBe('gpt-5.4');
});
it('handles null config values without crash', () => {
const route = resolveAiRoute('chat', {
AI_PROVIDER_CHAT: 'ollama',
AI_MODEL_CHAT: null as unknown as string,
});
expect(route.providerType).toBe('ollama');
expect(route.modelName).toBe('granite4:latest');
});
it('resolveAiRoute median stays under 50ms (warm CPU path)', () => {
const cfg = {
AI_PROVIDER_CHAT: 'deepseek',
AI_MODEL_CHAT: 'deepseek-v4-flash',
AI_MODEL_EMBEDDING: '',
};
const iterations = 400;
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
const t0 = performance.now();
resolveAiRoute('chat', cfg);
samples.push(performance.now() - t0);
}
samples.sort((a, b) => a - b);
const median = samples[Math.floor(samples.length / 2)]!;
expect(median).toBeLessThan(50);
});
it('resolve + instantiate (lmstudio) median stays under 50ms on warm path', () => {
process.env.LMSTUDIO_API_KEY = 'lm-studio';
const cfg: Record<string, string> = {
AI_PROVIDER_CHAT: 'lmstudio',
AI_MODEL_CHAT: 'local-model',
AI_MODEL_EMBEDDING: 'local-embed',
};
const iterations = 100;
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
const route = resolveAiRoute('chat', cfg);
const t0 = performance.now();
getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
samples.push(performance.now() - t0);
}
samples.sort((a, b) => a - b);
const median = samples[Math.floor(samples.length / 2)]!;
expect(median).toBeLessThan(50);
});
it('resolveAiRouteWithTiming injects meta.resolveMs', () => {
const route = resolveAiRouteWithTiming('chat', {
AI_PROVIDER_CHAT: 'openai',
AI_MODEL_CHAT: 'gpt-4.1-mini',
AI_MODEL_EMBEDDING: 'text-embedding-3-small',
});
expect(route.meta.resolveMs).toBeDefined();
expect(route.meta.resolveMs!).toBeGreaterThanOrEqual(0);
expect(route.meta.resolveMs!).toBeLessThan(50);
});
});