'use strict'; // ── Regression test for the "declined payment still granted access" bug ────── // PayPal's v2 capture-order endpoint can respond with HTTP 2xx even when the // charge itself was declined or held for review — the failure only shows up // in the response body's status field (e.g. capture.status: "DECLINED" or // "PENDING"), not as a thrown HTTP error. Before the fix, captureOrder() only // guarded against a *thrown* error from the provider and unconditionally // granted the tier on any non-throwing response. These tests pin the fix: // a non-COMPLETED capture must mark the payment failed and must NOT create // or extend a user_tiers row. jest.mock('../../models/tiers/tier_categories.mdl', () => ({})); jest.mock('../../models/tiers/tier_plans.mdl', () => ({ findOne: jest.fn() })); jest.mock('../../models/tiers/user_tiers.mdl', () => ({ findOne: jest.fn(), create: jest.fn() })); jest.mock('../../models/tiers/payments.mdl', () => ({ findAll: jest.fn() })); jest.mock('../../models/system_badges/system_badges.mdl', () => ({})); jest.mock('../../models/assets/assets.mdl', () => ({})); jest.mock('../../models/notifications/user_notification.mdl', () => ({ create: jest.fn() })); jest.mock('../../services/achievements.service', () => ({ onTierActivated: jest.fn() })); jest.mock('../../models/courses/courses.mdl', () => ({ Course: {} })); jest.mock('../../services/payment.service', () => ({ captureOrder: jest.fn() })); jest.mock('../../data/notifications.data', () => ({ NOTIFICATION_REGISTRY: {} })); jest.mock('../../models/tiers/tier.associations', () => ({})); // captureOrder now snapshots the plan's bundle into user_tier_grants on every // successful capture (Tier Plans v2 item-specific entitlement) — mocked out // here since these tests only pin the payment-status gating behavior. jest.mock('../../services/tierGrants.service', () => ({ snapshotPlanGrants: jest.fn() })); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); const paymentSvc = require('../../services/payment.service'); const controller = require('../../controllers/client/tiers.controller'); function mockRes() { const res = {}; res.status = jest.fn().mockReturnValue(res); res.json = jest.fn().mockReturnValue(res); return res; } function makePayment(overrides = {}) { return { payment_id: 1, plan_id: 10, provider: 'paypal', provider_payload: { order_id: 'ORDER-1' }, plan: { plan_id: 10, tier: 'premium', duration_days: 30, is_active: true }, update: jest.fn().mockResolvedValue(true), ...overrides, }; } beforeEach(() => jest.clearAllMocks()); describe('captureOrder() — declined/incomplete captures must not grant access', () => { test('capture.status "DECLINED" marks the payment failed and creates no tier', async () => { const payment = makePayment(); mdl_Payments.findAll.mockResolvedValue([payment]); paymentSvc.captureOrder.mockResolvedValue({ status: 'COMPLETED', // outer order status can still say COMPLETED purchase_units: [{ payments: { captures: [{ id: 'CAP-1', status: 'DECLINED' }] } }], }); const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } }; const res = mockRes(); await controller.captureOrder(req, res); expect(mdl_UserTiers.create).not.toHaveBeenCalled(); expect(payment.update).toHaveBeenCalledWith( expect.objectContaining({ status: 'failed' }) ); expect(res.status).toHaveBeenCalledWith(402); }); test('capture.status "PENDING" (e.g. eCheck review) also withholds access', async () => { const payment = makePayment(); mdl_Payments.findAll.mockResolvedValue([payment]); paymentSvc.captureOrder.mockResolvedValue({ status: 'COMPLETED', purchase_units: [{ payments: { captures: [{ id: 'CAP-2', status: 'PENDING' }] } }], }); const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } }; const res = mockRes(); await controller.captureOrder(req, res); expect(mdl_UserTiers.create).not.toHaveBeenCalled(); expect(payment.update).toHaveBeenCalledWith( expect.objectContaining({ status: 'failed' }) ); expect(res.status).toHaveBeenCalledWith(402); }); test('capture.status "COMPLETED" still grants the tier (control case)', async () => { const payment = makePayment(); mdl_Payments.findAll.mockResolvedValue([payment]); mdl_UserTiers.findOne.mockResolvedValue(null); // no existing active tier mdl_UserTiers.create.mockResolvedValue({ tier_id: 99, tier: 'premium', expires_at: new Date() }); paymentSvc.captureOrder.mockResolvedValue({ status: 'COMPLETED', payer: { payer_id: 'PAYER-1' }, purchase_units: [{ payments: { captures: [{ id: 'CAP-3', status: 'COMPLETED' }] } }], }); const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } }; const res = mockRes(); await controller.captureOrder(req, res); expect(mdl_UserTiers.create).toHaveBeenCalled(); expect(payment.update).toHaveBeenCalledWith( expect.objectContaining({ status: 'completed' }) ); expect(res.status).toHaveBeenCalledWith(200); }); });