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,124 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
});
|
||||
|
||||
vi.mock('../src/secrets.js', () => ({
|
||||
getSecret: vi.fn(async () => ({
|
||||
APPLE_SHARED_SECRET: 'apple-secret',
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON: JSON.stringify({
|
||||
client_email: 'svc@project.iam.gserviceaccount.com',
|
||||
private_key: privateKey,
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { appleProvider } from '../src/subscription/apple.js';
|
||||
import { googleProvider } from '../src/subscription/google.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => payload };
|
||||
}
|
||||
|
||||
describe('appleProvider', () => {
|
||||
const request = { platform: 'ios' as const, productId: 'semantika_monthly', receipt: 'r' };
|
||||
|
||||
it('marks an unexpired subscription active', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
status: 0,
|
||||
latest_receipt_info: [
|
||||
{ product_id: 'semantika_monthly', expires_date_ms: String(Date.now() + 60_000) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await appleProvider.verify(request);
|
||||
expect(result.active).toBe(true);
|
||||
});
|
||||
|
||||
it('retries against the sandbox on status 21007', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ status: 21007 })).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
status: 0,
|
||||
latest_receipt_info: [
|
||||
{ product_id: 'semantika_monthly', expires_date_ms: String(Date.now() + 60_000) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await appleProvider.verify(request);
|
||||
expect(result.active).toBe(true);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain('buy.itunes.apple.com');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toContain('sandbox.itunes.apple.com');
|
||||
});
|
||||
|
||||
it('marks an expired subscription inactive', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
status: 0,
|
||||
latest_receipt_info: [
|
||||
{ product_id: 'semantika_monthly', expires_date_ms: String(Date.now() - 60_000) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await appleProvider.verify(request);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores receipts for other products', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
status: 0,
|
||||
latest_receipt_info: [
|
||||
{ product_id: 'something_else', expires_date_ms: String(Date.now() + 60_000) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await appleProvider.verify(request);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('googleProvider', () => {
|
||||
const request = { platform: 'android' as const, productId: 'semantika_yearly', receipt: 'token' };
|
||||
|
||||
it('exchanges a signed JWT for a token and checks expiry', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ expiryTimeMillis: String(Date.now() + 60_000) }));
|
||||
|
||||
const result = await googleProvider.verify(request);
|
||||
expect(result.active).toBe(true);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://oauth2.googleapis.com/token');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toContain(
|
||||
'/purchases/subscriptions/semantika_yearly/tokens/token',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[1]?.headers?.Authorization).toBe('Bearer oauth-token');
|
||||
});
|
||||
|
||||
it('treats a gone purchase (410) as inactive', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, 410));
|
||||
const result = await googleProvider.verify(request);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
|
||||
it('marks an expired purchase inactive', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ expiryTimeMillis: String(Date.now() - 60_000) }));
|
||||
const result = await googleProvider.verify(request);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user