UNPKG

@accounter/server

Version:
229 lines 14.1 kB
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { TestDatabase } from '../../../__tests__/helpers/db-setup.js'; import { insertFixture } from '../../../__tests__/helpers/fixture-loader.js'; import { expenseScenarioA } from '../../../__tests__/fixtures/expenses/expense-scenario-a.js'; import { makeUUID } from '../../../demo-fixtures/helpers/deterministic-uuid.js'; import { qualifyTable } from '../../../__tests__/helpers/test-db-config.js'; import { buildAdminContextFromDb } from '../../../__tests__/helpers/admin-context-builder.js'; import { env } from '../../../environment.js'; import { ledgerGenerationByCharge } from '../helpers/ledger-by-charge-type.helper.js'; import { assertSimpleLocalExpenseScenario, assertAuditTrail } from './helpers/ledger-assertions.js'; import { createLedgerTestContext } from '../../../test-utils/ledger-injector.js'; import { Currency } from '../../../shared/enums.js'; /** * Ledger Integration Test for Expense Scenario A * * Tests ledger generation for a simple ILS receipt expense scenario. * This validates the complete flow: fixture insertion → ledger generation → record validation. * * Scenario: * - Admin business purchases from Local Supplier Ltd * - Transaction: -500 ILS (expense) * - Document: Receipt for 500 ILS * - Expected ledger: 2 balanced records (debit expense, credit bank) */ describe('Ledger Generation - Expense Scenario A', () => { let db; beforeAll(async () => { db = new TestDatabase(); await db.connect(); await db.ensureLatestSchema(); await db.seedAdmin(); }); afterAll(async () => { // Do NOT close db here — the shared pool is managed by vitest-global-setup teardown. // Closing it here destroys the pool for all other concurrently-running integration test suites. }); // Ensure ledger and related rows do not leak between tests afterEach(async () => { const client = await db.getPool().connect(); try { const chargeId = makeUUID('charge', 'charge-office-supplies'); const adminId = makeUUID('business', 'admin-business-scenario-a'); // Set context to allow deletion of protected rows await client.query("SELECT set_config('app.current_business_id', $1, false)", [adminId]); await client.query(`DELETE FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1`, [chargeId]); // Also clear the scenario's fixture rows to keep DB tidy await client.query(`DELETE FROM ${qualifyTable('documents')} WHERE charge_id = $1`, [chargeId]); await client.query(`DELETE FROM ${qualifyTable('transactions')} WHERE charge_id = $1`, [chargeId]); await client.query(`DELETE FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); // Clean up fixture-specific entities to prevent count inflation await client.query(`DELETE FROM ${qualifyTable('financial_accounts_tax_categories')} WHERE tax_category_id IN ($1, $2)`, [makeUUID('tax-category', 'expense-general'), makeUUID('tax-category', 'bank-account-tax-category')]); await client.query(`DELETE FROM ${qualifyTable('financial_accounts')} WHERE account_number = $1`, ['BANK-ACCOUNT-001']); await client.query(`DELETE FROM ${qualifyTable('tax_categories')} WHERE id IN ($1, $2)`, [makeUUID('tax-category', 'expense-general'), makeUUID('tax-category', 'bank-account-tax-category')]); await client.query(`DELETE FROM ${qualifyTable('businesses')} WHERE id IN ($1, $2)`, [makeUUID('business', 'admin-business-scenario-a'), makeUUID('business', 'supplier-local-ltd')]); await client.query(`DELETE FROM ${qualifyTable('financial_entities')} WHERE id IN ($1, $2, $3, $4)`, [ makeUUID('business', 'admin-business-scenario-a'), makeUUID('business', 'supplier-local-ltd'), makeUUID('tax-category', 'expense-general'), makeUUID('tax-category', 'bank-account-tax-category'), ]); } finally { client.release(); } }); it('should generate balanced ledger records for ILS receipt expense', async () => { // Insert fixture within an explicit transaction and COMMIT to persist rows. const insertClient = await db.getPool().connect(); try { await insertClient.query('BEGIN'); const adminContext = await buildAdminContextFromDb(insertClient); await insertFixture(insertClient, expenseScenarioA, adminContext.ownerId); // Reset RLS context to the charge owner before verification // (insertFixture might leave it set to the last inserted entity's owner) const chargeOwnerId = makeUUID('business', 'admin-business-scenario-a'); await insertClient.query("SELECT set_config('app.current_business_id', $1, true)", [chargeOwnerId]); const chargeId = makeUUID('charge', 'charge-office-supplies'); // Verify inserts within the same transaction const chargeResult = await insertClient.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); expect(chargeResult.rows).toHaveLength(1); const transactionsResult = await insertClient.query(`SELECT * FROM ${qualifyTable('transactions')} WHERE charge_id = $1`, [chargeId]); expect(transactionsResult.rows).toHaveLength(1); expect(transactionsResult.rows[0].amount).toBe('-500.00'); expect(transactionsResult.rows[0].currency).toBe(Currency.Ils); const documentsResult = await insertClient.query(`SELECT * FROM ${qualifyTable('documents')} WHERE charge_id = $1`, [chargeId]); expect(documentsResult.rows).toHaveLength(1); expect(documentsResult.rows[0].type).toBe('RECEIPT'); expect(Number(documentsResult.rows[0].total_amount)).toBe(500.0); await insertClient.query('COMMIT'); } catch (e) { try { await insertClient.query('ROLLBACK'); } catch { } throw e; } finally { insertClient.release(); } // Re-open a new client (outside prior transaction) for verification & generation const client = await db.getPool().connect(); try { const adminContext = await buildAdminContextFromDb(client); // Set context to allow reading protected rows const fixtureOwnerId = makeUUID('business', 'admin-business-scenario-a'); await client.query("SELECT set_config('app.current_business_id', $1, false)", [fixtureOwnerId]); const chargeId = makeUUID('charge', 'charge-office-supplies'); const chargeResult = await client.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); const charge = chargeResult.rows[0]; const context = createLedgerTestContext({ pool: db.getPool(), env, moduleId: 'ledger', businessId: adminContext.ownerId, }); const result = await ledgerGenerationByCharge(charge, { insertLedgerRecordsIfNotExists: true }, context, {}); // Verify result structure and success expect(result).toBeDefined(); expect(result).not.toBeNull(); // Type guard: ensure result is LedgerRecordsProto, not CommonError if (!result || 'message' in result) { throw new Error(`Ledger generation failed: ${result ? result.message : 'null result'}`); } expect(result.errors).toEqual([]); expect(result.balance?.isBalanced).toBe(true); expect(result.records).toHaveLength(2); // Query ledger records from database const ledgerResult = await client.query(`SELECT * FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1 ORDER BY created_at ASC`, [charge.id]); // High-level semantic assertions using helper const ledgerExpectation = expenseScenarioA.expectations?.ledger?.[0]; assertSimpleLocalExpenseScenario(ledgerResult.rows, { chargeId: charge.id, ownerId: charge.owner_id, // Use actual charge owner for ledger record owner assertions expenseEntity: makeUUID('tax-category', 'expense-general'), bankEntity: makeUUID('tax-category', 'bank-account-tax-category'), expectedCurrency: Currency.Ils, expectedTotal: ledgerExpectation?.totalDebitLocal || 500.0, }); // Audit trail assertions (timestamps, lock state) assertAuditTrail(ledgerResult.rows); // Basic record count sanity check retained expect(ledgerResult.rows.length).toBe(ledgerExpectation?.recordCount || 2); console.log('✅ Ledger generation semantic assertions passed for Scenario A'); } finally { client.release(); } }); it('should be idempotent on repeated ledger generation (no duplicate records)', async () => { const client = await db.getPool().connect(); try { const chargeId = makeUUID('charge', 'charge-office-supplies'); const adminId = makeUUID('business', 'admin-business-scenario-a'); // Set context to allow reading protected rows await client.query("SELECT set_config('app.current_business_id', $1, false)", [adminId]); // Ensure fixture inserted (if previous test did cleanup, reinsert minimal fixture) const existingCharge = await client.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); const adminContext = await buildAdminContextFromDb(client); if (existingCharge.rows.length === 0) { // Reinsert fixture quickly await client.query('BEGIN'); await insertFixture(client, expenseScenarioA, adminContext.ownerId); await client.query('COMMIT'); } const chargeResult = await client.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); const charge = chargeResult.rows[0]; const context = createLedgerTestContext({ pool: db.getPool(), env, moduleId: 'ledger', businessId: adminContext.ownerId, }); // First generation const first = await ledgerGenerationByCharge(charge, { insertLedgerRecordsIfNotExists: true }, context, {}); if (!first || 'message' in first) { throw new Error('First generation failed'); } const firstRecords = await client.query(`SELECT id FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1 ORDER BY id ASC`, [charge.id]); const firstIds = firstRecords.rows.map(r => r.id); // Second generation with insertion disabled (should not create duplicates) const second = await ledgerGenerationByCharge(charge, { insertLedgerRecordsIfNotExists: false }, context, {}); if (!second || 'message' in second) { throw new Error('Second generation failed'); } const secondRecords = await client.query(`SELECT id FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1 ORDER BY id ASC`, [charge.id]); const secondIds = secondRecords.rows.map(r => r.id); expect(secondIds.length).toBe(firstIds.length); expect(secondIds).toEqual(firstIds); } finally { client.release(); } }); it('should validate fixture structure matches expectations', () => { // Verify fixture has expected structure expect(expenseScenarioA.businesses?.businesses).toHaveLength(2); // Admin + supplier expect(expenseScenarioA.taxCategories?.taxCategories).toHaveLength(2); // Expense + bank expect(expenseScenarioA.charges?.charges).toHaveLength(1); expect(expenseScenarioA.transactions?.transactions).toHaveLength(1); expect(expenseScenarioA.documents?.documents).toHaveLength(1); // Verify expectations are defined expect(expenseScenarioA.expectations).toBeDefined(); expect(expenseScenarioA.expectations?.ledger).toHaveLength(1); const ledgerExpectation = expenseScenarioA.expectations?.ledger?.[0]; expect(ledgerExpectation?.chargeId).toBe(makeUUID('charge', 'charge-office-supplies')); expect(ledgerExpectation?.recordCount).toBe(2); expect(ledgerExpectation?.balanced).toBe(true); expect(ledgerExpectation?.totalDebitLocal).toBe(500.0); expect(ledgerExpectation?.totalCreditLocal).toBe(500.0); expect(ledgerExpectation?.debitEntities).toHaveLength(1); expect(ledgerExpectation?.creditEntities).toHaveLength(1); }); it('should create deterministic UUIDs for reproducible tests', () => { // Verify all IDs are deterministic and match expected values const adminBusinessId = makeUUID('business', 'admin-business-scenario-a'); const supplierId = makeUUID('business', 'supplier-local-ltd'); const chargeId = makeUUID('charge', 'charge-office-supplies'); const transactionId = makeUUID('transaction', 'transaction-supplies-payment'); const documentId = makeUUID('document', 'document-supplies-receipt'); expect(adminBusinessId).toBe(expenseScenarioA.businesses?.businesses[0]?.id); expect(supplierId).toBe(expenseScenarioA.businesses?.businesses[1]?.id); expect(chargeId).toBe(expenseScenarioA.charges?.charges[0]?.id); expect(transactionId).toBe(expenseScenarioA.transactions?.transactions[0]?.id); expect(documentId).toBe(expenseScenarioA.documents?.documents[0]?.id); }); }); //# sourceMappingURL=ledger-scenario-a.integration.test.js.map