Beta readiness: full API test suite, EAS build config, launch checklist
Takes V1 from code-complete toward the closed beta: - Tests: the suite grows from 5 to 29. handler.test.ts covers route dispatch, guest device-id validation and user creation, JWT provider derivation, the paywall flag pass-through, free/premium mode selection, the assistant-final transcript rule (post-unlock delivery), the 402 abuse cap and its premium exemption, and purchase-verify validation. chat.test.ts covers structured-output parsing, premium masking of analysis_ready, the exact Responses API payload (knowledge base + strict JSON schema + the no-manufactured-suspense rule) and error handling. subscription.test.ts covers the Apple adapter (active/expired/wrong-product/sandbox retry on 21007) and the Google adapter (real RS256 JWT signing against a generated key, token exchange, expiry, 410-gone), with fetch and secrets mocked. - EAS: apps/mobile/eas.json with development/preview/production profiles for TestFlight and Play internal-testing builds. - LAUNCH.md: step-by-step path to 20-50 beta users — AWS deploy and secrets, migration, sign-in providers, store products at $5.99/$49.99, EAS builds, CI role, and the beta measurement plan (retention, dialogue depth, upgrade timing, tester interviews). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../src/db.js', () => ({
|
||||
getOrCreateUser: vi.fn(),
|
||||
getUsage: vi.fn(),
|
||||
incrementUsage: vi.fn(),
|
||||
resetUsage: vi.fn(),
|
||||
}));
|
||||
vi.mock('../src/chat.js', () => ({
|
||||
generateReply: vi.fn(),
|
||||
}));
|
||||
vi.mock('../src/subscription/index.js', () => ({
|
||||
verifyAndApplyPurchase: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateReply } from '../src/chat.js';
|
||||
import { getOrCreateUser, getUsage, incrementUsage } from '../src/db.js';
|
||||
import { handler } from '../src/handler.js';
|
||||
import { verifyAndApplyPurchase } from '../src/subscription/index.js';
|
||||
|
||||
interface EventOptions {
|
||||
body?: unknown;
|
||||
claims?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
function makeEvent(method: string, path: string, opts: EventOptions = {}) {
|
||||
return {
|
||||
rawPath: path,
|
||||
headers: opts.headers ?? {},
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
requestContext: {
|
||||
http: { method },
|
||||
...(opts.claims ? { authorizer: { jwt: { claims: opts.claims } } } : {}),
|
||||
},
|
||||
// The handler only reads the fields above.
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function call(method: string, path: string, opts: EventOptions = {}) {
|
||||
const result = (await handler(makeEvent(method, path, opts))) as {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
};
|
||||
return { status: result.statusCode, body: JSON.parse(result.body) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
const freeUser = {
|
||||
id: 'user-1',
|
||||
email: 'a@b.se',
|
||||
auth_provider: 'email',
|
||||
subscription: 'free' as const,
|
||||
created_at: new Date(),
|
||||
};
|
||||
const premiumUser = { ...freeUser, subscription: 'active' as const };
|
||||
const freshUsage = { user_id: 'user-1', messages_used: 0, last_reset: new Date() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getOrCreateUser).mockResolvedValue(freeUser);
|
||||
vi.mocked(getUsage).mockResolvedValue(freshUsage);
|
||||
vi.mocked(generateReply).mockResolvedValue({ reply: 'A reply.', analysisReady: false });
|
||||
});
|
||||
|
||||
describe('public routes', () => {
|
||||
it('serves suggestions without authentication', async () => {
|
||||
const { status, body } = await call('GET', '/suggestions');
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(body.suggestions)).toBe(true);
|
||||
expect((body.suggestions as string[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects guest chat without a device id', async () => {
|
||||
const { status } = await call('POST', '/guest/chat', {
|
||||
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('creates a guest user from the device id and chats', async () => {
|
||||
const { status, body } = await call('POST', '/guest/chat', {
|
||||
headers: { 'x-device-id': 'aaaa-bbbb-cccc' },
|
||||
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.reply).toBe('A reply.');
|
||||
expect(getOrCreateUser).toHaveBeenCalledWith('guest:aaaa-bbbb-cccc', '', 'guest');
|
||||
expect(incrementUsage).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorization', () => {
|
||||
it('returns 401 on account routes without JWT claims', async () => {
|
||||
const { status } = await call('GET', '/me');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('derives the auth provider from the identities claim', async () => {
|
||||
await call('GET', '/me', {
|
||||
claims: { sub: 'sub-1', email: 'a@b.se', identities: '[{"providerName":"SignInWithApple"}]' },
|
||||
});
|
||||
expect(getOrCreateUser).toHaveBeenCalledWith('sub-1', 'a@b.se', 'apple');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat and the intelligent paywall', () => {
|
||||
const claims = { sub: 'sub-1', email: 'a@b.se' };
|
||||
const userMessages = { messages: [{ role: 'user', content: 'Hi' }] };
|
||||
|
||||
it('passes the paywall flag through when the analysis is ready', async () => {
|
||||
vi.mocked(generateReply).mockResolvedValue({ reply: 'Transition.', analysisReady: true });
|
||||
const { status, body } = await call('POST', '/chat', { claims, body: userMessages });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({ reply: 'Transition.', paywall: true });
|
||||
expect(generateReply).toHaveBeenCalledWith(userMessages.messages, 'free');
|
||||
});
|
||||
|
||||
it('runs premium chats in premium mode', async () => {
|
||||
vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser);
|
||||
await call('POST', '/chat', { claims, body: userMessages });
|
||||
expect(generateReply).toHaveBeenCalledWith(userMessages.messages, 'premium');
|
||||
});
|
||||
|
||||
it('rejects a transcript ending with an assistant message for free users', async () => {
|
||||
const { status } = await call('POST', '/chat', {
|
||||
claims,
|
||||
body: { messages: [{ role: 'assistant', content: 'Transition.' }] },
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts an assistant-final transcript for premium users (post-unlock delivery)', async () => {
|
||||
vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser);
|
||||
const { status } = await call('POST', '/chat', {
|
||||
claims,
|
||||
body: { messages: [{ role: 'assistant', content: 'Transition.' }] },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('enforces the abuse cap for free users with 402', async () => {
|
||||
vi.mocked(getUsage).mockResolvedValue({ ...freshUsage, messages_used: 100000 });
|
||||
const { status, body } = await call('POST', '/chat', { claims, body: userMessages });
|
||||
expect(status).toBe(402);
|
||||
expect(body.error).toBe('message_cap_reached');
|
||||
});
|
||||
|
||||
it('never blocks premium users on the cap', async () => {
|
||||
vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser);
|
||||
vi.mocked(getUsage).mockResolvedValue({ ...freshUsage, messages_used: 100000 });
|
||||
const { status } = await call('POST', '/chat', { claims, body: userMessages });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purchase verification', () => {
|
||||
const claims = { sub: 'sub-1', email: 'a@b.se' };
|
||||
|
||||
it('validates the request body', async () => {
|
||||
const { status } = await call('POST', '/subscription/verify', {
|
||||
claims,
|
||||
body: { platform: 'windows', productId: 'x' },
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns the resulting subscription status', async () => {
|
||||
vi.mocked(verifyAndApplyPurchase).mockResolvedValue('active');
|
||||
const { status, body } = await call('POST', '/subscription/verify', {
|
||||
claims,
|
||||
body: { platform: 'ios', productId: 'semantika_monthly', receipt: 'abc' },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.subscriptionStatus).toBe('active');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user