mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
client and some admin new
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ 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');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
// ── Regression tests for the "revoke plan access, no refund" feature ────────
|
||||
// revokePlanSubscriberAccess() must replicate revokeTier's single-record
|
||||
// "auto-downgrade to Free only if the user holds no other active tier" rule
|
||||
// (controllers/admin/tiers.controller.js) instead of blanket-downgrading
|
||||
// everyone, and must fire exactly one in-app notification batch plus one
|
||||
// email per affected user.
|
||||
|
||||
jest.mock('../../models/tiers/user_tiers.mdl', () => ({
|
||||
findAll: jest.fn(),
|
||||
update: jest.fn(),
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() }));
|
||||
jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() }));
|
||||
jest.mock('../../services/email.service', () => ({ sendEmail: jest.fn().mockResolvedValue(true) }));
|
||||
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
|
||||
const { revokePlanSubscriberAccess } = require('../../services/planAccess.service');
|
||||
|
||||
function makePlan(overrides = {}) {
|
||||
return { plan_id: 10, label: 'Premium – 1 Month', ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('revokePlanSubscriberAccess()', () => {
|
||||
test('no active subscribers → no-op, returns zero count', async () => {
|
||||
mdl_UserTiers.findAll.mockResolvedValue([]);
|
||||
|
||||
const result = await revokePlanSubscriberAccess(makePlan(), 99);
|
||||
|
||||
expect(result).toEqual({ revoked_user_count: 0 });
|
||||
expect(mdl_UserTiers.update).not.toHaveBeenCalled();
|
||||
expect(UserNotification.bulkCreate).not.toHaveBeenCalled();
|
||||
expect(sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a user with ONLY this plan active gets auto-downgraded to Free', async () => {
|
||||
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 1, user_id: 5 }]);
|
||||
mdl_UserTiers.count.mockResolvedValue(0); // no other active tier remains
|
||||
mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]);
|
||||
|
||||
const result = await revokePlanSubscriberAccess(makePlan(), 99);
|
||||
|
||||
expect(mdl_UserTiers.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'revoked', revoked_by: 99 }),
|
||||
{ where: { tier_id: [1] } }
|
||||
);
|
||||
expect(mdl_UserTiers.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' })
|
||||
);
|
||||
expect(result).toEqual({ revoked_user_count: 1 });
|
||||
});
|
||||
|
||||
test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => {
|
||||
mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 2, user_id: 6 }]);
|
||||
mdl_UserTiers.count.mockResolvedValue(1); // still holds a different active plan
|
||||
mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]);
|
||||
|
||||
await revokePlanSubscriberAccess(makePlan(), 99);
|
||||
|
||||
expect(mdl_UserTiers.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fires exactly one notification batch and one email per affected user', async () => {
|
||||
mdl_UserTiers.findAll.mockResolvedValue([
|
||||
{ tier_id: 1, user_id: 5 },
|
||||
{ tier_id: 2, user_id: 6 },
|
||||
]);
|
||||
mdl_UserTiers.count.mockResolvedValue(1);
|
||||
mdl_Users.findAll.mockResolvedValue([
|
||||
{ user_id: 5, email: 'a@b.com', personal_info: {} },
|
||||
{ user_id: 6, email: 'c@d.com', personal_info: {} },
|
||||
]);
|
||||
|
||||
const result = await revokePlanSubscriberAccess(makePlan(), 99);
|
||||
|
||||
expect(UserNotification.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(UserNotification.bulkCreate.mock.calls[0][0]).toHaveLength(2);
|
||||
expect(sendEmail).toHaveBeenCalledTimes(2);
|
||||
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@b.com', type: 'TIER_ACCESS_REVOKED' }));
|
||||
expect(result).toEqual({ revoked_user_count: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { evaluateCourseAccess, TIER_RANK } = require('../../utils/accessPolicy.util');
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ctx(tier = 'free', access_rules = [], group_ids = []) {
|
||||
return { tier, access_rules, group_ids };
|
||||
}
|
||||
|
||||
function course(subscription = 'free') {
|
||||
return { subscription };
|
||||
}
|
||||
|
||||
// ── Free course is always accessible ─────────────────────────────────────────
|
||||
|
||||
describe('free course (rank 0)', () => {
|
||||
test('free user can access free course', () => {
|
||||
expect(evaluateCourseAccess(ctx('free'), course('free'))).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('premium user can access free course', () => {
|
||||
expect(evaluateCourseAccess(ctx('premium'), course('free'))).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('free user with no rules can still access free course', () => {
|
||||
expect(evaluateCourseAccess(ctx('free', []), course('free'))).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ── No access_rules — fallback tier rank comparison ──────────────────────────
|
||||
|
||||
describe('no access_rules (fallback mode)', () => {
|
||||
test('premium user can access premium course', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', []), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('exclusive user can access exclusive course', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', []), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('free user cannot access premium course', () => {
|
||||
const result = evaluateCourseAccess(ctx('free', []), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
|
||||
});
|
||||
|
||||
test('premium user cannot access exclusive course', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', []), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
|
||||
});
|
||||
|
||||
test('unknown course subscription slug → denied (safe default)', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', []), course('unknown-tier'), TIER_RANK);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: course_subscription_access ─────────────────────────────────────────
|
||||
|
||||
describe('rule: course_subscription_access', () => {
|
||||
const rules = [{ type: 'course_subscription_access', levels: ['free', 'premium'] }];
|
||||
|
||||
test('plan covers premium → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('plan does not cover exclusive → denied', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', rules), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: required_active_tier ────────────────────────────────────────────────
|
||||
|
||||
describe('rule: required_active_tier', () => {
|
||||
const rules = [{ type: 'required_active_tier', tier: 'premium' }];
|
||||
|
||||
test('premium user meets premium requirement → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('exclusive user exceeds premium requirement → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', rules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('free user fails premium requirement → denied', () => {
|
||||
const result = evaluateCourseAccess(ctx('free', rules), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'required_tier' });
|
||||
});
|
||||
|
||||
test('unknown required tier slug → always denied', () => {
|
||||
const badRules = [{ type: 'required_active_tier', tier: 'ghost-tier' }];
|
||||
const result = evaluateCourseAccess(ctx('exclusive', badRules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: group_restriction ───────────────────────────────────────────────────
|
||||
|
||||
describe('rule: group_restriction', () => {
|
||||
const rules = [{ type: 'group_restriction', group_ids: [5, 10] }];
|
||||
|
||||
test('user in allowed group → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules, [10, 20]), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('user not in any allowed group → denied', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'group_restriction' });
|
||||
});
|
||||
|
||||
test('empty group_ids on rule → no restriction (passes)', () => {
|
||||
const openRules = [{ type: 'group_restriction', group_ids: [] }];
|
||||
const result = evaluateCourseAccess(ctx('premium', openRules, []), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: item_allowlist ───────────────────────────────────────────────────────
|
||||
|
||||
describe('rule: item_allowlist', () => {
|
||||
const rules = [
|
||||
{ type: 'course_subscription_access', levels: ['premium'] },
|
||||
{ type: 'item_allowlist', item_type: 'course', item_ids: [42, 99] },
|
||||
];
|
||||
|
||||
test('allowlisted exclusive course is granted even though the level-lock rule would block it', () => {
|
||||
const result = evaluateCourseAccess(
|
||||
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: 42 }
|
||||
);
|
||||
expect(result).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('matches item_ids as strings or numbers interchangeably', () => {
|
||||
const result = evaluateCourseAccess(
|
||||
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: '99' }
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('non-allowlisted exclusive course still falls through to the level-lock rule and is denied', () => {
|
||||
const result = evaluateCourseAccess(
|
||||
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: 7 }
|
||||
);
|
||||
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
|
||||
});
|
||||
|
||||
test('item_type must match — a unit id matching a course-scoped allowlist is not granted', () => {
|
||||
const result = evaluateCourseAccess(
|
||||
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'unit', id: 42 }
|
||||
);
|
||||
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
|
||||
});
|
||||
|
||||
test('no itemMeta (or null id) — item_allowlist is skipped entirely, existing callers unaffected', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
|
||||
});
|
||||
|
||||
test('allowlisted item still requires nothing when premium content is requested normally', () => {
|
||||
const result = evaluateCourseAccess(
|
||||
ctx('premium', rules), course('premium'), TIER_RANK, { type: 'course', id: 1 }
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple rules evaluated together ────────────────────────────────────────
|
||||
|
||||
describe('multiple rules', () => {
|
||||
test('fails if any one rule blocks', () => {
|
||||
const rules = [
|
||||
{ type: 'course_subscription_access', levels: ['premium', 'exclusive'] },
|
||||
{ type: 'required_active_tier', tier: 'premium' },
|
||||
{ type: 'group_restriction', group_ids: [7] },
|
||||
];
|
||||
// All pass
|
||||
expect(evaluateCourseAccess(ctx('premium', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(true);
|
||||
|
||||
// Fails group_restriction
|
||||
expect(evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK).allowed).toBe(false);
|
||||
|
||||
// Fails required_active_tier
|
||||
expect(evaluateCourseAccess(ctx('free', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user