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
+235
View File
@@ -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 });
});
});