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
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:
73
memento-note/tests/unit/billing-price-map.test.ts
Normal file
73
memento-note/tests/unit/billing-price-map.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
175
memento-note/tests/unit/billing-sync.test.ts
Normal file
175
memento-note/tests/unit/billing-sync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
140
memento-note/tests/unit/brainstorm-billing.test.ts
Normal file
140
memento-note/tests/unit/brainstorm-billing.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
53
memento-note/tests/unit/byok-entitlements.test.ts
Normal file
53
memento-note/tests/unit/byok-entitlements.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
55
memento-note/tests/unit/byok-factory.test.ts
Normal file
55
memento-note/tests/unit/byok-factory.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
36
memento-note/tests/unit/crypto.test.ts
Normal file
36
memento-note/tests/unit/crypto.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
251
memento-note/tests/unit/entitlements.test.ts
Normal file
251
memento-note/tests/unit/entitlements.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
223
memento-note/tests/unit/fallback.test.ts
Normal file
223
memento-note/tests/unit/fallback.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
177
memento-note/tests/unit/router.test.ts
Normal file
177
memento-note/tests/unit/router.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user