import { describe, it, expect, vi, afterEach } from 'vitest'; import { createSubscriptionClient } from './client.js'; import type { SubscriptionDoc, PlanConfig } from './types.js'; const baseConfig = { baseUrl: 'http://localhost:4003/api', productId: 'testapp', userId: 'user-1', getAccessToken: () => 'test-token', }; function mockSub(overrides?: Partial): SubscriptionDoc { return { id: 'sub-1', productId: 'testapp', userId: 'user-1', plan: 'pro', status: 'active', currentPeriodStart: '2026-01-01T00:00:00Z', currentPeriodEnd: '2026-12-31T23:59:59Z', cancelAtPeriodEnd: false, monthlyPrice: 999, tokensIncluded: 10000, tokensUsed: 500, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', ...overrides, }; } function mockPlan(overrides?: Partial): PlanConfig { return { id: 'plan-1', productId: 'testapp', name: 'pro', displayName: 'Pro', price: 999, tokens: 10000, words: 0, dictations: 0, features: ['ai_coaching', 'advanced_stats'], active: true, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', ...overrides, }; } describe('createSubscriptionClient', () => { afterEach(() => { vi.restoreAllMocks(); }); it('should get subscription by userId', async () => { const sub = mockSub(); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(sub), }) ); const client = createSubscriptionClient(baseConfig); const result = await client.getMySubscription(); expect(result).toEqual(sub); const fetchMock = globalThis.fetch as ReturnType; expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4003/api/subscriptions/user-1', expect.any(Object) ); }); it('should return null for 404 on getMySubscription', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 404, }) ); const client = createSubscriptionClient(baseConfig); const result = await client.getMySubscription(); expect(result).toBeNull(); }); it('should unwrap .plans from getPlans response', async () => { const plans = [mockPlan(), mockPlan({ name: 'free', displayName: 'Free', features: [] })]; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ plans }), }) ); const client = createSubscriptionClient(baseConfig); const result = await client.getPlans(); expect(result).toHaveLength(2); expect(result[0].name).toBe('pro'); }); it('should start a trial', async () => { const sub = mockSub({ status: 'trialing' }); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(sub), }) ); const client = createSubscriptionClient(baseConfig); const result = await client.startTrial(); expect(result.status).toBe('trialing'); }); it('should cancel subscription', async () => { const sub = mockSub({ cancelAtPeriodEnd: true }); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(sub), }) ); const client = createSubscriptionClient(baseConfig); const result = await client.cancelSubscription(); expect(result.cancelAtPeriodEnd).toBe(true); }); it('should report isPro correctly from cache', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockSub({ plan: 'pro', status: 'active' })), }) ); const client = createSubscriptionClient(baseConfig); expect(client.isPro()).toBe(false); // no cache yet await client.getMySubscription(); expect(client.isPro()).toBe(true); }); it('should report isPro false for free plan', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockSub({ plan: 'free', status: 'active' })), }) ); const client = createSubscriptionClient(baseConfig); await client.getMySubscription(); expect(client.isPro()).toBe(false); }); it('should check hasFeature from cached plans', async () => { let callIndex = 0; vi.stubGlobal( 'fetch', vi.fn().mockImplementation(() => { callIndex++; if (callIndex === 1) { return Promise.resolve({ ok: true, json: () => Promise.resolve(mockSub({ plan: 'pro' })), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({ plans: [mockPlan({ name: 'pro', features: ['ai_coaching', 'advanced_stats'] })], }), }); }) ); const client = createSubscriptionClient(baseConfig); await client.refresh(); expect(client.hasFeature('ai_coaching')).toBe(true); expect(client.hasFeature('nonexistent')).toBe(false); }); it('should report isTrialing correctly', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockSub({ status: 'trialing' })), }) ); const client = createSubscriptionClient(baseConfig); await client.getMySubscription(); expect(client.isTrialing()).toBe(true); }); it('should calculate daysRemaining', async () => { const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockSub({ currentPeriodEnd: futureDate })), }) ); const client = createSubscriptionClient(baseConfig); await client.getMySubscription(); const days = client.daysRemaining(); expect(days).toBeGreaterThanOrEqual(10); expect(days).toBeLessThanOrEqual(11); }); it('should persist and restore from storage', async () => { const store: Record = {}; const storage = { getItem: (k: string) => store[k] ?? null, setItem: (k: string, v: string) => { store[k] = v; }, }; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockSub()), }) ); const client1 = createSubscriptionClient({ ...baseConfig, storage }); await client1.getMySubscription(); expect(store['testapp-subscription']).toBeDefined(); // Create new client — should restore from storage const client2 = createSubscriptionClient({ ...baseConfig, storage }); expect(client2.getCachedSubscription()).not.toBeNull(); expect(client2.isPro()).toBe(true); }); it('should send correct headers', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ plans: [] }), }) ); const client = createSubscriptionClient(baseConfig); await client.getPlans(); const fetchMock = globalThis.fetch as ReturnType; const callHeaders = fetchMock.mock.calls[0][1].headers as Record; expect(callHeaders['x-product-id']).toBe('testapp'); expect(callHeaders['Authorization']).toBe('Bearer test-token'); }); it('should throw on non-ok response', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, }) ); const client = createSubscriptionClient(baseConfig); await expect(client.getPlans()).rejects.toThrow('getPlans failed: 500'); }); });