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,82 @@
|
|||||||
|
# Launch checklist — closed beta
|
||||||
|
|
||||||
|
The path from this repo to 20–50 test users. Everything below is
|
||||||
|
configuration and store work; the code is done (see Definition of Done in
|
||||||
|
the README).
|
||||||
|
|
||||||
|
## 1. AWS
|
||||||
|
|
||||||
|
- [ ] `cd infra && npx cdk bootstrap` (once per account/region).
|
||||||
|
- [ ] `npx cdk deploy` — note the outputs: `ApiUrl`, `UserPoolId`,
|
||||||
|
`UserPoolClientId`, `CognitoDomain`.
|
||||||
|
- [ ] Fill the `semantika/app` secret in Secrets Manager:
|
||||||
|
`OPENAI_API_KEY` (required for chat), `APPLE_SHARED_SECRET` and
|
||||||
|
`GOOGLE_SERVICE_ACCOUNT_JSON` (required before purchases work).
|
||||||
|
- [ ] Run `db/migrations/001_init.sql` against the Aurora cluster
|
||||||
|
(credentials in the RDS-managed secret; connect via a bastion or the
|
||||||
|
RDS query editor).
|
||||||
|
- [ ] Smoke test: `curl <ApiUrl>/suggestions` returns the starter list.
|
||||||
|
|
||||||
|
## 2. Sign-in
|
||||||
|
|
||||||
|
- [ ] Email sign-in works out of the box (Cognito hosted UI).
|
||||||
|
- [ ] Apple: create a Services ID + key in the Apple Developer portal, then
|
||||||
|
redeploy with CDK context `appleTeamId`, `appleKeyId`,
|
||||||
|
`applePrivateKeySecretName`.
|
||||||
|
- [ ] Google: create an OAuth client in Google Cloud, then redeploy with
|
||||||
|
`googleClientId`, `googleClientSecret`.
|
||||||
|
- [ ] For the beta, email-only is acceptable — Apple/Google can land in a
|
||||||
|
later build. Note: Apple's review requires Sign in with Apple if
|
||||||
|
other third-party logins are offered, so enable it before public
|
||||||
|
App Store release.
|
||||||
|
|
||||||
|
## 3. App configuration
|
||||||
|
|
||||||
|
- [ ] Put the CDK outputs into `apps/mobile/app.json` → `extra`
|
||||||
|
(`apiUrl`, `cognitoDomain`, `cognitoClientId`).
|
||||||
|
- [ ] Keep product ids as `semantika_monthly` / `semantika_yearly`.
|
||||||
|
|
||||||
|
## 4. Stores
|
||||||
|
|
||||||
|
- [ ] App Store Connect: create the app (bundle id `com.semantika.app`),
|
||||||
|
add auto-renewable subscriptions `semantika_monthly` ($5.99/month)
|
||||||
|
and `semantika_yearly` ($49.99/year) in one subscription group.
|
||||||
|
Generate the App-Specific Shared Secret → `APPLE_SHARED_SECRET`.
|
||||||
|
- [ ] Play Console: create the app (package `com.semantika.app`), add the
|
||||||
|
two subscriptions with the same ids and prices. Create a service
|
||||||
|
account with Play Developer API access → its JSON key becomes
|
||||||
|
`GOOGLE_SERVICE_ACCOUNT_JSON`.
|
||||||
|
- [ ] Store copy follows the honest-claims rule: inspiration, not proven
|
||||||
|
effects. Positioning: reflection partner — not therapy.
|
||||||
|
|
||||||
|
## 5. Builds
|
||||||
|
|
||||||
|
- [ ] `cd apps/mobile && npx eas build --profile preview --platform all`
|
||||||
|
(in-app purchases require a real build, not Expo Go).
|
||||||
|
- [ ] iOS: distribute via TestFlight (internal, then external testers).
|
||||||
|
- [ ] Android: distribute via Play Console internal testing track.
|
||||||
|
- [ ] Purchases in test: use TestFlight sandbox accounts / Play license
|
||||||
|
testers — no real charges.
|
||||||
|
|
||||||
|
## 6. CI/CD
|
||||||
|
|
||||||
|
- [ ] Create the GitHub OIDC deploy role in AWS; set repo secret
|
||||||
|
`AWS_DEPLOY_ROLE_ARN` (and optionally the `AWS_REGION` variable).
|
||||||
|
- [ ] Merge this branch to `main` — CI runs lint/typecheck/tests,
|
||||||
|
`deploy.yml` deploys the stack automatically.
|
||||||
|
|
||||||
|
## 7. The beta itself (20–50 users)
|
||||||
|
|
||||||
|
Measure before building anything new:
|
||||||
|
|
||||||
|
- **Do they come back?** — active users over time (`usage` activity).
|
||||||
|
- **How deep do dialogues go?** — `messages_used` distribution.
|
||||||
|
- **When do they upgrade?** — `subscription` transitions relative to
|
||||||
|
`created_at`.
|
||||||
|
- **Which starter questions create value?** — conversations are never
|
||||||
|
stored, so ask the testers directly (short interviews or a 3-question
|
||||||
|
survey beats analytics here).
|
||||||
|
|
||||||
|
Exit criteria for V1 → V2 decisions: a clear picture of retention, dialogue
|
||||||
|
depth, upgrade timing, and which conversation types resonate. Only then
|
||||||
|
open the Version 2 list.
|
||||||
@@ -172,7 +172,10 @@ After the first deploy:
|
|||||||
GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
|
GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
|
||||||
`deploy.yml` deploys the CDK stack on every push to `main` (set the
|
`deploy.yml` deploys the CDK stack on every push to `main` (set the
|
||||||
`AWS_DEPLOY_ROLE_ARN` secret for OIDC). Store builds ship via EAS
|
`AWS_DEPLOY_ROLE_ARN` secret for OIDC). Store builds ship via EAS
|
||||||
(`eas build`) when you choose to release.
|
(`eas build`, profiles in `apps/mobile/eas.json`).
|
||||||
|
|
||||||
|
The full path to the closed beta — AWS, stores, builds, testers — is in
|
||||||
|
[LAUNCH.md](LAUNCH.md).
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 12.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../src/secrets.js', () => ({
|
||||||
|
getSecret: vi.fn(async () => ({ OPENAI_API_KEY: 'test-key' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { generateReply } from '../src/chat.js';
|
||||||
|
|
||||||
|
function openAiResponse(reply: string, analysisReady: boolean) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: JSON.stringify({ reply, analysis_ready: analysisReady }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
text: async () => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.KNOWLEDGE_DIR = 'knowledge';
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateReply', () => {
|
||||||
|
it('parses structured output and surfaces analysis_ready in free mode', async () => {
|
||||||
|
fetchMock.mockResolvedValue(openAiResponse('A transition.', true));
|
||||||
|
const result = await generateReply([{ role: 'user', content: 'Hi' }], 'free');
|
||||||
|
expect(result).toEqual({ reply: 'A transition.', analysisReady: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reports analysis_ready in premium mode', async () => {
|
||||||
|
fetchMock.mockResolvedValue(openAiResponse('Full analysis.', true));
|
||||||
|
const result = await generateReply([{ role: 'user', content: 'Hi' }], 'premium');
|
||||||
|
expect(result).toEqual({ reply: 'Full analysis.', analysisReady: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the knowledge base and a strict JSON schema to the Responses API', async () => {
|
||||||
|
fetchMock.mockResolvedValue(openAiResponse('Ok.', false));
|
||||||
|
await generateReply([{ role: 'user', content: 'Hi' }], 'free');
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, { body: string }];
|
||||||
|
expect(url).toBe('https://api.openai.com/v1/responses');
|
||||||
|
const payload = JSON.parse(init.body) as {
|
||||||
|
instructions: string;
|
||||||
|
text: { format: { type: string; strict: boolean } };
|
||||||
|
};
|
||||||
|
expect(payload.instructions).toContain('Semantika');
|
||||||
|
expect(payload.instructions).toContain('Never manufacture suspense');
|
||||||
|
expect(payload.instructions).toContain('# Neurosemantics');
|
||||||
|
expect(payload.text.format.type).toBe('json_schema');
|
||||||
|
expect(payload.text.format.strict).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a non-ok response', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' });
|
||||||
|
await expect(generateReply([{ role: 'user', content: 'Hi' }], 'free')).rejects.toThrow(
|
||||||
|
'OpenAI request failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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