chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -0,0 +1,82 @@
'use strict';
// ── Regression tests for item-specific entitlement (Tier Plans v2) ──────────
// Before this change, canAccessCourse/canAccessUnit/canAccessLesson unlocked
// content purely off tier RANK (any active Premium purchase unlocked every
// premium-tagged course). Now a Tier Plan purchase only grants the EXACT
// items its bundle contained at purchase time (recorded in
// user_tier_grants) — plain tier rank alone must no longer be sufficient.
// These tests exercise the real controller/model objects (Sequelize doesn't
// connect until a query actually runs) and stub only the specific static
// methods each scenario touches, via jest.spyOn.
const { Course } = require('../../models/courses/courses.associations');
const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const { canAccessCourse } = require('../../controllers/client/courses.controller');
function mockActiveTierUnused() {
jest.spyOn(mdl_UserTiers, 'findAll').mockResolvedValue([]);
jest.spyOn(mdl_TierCategories, 'findAll').mockResolvedValue([]);
jest.spyOn(mdl_UserGroupMembers, 'findAll').mockResolvedValue([]);
}
afterEach(() => jest.restoreAllMocks());
describe('canAccessCourse() — item-specific entitlement', () => {
test('free/ungated course is always accessible, no grant lookup needed', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'free', status: 'published' });
const grantSpy = jest.spyOn(mdl_UserTierGrants, 'findOne');
const result = await canAccessCourse(1, 100);
expect(result).toBe(true);
expect(grantSpy).not.toHaveBeenCalled();
});
test('unpublished course is never accessible regardless of entitlement', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'draft' });
const result = await canAccessCourse(1, 100);
expect(result).toBe(false);
});
test('premium course with no grant, no purchase, no active tier → denied (tier rank alone no longer grants access)', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' });
jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue(null);
jest.spyOn(mdl_Product, 'findOne').mockResolvedValue(null);
mockActiveTierUnused();
const result = await canAccessCourse(1, 100);
expect(result).toBe(false);
});
test('premium course unlocked by an item-specific grant for THIS exact course', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' });
jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue({ item_id: 100 }); // active grant found
mockActiveTierUnused();
const result = await canAccessCourse(1, 100);
expect(result).toBe(true);
});
test('premium course still unlocked via a valid individual (a la carte) purchase, unrelated to any tier grant', async () => {
jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' });
jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue(null);
jest.spyOn(mdl_Product, 'findOne').mockResolvedValue({ id: 55 });
jest.spyOn(mdl_CoursePurchase, 'findOne').mockResolvedValue({ id: 1 });
mockActiveTierUnused();
const result = await canAccessCourse(1, 100);
expect(result).toBe(true);
});
});
@@ -0,0 +1,120 @@
'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', () => ({ findOne: 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.findOne.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.findOne.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.findOne.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);
});
});