mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
|
||||
// ── Env vars must be set BEFORE the service loads (enabled flags read process.env at load time)
|
||||
process.env.DB_HOST = 'test-db-host';
|
||||
process.env.DB_PORT = '5432';
|
||||
process.env.S3_ENDPOINT = 'http://test-s3:3900';
|
||||
process.env.S3_PUBLIC_URL = 'https://cdn.example.com';
|
||||
process.env.S3_BUCKET = 'test-bucket';
|
||||
process.env.GOOGLE_CLIENT_ID = 'test-client-id';
|
||||
process.env.GMAIL_REFRESH_TOKEN = 'test-refresh-token';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379';
|
||||
|
||||
// ── Mock all infrastructure before the service module loads ───────────────────
|
||||
jest.mock('../../config/db.config', () => ({ authenticate: jest.fn() }));
|
||||
jest.mock('../../config/redis.config', () => ({ ping: jest.fn() }));
|
||||
jest.mock('../../services/s3.service', () => ({ ping: jest.fn() }));
|
||||
jest.mock('../../services/email.service', () => ({ ping: jest.fn() }));
|
||||
|
||||
const db = require('../../config/db.config');
|
||||
const cache = require('../../config/redis.config');
|
||||
const { ping: s3 } = require('../../services/s3.service');
|
||||
const { ping: smtp } = require('../../services/email.service'); // alias kept for test-body brevity; JSON key is now "email"
|
||||
const { runDashboard, runReadiness } = require('../../services/health.service');
|
||||
|
||||
// ── Reset mocks between tests ─────────────────────────────────────────────────
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ── Preset helpers ────────────────────────────────────────────────────────────
|
||||
const resolve = (fn) => fn.mockResolvedValue();
|
||||
|
||||
function allHealthy() {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
}
|
||||
|
||||
// ── runDashboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runDashboard()', () => {
|
||||
|
||||
test('all healthy → status=healthy, HTTP 200', async () => {
|
||||
allHealthy();
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('healthy');
|
||||
});
|
||||
|
||||
test('database down (critical) → status=unhealthy, HTTP 503', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('Connection refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(503);
|
||||
expect(body.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
test('S3 down (non-critical) → status=degraded, HTTP 200', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('S3 storage unavailable'));
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
test('SMTP down (non-critical) → degraded, HTTP 200', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
smtp.mockRejectedValue(new Error('SMTP unreachable'));
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
test('both DB and S3 down → unhealthy (critical takes precedence), HTTP 503', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('s3 down'));
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(503);
|
||||
expect(body.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
test('body has app, system, uptime, timestamp, services', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(body).toHaveProperty('app');
|
||||
expect(body).toHaveProperty('system');
|
||||
expect(body).toHaveProperty('uptime');
|
||||
expect(body).toHaveProperty('timestamp');
|
||||
expect(body).toHaveProperty('services');
|
||||
});
|
||||
|
||||
test('app info has correct shape', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(body.app).toMatchObject({
|
||||
name: expect.any(String),
|
||||
version: expect.any(String),
|
||||
environment: expect.any(String),
|
||||
node_version: expect.stringMatching(/^v\d+/),
|
||||
pid: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
test('database connection label is "established" when healthy', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(body.services.database.connection).toBe('established');
|
||||
});
|
||||
|
||||
test('database connection label is "unavailable" when down', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { body } = await runDashboard();
|
||||
expect(body.services.database.connection).toBe('unavailable');
|
||||
});
|
||||
|
||||
test('latency_ms is a non-negative number for enabled checks', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(typeof body.services.database.latency_ms).toBe('number');
|
||||
expect(body.services.database.latency_ms).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('timestamp is a valid ISO 8601 string', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
// ── runReadiness ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runReadiness()', () => {
|
||||
|
||||
test('all healthy → status=healthy, HTTP 200', async () => {
|
||||
allHealthy();
|
||||
const { httpStatus, body } = await runReadiness();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('healthy');
|
||||
});
|
||||
|
||||
test('database down → HTTP 503', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus } = await runReadiness();
|
||||
expect(httpStatus).toBe(503);
|
||||
});
|
||||
|
||||
test('S3 down → HTTP 200, degraded — non-critical stays routable', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('S3 storage unavailable'));
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runReadiness();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
test('body has version, uptime, timestamp, checks, memory', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
expect(body).toHaveProperty('version');
|
||||
expect(body).toHaveProperty('uptime');
|
||||
expect(body).toHaveProperty('timestamp');
|
||||
expect(body).toHaveProperty('checks');
|
||||
expect(body).toHaveProperty('memory');
|
||||
});
|
||||
|
||||
test('checks object has database, cache, storage, email keys', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
['database', 'cache', 'storage', 'email'].forEach((key) => {
|
||||
expect(body.checks).toHaveProperty(key);
|
||||
});
|
||||
});
|
||||
|
||||
test('memory snapshot has heap_used_mb, heap_total_mb, rss_mb, external_mb', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
expect(body.memory).toMatchObject({
|
||||
heap_used_mb: expect.any(Number),
|
||||
heap_total_mb: expect.any(Number),
|
||||
rss_mb: expect.any(Number),
|
||||
external_mb: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
test('database check entry has status=unhealthy and critical=true when DB fails', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { body } = await runReadiness();
|
||||
expect(body.checks.database.status).toBe('unhealthy');
|
||||
expect(body.checks.database.critical).toBe(true);
|
||||
});
|
||||
|
||||
test('storage check entry has status=unhealthy and critical=false when S3 fails', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('S3 storage unavailable'));
|
||||
resolve(smtp);
|
||||
|
||||
const { body } = await runReadiness();
|
||||
expect(body.checks.storage.status).toBe('unhealthy');
|
||||
expect(body.checks.storage.critical).toBe(false);
|
||||
});
|
||||
|
||||
test('cache check entry has status=skipped when redis is null (CACHE_DRIVER=memory)', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
// cache mock returns { ping: fn } so it is enabled; but the skipped test
|
||||
// is covered when redis module exports null — we verify the shape here instead
|
||||
expect(['healthy', 'skipped']).toContain(body.checks.cache.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
// ── Integration test: payment.service.js → providers/registry → paypal.provider ──
|
||||
// Only the DB models and axios (the two real external boundaries) are mocked.
|
||||
// registry.js and paypal.provider.js run unmodified, so this proves the full
|
||||
// orchestration chain (service → registry lookup → provider → HTTP) is wired
|
||||
// correctly end to end, not just that each piece works in isolation.
|
||||
|
||||
process.env.PAYPAL_CLIENT_ID = 'test-client-id';
|
||||
process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret';
|
||||
process.env.FRONTEND_URL = 'https://app.new-starr.test';
|
||||
|
||||
const axios = require('axios');
|
||||
jest.mock('axios');
|
||||
|
||||
jest.mock('../../models/tiers/payment_policies.mdl', () => ({ findOne: jest.fn() }));
|
||||
jest.mock('../../models/tiers/payments.mdl', () => ({ count: jest.fn() }));
|
||||
|
||||
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const paymentService = require('../../services/payment.service');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
function mockAccessToken(token = 'tok-abc') {
|
||||
axios.post.mockImplementationOnce((url) => {
|
||||
expect(url).toMatch(/\/v1\/oauth2\/token$/);
|
||||
return Promise.resolve({ data: { access_token: token } });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Provider delegation ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('provider delegation (registry → paypal.provider → axios)', () => {
|
||||
|
||||
test('createOrder("paypal", ...) reaches PayPal\'s create-order endpoint', async () => {
|
||||
mockAccessToken();
|
||||
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } });
|
||||
|
||||
const result = await paymentService.createOrder('paypal', { amount: 25, referenceId: 'plan-1' });
|
||||
|
||||
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
|
||||
expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' });
|
||||
});
|
||||
|
||||
test('captureOrder("paypal", ...) reaches PayPal\'s capture endpoint', async () => {
|
||||
mockAccessToken();
|
||||
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } });
|
||||
|
||||
const result = await paymentService.captureOrder('paypal', 'ORDER-1');
|
||||
|
||||
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture');
|
||||
expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' });
|
||||
});
|
||||
|
||||
test('refundCapture("paypal", ...) reaches PayPal\'s refund endpoint', async () => {
|
||||
mockAccessToken();
|
||||
axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } });
|
||||
|
||||
const result = await paymentService.refundCapture('paypal', 'CAPTURE-1', 10, 'USD');
|
||||
|
||||
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund');
|
||||
expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' });
|
||||
});
|
||||
|
||||
test('an unconfigured/unknown provider name fails fast with a descriptive error instead of hitting axios', () => {
|
||||
expect(() => paymentService.createOrder('stripe', { amount: 25, referenceId: 'plan-1' }))
|
||||
.toThrow(/Unknown payment provider: "stripe"/);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refund policy ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('refund policy', () => {
|
||||
|
||||
test('falls back to the 5-minute default window when no policy is configured', () => {
|
||||
expect(paymentService.getRefundWindowMs(null)).toBe(5 * 60_000);
|
||||
expect(paymentService.isRefundAllowed(null)).toBe(true);
|
||||
});
|
||||
|
||||
test('honors a custom policy window and unit', () => {
|
||||
const policy = { refund_policy: { allowed: false, window_value: 2, window_unit: 'hours' } };
|
||||
expect(paymentService.getRefundWindowMs(policy)).toBe(2 * 3_600_000);
|
||||
expect(paymentService.isRefundAllowed(policy)).toBe(false);
|
||||
});
|
||||
|
||||
test('getPolicyForPlan() looks up the policy by plan_id', async () => {
|
||||
mdl_PaymentPolicies.findOne.mockResolvedValue({ plan_id: 7 });
|
||||
const policy = await paymentService.getPolicyForPlan(7);
|
||||
expect(mdl_PaymentPolicies.findOne).toHaveBeenCalledWith({ where: { plan_id: 7 } });
|
||||
expect(policy).toEqual({ plan_id: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Promo evaluation ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluatePromo()', () => {
|
||||
const plan = { plan_id: 1, price: 100 };
|
||||
|
||||
test('rejects when no code is provided', async () => {
|
||||
const result = await paymentService.evaluatePromo({}, plan, '');
|
||||
expect(result).toEqual({ valid: false, reason: 'No promo code provided.' });
|
||||
});
|
||||
|
||||
test('rejects an unknown code', async () => {
|
||||
const policy = { promo_rules: [] };
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'BOGUS');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toBe('Invalid promo code.');
|
||||
});
|
||||
|
||||
test('applies a flat discount', async () => {
|
||||
const policy = { promo_rules: [{ code: 'FLAT10', type: 'flat', value: 10 }] };
|
||||
mdl_Payments.count.mockResolvedValue(0);
|
||||
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'flat10');
|
||||
expect(result).toMatchObject({ valid: true, code: 'FLAT10', discount: 10 });
|
||||
});
|
||||
|
||||
test('applies a percent discount capped by max_discount', async () => {
|
||||
const policy = { promo_rules: [{ code: 'PCT50', type: 'percent', value: 50, max_discount: 30 }] };
|
||||
mdl_Payments.count.mockResolvedValue(0);
|
||||
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'PCT50');
|
||||
expect(result).toMatchObject({ valid: true, discount: 30 });
|
||||
});
|
||||
|
||||
test('rejects an expired code', async () => {
|
||||
const policy = { promo_rules: [{ code: 'OLD', type: 'flat', value: 5, expires_at: '2000-01-01' }] };
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'OLD');
|
||||
expect(result).toMatchObject({ valid: false, reason: 'Promo code has expired.' });
|
||||
});
|
||||
|
||||
test('rejects once max_uses has been reached', async () => {
|
||||
const policy = { promo_rules: [{ code: 'LIMITED', type: 'flat', value: 5, max_uses: 2 }] };
|
||||
mdl_Payments.count.mockResolvedValue(2);
|
||||
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'LIMITED');
|
||||
expect(result).toMatchObject({ valid: false, reason: 'Promo code has reached its usage limit.' });
|
||||
});
|
||||
|
||||
test('rejects when the subtotal is below min_amount', async () => {
|
||||
const policy = { promo_rules: [{ code: 'BIGSPEND', type: 'flat', value: 5, min_amount: 200 }] };
|
||||
mdl_Payments.count.mockResolvedValue(0);
|
||||
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'BIGSPEND');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.reason).toMatch(/minimum purchase/);
|
||||
});
|
||||
|
||||
test('evaluates against effectivePrice (localized currency) instead of plan.price when provided', async () => {
|
||||
const policy = { promo_rules: [{ code: 'PCT10', type: 'percent', value: 10 }] };
|
||||
mdl_Payments.count.mockResolvedValue(0);
|
||||
|
||||
const result = await paymentService.evaluatePromo(policy, plan, 'PCT10', 50);
|
||||
expect(result.discount).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
'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(),
|
||||
bulkCreate: 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, revokePlanSubscriberAccessBulk } = 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
|
||||
.mockResolvedValueOnce([{ tier_id: 1, user_id: 5, plan_id: 10 }]) // active rows for the plan
|
||||
.mockResolvedValueOnce([]); // grouped "still active elsewhere" check — none
|
||||
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.bulkCreate).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
|
||||
.mockResolvedValueOnce([{ tier_id: 2, user_id: 6, plan_id: 10 }])
|
||||
.mockResolvedValueOnce([{ user_id: 6 }]); // still holds a different active tier
|
||||
mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]);
|
||||
|
||||
await revokePlanSubscriberAccess(makePlan(), 99);
|
||||
|
||||
expect(mdl_UserTiers.bulkCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fires exactly one notification batch and one email per affected user', async () => {
|
||||
mdl_UserTiers.findAll
|
||||
.mockResolvedValueOnce([
|
||||
{ tier_id: 1, user_id: 5, plan_id: 10 },
|
||||
{ tier_id: 2, user_id: 6, plan_id: 10 },
|
||||
])
|
||||
.mockResolvedValueOnce([{ user_id: 5 }, { user_id: 6 }]);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokePlanSubscriberAccessBulk() — N+1 regression', () => {
|
||||
test('query count stays flat regardless of plan/subscriber count (no per-user or per-plan loop)', async () => {
|
||||
const plans = [
|
||||
{ plan_id: 10, label: 'Premium – 1 Month' },
|
||||
{ plan_id: 11, label: 'Premium – 1 Year' },
|
||||
{ plan_id: 12, label: 'Basic' },
|
||||
];
|
||||
const activeRows = Array.from({ length: 25 }, (_, i) => ({
|
||||
tier_id: i + 1,
|
||||
user_id: i + 1,
|
||||
plan_id: plans[i % plans.length].plan_id,
|
||||
}));
|
||||
mdl_UserTiers.findAll
|
||||
.mockResolvedValueOnce(activeRows) // active rows across all 3 plans
|
||||
.mockResolvedValueOnce([]); // grouped "still active" check — nobody else active
|
||||
mdl_Users.findAll.mockResolvedValue(
|
||||
activeRows.map((r) => ({ user_id: r.user_id, email: `${r.user_id}@x.com`, personal_info: {} })),
|
||||
);
|
||||
|
||||
const result = await revokePlanSubscriberAccessBulk(plans, 99);
|
||||
|
||||
// Exactly 2 findAll calls total (active rows + grouped still-active check),
|
||||
// 1 bulk update, 1 bulk downgrade create, 1 notification bulkCreate —
|
||||
// no matter how many plans/users were involved.
|
||||
expect(mdl_UserTiers.findAll).toHaveBeenCalledTimes(2);
|
||||
expect(mdl_UserTiers.update).toHaveBeenCalledTimes(1);
|
||||
expect(mdl_UserTiers.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mdl_UserTiers.bulkCreate.mock.calls[0][0]).toHaveLength(25);
|
||||
expect(UserNotification.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(UserNotification.bulkCreate.mock.calls[0][0]).toHaveLength(25);
|
||||
expect(result).toEqual({ revoked_user_count: 25 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user