Files
alva/services/api/test/subscription.test.ts
T
Claude bf5c4e5f00 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
2026-08-03 22:08:39 +00:00

125 lines
4.2 KiB
TypeScript

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);
});
});