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
+74
View File
@@ -0,0 +1,74 @@
'use strict';
const { fmtDate, fmtDateTime, fmtTime } = require('../../utils/datetime.util');
const ISO = '2026-06-20T14:30:00.000Z'; // Saturday, June 20, 2026, 2:30 PM UTC
// ── fmtDate ───────────────────────────────────────────────────────────────────
describe('fmtDate()', () => {
test('null → returns —', () => {
expect(fmtDate(null)).toBe('—');
});
test('empty string → returns —', () => {
expect(fmtDate('')).toBe('—');
});
test('formats year correctly', () => {
expect(fmtDate(ISO)).toContain('2026');
});
test('formats month correctly (June)', () => {
expect(fmtDate(ISO)).toContain('June');
});
test('formats day correctly (20)', () => {
expect(fmtDate(ISO)).toContain('20');
});
test('accepts Date object as well as ISO string', () => {
const d = new Date(ISO);
expect(fmtDate(d)).toContain('2026');
});
});
// ── fmtDateTime ───────────────────────────────────────────────────────────────
describe('fmtDateTime()', () => {
test('null → returns —', () => {
expect(fmtDateTime(null)).toBe('—');
});
test('contains date portion', () => {
expect(fmtDateTime(ISO)).toContain('2026');
expect(fmtDateTime(ISO)).toContain('June');
});
test('contains time portion (2:30 PM UTC)', () => {
const out = fmtDateTime(ISO);
expect(out).toMatch(/2:30/);
expect(out).toContain('UTC');
});
});
// ── fmtTime ───────────────────────────────────────────────────────────────────
describe('fmtTime()', () => {
test('null → returns —', () => {
expect(fmtTime(null)).toBe('—');
});
test('outputs time with AM/PM', () => {
expect(fmtTime(ISO)).toMatch(/\d+:\d{2}\s*(AM|PM)/);
});
test('contains timezone label (UTC)', () => {
expect(fmtTime(ISO)).toContain('UTC');
});
test('locale option changes output', () => {
const en = fmtTime(ISO, { locale: 'en-US' });
expect(en).toBeTruthy();
});
});
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../../utils/otp.util');
// ── generateOTP ───────────────────────────────────────────────────────────────
describe('generateOTP()', () => {
test('returns a 6-character string', () => {
expect(generateOTP()).toHaveLength(6);
});
test('contains only digits', () => {
expect(generateOTP()).toMatch(/^\d{6}$/);
});
test('value is within 000000-999999', () => {
const n = Number(generateOTP());
expect(n).toBeGreaterThanOrEqual(0);
expect(n).toBeLessThanOrEqual(999999);
});
test('zero-pads values under 100000', () => {
// Run many samples to catch low values; deterministic check via string length
for (let i = 0; i < 50; i++) {
expect(generateOTP()).toHaveLength(6);
}
});
});
// ── getOTPExpiry ──────────────────────────────────────────────────────────────
describe('getOTPExpiry()', () => {
test('returns a Date instance', () => {
expect(getOTPExpiry()).toBeInstanceOf(Date);
});
test('expiry is in the future', () => {
expect(getOTPExpiry().getTime()).toBeGreaterThan(Date.now());
});
test('default offset is approximately 10 minutes', () => {
const before = Date.now();
const expiry = getOTPExpiry().getTime();
const after = Date.now();
const tenMin = 10 * 60 * 1000;
expect(expiry).toBeGreaterThanOrEqual(before + tenMin - 100);
expect(expiry).toBeLessThanOrEqual(after + tenMin + 100);
});
test('custom minutes are respected', () => {
const before = Date.now();
const expiry = getOTPExpiry(5).getTime();
const fiveMin = 5 * 60 * 1000;
expect(expiry).toBeGreaterThanOrEqual(before + fiveMin - 100);
});
});
// ── isOTPExpired ──────────────────────────────────────────────────────────────
describe('isOTPExpired()', () => {
test('null → expired (true)', () => {
expect(isOTPExpired(null)).toBe(true);
});
test('undefined → expired (true)', () => {
expect(isOTPExpired(undefined)).toBe(true);
});
test('past date → expired (true)', () => {
const past = new Date(Date.now() - 60_000);
expect(isOTPExpired(past)).toBe(true);
});
test('future date → not expired (false)', () => {
const future = new Date(Date.now() + 60_000);
expect(isOTPExpired(future)).toBe(false);
});
test('accepts ISO string as well as Date', () => {
const future = new Date(Date.now() + 60_000).toISOString();
expect(isOTPExpired(future)).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const R = require('../../utils/response.util');
function makeRes() {
const res = {
_status: null,
_body: null,
status(code) { this._status = code; return this; },
json(body) { this._body = body; return this; },
};
return res;
}
// ── R.success ─────────────────────────────────────────────────────────────────
describe('R.success()', () => {
test('default status is 200', () => {
const res = makeRes();
R.success(res, 'OK');
expect(res._status).toBe(200);
});
test('envelope shape: status=success, message, data', () => {
const res = makeRes();
R.success(res, 'Created', { id: 1 }, 201);
expect(res._body).toEqual({ status: 'success', message: 'Created', data: { id: 1 } });
});
test('custom status code is used', () => {
const res = makeRes();
R.success(res, 'Created', null, 201);
expect(res._status).toBe(201);
});
test('data is null when omitted', () => {
const res = makeRes();
R.success(res, 'OK');
expect(res._body.data).toBeNull();
});
});
// ── R.error ───────────────────────────────────────────────────────────────────
describe('R.error()', () => {
test('default status is 500', () => {
const res = makeRes();
R.error(res, 'Something broke');
expect(res._status).toBe(500);
});
test('envelope shape: status=error, message', () => {
const res = makeRes();
R.error(res, 'Not found', 404);
expect(res._body).toMatchObject({ status: 'error', message: 'Not found' });
expect(res._status).toBe(404);
});
test('errors field is absent when not provided', () => {
const res = makeRes();
R.error(res, 'Bad request', 400);
expect(res._body).not.toHaveProperty('errors');
});
test('errors field is included when provided', () => {
const res = makeRes();
const errs = [{ field: 'email', msg: 'Invalid' }];
R.error(res, 'Validation failed', 422, errs);
expect(res._body.errors).toEqual(errs);
});
});
// ── R.validationError ─────────────────────────────────────────────────────────
describe('R.validationError()', () => {
test('status is always 422', () => {
const res = makeRes();
R.validationError(res, []);
expect(res._status).toBe(422);
});
test('envelope shape: status=error, message=Validation failed, errors', () => {
const res = makeRes();
const errs = [{ field: 'name', msg: 'Required' }];
R.validationError(res, errs);
expect(res._body).toEqual({
status: 'error',
message: 'Validation failed',
errors: errs,
});
});
});
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// Set secrets BEFORE requiring — jwt.sign/verify reads process.env at call time
// but we want a clean, isolated secret that does not change across test runs.
process.env.JWT_SECRET = 'test-jwt-secret-32chars-padding!!';
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-32chars-pad!!';
process.env.JWT_EXPIRES_IN = '15m';
process.env.JWT_REFRESH_EXPIRES_IN = '7d';
const jwt = require('jsonwebtoken');
const {
generateTokens,
verifyAccessToken,
verifyRefreshToken,
hashToken,
shouldRotateRefreshToken,
} = require('../../utils/token.util');
const MOCK_USER = { user_id: 1, email: 'test@example.com', acc_type: 'user', reg_type: 'system' };
// ── generateTokens ────────────────────────────────────────────────────────────
describe('generateTokens()', () => {
test('returns accessToken and refreshToken strings', () => {
const { accessToken, refreshToken } = generateTokens(MOCK_USER);
expect(typeof accessToken).toBe('string');
expect(typeof refreshToken).toBe('string');
});
test('accessToken contains correct payload fields', () => {
const { accessToken } = generateTokens(MOCK_USER);
const decoded = jwt.decode(accessToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
expect(decoded.email).toBe(MOCK_USER.email);
expect(decoded.acc_type).toBe(MOCK_USER.acc_type);
expect(decoded.reg_type).toBe(MOCK_USER.reg_type);
});
test('refreshToken contains only user_id', () => {
const { refreshToken } = generateTokens(MOCK_USER);
const decoded = jwt.decode(refreshToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
expect(decoded.email).toBeUndefined();
});
});
// ── verifyAccessToken ─────────────────────────────────────────────────────────
describe('verifyAccessToken()', () => {
test('valid token → returns decoded payload', () => {
const { accessToken } = generateTokens(MOCK_USER);
const decoded = verifyAccessToken(accessToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
});
test('tampered token → throws JsonWebTokenError', () => {
const { accessToken } = generateTokens(MOCK_USER);
expect(() => verifyAccessToken(accessToken + 'tampered')).toThrow();
});
test('expired token → throws TokenExpiredError', () => {
const expired = jwt.sign(
{ user_id: 99, exp: Math.floor(Date.now() / 1000) - 10 },
process.env.JWT_SECRET
);
let err;
try { verifyAccessToken(expired); } catch (e) { err = e; }
expect(err.name).toBe('TokenExpiredError');
});
test('token signed with wrong secret → throws', () => {
const bad = jwt.sign({ user_id: 1 }, 'wrong-secret');
expect(() => verifyAccessToken(bad)).toThrow();
});
});
// ── verifyRefreshToken ────────────────────────────────────────────────────────
describe('verifyRefreshToken()', () => {
test('valid refresh token → returns payload with user_id', () => {
const { refreshToken } = generateTokens(MOCK_USER);
const decoded = verifyRefreshToken(refreshToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
});
test('access token used as refresh token → throws (different secret)', () => {
const { accessToken } = generateTokens(MOCK_USER);
expect(() => verifyRefreshToken(accessToken)).toThrow();
});
});
// ── hashToken ─────────────────────────────────────────────────────────────────
describe('hashToken()', () => {
test('returns a 64-character hex string (SHA-256)', () => {
expect(hashToken('some-token')).toMatch(/^[a-f0-9]{64}$/);
});
test('is deterministic — same input produces same hash', () => {
expect(hashToken('abc')).toBe(hashToken('abc'));
});
test('different inputs produce different hashes', () => {
expect(hashToken('token-a')).not.toBe(hashToken('token-b'));
});
});
// ── shouldRotateRefreshToken ──────────────────────────────────────────────────
describe('shouldRotateRefreshToken()', () => {
const now = Math.floor(Date.now() / 1000);
test('expires in 10 minutes (within 1-day threshold) → should rotate', () => {
const decoded = { exp: now + 600 };
expect(shouldRotateRefreshToken(decoded, 1)).toBe(true);
});
test('expires in 5 days (well beyond 1-day threshold) → should not rotate', () => {
const decoded = { exp: now + 5 * 24 * 60 * 60 };
expect(shouldRotateRefreshToken(decoded, 1)).toBe(false);
});
test('custom threshold is respected', () => {
const decoded = { exp: now + 2 * 24 * 60 * 60 }; // 2 days left
expect(shouldRotateRefreshToken(decoded, 3)).toBe(true); // 3-day threshold → rotate
expect(shouldRotateRefreshToken(decoded, 1)).toBe(false); // 1-day threshold → don't rotate
});
});