269 lines
16 KiB
Python
269 lines
16 KiB
Python
"""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)
|