Files
crmApi/apps/customer/test_mobile_audit.py
T
2026-10-08 18:09:12 +03:30

206 lines
11 KiB
Python

"""Legacy deployment diagnostics must expose conflicts without changing data."""
import json
from importlib import import_module
from io import StringIO
from unittest.mock import MagicMock, Mock, patch
from django.apps import apps
from django.contrib import admin
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import connection, transaction
from django.db.migrations.executor import MigrationExecutor
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer, CustomerNote, MobileConflict
from apps.jwt_auth.test_regressions import RegressionFixtures
from apps.workspace.models import Workspace
from apps.shop.models import Order
def audit(*args):
output = StringIO()
call_command('audit_mobile_duplicates', *args, stdout=output)
return json.loads(output.getvalue())
def repair():
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
class MobileAuditTests(RegressionFixtures):
def test_country_prefix_conflict_reports_all_members_and_dependencies_read_only(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=other, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
before = list(Customer.objects.order_by('pk').values())
with CaptureQueriesContext(connection) as queries:
report = audit()
self.assertEqual(report['customer_duplicate_groups'], [{
'workspace_id': self.workspace.pk, 'canonical_mobile': self.customer.mobile,
'record_ids': [self.customer.pk, other.pk],
}])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertEqual(report['customers'][1]['related_counts']['customer.CustomerNote.customer'], 1)
self.assertEqual(report['customers'][0]['panel_role']['user_id'], self.user.pk)
self.assertEqual(before, list(Customer.objects.order_by('pk').values()))
self.assertTrue(CustomerNote.objects.filter(pk=note.pk).exists())
writes = [query['sql'] for query in queries if query['sql'].lstrip().split()[0].upper()
in {'INSERT', 'UPDATE', 'DELETE', 'ALTER', 'DROP', 'CREATE'}]
self.assertEqual(writes, [])
def test_cross_workspace_numbers_and_multiple_nulls_are_allowed(self):
Customer.objects.create(name_family='Other tenant', mobile=989123456789, sys_ws=self.other_workspace)
Customer.objects.create(name_family='No number 1', mobile=None, sys_ws=self.workspace)
Customer.objects.create(name_family='No number 2', mobile=None, sys_ws=self.workspace)
self.assertEqual(audit()['duplicate_group_count'], 0)
def test_user_country_prefix_conflict_is_global_and_excludes_secrets(self):
user = CUser.objects.create(username='legacy', name_family='Legacy', mobile=989123456789)
report = audit()
self.assertEqual(report['user_duplicate_groups'][0]['record_ids'], [self.user.pk, user.pk])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertNotIn('password', report['users'][0])
user.refresh_from_db()
self.assertEqual(user.mobile, 989123456789)
def test_requested_customer_details_and_missing_ids_without_duplicates(self):
report = audit('--customer-ids', str(self.customer.pk), '999999')
self.assertEqual(report['customers'][0]['id'], self.customer.pk)
self.assertEqual(report['missing_requested_customer_ids'], [999999])
self.assertEqual(report['duplicate_group_count'], 0)
def test_invalid_database_has_clear_error(self):
with self.assertRaisesMessage(CommandError, 'Unknown database alias'):
audit('--database', 'missing')
def test_automatic_repair_preserves_notes_orders_audit_and_blocks_old_panel_session(self):
tokens = self.use_role()
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=self.customer, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
order = Order.objects.create(customer=other, total_price=100, factor_type=1, sys_ws=self.workspace)
repair()
self.assertTrue(CustomerNote.objects.filter(pk=note.pk, customer=self.customer).exists())
self.assertTrue(Order.objects.filter(pk=order.pk, customer=other).exists())
entries = MobileConflict.objects.filter(source_model='customer.Customer')
self.assertEqual(entries.count(), 2)
archived = entries.get(record_id=self.customer.pk)
self.assertEqual(archived.snapshot['cuser_role_id'], self.role.pk)
self.assertTrue(archived.snapshot['roles'][0]['customer_panel'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
repair()
self.assertEqual(entries.count(), 2)
self.assertEqual(entries.get(record_id=self.customer.pk).snapshot, archived.snapshot)
def test_account_collision_disables_all_ambiguous_accounts_without_changing_their_numbers(self):
tokens = self.use_role()
other = CUser.objects.create(username='legacy', mobile=989123456789, is_active=True)
repair()
self.user.refresh_from_db()
other.refresh_from_db()
self.role.refresh_from_db()
self.operator.refresh_from_db()
self.assertFalse(self.user.is_active)
self.assertFalse(other.is_active)
self.assertFalse(self.role.is_active)
self.assertFalse(self.operator.is_active)
self.assertEqual(self.user.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.filter(source_model='cuser.CUser').count(), 2)
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
before = list(MobileConflict.objects.order_by('pk').values())
repair()
self.assertEqual(before, list(MobileConflict.objects.order_by('pk').values()))
def test_failed_archival_rolls_back_entire_repair(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group', mobile=9333333333, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group legacy', mobile=989333333333, sys_ws=self.workspace)
module = import_module('apps.customer.mobile_migration_repair')
archive = module._archive
calls = 0
def failing_archive(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 3:
raise RuntimeError('Simulated audit storage failure')
return archive(*args, **kwargs)
with self.assertRaisesMessage(RuntimeError, 'Simulated audit storage failure'):
with transaction.atomic(), patch.object(module, '_archive', side_effect=failing_archive):
repair()
self.customer.refresh_from_db()
other.refresh_from_db()
self.assertEqual(self.customer.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.count(), 0)
def test_admin_audit_history_cannot_be_added_changed_or_deleted(self):
model_admin = admin.site._registry[MobileConflict]
request = Mock(user=Mock(is_superuser=True, is_active=True))
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request))
self.assertFalse(model_admin.has_delete_permission(request))
def test_postgres_migration_locks_legacy_writers_and_drains_deferred_checks(self):
cursor = MagicMock()
postgres = Mock(alias='default', vendor='postgresql', ops=connection.ops)
postgres.cursor.return_value.__enter__ = Mock(return_value=cursor)
postgres.cursor.return_value.__exit__ = Mock(return_value=False)
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=postgres))
statements = [call.args[0] for call in cursor.execute.call_args_list]
self.assertTrue(statements[0].startswith('LOCK TABLE '))
self.assertIn('SHARE ROW EXCLUSIVE MODE', statements[0])
self.assertEqual(statements[-1], 'SET CONSTRAINTS ALL IMMEDIATE')
class LegacyMobileAuditTests(TransactionTestCase):
def test_exact_duplicates_before_migration_are_automatically_quarantined(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0007_alter_customer_name_family')])
try:
# Real legacy databases do not have the new audit table yet.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
workspace = Workspace.objects.create(name='Legacy workspace', username='legacy-workspace')
first = Customer.objects.create(name_family='First', mobile=9123456789, sys_ws=workspace)
second = Customer.objects.create(name_family='Second', mobile=9123456789, sys_ws=workspace)
report = audit('--customer-ids', str(first.pk), str(second.pk))
self.assertEqual(report['customer_duplicate_groups'][0]['record_ids'], [first.pk, second.pk])
self.assertEqual(Customer.objects.filter(mobile=9123456789).count(), 2)
MigrationExecutor(connection).migrate(targets)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk]).count(), 2)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk], mobile=None).count(), 2)
self.assertEqual(MobileConflict.objects.filter(source_model='customer.Customer').count(), 2)
self.assertEqual(MobileConflict.objects.get(record_id=first.pk).original_mobile, 9123456789)
self.assertEqual(audit()['duplicate_group_count'], 0)
finally:
# Test data only: restore the normal schema for subsequent tests.
Customer.objects.all().delete()
MigrationExecutor(connection).migrate(targets)
def test_already_applied_0008_installation_creates_missing_audit_table(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0008_customer_mobile_unique')])
try:
# Simulate an installation that applied the original 0008 before
# this automatic repair feature existed.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
self.assertNotIn(MobileConflict._meta.db_table, connection.introspection.table_names())
MigrationExecutor(connection).migrate(targets)
self.assertIn(MobileConflict._meta.db_table, connection.introspection.table_names())
self.assertEqual(MobileConflict.objects.count(), 0)
finally:
MigrationExecutor(connection).migrate(targets)