"""Customer merge contract and PostgreSQL integration tests on an isolated schema.""" import os import uuid from pathlib import Path from contextlib import contextmanager import pytest import psycopg2 from psycopg2 import sql from psycopg2.extras import RealDictCursor from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from app.customers.backend import merge as m def test_selection_and_identity_conflicts(): assert m.selections(1,[3,2,3])==[2,3] with pytest.raises(HTTPException):m.selections(1,[1,2]) with pytest.raises(HTTPException):m.selections(1,[-2]) with pytest.raises(HTTPException): m.field_plan([{'economic_customer_number':1},{'economic_customer_number':2}],{}) changes,differences=m.field_plan([{'email':'a'},{'email':'b'}],{'email':'a'}) assert changes=={} and differences==['email'] def test_nested_workflow_customer_references(): assert m.mentions_customer({'branches':[{'condition':{'all':[{'field':'customer_id','value':[2,4]}]}}]},[2]) assert not m.mentions_customer({'customer_number':2},[2]) def test_routes_require_edit_permission_and_confirmation_token(monkeypatch): app=FastAPI();app.include_router(m.router) client=TestClient(app) assert client.post('/customers/merge/preview',json={'target_id':1,'source_ids':[2]}).status_code==401 # Same dependency instance registered on each route is overridden explicitly. for route in m.router.routes: app.dependency_overrides[route.dependant.dependencies[0].call]=lambda:{'id':5} assert client.post('/customers/merge',json={'target_id':1,'source_ids':[2]}).status_code==422 def test_customer_reader_cannot_preview_or_merge(monkeypatch): from app.core.auth_dependencies import get_current_user, AuthService app=FastAPI();app.include_router(m.router) app.dependency_overrides[get_current_user]=lambda:{'id':5,'username':'reader','is_superadmin':False} monkeypatch.setattr(AuthService,'user_has_permission',lambda *args:False) client=TestClient(app) for endpoint in ('/customers/merge/preview','/customers/merge'): assert client.post(endpoint,json={'target_id':1,'source_ids':[2],'preview_token':'a'*64}).status_code==403 @pytest.fixture def db(monkeypatch): if os.getenv('BMC_CUSTOMER_MERGE_DB_TESTS')!='1':pytest.skip('Opt-in isolated PostgreSQL tests') conn=psycopg2.connect(m.settings.DATABASE_URL) schema='test_customer_merge_'+uuid.uuid4().hex with conn.cursor() as c: c.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(schema))) c.execute(sql.SQL('SET search_path TO {}').format(sql.Identifier(schema))) c.execute('''CREATE TABLE customers(id INTEGER PRIMARY KEY,name TEXT,email TEXT,phone TEXT,address TEXT, economic_customer_number INTEGER,vtiger_id TEXT UNIQUE,cvr_number TEXT,is_active BOOLEAN DEFAULT TRUE, parent_customer_id INTEGER REFERENCES customers(id),updated_at TIMESTAMP DEFAULT now()); CREATE TABLE sag_sager(id INTEGER PRIMARY KEY,customer_id INTEGER); CREATE TABLE invoices(id INTEGER PRIMARY KEY,customer_id INTEGER REFERENCES customers(id),amount NUMERIC); CREATE TABLE contact_companies(id SERIAL PRIMARY KEY,contact_id INTEGER,customer_id INTEGER REFERENCES customers(id), is_primary BOOLEAN,role VARCHAR(100),notes TEXT,UNIQUE(contact_id,customer_id)); CREATE TABLE entity_tags(id SERIAL PRIMARY KEY,entity_type TEXT,entity_id INTEGER,tag_id INTEGER, UNIQUE(entity_type,entity_id,tag_id)); CREATE TABLE tmodule_customers(id INTEGER PRIMARY KEY,hub_customer_id INTEGER); CREATE TABLE tmodule_times(id INTEGER PRIMARY KEY,customer_id INTEGER REFERENCES tmodule_customers(id)); CREATE TABLE singleton(id INTEGER PRIMARY KEY,customer_id INTEGER UNIQUE REFERENCES customers(id)); INSERT INTO customers(id,name,email,economic_customer_number) VALUES(1,'Main','main@example.test',123),(2,'Other','other@example.test',123),(3,'Third',NULL,NULL); INSERT INTO sag_sager VALUES(1,2),(2,3); INSERT INTO invoices VALUES(1,2,100),(2,3,200); INSERT INTO contact_companies(contact_id,customer_id,is_primary,role,notes) VALUES (10,1,TRUE,'owner','main note'),(10,2,FALSE,'billing','source note'),(11,3,FALSE,NULL,NULL); INSERT INTO entity_tags(entity_type,entity_id,tag_id) VALUES('customer',1,10),('customer',2,10),('contact',2,11); INSERT INTO tmodule_customers VALUES(1,2),(2,3); INSERT INTO tmodule_times VALUES(1,2);''') c.execute(Path('migrations/1074_customer_merge.sql').read_text()) conn.commit() monkeypatch.setattr(m,'get_db_connection',lambda:conn) monkeypatch.setattr(m,'release_db_connection',lambda c:None) monkeypatch.setattr(m.settings,'WORKFLOWS_CUSTOMER_IDS',[]) try:yield conn finally: conn.rollback() with conn.cursor() as c:c.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(schema))) conn.commit();conn.close() def rows(conn,query): with conn.cursor(cursor_factory=RealDictCursor) as c: c.execute(query);return [dict(r) for r in c.fetchall()] def test_multi_customer_preview_is_rolled_back_and_merge_preserves_relations(db): selection=m.MergeSelection(target_id=1,source_ids=[2,3]) preview=m.run(selection,5) assert rows(db,'SELECT customer_id FROM invoices ORDER BY id')==[{'customer_id':2},{'customer_id':3}] assert rows(db,'SELECT count(*) n FROM contact_companies')[0]['n']==3 assert rows(db,'SELECT count(*) n FROM customer_merge_history')[0]['n']==0 merged=m.run(selection,5,preview['preview_token']) assert merged['history_id'] assert rows(db,'SELECT customer_id,amount FROM invoices ORDER BY id')==[{'customer_id':1,'amount':100},{'customer_id':1,'amount':200}] assert rows(db,'SELECT customer_id FROM sag_sager ORDER BY id')==[{'customer_id':1},{'customer_id':1}] assert rows(db,'SELECT customer_id FROM tmodule_times')==[{'customer_id':2}] assert rows(db,'SELECT DISTINCT hub_customer_id FROM tmodule_customers')==[{'hub_customer_id':1}] contacts=rows(db,'SELECT * FROM contact_companies ORDER BY contact_id') assert len(contacts)==2 and contacts[0]['notes']=='main note\nsource note' assert rows(db,"SELECT entity_id FROM entity_tags WHERE entity_type='contact'")==[{'entity_id':2}] source=rows(db,'SELECT is_active,merged_into_customer_id,economic_customer_number FROM customers WHERE id=2')[0] assert source=={'is_active':False,'merged_into_customer_id':1,'economic_customer_number':None} audit=rows(db,'SELECT snapshot FROM customer_merge_history')[0]['snapshot'] assert len(audit['customers'])==3 and audit['relation_origins'] and audit['archived_memberships'] with pytest.raises(HTTPException):m.run(selection,5,preview['preview_token']) def test_stale_preview_refuses_all_writes(db): selection=m.MergeSelection(target_id=1,source_ids=[2]) preview=m.run(selection,5) with db.cursor() as c:c.execute("UPDATE invoices SET amount=999 WHERE id=1") db.commit() with pytest.raises(HTTPException,match='409'):m.run(selection,5,preview['preview_token']) assert rows(db,'SELECT customer_id FROM invoices WHERE id=1')[0]['customer_id']==2 def test_unique_conflict_rolls_back_everything(db): with db.cursor() as c:c.execute('INSERT INTO singleton VALUES(1,1),(2,2)') db.commit() with pytest.raises(HTTPException,match='409'):m.run(m.MergeSelection(target_id=1,source_ids=[2]),5) assert rows(db,'SELECT customer_id FROM invoices WHERE id=1')[0]['customer_id']==2 assert rows(db,'SELECT count(*) n FROM contact_companies')[0]['n']==3 def test_new_unlisted_foreign_key_is_automatically_moved(db): with db.cursor() as c: c.execute('CREATE TABLE new_relation(id INTEGER PRIMARY KEY,owner INTEGER REFERENCES customers(id)); INSERT INTO new_relation VALUES(1,2)') db.commit() selection=m.MergeSelection(target_id=1,source_ids=[2]) preview=m.run(selection,5) assert any(r['table']=='new_relation' for r in preview['relations']) m.run(selection,5,preview['preview_token']) assert rows(db,'SELECT owner FROM new_relation')==[{'owner':1}] def test_audit_failure_rolls_back_customer_relations_and_membership_changes(db): selection=m.MergeSelection(target_id=1,source_ids=[2,3]) preview=m.run(selection,5) with db.cursor() as c: c.execute('''CREATE FUNCTION reject_merge_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'test audit rejection' USING ERRCODE='23514'; END $$; CREATE TRIGGER reject_merge_audit BEFORE INSERT ON customer_merge_history FOR EACH ROW EXECUTE FUNCTION reject_merge_audit();''') db.commit() with pytest.raises(HTTPException):m.run(selection,5,preview['preview_token']) assert rows(db,'SELECT customer_id FROM invoices ORDER BY id')==[{'customer_id':2},{'customer_id':3}] assert rows(db,'SELECT count(*) n FROM contact_companies')[0]['n']==3 assert rows(db,'SELECT is_active FROM customers WHERE id=2')[0]['is_active'] is True assert rows(db,'SELECT count(*) n FROM customer_merge_history')[0]['n']==0 def test_external_identity_transfer_and_hierarchy_do_not_create_self_links(db): with db.cursor() as c: c.execute('''CREATE TABLE customer_relationships(id INTEGER PRIMARY KEY, parent_customer_id INTEGER REFERENCES customers(id),child_customer_id INTEGER REFERENCES customers(id)); INSERT INTO customer_relationships VALUES(1,1,2); UPDATE customers SET vtiger_id='test-vtiger-source' WHERE id=2; UPDATE customers SET parent_customer_id=2 WHERE id=1;''') db.commit() selection=m.MergeSelection(target_id=1,source_ids=[2]) preview=m.run(selection,5) assert rows(db,'SELECT vtiger_id FROM customers WHERE id=2')[0]['vtiger_id']=='test-vtiger-source' m.run(selection,5,preview['preview_token']) assert rows(db,'SELECT vtiger_id,parent_customer_id FROM customers WHERE id=1')[0]=={'vtiger_id':'test-vtiger-source','parent_customer_id':None} assert rows(db,'SELECT vtiger_id FROM customers WHERE id=2')[0]['vtiger_id'] is None assert rows(db,'SELECT count(*) n FROM customer_relationships')[0]['n']==0 def test_conflicting_exported_invoice_customer_blocks_merge(db): with db.cursor() as c: c.execute('''CREATE TABLE ordre_drafts(id INTEGER PRIMARY KEY,customer_id INTEGER, exported_economic_customer_number TEXT); INSERT INTO ordre_drafts VALUES(1,2,'999');''') db.commit() with pytest.raises(HTTPException,match='409'):m.run(m.MergeSelection(target_id=1,source_ids=[2]),5) assert rows(db,'SELECT customer_id FROM invoices WHERE id=1')[0]['customer_id']==2