@accounter/server
Version:
Accounter GraphQL server
328 lines (289 loc) • 13.7 kB
text/typescript
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: TestDatabase;
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 as any,
{} as any,
);
// 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 as any).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 as any, {
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 as any);
// 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 as any,
{} as any,
);
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 as any,
{} as any,
);
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);
});
});