bmc_hub/tests/test_workflow_db.py

343 lines
21 KiB
Python
Raw Normal View History

"""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)
def _reviewed_queue_request(db, entry, monkeypatch, method='internal'):
from app.core.config import settings
from app.economy.backend import router as economy
monkeypatch.setattr(settings, 'WORKFLOWS_ENABLED', False)
async def actor(_request):
return {'id': entry['actor'], 'username': 'reviewed-db-test', 'is_superadmin': True}
monkeypatch.setattr(economy, '_queue_actor', actor)
with db.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute('UPDATE tmodule_times SET billing_method=%s,prepaid_card_id=%s,billable=%s WHERE id=%s',
(method, entry['card_id'] if method == 'prepaid' else None,
method == 'prepaid', entry['time_id']))
cursor.execute('SELECT id,updated_at,billing_method,prepaid_card_id,fixed_price_agreement_id,workflow_revision FROM tmodule_times WHERE id=%s', (entry['time_id'],))
snapshot = dict(cursor.fetchone())
snapshot['updated_at'] = snapshot['updated_at'].isoformat()
snapshot['entry_fingerprint'] = economy._queue_review_fingerprint(
economy._selected_time_entries([entry['time_id']])[0])
return economy, economy.ReviewedTimeRequest(ids=[entry['time_id']], snapshots=[snapshot])
def test_reviewed_queue_approval_has_no_financial_effects(db, entry, monkeypatch):
import asyncio
from starlette.requests import Request
economy, payload = _reviewed_queue_request(db, entry, monkeypatch)
with db.cursor() as cursor:
cursor.execute('SELECT count(*) FROM ordre_drafts')
drafts_before = cursor.fetchone()[0]
result = asyncio.run(economy.approve_reviewed_time_queue(payload, Request({'type': 'http', 'headers': []})))
assert result['approved'] == 1
with db.cursor() as cursor:
cursor.execute('SELECT status,approved_by,economy_settled_at,economy_order_draft_id FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert cursor.fetchone() == ('approved', entry['actor'], None, None)
cursor.execute('SELECT used_hours FROM tticket_prepaid_cards WHERE id=%s', (entry['card_id'],))
assert float(cursor.fetchone()[0]) == 0
cursor.execute('SELECT count(*) FROM ordre_drafts')
assert cursor.fetchone()[0] == drafts_before
cursor.execute('SELECT count(*) FROM bmc_workflow_settlements WHERE time_id=%s', (entry['time_id'],))
assert cursor.fetchone()[0] == 0
def test_reviewed_queue_stale_snapshot_does_not_approve(db, entry, monkeypatch):
import asyncio
from fastapi import HTTPException
from starlette.requests import Request
economy, payload = _reviewed_queue_request(db, entry, monkeypatch)
with db.cursor() as cursor:
cursor.execute("UPDATE tmodule_times SET description='Changed after review',updated_at=updated_at+interval '1 second' WHERE id=%s", (entry['time_id'],))
with pytest.raises(HTTPException) as error:
asyncio.run(economy.approve_reviewed_time_queue(payload, Request({'type': 'http', 'headers': []})))
assert error.value.status_code == 409
with db.cursor() as cursor:
cursor.execute('SELECT status,approved_at,economy_settled_at FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert cursor.fetchone() == ('pending', None, None)
def test_reviewed_queue_send_rolls_back_card_debit_on_failure(db, entry, monkeypatch):
import asyncio
from starlette.requests import Request
economy, payload = _reviewed_queue_request(db, entry, monkeypatch, method='prepaid')
def fail_after_debit(*_args, **_kwargs):
raise RuntimeError('Injected reviewed queue failure after card debit')
monkeypatch.setattr(economy, 'execute_insert', fail_after_debit)
request = Request({'type': 'http', 'headers': []})
request.state.user_id = entry['actor']
with pytest.raises(RuntimeError, match='after card debit'):
asyncio.run(economy.send_reviewed_time_queue(payload, request))
with db.cursor() as cursor:
cursor.execute('SELECT used_hours FROM tticket_prepaid_cards WHERE id=%s', (entry['card_id'],))
assert float(cursor.fetchone()[0]) == 0
cursor.execute('SELECT status,economy_settled_at,economy_order_draft_id FROM tmodule_times WHERE id=%s', (entry['time_id'],))
assert cursor.fetchone() == ('pending', None, None)
cursor.execute('SELECT count(*) FROM tticket_prepaid_transactions WHERE card_id=%s', (entry['card_id'],))
assert cursor.fetchone()[0] == 0