Session 7i: Stripe test coverage gaps filled, dual-provider cutover documented (1042 tests)
This commit is contained in:
+35
-1
@@ -4,7 +4,41 @@
|
||||
2026-06-10
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v7.0 — Stripe Payment Infrastructure + Free-Tier Gating (Session 7h)
|
||||
SHIP BUILD v7.1 — Stripe Route + Webhook Verification (Session 7i)
|
||||
|
||||
## Session 7i (2026-06-10) — SHIPPED
|
||||
|
||||
### Stripe checkout + webhook (no new routes — gap-fill on existing)
|
||||
|
||||
Pre-audit revealed Session 3.4 already shipped a fuller Stripe
|
||||
integration than this session's spec asked for: route, sig verify,
|
||||
all 4 event handlers with 48h grace, customer create + persist,
|
||||
portal + status endpoints, founder-code system, and `users` ↔
|
||||
`user_profiles` dual writes. Raw-body middleware was already correctly
|
||||
positioned at `src/app.js:52` (before global `express.json()`).
|
||||
|
||||
What this session added on top:
|
||||
- `tests/integration/stripe.test.js` — refactored stripe mock to a
|
||||
singleton handle, then added two route-level tests:
|
||||
1. `constructEvent` throws → route returns 400 with `{ error: /signature/i }`
|
||||
2. valid signature → route dispatches to `handleWebhookEvent` and returns `{ received: true }`
|
||||
- `tests/unit/stripeService.test.js` — added `customer.subscription.updated`
|
||||
test covering portal-driven plan-change: maps `items.data[0].price.id`
|
||||
back to a tier via `PRICE_MAP`, writes to both `users` + `user_profiles`,
|
||||
clears grace.
|
||||
- `docs/SYSTEM-MANIFEST.md` — appended a *Payments: dual-provider divergence*
|
||||
subsection under § 8 Findings → Frontend ↔ Backend contract, documenting
|
||||
that the Next.js `/api/checkout` still routes to NexaPay while Express
|
||||
Stripe is wired but uncalled by the frontend, with a 4-step cutover
|
||||
punch list for a follow-up session.
|
||||
|
||||
### Quality gates (all green)
|
||||
- `npm test`: **1042 / 1042 passing** (delta +3 from 1039 baseline, 0 regressions)
|
||||
- `web/npm run build`: clean
|
||||
- License audit: third-party deps only permissive (MIT/Apache-2.0/BSD/ISC/MPL/BlueOak/CC-BY/0BSD)
|
||||
- `curl https://api.vyndr.app/api/health` → `{"status":"healthy"}`
|
||||
|
||||
---
|
||||
|
||||
## Session 7h (2026-06-10) — SHIPPED
|
||||
|
||||
|
||||
@@ -411,3 +411,17 @@
|
||||
{"ts":"2026-06-10T16:37:41.681Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T16:37:41.734Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T16:37:41.759Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T17:34:53.024Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T17:34:53.071Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T17:34:53.071Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T17:34:53.071Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T17:34:53.133Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T17:34:53.150Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T17:34:53.671Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T17:38:50.233Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T17:38:50.381Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T17:38:50.408Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T17:38:50.501Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T17:38:51.619Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
|
||||
@@ -584,6 +584,29 @@ All frontend API paths discovered are either:
|
||||
handler. Spot-checked: `/api/players/search` (Next → Python),
|
||||
`/api/scan` (Next → Express), `/api/intelligence/feed` (Next direct DB).
|
||||
|
||||
#### Payments: dual-provider divergence (Session 7h)
|
||||
|
||||
The frontend `/api/checkout` (Next.js) creates **NexaPay** payment
|
||||
links and is what `web/src/components/Pricing.tsx` CTAs currently hit.
|
||||
The Express `POST /api/stripe/checkout` (Stripe Checkout Sessions) is
|
||||
fully wired, tested in test mode against real Stripe resources
|
||||
(products + prices + webhook all created), and ready for traffic —
|
||||
but no frontend caller invokes it yet. Cutover work for a follow-up
|
||||
session:
|
||||
|
||||
1. Replace `web/src/app/api/checkout/route.ts` body to fetch
|
||||
`${BACKEND_URL}/api/stripe/checkout` with the user's bearer token
|
||||
instead of calling NexaPay's `createPaymentLink`.
|
||||
2. Wire `Pricing.tsx` CTAs through that same Next.js route (response
|
||||
shape is already `{ url, ... }`-compatible; Express returns
|
||||
`{ checkout_url, session_id }`, so the proxy needs to remap
|
||||
`checkout_url → url`).
|
||||
3. Add `/upgrade/success?session_id=...` and `/upgrade/cancel` pages.
|
||||
Current Stripe `success_url` points at `/scan?upgraded=true` and
|
||||
`cancel_url` at `/#pricing` — those work but a confirmation page
|
||||
reads better.
|
||||
4. Decide on NexaPay: keep as fallback, remove, or feature-flag.
|
||||
|
||||
---
|
||||
|
||||
## 9. How to update this manifest
|
||||
|
||||
@@ -12,30 +12,32 @@ jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
|
||||
}));
|
||||
|
||||
// Mock Stripe
|
||||
jest.mock('stripe', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
customers: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
|
||||
// Mock Stripe — singleton handle so tests can override constructEvent
|
||||
// per-case. The service caches `new Stripe()` once; the mock returns
|
||||
// the same instance every time so the override applies to the cached
|
||||
// reference too.
|
||||
const mockStripeInstance = {
|
||||
customers: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
|
||||
},
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
|
||||
},
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
|
||||
},
|
||||
},
|
||||
billingPortal: {
|
||||
sessions: {
|
||||
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
|
||||
},
|
||||
billingPortal: {
|
||||
sessions: {
|
||||
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
list: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
webhooks: {
|
||||
constructEvent: jest.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
},
|
||||
subscriptions: {
|
||||
list: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
webhooks: {
|
||||
constructEvent: jest.fn(),
|
||||
},
|
||||
};
|
||||
jest.mock('stripe', () => jest.fn(() => mockStripeInstance));
|
||||
|
||||
jest.mock('axios');
|
||||
process.env.ODDS_API_KEY = 'test';
|
||||
@@ -154,4 +156,44 @@ describe('POST /api/stripe/webhook', () => {
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('returns 400 when signature header is present but invalid', async () => {
|
||||
// Header present, but constructEvent throws — Stripe's real behavior
|
||||
// when the signed payload doesn't match the secret. The route must
|
||||
// surface 400 and never invoke the event-dispatch path.
|
||||
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => {
|
||||
throw new Error('No signatures found matching the expected signature');
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stripe/webhook')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('stripe-signature', 't=1700000000,v1=deadbeef')
|
||||
.send(Buffer.from('{"id":"evt_forged","type":"checkout.session.completed"}'))
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toMatch(/signature/i);
|
||||
expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('valid signature dispatches to handler and returns 200', async () => {
|
||||
// Positive case: route → service → 200 with `received: true`. We can't
|
||||
// observe the supabase write here without entangling with auth setup;
|
||||
// the unit suite (stripeService.test.js) already proves the dispatch
|
||||
// wiring per event type. This test pins the route-level contract.
|
||||
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => ({
|
||||
id: 'evt_ok',
|
||||
type: 'invoice.payment_failed', // chosen because it requires no users-table fixture beyond the default
|
||||
data: { object: { customer: 'cus_test123' } },
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stripe/webhook')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('stripe-signature', 't=1700000000,v1=stub')
|
||||
.send(Buffer.from('{}'))
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,31 @@ describe('stripeService', () => {
|
||||
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
|
||||
});
|
||||
|
||||
test('customer.subscription.updated active → flips tier to the new plan + clears grace', async () => {
|
||||
// Plan-change flow (portal-driven upgrade/downgrade). Stripe sends
|
||||
// the new price on items.data[0].price.id; the service must map it
|
||||
// back to a tier via PRICE_MAP and reflect that on the user.
|
||||
const fake = makeFake();
|
||||
mockSupabaseClient.current = fake;
|
||||
const newPriceId = process.env.STRIPE_PRICE_DESK || 'price_desk_monthly';
|
||||
await handleWebhookEvent({
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: {
|
||||
customer: 'cus_active',
|
||||
status: 'active',
|
||||
items: { data: [{ price: { id: newPriceId } }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
const usersUpdate = fake.updates.find((u) => u.table === 'users');
|
||||
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
|
||||
expect(usersUpdate.patch.tier).toBe('desk');
|
||||
expect(usersUpdate.patch.grace_period_until).toBeNull();
|
||||
expect(profilesUpdate.patch.tier).toBe('desk');
|
||||
expect(profilesUpdate.patch.subscription_status).toBe('active');
|
||||
});
|
||||
|
||||
test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => {
|
||||
const fake = makeFake();
|
||||
mockSupabaseClient.current = fake;
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user