Packages added: - @bytelyst/referral-client — referral API client + share helpers - @bytelyst/subscription-client — subscription/plan API client + cache - @bytelyst/celebrations — milestone triggers, confetti, positive messages - @bytelyst/gentle-notifications — ND-friendly messaging, forbidden phrases - @bytelyst/accessibility — VoiceOver/TalkBack label generators - @bytelyst/quick-actions — progressive disclosure, smart defaults - @bytelyst/time-references — familiar duration references - @bytelyst/org-client — org/workspace/membership/license API client - @bytelyst/marketplace-client — listing/review/install API client All packages: pure TS, ESM, globalThis.fetch, no Node.js deps. 99 Vitest tests across 9 packages, 79/79 public methods covered. Review fixes applied: - time-references: fix module-level mutable state leak + add clearCustomReferences() - accessibility: fix parameter reassignment in formatDurationForA11y/numberToWords - subscription-client: fix flaky daysRemaining test (ms boundary race)
284 lines
7.7 KiB
TypeScript
284 lines
7.7 KiB
TypeScript
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>): 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>): 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<typeof vi.fn>;
|
|
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<string, string> = {};
|
|
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<typeof vi.fn>;
|
|
const callHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
|
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');
|
|
});
|
|
});
|