| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- """Offline restoration regressions; extract functions, never import scripts or run main.
- The historical filename remains so the session's blanket-disable tests are replaced
- in place. Explicit migration clear modes retain their original dry-run behavior.
- """
- import argparse
- import ast
- from collections import defaultdict
- import os
- from pathlib import Path
- import sys
- from types import SimpleNamespace
- import pytest
- SCRIPTS = Path(__file__).resolve().parents[1] / 'scripts'
- CASES = [
- ('migrate_crm_product.py', 'run_migration', 'clear_target', 'clear_target_products'),
- ('migrate_crm_product_category.py', 'run_migration', 'clear_target', 'clear_target_categories'),
- ('migrate_dibang_data.py', 'migrate', 'truncate_target', 'truncate_target_tables'),
- ('migrate_bx_data.py', 'migrate_table', 'replace_workflow', 'clear_portal_workflow'),
- ]
- def tree(filename):
- return ast.parse((SCRIPTS / filename).read_text())
- def function_node(filename, name):
- return next(n for n in tree(filename).body if isinstance(n, ast.FunctionDef) and n.name == name)
- def extract(filename, name, namespace):
- module = ast.Module(body=[ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0), function_node(filename, name)], type_ignores=[])
- exec(compile(ast.fix_missing_locations(module), filename, 'exec'), namespace)
- return namespace[name]
- class FakeEngine:
- """Records transaction/SQL intent without creating any database engine."""
- def __init__(self):
- self.statements = []
- self.transactions = 0
- def begin(self):
- self.transactions += 1
- return self
- def __enter__(self):
- return self
- def __exit__(self, *args):
- return False
- def execute(self, sql, params=None):
- self.statements.append((sql, params))
- return SimpleNamespace(rowcount=2)
- class Stats:
- def __init__(self):
- self.source_counts = {}
- self.inserted = {}
- self.steps = defaultdict(lambda: SimpleNamespace(source_count=0, inserted=0, skipped=0))
- def step(self, table):
- return self.steps[table]
- def namespace_for(filename, helper):
- keys = ('purchase_plans', 'inbound_outbound', 'weighing_slips')
- namespace = dict(
- text=lambda sql: sql, TARGET_TABLE='crm_test',
- TARGET_TABLES={key: 'db_' + key for key in keys},
- SOURCE_TABLES={key: key for key in keys},
- TABLE_OPTIONS={}, MigrationStats=Stats,
- load_user_mapping=lambda _: ({}, 'admin'),
- load_bx_user_mapping=lambda _: ({}, 'admin'),
- fetch_source_rows=lambda *a, **kw: [], fetch_rows=lambda *a: [],
- topo_sort_categories=lambda rows: rows,
- build_id_maps=lambda rows: {key: {} for key in rows},
- print_report=lambda *a, **kw: None,
- )
- extract(filename, helper, namespace)
- return namespace
- @pytest.mark.parametrize('filename,name,flag,helper', CASES)
- @pytest.mark.parametrize('dry_run', [False, True])
- @pytest.mark.parametrize('clear', [False, True])
- def test_original_clear_mode_and_dry_run(filename, name, flag, helper, dry_run, clear):
- import inspect
- namespace = namespace_for(filename, helper)
- fn = extract(filename, name, namespace)
- kwargs = {key: None for key in inspect.signature(fn).parameters}
- target = FakeEngine()
- kwargs.update(source_engine=FakeEngine(), target_engine=target, tenant_no='tenant', dry_run=dry_run)
- kwargs[flag] = clear
- if name == 'migrate_table':
- kwargs.update(table='bx_workflow_def', stats=Stats())
- fn(**kwargs)
- assert target.transactions == (0 if dry_run else 1)
- expected = 0 if dry_run or not clear else (2 if filename == 'migrate_bx_data.py' else 3 if filename == 'migrate_dibang_data.py' else 1)
- assert len(target.statements) == expected
- @pytest.mark.parametrize('filename,name,flag,helper', CASES)
- def test_original_clear_helpers_execute_expected_sql_offline(filename, name, flag, helper):
- namespace = namespace_for(filename, helper)
- target = FakeEngine()
- fn = namespace[helper]
- if filename == 'migrate_bx_data.py':
- fn(target)
- assert target.statements == [('DELETE FROM bx_workflow_node', None), ('DELETE FROM bx_workflow_def', None)]
- else:
- result = fn(target, 'tenant')
- assert all('DELETE FROM' in sql for sql, _ in target.statements)
- key = 'tenant_no' if filename == 'migrate_dibang_data.py' else 't'
- assert all(params == {key: 'tenant'} for _, params in target.statements)
- if key == 't':
- assert result == 2
- assert "tenant_no='' OR tenant_no IS NULL" in target.statements[0][0]
- def cli_parser(filename):
- """Execute only argparse setup, stopping immediately after parse_args."""
- node = function_node(filename, 'main')
- statements = []
- for statement in node.body:
- statements.append(statement)
- if isinstance(statement, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'args' for t in statement.targets):
- break
- else:
- raise AssertionError('parse_args not found')
- node.name = 'parse_only'
- node.body = statements + [ast.Return(value=ast.Name(id='args', ctx=ast.Load()))]
- namespace = dict(argparse=argparse, os=os, sys=sys, Path=Path, DEFAULT_TENANT_NO='t', BACKEND_ROOT=SCRIPTS.parent)
- exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), filename, 'exec'), namespace)
- return namespace['parse_only']
- @pytest.mark.parametrize('filename,flag,attribute', [
- ('migrate_crm_product.py', '--clear-target', 'clear_target'),
- ('migrate_crm_product_category.py', '--clear-target', 'clear_target'),
- ('migrate_dibang_data.py', '--truncate-target', 'truncate_target'),
- ])
- def test_cli_preserves_explicit_clear_option(filename, flag, attribute, monkeypatch):
- monkeypatch.setattr(sys, 'argv', ['offline-test', flag])
- assert getattr(cli_parser(filename)(), attribute) is True
- main = function_node(filename, 'main')
- assert not any(isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == 'error' for n in ast.walk(main))
- def test_dibang_cli_keeps_dry_run_clear_conflict_guard():
- main = function_node('migrate_dibang_data.py', 'main')
- guards = [n for n in main.body if isinstance(n, ast.If) and 'args.truncate_target' in ast.unparse(n.test) and 'args.dry_run' in ast.unparse(n.test)]
- assert len(guards) == 1
- assert any(isinstance(n, ast.Return) and isinstance(n.value, ast.Constant) and n.value.value == 2 for n in ast.walk(guards[0]))
- @pytest.mark.parametrize('legacy_flag', [False, True])
- def test_bx_cli_preserves_original_replace_default_and_opt_out(legacy_flag, monkeypatch):
- monkeypatch.setattr(sys, 'argv', ['offline-test'] + (['--no-replace-workflow'] if legacy_flag else []))
- args = cli_parser('migrate_bx_data.py')()
- main = function_node('migrate_bx_data.py', 'main')
- assignment = next(n for n in main.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'replace_workflow' for t in n.targets))
- assert eval(compile(ast.Expression(assignment.value), '<offline-default>', 'eval'), {'args': args}) is (not legacy_flag)
- @pytest.mark.parametrize('dry_run', [False, True])
- @pytest.mark.parametrize('replace', [False, True])
- def test_bx_run_migration_forwards_replace_and_dry_run(dry_run, replace):
- calls = []
- namespace = dict(MigrationStats=Stats, load_bx_user_mapping=lambda _: ({}, 'admin'),
- resolve_steps=lambda *a: ['bx_workflow_def'],
- migrate_table=lambda *a, **kw: calls.append(kw) or [],
- print_report=lambda *a: None)
- fn = extract('migrate_bx_data.py', 'run_migration', namespace)
- fn(FakeEngine(), FakeEngine(), tenant_no='tenant', user_mapping_path=None,
- mapping_dir=None, dry_run=dry_run, phase='base', only_tables=None,
- replace_workflow=replace)
- assert len(calls) == 1
- assert calls[0]['replace_workflow'] is replace
- assert calls[0]['dry_run'] is dry_run
|