188 lines
11 KiB
Python
188 lines
11 KiB
Python
|
|
"""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
|