Session 7i: Stripe test coverage gaps filled, dual-provider cutover documented (1042 tests)

This commit is contained in:
Kev
2026-06-10 13:55:59 -04:00
parent d4e5e76452
commit b9084408bf
6 changed files with 162 additions and 24 deletions
+35 -1
View File
@@ -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
+14
View File
@@ -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"}
+23
View File
@@ -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
+64 -22
View File
@@ -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);
});
});
+25
View File
@@ -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
View File
File diff suppressed because one or more lines are too long