"""Opt-in integration tests against a disposable restored BMC database only.""" import os import uuid from concurrent.futures import ThreadPoolExecutor from threading import Barrier import pytest import psycopg2 from psycopg2.extensions import parse_dsn from psycopg2.extras import RealDictCursor, Json from app.core import database from app.workflows import settlement, service, worker @pytest.fixture def db(monkeypatch): dsn = os.environ.get('BMC_WORKFLOW_TEST_DSN') if not dsn: pytest.skip('BMC_WORKFLOW_TEST_DSN required for disposable database tests') if not parse_dsn(dsn).get('dbname', '').startswith('bmc_workflow_test'): pytest.fail('Database must be named bmc_workflow_test*') monkeypatch.setattr(database, 'get_db_connection', lambda: psycopg2.connect(dsn)) monkeypatch.setattr(database, 'release_db_connection', lambda conn: conn.close()) monkeypatch.setattr(service, 'get_db_connection', lambda: psycopg2.connect(dsn)) monkeypatch.setattr(service, 'release_db_connection', lambda conn: conn.close()) monkeypatch.setattr(settlement, 'active_for_customer', lambda _: True) conn = psycopg2.connect(dsn) conn.autocommit = True with conn.cursor(cursor_factory=RealDictCursor) as c: c.execute('SELECT * FROM bmc_workflow_runtime WHERE id=1') runtime = dict(c.fetchone()) c.execute('UPDATE bmc_workflow_runtime SET enabled=FALSE WHERE id=1') yield conn with conn.cursor() as c: c.execute('UPDATE bmc_workflow_runtime SET enabled=%s,mode=%s,customer_ids=%s WHERE id=1', (runtime['enabled'], runtime['mode'], Json(runtime['customer_ids']))) conn.close() @pytest.fixture def entry(db): token = str(uuid.uuid4()) with db.cursor(cursor_factory=RealDictCursor) as c: c.execute('SELECT user_id FROM users ORDER BY user_id LIMIT 1') actor = c.fetchone()['user_id'] # Hub mapping is read-only and cards have no customer FK in BMC. customer = 2000000000 + uuid.uuid4().int % 100000000 c.execute('INSERT INTO tmodule_customers(vtiger_id,name,hub_customer_id) VALUES(%s,%s,%s) RETURNING id', ('workflow-test-'+token, 'Workflow integration test', customer)) tracking = c.fetchone()['id'] c.execute("INSERT INTO tticket_prepaid_cards(card_number,customer_id,purchased_hours,price_per_hour,total_amount,status,rounding_minutes) VALUES(%s,%s,10,100,1000,'active',30) RETURNING id", ('WF-'+token, customer)) card = c.fetchone()['id'] c.execute("INSERT INTO tmodule_times(customer_id,description,original_hours,worked_date,status,billing_method,faktisk_tid_min) VALUES(%s,%s,1,CURRENT_DATE,'pending','prepaid',60) RETURNING id,workflow_revision", (tracking, 'Workflow test '+token)) time = c.fetchone() c.execute("INSERT INTO bmc_billing_decisions(time_id,revision,status,method,details) VALUES(%s,%s,'decided','prepaid',%s)", (time['id'], time['workflow_revision'], Json({'billing': {'card_id': card}, 'input': {'tag_ids': []}}))) data = {'time_id': time['id'], 'card_id': card, 'actor': actor, 'customer_id': customer, 'tracking_id': tracking} yield data with db.cursor() as c: c.execute('DELETE FROM bmc_workflow_settlements WHERE time_id=%s', (time['id'],)) c.execute('DELETE FROM bmc_billing_decisions WHERE time_id=%s', (time['id'],)) c.execute('DELETE FROM tticket_prepaid_transactions WHERE workflow_time_entry_id=%s', (time['id'],)) c.execute('DELETE FROM bmc_workflow_effects WHERE event_id IN(SELECT id FROM bmc_workflow_events WHERE entity_type IN (\'time\',\'time_entry\') AND entity_id=%s)', (str(time['id']),)) c.execute('DELETE FROM bmc_workflow_executions WHERE event_id IN(SELECT id FROM bmc_workflow_events WHERE entity_type IN (\'time\',\'time_entry\') AND entity_id=%s)', (str(time['id']),)) c.execute('DELETE FROM bmc_workflow_events WHERE entity_type IN (\'time\',\'time_entry\') AND entity_id=%s', (str(time['id']),)) c.execute('DELETE FROM tmodule_times WHERE id=%s', (time['id'],)) c.execute('DELETE FROM tticket_prepaid_cards WHERE id=%s', (card,)) c.execute('DELETE FROM tmodule_customers WHERE id=%s', (tracking,)) def test_two_concurrent_settlements_debit_exactly_once(db, entry): barrier = Barrier(2) def settle(): barrier.wait(timeout=10) return settlement.settle_workflow_entries([entry['time_id']], entry['actor']) with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map(lambda _: settle(), range(2))) assert all(result['success'] for result in results) with db.cursor() as c: c.execute('SELECT used_hours FROM tticket_prepaid_cards WHERE id=%s', (entry['card_id'],)) assert float(c.fetchone()[0]) == 1 c.execute('SELECT count(*) FROM tticket_prepaid_transactions WHERE workflow_time_entry_id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 1 c.execute('SELECT count(*) FROM bmc_workflow_settlements WHERE time_id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 1 def test_failure_after_debit_rolls_back_all_changes(db, entry, monkeypatch): def fail(*args, **kwargs): raise RuntimeError('Injected failure after debit') monkeypatch.setattr(settlement, 'execute_insert', fail) with pytest.raises(RuntimeError, match='Injected failure'): settlement.settle_workflow_entries([entry['time_id']], entry['actor']) with db.cursor() as c: c.execute('SELECT used_hours FROM tticket_prepaid_cards WHERE id=%s', (entry['card_id'],)) assert float(c.fetchone()[0]) == 0 c.execute('SELECT count(*) FROM bmc_workflow_settlements WHERE time_id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 0 c.execute('SELECT status FROM tmodule_times WHERE id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 'pending' def test_disabled_capture_creates_no_events(db, entry): with db.cursor() as c: c.execute("UPDATE tmodule_times SET description='Disabled workflow test' WHERE id=%s", (entry['time_id'],)) c.execute('SELECT count(*) FROM bmc_workflow_events WHERE entity_id=%s AND entity_type=\'time_entry\'', (str(entry['time_id']),)) assert c.fetchone()[0] == 0 def test_enabled_capture_rolls_back_with_source_change(db, entry): with db.cursor() as c: c.execute('UPDATE bmc_workflow_runtime SET enabled=TRUE WHERE id=1') db.autocommit = False try: with db.cursor() as c: c.execute("UPDATE tmodule_times SET description='Transactional workflow test' WHERE id=%s", (entry['time_id'],)) c.execute('SELECT count(*) FROM bmc_workflow_events WHERE entity_id=%s AND entity_type=\'time_entry\'', (str(entry['time_id']),)) assert c.fetchone()[0] == 1 db.rollback() finally: db.autocommit = True with db.cursor() as c: c.execute('SELECT count(*) FROM bmc_workflow_events WHERE entity_id=%s AND entity_type=\'time_entry\'', (str(entry['time_id']),)) assert c.fetchone()[0] == 0 def test_version_is_immutable(db): db.autocommit = False try: with db.cursor() as c: c.execute("INSERT INTO bmc_workflows(name) VALUES('Workflow immutability test') RETURNING id") workflow_id = c.fetchone()[0] c.execute('INSERT INTO bmc_workflow_versions(workflow_id,version,definition) VALUES(%s,1,\'{}\') RETURNING id', (workflow_id,)) version_id = c.fetchone()[0] with pytest.raises(psycopg2.Error): c.execute("UPDATE bmc_workflow_versions SET definition='{}' WHERE id=%s", (version_id,)) db.rollback() finally: db.rollback() db.autocommit = True @pytest.fixture def worker_rule(db, entry): definition = {'trigger': 'time.updated', 'branches': [{'condition': {'field': 'description', 'operator': 'contains', 'value': 'worker test'}, 'actions': [{'type': 'set_billing', 'method': 'invoice'}]}]} with db.cursor() as c: c.execute('INSERT INTO bmc_workflows(name,customer_id,priority,enabled) VALUES(%s,%s,0,FALSE) RETURNING id', ('Disposable worker integration '+str(uuid.uuid4()), str(entry['customer_id']))) workflow_id = c.fetchone()[0] c.execute('INSERT INTO bmc_workflow_versions(workflow_id,version,definition) VALUES(%s,1,%s) RETURNING id', (workflow_id, Json(definition))) version_id = c.fetchone()[0] c.execute('UPDATE bmc_workflows SET enabled=TRUE,active_version_id=%s WHERE id=%s', (version_id, workflow_id)) c.execute("UPDATE bmc_workflow_runtime SET enabled=TRUE,mode='active',customer_ids=%s WHERE id=1", (Json([entry['customer_id']]),)) yield workflow_id # Immutable version history deliberately remains in this disposable DB. with db.cursor() as c: c.execute('UPDATE bmc_workflows SET enabled=FALSE WHERE id=%s', (workflow_id,)) def test_worker_shadow_then_active_revision_and_repeat_once(db, entry, worker_rule): with db.cursor() as c: c.execute('DELETE FROM bmc_billing_decisions WHERE time_id=%s', (entry['time_id'],)) c.execute("UPDATE tmodule_times SET description='First worker test' WHERE id=%s", (entry['time_id'],)) assert worker.process_pending_events(limit=100, mode='shadow') >= 1 with db.cursor() as c: c.execute('SELECT count(*) FROM bmc_billing_decisions WHERE time_id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 0 c.execute("SELECT count(*) FROM bmc_workflow_executions x JOIN bmc_workflow_events e ON e.id=x.event_id WHERE e.entity_id=%s AND x.mode='shadow'", (str(entry['time_id']),)) assert c.fetchone()[0] == 1 c.execute("UPDATE tmodule_times SET description='Second worker test' WHERE id=%s RETURNING workflow_revision", (entry['time_id'],)) revision = c.fetchone()[0] assert worker.process_pending_events(limit=100, mode='active', customer_ids=[entry['customer_id']]) >= 1 with db.cursor() as c: c.execute('SELECT revision,method,status FROM bmc_billing_decisions WHERE time_id=%s AND is_current', (entry['time_id'],)) assert c.fetchone() == (revision, 'invoice', 'decided') assert worker.process_pending_events(limit=100, mode='active', customer_ids=[entry['customer_id']]) == 0 with db.cursor() as c: c.execute('SELECT count(*) FROM bmc_billing_decisions WHERE time_id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 1 def test_worker_preserves_manual_override(db, entry, worker_rule): with db.cursor() as c: c.execute('UPDATE bmc_billing_decisions SET manual_override=TRUE WHERE time_id=%s', (entry['time_id'],)) c.execute("UPDATE tmodule_times SET description='Manual worker test' WHERE id=%s", (entry['time_id'],)) assert worker.process_pending_events(limit=100, mode='active', customer_ids=[entry['customer_id']]) >= 1 with db.cursor() as c: c.execute('SELECT method,manual_override FROM bmc_billing_decisions WHERE time_id=%s AND is_current', (entry['time_id'],)) assert c.fetchone() == ('prepaid', True) c.execute('SELECT count(*) FROM bmc_billing_decisions WHERE time_id=%s', (entry['time_id'],)) assert c.fetchone()[0] == 1 @pytest.mark.parametrize('line_mode,expected_lines', [('separate', 2), ('combined', 1)]) def test_invoice_draft_lines_preserve_sources_and_retry_once(db, entry, line_mode, expected_lines): second = draft = None try: with db.cursor(cursor_factory=RealDictCursor) as c: c.execute('SELECT id FROM customers ORDER BY id LIMIT 1') customer = c.fetchone()['id'] c.execute('UPDATE tmodule_customers SET hub_customer_id=%s WHERE id=%s', (customer, entry['tracking_id'])) c.execute("UPDATE bmc_billing_decisions SET method='invoice',details=%s WHERE time_id=%s", (Json({'billing': {}}), entry['time_id'])) c.execute("INSERT INTO tmodule_times(customer_id,description,original_hours,worked_date,status,billing_method,faktisk_tid_min) VALUES(%s,'Second invoice integration source',1,CURRENT_DATE,'pending','invoice',60) RETURNING id,workflow_revision", (entry['tracking_id'],)) row = c.fetchone(); second = row['id'] c.execute("INSERT INTO bmc_billing_decisions(time_id,revision,status,method,details) VALUES(%s,%s,'decided','invoice',%s)", (second, row['workflow_revision'], Json({'billing': {}}))) result = settlement.settle_workflow_entries([entry['time_id'], second], entry['actor'], line_mode=line_mode) assert len(result['created_drafts']) == 1 draft = result['created_drafts'][0]['draft_id'] with db.cursor() as c: c.execute('SELECT lines_json FROM ordre_drafts WHERE id=%s', (draft,)) lines = [line for line in c.fetchone()[0] if line.get('source_type') == 'timequeue'] assert len(lines) == expected_lines assert {time_id for line in lines for time_id in line['time_entry_ids']} == {entry['time_id'], second} retry = settlement.settle_workflow_entries([entry['time_id'], second], entry['actor'], line_mode=line_mode) assert retry['created_drafts'] == [] assert set(retry['already_settled_ids']) == {entry['time_id'], second} finally: with db.cursor() as c: if second: c.execute('DELETE FROM bmc_workflow_settlements WHERE time_id=ANY(%s)', ([entry['time_id'], second],)) c.execute('DELETE FROM bmc_billing_decisions WHERE time_id=%s', (second,)) c.execute('DELETE FROM tmodule_times WHERE id=%s', (second,)) if draft: c.execute('UPDATE tmodule_times SET economy_order_draft_id=NULL WHERE id=%s', (entry['time_id'],)) c.execute('DELETE FROM ordre_drafts WHERE id=%s', (draft,)) def test_actual_api_override_reset_and_existing_simulation(db, entry, worker_rule, monkeypatch): from fastapi import FastAPI from fastapi.testclient import TestClient from app.workflows import decisions, router as workflow_api monkeypatch.setattr(decisions, 'active_for_customer', lambda _: True) app = FastAPI() app.include_router(decisions.router) app.include_router(workflow_api.router) for dep in (decisions.edit_access, decisions.view_access, workflow_api.manage_access, workflow_api.view_access): app.dependency_overrides[dep] = lambda: {'id': entry['actor'], 'is_superadmin': True} client = TestClient(app) with db.cursor() as c: c.execute("UPDATE tmodule_times SET description='Actual API worker test' WHERE id=%s", (entry['time_id'],)) override = client.post(f"/timetracking/time/{entry['time_id']}/workflow-override", json={'method': 'prepaid', 'card_id': entry['card_id'], 'reason': 'Verified card choice'}) assert override.status_code == 200 assert override.json()['manual_override'] is True preserved = client.post(f"/timetracking/time/{entry['time_id']}/workflow-recalculate", json={}) assert preserved.status_code == 200 assert preserved.json()['preserved_override'] is True reset = client.post(f"/timetracking/time/{entry['time_id']}/workflow-recalculate", json={'reset_override': True, 'reason': 'Apply verified invoice rule'}) assert reset.status_code == 200 assert reset.json()['decision']['method'] == 'invoice' assert reset.json()['decision']['manual_override'] is False simulation = client.post(f'/workflows/{worker_rule}/simulate', json={'entity_type': 'time_entry', 'entity_id': entry['time_id']}) assert simulation.status_code == 200 assert simulation.json()['result']['method'] == 'invoice' assert 'keyword_matches' in simulation.json()['context'] def test_unknown_invoice_product_rolls_back_without_draft(db, entry): from fastapi import HTTPException with db.cursor() as c: c.execute('SELECT id FROM customers ORDER BY id LIMIT 1') customer = c.fetchone()[0] c.execute('UPDATE tmodule_customers SET hub_customer_id=%s WHERE id=%s', (customer, entry['tracking_id'])) c.execute("UPDATE bmc_billing_decisions SET method='invoice',details=%s WHERE time_id=%s", (Json({'billing': {'product_number': 'missing-'+str(uuid.uuid4())}}), entry['time_id'])) c.execute('SELECT count(*) FROM ordre_drafts') before = c.fetchone()[0] with pytest.raises(HTTPException) as exc: settlement.settle_workflow_entries([entry['time_id']], entry['actor']) assert exc.value.status_code == 409 with db.cursor() as c: c.execute('SELECT count(*) FROM ordre_drafts') assert c.fetchone()[0] == before c.execute('SELECT status,economy_order_draft_id FROM tmodule_times WHERE id=%s', (entry['time_id'],)) assert c.fetchone() == ('pending', None)