diff --git a/tests/database/migrationRollbacks.test.ts b/tests/database/migrationRollbacks.test.ts new file mode 100644 index 00000000..b71cc2d8 --- /dev/null +++ b/tests/database/migrationRollbacks.test.ts @@ -0,0 +1,993 @@ +/** + * Database Migration Rollbacks Tests + * + * Validates that migrations can be safely applied and rolled back. + * + * Two tiers of testing: + * 1. Filesystem/discovery tests — always run (no DB required) + * 2. Database integration tests — run only when DATABASE_URL points to a reachable PostgreSQL instance + * + * Coverage: + * - Migration file discovery, naming conventions, uniqueness + * - Applying a migration successfully + * - Rolling a migration back successfully + * - Schema verification before/after migration and rollback + * - Data preservation during rollback + * - Multiple migrations applied and rolled back in order + * - Rollback of the latest migration + * - Irreversible migrations (missing down files) + * - Re-running migrations after rollback + * - Edge cases: empty database, duplicate application + */ + +import fs from "fs"; +import path from "path"; +import { Pool } from "pg"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const TEST_DB_URL = process.env.DATABASE_URL; +const MIGRATIONS_DIR = path.resolve(__dirname, "..", "..", "migrations"); +const TEST_SCHEMA = `test_migration_${Date.now()}`; + +// --------------------------------------------------------------------------- +// Migration file discovery (mirrors src/scripts/migrate.ts logic) +// --------------------------------------------------------------------------- + +interface MigrationFile { + version: string; + name: string; + upPath: string; + downPath: string | null; +} + +function discoverMigrations(): MigrationFile[] { + const files = fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => /^\d+_.+\.sql$/.test(f) && !f.endsWith(".down.sql")) + .sort(); + + return files.map((filename) => { + const match = filename.match(/^(\d+)_(.+)\.sql$/); + if (!match) throw new Error(`Unexpected migration filename: ${filename}`); + + const [, legacyVersion, label] = match; + const downFilename = `${legacyVersion}_${label}.down.sql`; + const downPath = path.join(MIGRATIONS_DIR, downFilename); + + return { + version: filename.replace(/\.sql$/, ""), + name: filename, + upPath: path.join(MIGRATIONS_DIR, filename), + downPath: fs.existsSync(downPath) ? downPath : null, + }; + }); +} + +// --------------------------------------------------------------------------- +// Schema inspection helpers (require DB) +// --------------------------------------------------------------------------- + +async function getTables(pool: Pool, schema: string): Promise { + const result = await pool.query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = $1 AND table_type = 'BASE TABLE' + ORDER BY table_name`, + [schema], + ); + return result.rows.map((r) => r.table_name); +} + +async function getColumns( + pool: Pool, + schema: string, + table: string, +): Promise { + const result = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position`, + [schema, table], + ); + return result.rows.map((r) => r.column_name); +} + +async function getIndexes( + pool: Pool, + schema: string, + table: string, +): Promise { + const result = await pool.query( + `SELECT indexname FROM pg_indexes + WHERE schemaname = $1 AND tablename = $2 + ORDER BY indexname`, + [schema, table], + ); + return result.rows.map((r) => r.indexname); +} + +async function getEnums( + pool: Pool, + schema: string, +): Promise> { + const result = await pool.query( + `SELECT t.typname AS enum_name, + array_agg(e.enumlabel ORDER BY e.enumsortorder) AS enum_values + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE n.nspname = $1 + GROUP BY t.typname`, + [schema], + ); + const enums: Record = {}; + for (const row of result.rows) { + enums[row.enum_name] = row.enum_values; + } + return enums; +} + +async function tableExists( + pool: Pool, + schema: string, + table: string, +): Promise { + const result = await pool.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2 + )`, + [schema, table], + ); + return result.rows[0].exists; +} + +// --------------------------------------------------------------------------- +// Migration runner helpers (require DB) +// --------------------------------------------------------------------------- + +async function applyMigration( + pool: Pool, + migration: MigrationFile, +): Promise { + const sql = fs.readFileSync(migration.upPath, "utf-8"); + await pool.query("BEGIN"); + try { + await pool.query(sql); + await pool.query( + "INSERT INTO schema_migrations (version) VALUES ($1)", + [migration.version], + ); + await pool.query("COMMIT"); + } catch (err) { + await pool.query("ROLLBACK"); + throw err; + } +} + +async function rollbackMigration( + pool: Pool, + migration: MigrationFile, +): Promise { + if (!migration.downPath) { + throw new Error(`No down migration file for ${migration.name}`); + } + const sql = fs.readFileSync(migration.downPath, "utf-8"); + await pool.query("BEGIN"); + try { + await pool.query(sql); + await pool.query( + "DELETE FROM schema_migrations WHERE version = $1", + [migration.version], + ); + await pool.query("COMMIT"); + } catch (err) { + await pool.query("ROLLBACK"); + throw err; + } +} + +async function getAppliedVersions(pool: Pool): Promise> { + const result = await pool.query<{ version: string }>( + "SELECT version FROM schema_migrations ORDER BY version", + ); + return new Set(result.rows.map((r) => r.version)); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("Database Migration Rollbacks", () => { + const allMigrations = discoverMigrations(); + + // ═══════════════════════════════════════════════════════════════════════════ + // FILESYSTEM TESTS — always run, no DB required + // ═══════════════════════════════════════════════════════════════════════════ + + describe("Migration discovery", () => { + it("discovers all migration files from the migrations directory", () => { + expect(allMigrations.length).toBeGreaterThan(0); + }); + + it("sorts migrations lexicographically by version", () => { + for (let i = 1; i < allMigrations.length; i++) { + expect( + allMigrations[i].version >= allMigrations[i - 1].version, + ).toBe(true); + } + }); + + it("each migration has a version, name, and upPath", () => { + for (const migration of allMigrations) { + expect(migration.version).toBeTruthy(); + expect(migration.name).toBeTruthy(); + expect(migration.upPath).toBeTruthy(); + } + }); + + it("all migration versions are unique", () => { + const versions = allMigrations.map((m) => m.version); + const uniqueVersions = new Set(versions); + expect(uniqueVersions.size).toBe(versions.length); + }); + + it("all migration files exist on disk", () => { + for (const migration of allMigrations) { + expect(fs.existsSync(migration.upPath)).toBe(true); + if (migration.downPath) { + expect(fs.existsSync(migration.downPath)).toBe(true); + } + } + }); + + it("down migration files match up migration naming convention", () => { + for (const migration of allMigrations) { + if (migration.downPath) { + const downName = path.basename(migration.downPath); + expect(downName).toMatch(/^\d+_.+\.down\.sql$/); + expect(downName).toContain(migration.version); + } + } + }); + }); + + describe("Irreversible migrations (no down file)", () => { + it("identifies exactly 5 migrations without down files", () => { + const irreversible = allMigrations.filter((m) => m.downPath === null); + expect(irreversible.length).toBe(5); + }); + + it("lists the expected irreversible migrations", () => { + const irreversible = allMigrations + .filter((m) => m.downPath === null) + .map((m) => m.version); + + expect(irreversible).toContain( + "20260426_create_compliance_documents", + ); + expect(irreversible).toContain( + "20260427_create_provider_reconciliation_tables", + ); + expect(irreversible).toContain("20260428_create_anchored_assets"); + expect(irreversible).toContain( + "20260428_create_exchange_rate_buffers", + ); + expect(irreversible).toContain( + "20260428_create_reconciliation_tables", + ); + }); + + it("all other migrations have down files", () => { + const reversible = allMigrations.filter((m) => m.downPath !== null); + expect(reversible.length).toBeGreaterThan(0); + }); + }); + + describe("Down file structure validation", () => { + it("down files for reversible migrations contain rollback operations", () => { + const reversible = allMigrations.filter((m) => m.downPath !== null); + for (const migration of reversible) { + const sql = fs.readFileSync(migration.downPath!, "utf-8"); + // Down migrations should contain DROP statements or ALTER TABLE ... DROP + // Empty files are also flagged separately + if (sql.trim().length === 0) { + // Empty down file is a known defect — flag it but don't fail + console.warn( + `WARNING: ${migration.name} has an empty down migration file`, + ); + return; + } + const hasRollbackOp = + /DROP\s+(TABLE|INDEX|TYPE|FUNCTION|TRIGGER|SCHEMA|COLUMN)/i.test( + sql, + ) || + /ALTER\s+TABLE.*DROP/i.test(sql) || + /ALTER\s+TABLE.*ALTER\s+COLUMN/i.test(sql) || + /CREATE\s+TABLE/i.test(sql); // Some down files recreate dropped tables + expect(hasRollbackOp).toBe(true); + } + }); + + it("down file for 20260426_add_clawback_status is empty (known issue)", () => { + const migration = allMigrations.find( + (m) => m.version === "20260426_add_clawback_status", + )!; + const sql = fs.readFileSync(migration.downPath!, "utf-8"); + // This down file exists but is empty — rollback would be a no-op + expect(sql.trim().length).toBe(0); + }); + + it("down files for fee strategies drop custom types", () => { + const feeStrategiesDown = allMigrations.find( + (m) => m.version === "20260424_create_fee_strategies", + )!.downPath!; + const sql = fs.readFileSync(feeStrategiesDown, "utf-8"); + expect(sql).toContain("DROP TYPE IF EXISTS fee_strategy_scope"); + expect(sql).toContain("DROP TYPE IF EXISTS fee_strategy_type"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // DATABASE INTEGRATION TESTS — require PostgreSQL + // ═══════════════════════════════════════════════════════════════════════════ + + describe("Database integration tests", () => { + let pool: Pool; + let dbAvailable = false; + + beforeAll(async () => { + if (!TEST_DB_URL) { + console.warn( + "DATABASE_URL not set — skipping database integration tests", + ); + return; + } + + try { + pool = new Pool({ + connectionString: TEST_DB_URL, + max: 5, + connectionTimeoutMillis: 5000, + }); + // Test connection + await pool.query("SELECT 1"); + dbAvailable = true; + + // Create isolated schema + await pool.query(`CREATE SCHEMA IF NOT EXISTS "${TEST_SCHEMA}"`); + await pool.query(`SET search_path TO "${TEST_SCHEMA}", public`); + } catch (err) { + console.warn( + "PostgreSQL not available — skipping database integration tests:", + (err as Error).message, + ); + } + }); + + afterAll(async () => { + if (pool && dbAvailable) { + try { + await pool.query( + `DROP SCHEMA IF EXISTS "${TEST_SCHEMA}" CASCADE`, + ); + } catch { + // ignore cleanup errors + } + await pool.end(); + } + }); + + // Helper: reset the test schema to empty state + async function resetSchema(): Promise { + const objects = await pool.query( + `SELECT c.relname AS name, c.relkind AS type + FROM pg_class c + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE n.nspname = $1 + AND c.relkind IN ('r', 'v', 'm', 'i', 'S', 'T')`, + [TEST_SCHEMA], + ); + for (const row of objects.rows) { + const dropType = + row.type === "i" + ? "INDEX" + : row.type === "S" + ? "SEQUENCE" + : row.type === "T" + ? "TYPE" + : "TABLE"; + await pool.query( + `DROP ${dropType} IF EXISTS "${row.name}" CASCADE`, + ); + } + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + } + + // ── Basic apply and rollback ───────────────────────────────────────── + + describe("Basic apply and rollback", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("applies migration 000 successfully", async () => { + if (!dbAvailable) return; + const migration = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + + await applyMigration(pool, migration); + + const applied = await getAppliedVersions(pool); + expect(applied.has(migration.version)).toBe(true); + + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + expect(await tableExists(pool, TEST_SCHEMA, "permissions")).toBe(true); + expect( + await tableExists(pool, TEST_SCHEMA, "role_permissions"), + ).toBe(true); + }); + + it("rolls back migration 000 successfully", async () => { + if (!dbAvailable) return; + const migration = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + + await applyMigration(pool, migration); + await rollbackMigration(pool, migration); + + const applied = await getAppliedVersions(pool); + expect(applied.has(migration.version)).toBe(false); + + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(false); + expect(await tableExists(pool, TEST_SCHEMA, "permissions")).toBe( + false, + ); + expect( + await tableExists(pool, TEST_SCHEMA, "role_permissions"), + ).toBe(false); + }); + }); + + // ── Schema verification ────────────────────────────────────────────── + + describe("Schema verification", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("verifies schema before migration is empty", async () => { + if (!dbAvailable) return; + const tables = await getTables(pool, TEST_SCHEMA); + expect(tables).toContain("schema_migrations"); + expect(tables.filter((t) => t !== "schema_migrations")).toHaveLength( + 0, + ); + }); + + it("verifies expected schema after migration 001", async () => { + if (!dbAvailable) return; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + + await applyMigration(pool, migration001); + + const userColumns = await getColumns(pool, TEST_SCHEMA, "users"); + expect(userColumns).toContain("id"); + expect(userColumns).toContain("phone_number"); + expect(userColumns).toContain("kyc_level"); + + const txColumns = await getColumns( + pool, + TEST_SCHEMA, + "transactions", + ); + expect(txColumns).toContain("id"); + expect(txColumns).toContain("reference_number"); + expect(txColumns).toContain("status"); + + const txIndexes = await getIndexes( + pool, + TEST_SCHEMA, + "transactions", + ); + expect(txIndexes).toContain("idx_transactions_status"); + }); + + it("verifies rollback restores previous schema", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + expect(await tableExists(pool, TEST_SCHEMA, "users")).toBe(true); + + await rollbackMigration(pool, migration001); + + expect(await tableExists(pool, TEST_SCHEMA, "users")).toBe(false); + expect(await tableExists(pool, TEST_SCHEMA, "transactions")).toBe( + false, + ); + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + }); + }); + + // ── Data preservation ──────────────────────────────────────────────── + + describe("Data preservation", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("preserves existing data when rolling back a column-adding migration", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + const migration010 = allMigrations.find( + (m) => + m.version === + "010_add_fee_and_provider_fee_to_transactions", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + // Insert test data + await pool.query( + `INSERT INTO users (phone_number, kyc_level) VALUES ('+1111111111', 'full')`, + ); + const userResult = await pool.query( + `SELECT id FROM users WHERE phone_number = '+1111111111'`, + ); + const userId = userResult.rows[0].id; + + await pool.query( + `INSERT INTO transactions (reference_number, type, amount, phone_number, provider, stellar_address, status, user_id) + VALUES ('REF-001', 'deposit', 100.50, '+1111111111', 'mtn', 'GABC123', 'completed', $1)`, + [userId], + ); + + // Apply column-adding migration + await applyMigration(pool, migration010); + + let txColumns = await getColumns(pool, TEST_SCHEMA, "transactions"); + expect(txColumns).toContain("fee_amount"); + expect(txColumns).toContain("provider_fee"); + + // Verify data intact + const txResult = await pool.query( + `SELECT * FROM transactions WHERE reference_number = 'REF-001'`, + ); + expect(txResult.rows).toHaveLength(1); + + // Rollback column migration + await rollbackMigration(pool, migration010); + + txColumns = await getColumns(pool, TEST_SCHEMA, "transactions"); + expect(txColumns).not.toContain("fee_amount"); + expect(txColumns).not.toContain("provider_fee"); + + // Data still intact + const txResultAfter = await pool.query( + `SELECT * FROM transactions WHERE reference_number = 'REF-001'`, + ); + expect(txResultAfter.rows).toHaveLength(1); + expect(txResultAfter.rows[0].type).toBe("deposit"); + }); + }); + + // ── Multiple migrations in order ───────────────────────────────────── + + describe("Multiple migrations in order", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("applies migrations 000 and 001 in sequence", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + const applied = await getAppliedVersions(pool); + expect(applied.has(migration000.version)).toBe(true); + expect(applied.has(migration001.version)).toBe(true); + + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + expect(await tableExists(pool, TEST_SCHEMA, "users")).toBe(true); + }); + + it("rolls back migrations in reverse order", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + await rollbackMigration(pool, migration001); + let applied = await getAppliedVersions(pool); + expect(applied.has(migration001.version)).toBe(false); + expect(applied.has(migration000.version)).toBe(true); + + await rollbackMigration(pool, migration000); + applied = await getAppliedVersions(pool); + expect(applied.has(migration000.version)).toBe(false); + + const tables = await getTables(pool, TEST_SCHEMA); + expect(tables.filter((t) => t !== "schema_migrations")).toHaveLength( + 0, + ); + }); + + it("rolls back only the latest migration", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + await rollbackMigration(pool, migration001); + + const applied = await getAppliedVersions(pool); + expect(applied.has(migration000.version)).toBe(true); + expect(applied.has(migration001.version)).toBe(false); + + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + expect(await tableExists(pool, TEST_SCHEMA, "users")).toBe(false); + }); + }); + + // ── Fee strategies migration (enums, constraints) ──────────────────── + + describe("Fee strategies migration", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("applies and rolls back fee strategies migration cleanly", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + const migrationFeeStrategies = allMigrations.find( + (m) => m.version === "20260424_create_fee_strategies", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + await applyMigration(pool, migrationFeeStrategies); + + expect( + await tableExists(pool, TEST_SCHEMA, "fee_strategies"), + ).toBe(true); + expect( + await tableExists(pool, TEST_SCHEMA, "fee_strategy_audit"), + ).toBe(true); + + const enums = await getEnums(pool, TEST_SCHEMA); + expect(enums.fee_strategy_type).toBeDefined(); + expect(enums.fee_strategy_type).toContain("flat"); + expect(enums.fee_strategy_type).toContain("percentage"); + expect(enums.fee_strategy_scope).toBeDefined(); + expect(enums.fee_strategy_scope).toContain("global"); + + const columns = await getColumns( + pool, + TEST_SCHEMA, + "fee_strategies", + ); + expect(columns).toContain("strategy_type"); + expect(columns).toContain("scope"); + expect(columns).toContain("fee_percentage"); + expect(columns).toContain("volume_tiers"); + + // Rollback + await rollbackMigration(pool, migrationFeeStrategies); + + expect( + await tableExists(pool, TEST_SCHEMA, "fee_strategies"), + ).toBe(false); + expect( + await tableExists(pool, TEST_SCHEMA, "fee_strategy_audit"), + ).toBe(false); + + const enumsAfter = await getEnums(pool, TEST_SCHEMA); + expect(enumsAfter.fee_strategy_type).toBeUndefined(); + expect(enumsAfter.fee_strategy_scope).toBeUndefined(); + }); + }); + + // ── Subscriptions migration (ALTER TABLE, FK) ──────────────────────── + + describe("Subscriptions migration", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("applies and rolls back subscriptions migration with data", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + const migrationSubs = allMigrations.find( + (m) => m.version === "20260529_create_subscriptions", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + await pool.query( + `INSERT INTO users (phone_number, kyc_level) VALUES ('+2222222222', 'full')`, + ); + const userResult = await pool.query( + `SELECT id FROM users WHERE phone_number = '+2222222222'`, + ); + const userId = userResult.rows[0].id; + + await applyMigration(pool, migrationSubs); + + expect( + await tableExists(pool, TEST_SCHEMA, "subscriptions"), + ).toBe(true); + expect( + await tableExists(pool, TEST_SCHEMA, "subscription_attempts"), + ).toBe(true); + + const txColumns = await getColumns( + pool, + TEST_SCHEMA, + "transactions", + ); + expect(txColumns).toContain("subscription_id"); + + // Insert and verify data + await pool.query( + `INSERT INTO subscriptions (merchant_id, amount, currency, interval, status) + VALUES ($1, 25.00, 'USD', 'monthly', 'active')`, + [userId], + ); + const subResult = await pool.query( + `SELECT * FROM subscriptions WHERE merchant_id = $1`, + [userId], + ); + expect(subResult.rows).toHaveLength(1); + + // Rollback + await rollbackMigration(pool, migrationSubs); + + expect( + await tableExists(pool, TEST_SCHEMA, "subscriptions"), + ).toBe(false); + expect( + await tableExists(pool, TEST_SCHEMA, "subscription_attempts"), + ).toBe(false); + + const txColumnsAfter = await getColumns( + pool, + TEST_SCHEMA, + "transactions", + ); + expect(txColumnsAfter).not.toContain("subscription_id"); + + // User data preserved + const userAfter = await pool.query( + `SELECT * FROM users WHERE phone_number = '+2222222222'`, + ); + expect(userAfter.rows).toHaveLength(1); + }); + }); + + // ── Fee configurations migration (seed data, triggers) ─────────────── + + describe("Fee configurations migration", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("applies and rolls back fee configurations with trigger and audit", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + const migrationFeeConfig = allMigrations.find( + (m) => m.version === "008_add_fee_configurations", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + + await pool.query( + `INSERT INTO users (phone_number, kyc_level) VALUES ('+3333333333', 'full')`, + ); + + await applyMigration(pool, migrationFeeConfig); + + expect( + await tableExists(pool, TEST_SCHEMA, "fee_configurations"), + ).toBe(true); + expect( + await tableExists(pool, TEST_SCHEMA, "fee_configuration_audit"), + ).toBe(true); + + const seedResult = await pool.query( + `SELECT * FROM fee_configurations WHERE name = 'default'`, + ); + expect(seedResult.rows).toHaveLength(1); + expect(Number(seedResult.rows[0].fee_percentage)).toBe(1.5); + expect(Number(seedResult.rows[0].fee_minimum)).toBe(50); + expect(Number(seedResult.rows[0].fee_maximum)).toBe(5000); + + const indexes = await getIndexes( + pool, + TEST_SCHEMA, + "fee_configurations", + ); + expect(indexes).toContain("idx_fee_configurations_name"); + + // Rollback + await rollbackMigration(pool, migrationFeeConfig); + + expect( + await tableExists(pool, TEST_SCHEMA, "fee_configurations"), + ).toBe(false); + expect( + await tableExists(pool, TEST_SCHEMA, "fee_configuration_audit"), + ).toBe(false); + }); + }); + + // ── Re-running migrations after rollback ───────────────────────────── + + describe("Re-running migrations after rollback", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("can apply, rollback, and re-apply a migration", async () => { + if (!dbAvailable) return; + const migration = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + + await applyMigration(pool, migration); + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + + await rollbackMigration(pool, migration); + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(false); + + await applyMigration(pool, migration); + expect(await tableExists(pool, TEST_SCHEMA, "roles")).toBe(true); + + const applied = await getAppliedVersions(pool); + expect(applied.has(migration.version)).toBe(true); + }); + + it("can apply, rollback, and re-apply a complex migration", async () => { + if (!dbAvailable) return; + const migration000 = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + const migration001 = allMigrations.find( + (m) => m.version === "001_initial_schema", + )!; + const migration010 = allMigrations.find( + (m) => + m.version === + "010_add_fee_and_provider_fee_to_transactions", + )!; + + await applyMigration(pool, migration000); + await applyMigration(pool, migration001); + await applyMigration(pool, migration010); + + let txColumns = await getColumns(pool, TEST_SCHEMA, "transactions"); + expect(txColumns).toContain("fee_amount"); + + await rollbackMigration(pool, migration010); + txColumns = await getColumns(pool, TEST_SCHEMA, "transactions"); + expect(txColumns).not.toContain("fee_amount"); + + await applyMigration(pool, migration010); + txColumns = await getColumns(pool, TEST_SCHEMA, "transactions"); + expect(txColumns).toContain("fee_amount"); + }); + }); + + // ── Schema_migrations tracking ─────────────────────────────────────── + + describe("schema_migrations tracking", () => { + beforeEach(async () => { + if (!dbAvailable) return; + await resetSchema(); + }); + + it("tracks applied versions correctly through apply and rollback", async () => { + if (!dbAvailable) return; + const migration = allMigrations.find( + (m) => m.version === "000_create_roles_and_permissions", + )!; + + let applied = await getAppliedVersions(pool); + expect(applied.size).toBe(0); + + await applyMigration(pool, migration); + applied = await getAppliedVersions(pool); + expect(applied.size).toBe(1); + expect(applied.has(migration.version)).toBe(true); + + await rollbackMigration(pool, migration); + applied = await getAppliedVersions(pool); + expect(applied.size).toBe(0); + }); + }); + + // ── Irreversible migration error handling ──────────────────────────── + + describe("Irreversible migration error handling", () => { + it("rollbackMigration throws for migration without down file", async () => { + if (!dbAvailable) return; + const irreversible = allMigrations.find( + (m) => m.version === "20260426_create_compliance_documents", + )!; + + await expect( + rollbackMigration(pool, irreversible), + ).rejects.toThrow("No down migration file"); + }); + }); + }); +}); diff --git a/tests/services/dynamicFeeCalculations.test.ts b/tests/services/dynamicFeeCalculations.test.ts new file mode 100644 index 00000000..5b365616 --- /dev/null +++ b/tests/services/dynamicFeeCalculations.test.ts @@ -0,0 +1,1146 @@ +/** + * Focused unit tests for dynamic fee calculation logic. + * + * Covers gaps in existing test suites: + * - FeeStrategyEngine: flat fee clamping, volume tier boundaries, time-based + * overrides, user-scope percentage (no min), VIP discount with max cap, + * rounding/precision, unknown strategy type. + * - Dynamic spread: negative/zero edge inputs, monotonicity, exact boundary factors. + * - Airtel fee tiers: exact tier boundaries, bulk tier, invalid inputs, rounding. + * - calculateFeeSync: custom env configs, fractional precision, negative amounts. + * + * All DB/Redis/network calls are mocked so tests run in isolation. + */ + +// ── Module mocks (hoisted before imports) ─────────────────────────────────── + +jest.mock("../../src/config/database", () => ({ + pool: { query: jest.fn(), connect: jest.fn() }, +})); + +jest.mock("../../src/config/redis", () => ({ + redisClient: { + get: jest.fn().mockResolvedValue(null), + setEx: jest.fn().mockResolvedValue("OK"), + del: jest.fn().mockResolvedValue(1), + keys: jest.fn().mockResolvedValue([]), + isOpen: true, + }, +})); + +jest.mock("../../src/utils/fees", () => { + const actual = jest.requireActual("../../src/utils/fees"); + return { + ...actual, + getThirtyDayVolume: jest.fn().mockResolvedValue(0), + mapVolumeToTier: jest.fn().mockReturnValue({ discountPercent: 0 }), + }; +}); + +jest.mock("../../src/services/feeService", () => ({ + feeService: { + calculateFee: jest.fn(), + getActiveConfiguration: jest.fn(), + }, +})); + +jest.mock("../../src/services/providerSettingsService", () => ({ + providerSettingsService: { + getProviderSettings: jest.fn().mockResolvedValue({ + provider_name: "mtn", + timeout_ms: 30000, + }), + }, +})); + +jest.mock("../../src/utils/logger", () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +// ── Imports ───────────────────────────────────────────────────────────────── + +import { pool } from "../../src/config/database"; +import { redisClient } from "../../src/config/redis"; +import { + getThirtyDayVolume, + mapVolumeToTier, +} from "../../src/utils/fees"; +import { + FeeStrategyEngine, + FeeStrategy, +} from "../../src/services/feeStrategyEngine"; +import { + computeLiquidityScaleFactor, + computeSettlementScaleFactor, + computeSpread, +} from "../../src/services/dynamicSpreadService"; +import { + calculateAirtelFee, + AIRTEL_FEE_TIERS, + AIRTEL_MIN_FEE, +} from "../../src/services/currency"; + +const mockPool = pool as jest.Mocked; +const mockRedis = redisClient as jest.Mocked; +const mockGetThirtyDayVolume = getThirtyDayVolume as jest.Mock; +const mockMapVolumeToTier = mapVolumeToTier as jest.Mock; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const ADMIN_ID = "00000000-0000-0000-0000-000000000001"; +const USER_ID = "00000000-0000-0000-0000-000000000002"; + +function makeStrategy(overrides: Partial = {}): FeeStrategy { + return { + id: "aaaaaaaa-0000-0000-0000-000000000001", + name: "Test Strategy", + strategyType: "percentage", + scope: "global", + priority: 100, + isActive: true, + feePercentage: 1.5, + feeMinimum: 50, + feeMaximum: 5000, + createdBy: ADMIN_ID, + updatedBy: ADMIN_ID, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + ...overrides, + }; +} + +function pgResult(strategies: FeeStrategy[]) { + const rows = strategies.map((s) => ({ + id: s.id, + name: s.name, + description: s.description ?? null, + strategy_type: s.strategyType, + scope: s.scope, + user_id: s.userId ?? null, + provider: s.provider ?? null, + priority: s.priority, + is_active: s.isActive, + flat_amount: s.flatAmount ?? null, + fee_percentage: s.feePercentage ?? null, + fee_minimum: s.feeMinimum ?? null, + fee_maximum: s.feeMaximum ?? null, + days_of_week: s.daysOfWeek ?? null, + time_start: s.timeStart ?? null, + time_end: s.timeEnd ?? null, + override_percentage: s.overridePercentage ?? null, + override_flat_amount: s.overrideFlatAmount ?? null, + volume_tiers: s.volumeTiers ?? null, + created_by: s.createdBy, + updated_by: s.updatedBy, + created_at: s.createdAt, + updated_at: s.updatedAt, + })); + return { rows, rowCount: rows.length }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 1. FeeStrategyEngine — additional dynamic fee calculation coverage +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("FeeStrategyEngine — dynamic fee calculation", () => { + let engine: FeeStrategyEngine; + + beforeEach(() => { + jest.clearAllMocks(); + mockRedis.get.mockResolvedValue(null); + mockRedis.keys.mockResolvedValue([]); + engine = new FeeStrategyEngine(); + }); + + // ── Flat fee with min/max clamping ──────────────────────────────────────── + + describe("FlatFeeStrategy — clamping", () => { + it("clamps flat fee to minimum when flat amount is below minimum", async () => { + const strategy = makeStrategy({ + strategyType: "flat", + flatAmount: 10, + feeMinimum: 50, + feeMaximum: 5000, + feePercentage: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(50); + expect(result.breakdown.appliedMinimum).toBe(50); + }); + + it("clamps flat fee to maximum when flat amount exceeds maximum", async () => { + const strategy = makeStrategy({ + strategyType: "flat", + flatAmount: 10_000, + feeMinimum: 50, + feeMaximum: 5000, + feePercentage: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(5000); + expect(result.breakdown.appliedMaximum).toBe(5000); + }); + + it("returns exact flat fee when within min/max bounds", async () => { + const strategy = makeStrategy({ + strategyType: "flat", + flatAmount: 250, + feeMinimum: 50, + feeMaximum: 5000, + feePercentage: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(250); + expect(result.breakdown.appliedMinimum).toBeUndefined(); + expect(result.breakdown.appliedMaximum).toBeUndefined(); + }); + + it("handles flat fee with no min/max configured", async () => { + const strategy = makeStrategy({ + strategyType: "flat", + flatAmount: 100, + feeMinimum: undefined, + feeMaximum: undefined, + feePercentage: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(100); + expect(result.breakdown.appliedMinimum).toBeUndefined(); + expect(result.breakdown.appliedMaximum).toBeUndefined(); + }); + + it("defaults to 0 fee when flatAmount is not set", async () => { + const strategy = makeStrategy({ + strategyType: "flat", + flatAmount: undefined, + feeMinimum: undefined, + feeMaximum: undefined, + feePercentage: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(0); + }); + }); + + // ── Volume-based strategy — tier boundaries and flat amounts ────────────── + + describe("VolumeBasedFeeStrategy — tier boundaries and flat amounts", () => { + it("applies correct tier when amount equals tier minAmount (inclusive)", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + feeMinimum: 0, + volumeTiers: [ + { minAmount: 0, maxAmount: 1000, feePercentage: 2.0 }, + { minAmount: 1000, maxAmount: 10000, feePercentage: 1.5 }, + { minAmount: 10000, maxAmount: null, feePercentage: 0.8 }, + ], + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // Amount exactly 1000 → second tier (1.5%) + const result = await engine.calculateFee({ amount: 1000 }); + expect(result.fee).toBe(15); // 1000 * 1.5% + }); + + it("applies first tier when amount is just below second tier minAmount", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + feeMinimum: 0, + volumeTiers: [ + { minAmount: 0, maxAmount: 1000, feePercentage: 2.0 }, + { minAmount: 1000, maxAmount: 10000, feePercentage: 1.5 }, + ], + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // Amount 999.99 → first tier (2.0%) + const result = await engine.calculateFee({ amount: 999.99 }); + expect(result.fee).toBeCloseTo(20, 1); // 999.99 * 2% + }); + + it("applies unbounded top tier for very large amounts", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + feeMinimum: 0, + feeMaximum: 100_000, + volumeTiers: [ + { minAmount: 0, maxAmount: 100000, feePercentage: 1.5 }, + { minAmount: 100000, maxAmount: null, feePercentage: 0.5 }, + ], + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000_000 }); + expect(result.fee).toBe(50_000); // 10M * 0.5% + }); + + it("uses flat amount tier instead of percentage when configured", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + feeMinimum: 0, + volumeTiers: [ + { minAmount: 0, maxAmount: 1000, flatAmount: 5 }, + { minAmount: 1000, maxAmount: null, flatAmount: 25 }, + ], + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 500 }); + expect(result.fee).toBe(5); + }); + + it("applies flat amount tier and clamps to minimum", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + feeMinimum: 10, + volumeTiers: [ + { minAmount: 0, maxAmount: 1000, flatAmount: 3 }, + ], + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // Flat amount (3) is below minimum (10) + const result = await engine.calculateFee({ amount: 500 }); + expect(result.fee).toBe(10); + expect(result.breakdown.appliedMinimum).toBe(10); + }); + + it("returns zero fee when no tiers are configured", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + volumeTiers: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 10_000 }); + expect(result.fee).toBe(0); + }); + + it("returns zero fee when amount does not match any tier", async () => { + const strategy = makeStrategy({ + strategyType: "volume_based", + feePercentage: undefined, + volumeTiers: [ + { minAmount: 50000, maxAmount: null, feePercentage: 0.5 }, + ], + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 1000 }); + expect(result.fee).toBe(0); + }); + }); + + // ── Time-based strategy — override flat amount and exact boundaries ─────── + + describe("TimeBasedFeeStrategy — override flat amount and boundaries", () => { + it("applies override flat amount on matching day", async () => { + // 2026-04-24 is a Friday (ISO weekday 5) + const FRIDAY = new Date("2026-04-24T12:00:00Z"); + const timeStrategy = makeStrategy({ + strategyType: "time_based", + scope: "global", + priority: 10, + daysOfWeek: [5], + overrideFlatAmount: 25, + overridePercentage: undefined, + feePercentage: undefined, + }); + const fallback = makeStrategy({ priority: 100 }); + mockPool.query.mockResolvedValueOnce( + pgResult([timeStrategy, fallback]) as any, + ); + + const result = await engine.calculateFee({ + amount: 10_000, + evaluationTime: FRIDAY, + }); + + expect(result.fee).toBe(25); + expect(result.timeOverrideActive).toBe(true); + }); + + it("clamps override flat amount to maximum", async () => { + const FRIDAY = new Date("2026-04-24T12:00:00Z"); + const timeStrategy = makeStrategy({ + strategyType: "time_based", + scope: "global", + priority: 10, + daysOfWeek: [5], + overrideFlatAmount: 10_000, + feeMaximum: 5000, + feePercentage: undefined, + }); + const fallback = makeStrategy({ priority: 100 }); + mockPool.query.mockResolvedValueOnce( + pgResult([timeStrategy, fallback]) as any, + ); + + const result = await engine.calculateFee({ + amount: 10_000, + evaluationTime: FRIDAY, + }); + + expect(result.fee).toBe(5000); + expect(result.breakdown.appliedMaximum).toBe(5000); + }); + + it("falls through at exact timeEnd boundary (exclusive)", async () => { + // 2026-04-24 is a Friday + const atTimeEnd = new Date("2026-04-24T17:00:00Z"); // exactly timeEnd + const timeStrategy = makeStrategy({ + strategyType: "time_based", + scope: "global", + priority: 10, + daysOfWeek: [5], + timeStart: "09:00", + timeEnd: "17:00", + overridePercentage: 0, + feePercentage: undefined, + }); + const fallback = makeStrategy({ name: "Standard", priority: 100 }); + mockPool.query.mockResolvedValueOnce( + pgResult([timeStrategy, fallback]) as any, + ); + + const result = await engine.calculateFee({ + amount: 10_000, + evaluationTime: atTimeEnd, + }); + + // timeEnd is exclusive, so should fall through to fallback + expect(result.fee).toBe(150); // fallback 1.5% + expect(result.timeOverrideActive).toBe(false); + }); + + it("applies override at timeStart boundary (inclusive)", async () => { + const atTimeStart = new Date("2026-04-24T09:00:00Z"); + const timeStrategy = makeStrategy({ + strategyType: "time_based", + scope: "global", + priority: 10, + daysOfWeek: [5], + timeStart: "09:00", + timeEnd: "17:00", + overridePercentage: 0, + feePercentage: undefined, + }); + const fallback = makeStrategy({ priority: 100 }); + mockPool.query.mockResolvedValueOnce( + pgResult([timeStrategy, fallback]) as any, + ); + + const result = await engine.calculateFee({ + amount: 10_000, + evaluationTime: atTimeStart, + }); + + expect(result.fee).toBe(0); + expect(result.timeOverrideActive).toBe(true); + }); + + it("handles multiple days of week", async () => { + // Saturday 2026-04-25 (ISO weekday 6) + const SATURDAY = new Date("2026-04-25T12:00:00Z"); + const timeStrategy = makeStrategy({ + strategyType: "time_based", + scope: "global", + priority: 10, + daysOfWeek: [5, 6, 7], // Fri, Sat, Sun + overridePercentage: 0, + feePercentage: undefined, + }); + const fallback = makeStrategy({ priority: 100 }); + mockPool.query.mockResolvedValueOnce( + pgResult([timeStrategy, fallback]) as any, + ); + + const result = await engine.calculateFee({ + amount: 10_000, + evaluationTime: SATURDAY, + }); + + expect(result.fee).toBe(0); + expect(result.timeOverrideActive).toBe(true); + }); + }); + + // ── Percentage strategy — user scope skips minimum ──────────────────────── + + describe("PercentageFeeStrategy — user scope skips minimum", () => { + it("does not apply minimum when scope is user", async () => { + const strategy = makeStrategy({ + name: "User 0.1%", + scope: "user", + userId: USER_ID, + feePercentage: 0.1, + feeMinimum: 100, + feeMaximum: 5000, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // 1000 * 0.1% = 1, which is below feeMinimum (100), but user scope skips min + const result = await engine.calculateFee({ + amount: 1000, + userId: USER_ID, + }); + + expect(result.fee).toBe(1); + expect(result.breakdown.appliedMinimum).toBeUndefined(); + }); + + it("still applies maximum when scope is user", async () => { + const strategy = makeStrategy({ + name: "User 10%", + scope: "user", + userId: USER_ID, + feePercentage: 10, + feeMinimum: 50, + feeMaximum: 500, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ + amount: 10_000, + userId: USER_ID, + }); + + // 10000 * 10% = 1000, clamped to max 500 + expect(result.fee).toBe(500); + expect(result.breakdown.appliedMaximum).toBe(500); + }); + }); + + // ── Unknown strategy type ───────────────────────────────────────────────── + + describe("Unknown strategy type", () => { + it("returns null for unrecognized strategy type, engine falls through", async () => { + const unknownStrategy = makeStrategy({ + name: "Mystery", + strategyType: "unknown_type" as any, + priority: 10, + }); + const fallback = makeStrategy({ name: "Fallback", priority: 100 }); + mockPool.query.mockResolvedValueOnce( + pgResult([unknownStrategy, fallback]) as any, + ); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(150); // fallback + expect(result.strategyUsed).toBe("Fallback"); + }); + }); + + // ── VIP discount with max cap ───────────────────────────────────────────── + + describe("VIP discount — max cap clamping", () => { + it("applies discounted maximum cap when VIP discount reduces fee above max", async () => { + const strategy = makeStrategy({ + name: "High Fee 5%", + scope: "global", + feePercentage: 5, + feeMinimum: 50, + feeMaximum: 1000, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // VIP discount 50% + mockGetThirtyDayVolume.mockResolvedValueOnce(25000); + mockMapVolumeToTier.mockReturnValueOnce({ discountPercent: 50 }); + + // 100000 * 5% = 5000. With 50% discount: 2500 raw. + // Discounted max: 1000 * 0.5 = 500. So fee = 500. + const result = await engine.calculateFee({ + amount: 100_000, + userId: USER_ID, + }); + + expect(result.fee).toBe(500); + expect(result.breakdown.appliedMaximum).toBe(500); + }); + + it("applies discounted minimum when VIP discount raises fee above discounted min", async () => { + const strategy = makeStrategy({ + name: "Low Fee 0.5% min 200", + scope: "global", + feePercentage: 0.5, + feeMinimum: 200, + feeMaximum: 5000, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // VIP discount 60% + mockGetThirtyDayVolume.mockResolvedValueOnce(60000); + mockMapVolumeToTier.mockReturnValueOnce({ discountPercent: 60 }); + + // Amount 1000: 1000 * 0.5% = 5 raw. With 60% discount: 5 * 0.4 = 2. + // Discounted min: 200 * 0.4 = 80. Since 2 < 80, fee = 80. + const result = await engine.calculateFee({ + amount: 1000, + userId: USER_ID, + }); + + expect(result.fee).toBe(80); + expect(result.breakdown.appliedMinimum).toBe(80); + }); + }); + + // ── Rounding / precision ────────────────────────────────────────────────── + + describe("Rounding and precision", () => { + it("rounds fee and total to 2 decimal places", async () => { + const strategy = makeStrategy({ + feePercentage: 1.5, + feeMinimum: 0, + feeMaximum: 999_999, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + // 1234.56 * 1.5% = 18.5184 → should round to 18.52 + const result = await engine.calculateFee({ amount: 1234.56 }); + + expect(result.fee).toBe(18.52); + expect(result.total).toBe(1253.08); + }); + + it("rounds breakdown values to 2 decimal places", async () => { + const strategy = makeStrategy({ + feePercentage: 1.5, + feeMinimum: 0, + feeMaximum: 999_999, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 1234.56 }); + + expect(result.breakdown.rawFee).toBe(18.52); + expect(result.breakdown.clampedFee).toBe(18.52); + }); + + it("handles very small amounts with precision", async () => { + const strategy = makeStrategy({ + feePercentage: 1.5, + feeMinimum: 0, + feeMaximum: 999_999, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 0.01 }); + + // 0.01 * 1.5% = 0.00015 → rounds to 0 + expect(result.fee).toBe(0); + expect(result.total).toBe(0.01); + }); + }); + + // ── No strategies fallback ──────────────────────────────────────────────── + + describe("No strategies — zero fee default", () => { + it("returns zero fee with strategyUsed 'none' when no strategies match", async () => { + mockPool.query.mockResolvedValueOnce(pgResult([]) as any); + + const result = await engine.calculateFee({ amount: 50_000 }); + + expect(result.fee).toBe(0); + expect(result.total).toBe(50_000); + expect(result.strategyUsed).toBe("none"); + expect(result.scopeUsed).toBe("global"); + expect(result.timeOverrideActive).toBe(false); + }); + }); + + // ── Multiple strategies — first match wins ──────────────────────────────── + + describe("Multiple strategies — first matching wins", () => { + it("uses first global strategy when only global strategies exist", async () => { + const first = makeStrategy({ + name: "First 2%", + priority: 10, + feePercentage: 2, + }); + const second = makeStrategy({ + id: "bbbbbbbb-0000-0000-0000-000000000001", + name: "Second 1%", + priority: 20, + feePercentage: 1, + }); + mockPool.query.mockResolvedValueOnce( + pgResult([first, second]) as any, + ); + + const result = await engine.calculateFee({ amount: 10_000 }); + + expect(result.fee).toBe(200); // 10000 * 2% + expect(result.strategyUsed).toBe("First 2%"); + }); + + it("time_based falls through when condition not met, next strategy applies", async () => { + // 2026-04-24 is Friday + const MONDAY = new Date("2026-04-20T12:00:00Z"); + const timeStrategy = makeStrategy({ + name: "Fee-free Friday", + strategyType: "time_based", + priority: 10, + daysOfWeek: [5], + overridePercentage: 0, + feePercentage: undefined, + }); + const percentageStrategy = makeStrategy({ + id: "bbbbbbbb-0000-0000-0000-000000000001", + name: "Standard 1.5%", + priority: 20, + feePercentage: 1.5, + }); + mockPool.query.mockResolvedValueOnce( + pgResult([timeStrategy, percentageStrategy]) as any, + ); + + const result = await engine.calculateFee({ + amount: 10_000, + evaluationTime: MONDAY, + }); + + expect(result.fee).toBe(150); + expect(result.timeOverrideActive).toBe(false); + expect(result.strategyUsed).toBe("Standard 1.5%"); + }); + }); + + // ── Large amount stress ─────────────────────────────────────────────────── + + describe("Large amount handling", () => { + it("handles very large transaction amounts", async () => { + const strategy = makeStrategy({ + feePercentage: 1.5, + feeMinimum: 50, + feeMaximum: 100_000, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 1_000_000_000 }); + + // 1B * 1.5% = 15M → clamped to max 100000 + expect(result.fee).toBe(100_000); + expect(result.total).toBe(1_000_100_000); + }); + + it("handles zero amount with zero-fee strategy", async () => { + const strategy = makeStrategy({ + strategyType: "flat", + flatAmount: 0, + feeMinimum: 0, + feePercentage: undefined, + }); + mockPool.query.mockResolvedValueOnce(pgResult([strategy]) as any); + + const result = await engine.calculateFee({ amount: 0 }); + + expect(result.fee).toBe(0); + expect(result.total).toBe(0); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// 2. Dynamic Spread — additional edge cases +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("computeLiquidityScaleFactor — edge cases", () => { + it("handles zero volume (clamped to min 1 internally)", () => { + const factor = computeLiquidityScaleFactor(0); + expect(factor).toBeGreaterThanOrEqual(0.2); + expect(factor).toBeLessThanOrEqual(4.0); + }); + + it("handles volume of 1 (minimum safe value)", () => { + const factor = computeLiquidityScaleFactor(1); + expect(factor).toBeGreaterThan(1.0); + expect(factor).toBeLessThanOrEqual(4.0); + }); + + it("handles negative volume (treated as 0 via clamping)", () => { + const factor = computeLiquidityScaleFactor(-1000); + expect(factor).toBeGreaterThanOrEqual(0.2); + expect(factor).toBeLessThanOrEqual(4.0); + }); + + it("handles very small positive volume", () => { + const factor = computeLiquidityScaleFactor(0.01); + expect(factor).toBeGreaterThan(1.0); + }); + + it("is monotonically decreasing across full range", () => { + const volumes = [100, 1000, 10000, 100000, 1000000, 10000000]; + const factors = volumes.map((v) => computeLiquidityScaleFactor(v)); + for (let i = 1; i < factors.length; i++) { + expect(factors[i]).toBeLessThanOrEqual(factors[i - 1]); + } + }); + + it("returns exactly 1.0 at reference volume (100,000)", () => { + const factor = computeLiquidityScaleFactor(100_000); + expect(factor).toBeCloseTo(1.0, 2); + }); +}); + +describe("computeSettlementScaleFactor — edge cases", () => { + it("handles zero settlement time", () => { + const factor = computeSettlementScaleFactor(0); + expect(factor).toBeGreaterThanOrEqual(0.7); + expect(factor).toBeLessThanOrEqual(3.0); + }); + + it("handles negative settlement time", () => { + const factor = computeSettlementScaleFactor(-5000); + expect(factor).toBeGreaterThanOrEqual(0.7); + }); + + it("returns 1.0 at reference settlement (30000ms)", () => { + const factor = computeSettlementScaleFactor(30_000); + expect(factor).toBeCloseTo(1.0, 5); + }); + + it("is monotonically increasing", () => { + const times = [0, 5000, 15000, 30000, 60000, 120000, 600000]; + const factors = times.map((t) => computeSettlementScaleFactor(t)); + for (let i = 1; i < factors.length; i++) { + expect(factors[i]).toBeGreaterThanOrEqual(factors[i - 1]); + } + }); + + it("returns exactly 0.7 at very fast settlement (clamped)", () => { + const factor = computeSettlementScaleFactor(0); + expect(factor).toBeGreaterThanOrEqual(0.7); + }); + + it("returns exactly 3.0 at very slow settlement (clamped)", () => { + const factor = computeSettlementScaleFactor(1_000_000_000); + expect(factor).toBeLessThanOrEqual(3.0); + }); +}); + +describe("computeSpread — boundary conditions", () => { + it("returns base spread (1.5%) when both factors are 1.0", () => { + expect(computeSpread(1.0, 1.0)).toBeCloseTo(1.5, 4); + }); + + it("returns minimum spread (0.3%) when factors are very low", () => { + expect(computeSpread(0.2, 0.7)).toBeGreaterThanOrEqual(0.3); + expect(computeSpread(0.2, 0.7)).toBeLessThanOrEqual(8.0); + }); + + it("returns maximum spread (8.0%) when factors are very high", () => { + expect(computeSpread(4.0, 3.0)).toBeLessThanOrEqual(8.0); + expect(computeSpread(4.0, 3.0)).toBeGreaterThanOrEqual(0.3); + }); + + it("spread with both factors at minimum clamp is still at least MIN_SPREAD", () => { + const spread = computeSpread(0.2, 0.7); + expect(spread).toBeGreaterThanOrEqual(0.3); + }); + + it("spread with both factors at maximum clamp is at most MAX_SPREAD", () => { + const spread = computeSpread(4.0, 3.0); + expect(spread).toBeLessThanOrEqual(8.0); + }); + + it("is symmetric in factor multiplication", () => { + // 1.5 * 2.0 * 1.5 = 4.5 + const spread1 = computeSpread(2.0, 1.5); + const spread2 = computeSpread(1.5, 2.0); + expect(spread1).toBeCloseTo(spread2, 6); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// 3. Airtel fee tiers — additional edge cases +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("calculateAirtelFee — additional coverage", () => { + it("applies micro tier (1%) for amount of 500", () => { + const result = calculateAirtelFee(500); + expect(result.tier).toBe("micro"); + expect(result.rate).toBe(0.01); + expect(result.fee).toBe(5); // 500 * 0.01 = 5, floored at min 5 + expect(result.netAmount).toBe(495); + }); + + it("applies micro tier at exact boundary (999.99)", () => { + const result = calculateAirtelFee(999.99); + expect(result.tier).toBe("micro"); + expect(result.rate).toBe(0.01); + }); + + it("applies standard tier at exact lower bound (1000)", () => { + const result = calculateAirtelFee(1000); + expect(result.tier).toBe("standard"); + expect(result.rate).toBe(0.008); + expect(result.fee).toBe(8); // 1000 * 0.008 + expect(result.netAmount).toBe(992); + }); + + it("applies standard tier for mid-range (5000)", () => { + const result = calculateAirtelFee(5000); + expect(result.tier).toBe("standard"); + expect(result.rate).toBe(0.008); + expect(result.fee).toBe(40); + expect(result.netAmount).toBe(4960); + }); + + it("applies standard tier at upper boundary (9999.99)", () => { + const result = calculateAirtelFee(9999.99); + expect(result.tier).toBe("standard"); + expect(result.rate).toBe(0.008); + }); + + it("applies bulk tier at exact lower bound (10000)", () => { + const result = calculateAirtelFee(10000); + expect(result.tier).toBe("bulk"); + expect(result.rate).toBe(0.005); + expect(result.fee).toBe(50); // 10000 * 0.005 + expect(result.netAmount).toBe(9950); + }); + + it("applies bulk tier for mid-range (30000)", () => { + const result = calculateAirtelFee(30000); + expect(result.tier).toBe("bulk"); + expect(result.rate).toBe(0.005); + expect(result.fee).toBe(150); // 30000 * 0.005 + expect(result.netAmount).toBe(29850); + }); + + it("applies bulk tier at upper boundary (49999.99)", () => { + const result = calculateAirtelFee(49999.99); + expect(result.tier).toBe("bulk"); + expect(result.rate).toBe(0.005); + }); + + it("applies enterprise tier at exact lower bound (50000)", () => { + const result = calculateAirtelFee(50000); + expect(result.tier).toBe("enterprise"); + expect(result.rate).toBe(0.003); + expect(result.fee).toBe(150); // 50000 * 0.003 + expect(result.netAmount).toBe(49850); + }); + + it("applies enterprise tier for large amounts (500000)", () => { + const result = calculateAirtelFee(500000); + expect(result.tier).toBe("enterprise"); + expect(result.rate).toBe(0.003); + expect(result.fee).toBe(1500); + expect(result.netAmount).toBe(498500); + }); + + it("enforces minimum fee for micro tier amounts below min", () => { + // 100 * 1% = 1, below AIRTEL_MIN_FEE (5) + const result = calculateAirtelFee(100); + expect(result.fee).toBe(5); + expect(result.netAmount).toBe(95); + }); + + it("returns exact fee of 0 for amount 0", () => { + const result = calculateAirtelFee(0); + expect(result.fee).toBe(AIRTEL_MIN_FEE); // max(0, 5) = 5 + expect(result.netAmount).toBe(-5); // 0 - 5 = -5 + }); + + it("rounds fee to 2 decimal places", () => { + // 1234.56 * 0.008 = 9.87648 → rounds to 9.88 + const result = calculateAirtelFee(1234.56); + expect(result.fee).toBe(9.88); + }); + + it("rounds netAmount to 2 decimal places", () => { + const result = calculateAirtelFee(1234.56); + expect(result.netAmount).toBe(1224.68); // 1234.56 - 9.88 + }); + + it("handles very large amount", () => { + const result = calculateAirtelFee(10_000_000); + expect(result.tier).toBe("enterprise"); + expect(result.rate).toBe(0.003); + expect(result.fee).toBe(30_000); + expect(result.netAmount).toBe(9_970_000); + }); + + it("throws for negative amount", () => { + expect(() => calculateAirtelFee(-100)).toThrow( + "Amount must be a finite, non-negative number", + ); + }); + + it("throws for NaN", () => { + expect(() => calculateAirtelFee(NaN)).toThrow( + "Amount must be a finite, non-negative number", + ); + }); + + it("throws for Infinity", () => { + expect(() => calculateAirtelFee(Infinity)).toThrow( + "Amount must be a finite, non-negative number", + ); + }); + + it("throws for negative Infinity", () => { + expect(() => calculateAirtelFee(-Infinity)).toThrow( + "Amount must be a finite, non-negative number", + ); + }); + + it("tier boundaries are contiguous with no gaps", () => { + // Each tier's min should equal the previous tier's max + for (let i = 1; i < AIRTEL_FEE_TIERS.length; i++) { + expect(AIRTEL_FEE_TIERS[i].min).toBe(AIRTEL_FEE_TIERS[i - 1].max); + } + }); + + it("fee rates decrease as tiers increase (regressive structure)", () => { + for (let i = 1; i < AIRTEL_FEE_TIERS.length; i++) { + expect(AIRTEL_FEE_TIERS[i].rate).toBeLessThan( + AIRTEL_FEE_TIERS[i - 1].rate, + ); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// 4. calculateFeeSync — additional edge cases +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("calculateFeeSync — additional coverage", () => { + const originalEnv = { ...process.env }; + + beforeAll(() => { + jest.unmock("../../src/utils/fees"); + }); + + afterAll(() => { + jest.resetModules(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + jest.resetModules(); + }); + + function loadCalculateFeeSync() { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("../../src/utils/fees"); + return mod.calculateFeeSync as typeof import("../../src/utils/fees").calculateFeeSync; + } + + it("verifies default FEE_PERCENTAGE (1.5%) calculation", () => { + // Default env: FEE_PERCENTAGE=1.5, FEE_MINIMUM=50, FEE_MAXIMUM=5000 + const calculateFeeSync = loadCalculateFeeSync(); + const result = calculateFeeSync(10_000); + // 10000 * 1.5% = 150 + expect(result.fee).toBe(150); + expect(result.total).toBe(10_150); + }); + + it("applies custom minimum fee from env", () => { + process.env.FEE_PERCENTAGE = "0.5"; + process.env.FEE_MINIMUM = "100"; + process.env.FEE_MAXIMUM = "5000"; + + const calculateFeeSync = loadCalculateFeeSync(); + // 1000 * 0.5% = 5, below min of 100 + const result = calculateFeeSync(1000); + expect(result.fee).toBe(100); + expect(result.total).toBe(1100); + }); + + it("applies custom maximum fee from env", () => { + process.env.FEE_PERCENTAGE = "5"; + process.env.FEE_MINIMUM = "50"; + process.env.FEE_MAXIMUM = "200"; + + const calculateFeeSync = loadCalculateFeeSync(); + // 10000 * 5% = 500, above max of 200 + const result = calculateFeeSync(10_000); + expect(result.fee).toBe(200); + expect(result.total).toBe(10_200); + }); + + it("rounds to 2 decimal places", () => { + process.env.FEE_PERCENTAGE = "1.5"; + process.env.FEE_MINIMUM = "0"; + process.env.FEE_MAXIMUM = "5000"; + + const calculateFeeSync = loadCalculateFeeSync(); + // 999.99 * 1.5% = 14.99985 → rounds to 15.00 + const result = calculateFeeSync(999.99); + expect(result.fee).toBe(15); + expect(result.total).toBe(1014.99); + }); + + it("handles fractional amounts precisely", () => { + process.env.FEE_PERCENTAGE = "1.5"; + process.env.FEE_MINIMUM = "0"; + process.env.FEE_MAXIMUM = "5000"; + + const calculateFeeSync = loadCalculateFeeSync(); + const result = calculateFeeSync(1234.56); + // 1234.56 * 1.5% = 18.5184 → rounds to 18.52 + expect(result.fee).toBe(18.52); + expect(result.total).toBe(1253.08); + }); + + it("returns configUsed 'env_fallback'", () => { + process.env.FEE_PERCENTAGE = "1.5"; + process.env.FEE_MINIMUM = "50"; + process.env.FEE_MAXIMUM = "5000"; + + const calculateFeeSync = loadCalculateFeeSync(); + const result = calculateFeeSync(10_000); + expect(result.configUsed).toBe("env_fallback"); + }); + + it("handles minimum equal to maximum (both clamped to same value)", () => { + process.env.FEE_PERCENTAGE = "1.5"; + process.env.FEE_MINIMUM = "100"; + process.env.FEE_MAXIMUM = "100"; + + const calculateFeeSync = loadCalculateFeeSync(); + const result = calculateFeeSync(10_000); + // 10000 * 1.5% = 150, clamped to both min and max = 100 + expect(result.fee).toBe(100); + }); + + it("handles negative amount gracefully (applies min fee)", () => { + process.env.FEE_PERCENTAGE = "1.5"; + process.env.FEE_MINIMUM = "50"; + process.env.FEE_MAXIMUM = "5000"; + + const calculateFeeSync = loadCalculateFeeSync(); + // Negative amount: -100 * 1.5% = -1.5, clamped to min 50 + const result = calculateFeeSync(-100); + expect(result.fee).toBe(50); + }); +});