netx/tests/test_biz_migration_async.py
oliver 315539e0fd Optimize cutover compare/monitor per the 2026-10-12 plan.
Correctness (A): normalize port-map names with the template rules (snapshot v2,
v1 replays unchanged); protect mappings/templates that are referenced; stop
dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated;
validate baseline ownership on project create; one canonical key form; fix
built-in template tolerances and upgrade old packaged values in place.

Cross-vendor identity (B): single identity pipeline for every path, per-side and
per-vendor interface rules, value maps, filters after normalization, mapping
pre-flight for compare and cutover projects, SQL eligibility fixes.

Data quality (V): no_data / suspect_parse / not_collected sheet states, command
slice coverage, directional compare modes, value-sorted duplicate pairing,
before-side raw names in results and CSV.

Robustness (H): bounded auto-run history, background evaluate/finish with
polling, manual route tables out of the acceptance gate, baseline row cache,
scoped duplicate-key failures, merged HF tasks with a per-NE session cap,
same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red
tickets.

Frontend: pre-flight report, mapping table view, template editor support for the
new rules, data-state banners. Docs: canonical field contract.

Not included: Huawei status parsers (no real samples), SQL-path route compare.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:51:55 +08:00

284 lines
15 KiB
Python

"""H2/H3/H5: background evaluation, manual route tables, duplicate-key scope."""
from __future__ import annotations
import unittest
from unittest import mock
from fastapi import HTTPException
from sqlalchemy.orm import sessionmaker
from netx_api.biz_migration import service as mig
from netx_api.models import BizMigrationBatch, BizMigrationRun, BizStateBatchCommand, BizStateMetricRow
import tests.test_biz_migration_evidence as evidence
class _Inline:
"""threading.Thread stand-in: remember the target, run it on demand."""
started: list = []
def __init__(self, target=None, **_):
self.target = target
def start(self):
_Inline.started.append(self.target)
class _Fixture(unittest.TestCase):
def setUp(self) -> None:
self.ctx = evidence.EvidencePersistTests("test_evaluate_persists_display_ab_and_evidence_with_command")
self.ctx.setUp()
self.db = self.ctx.db
self.factory = sessionmaker(bind=self.db.get_bind(), expire_on_commit=False)
self.session_patch = mock.patch("netx_api.db.SessionLocal", self.factory)
self.session_patch.start()
_Inline.started = []
proj = self.ctx._create_project()
c = self.ctx
for bid, tid in (("bl_old", c.old_p.id), ("bl_new", c.new_p.id), ("cur_old", proj["old_hf_task_id"]),
("cur_new", proj["new_hf_task_id"])):
c._mk_batch(bid, tid)
c._seed_if_row(batch_id="bl_old", task_id=c.old_p.id, ne_id="ne-old", command_id="c1",
raw_command="show interface brief", interface="GE-old")
c._seed_if_row(batch_id="cur_new", task_id=proj["new_hf_task_id"], ne_id="ne-new", command_id="c2",
raw_command="show interface brief", interface="xgei-new")
mig.patch_project(self.db, proj["id"], {"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"})
self.batch = mig.create_batch(self.db, proj["id"], {"batch_label": "w", "expect_set": {"ports": ["gei-old"]}})
mig.patch_batch(self.db, self.batch["id"], {"status": "active"})
self.project_id = proj["id"]
def tearDown(self) -> None:
self.session_patch.stop()
self.ctx.tearDown()
def run_background(self):
for target in list(_Inline.started):
target()
_Inline.started.clear()
class AsyncEvaluateTests(_Fixture):
def test_evaluate_returns_a_running_run_immediately_then_completes(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_evaluate(self.db, self.batch["id"])
self.assertEqual(started["status"], "running")
self.assertEqual(self.db.get(BizMigrationRun, started["id"]).status, "running")
self.assertEqual(len(_Inline.started), 1)
self.run_background()
self.db.expire_all()
run = self.db.get(BizMigrationRun, started["id"])
self.assertEqual(run.status, "success")
self.assertEqual(run.purpose, "manual")
self.assertIn("sheet_cards", run.summary_json)
self.assertEqual(self.db.query(BizMigrationRun).count(), 1) # the claim row was filled, not duplicated
def test_second_request_while_one_runs_is_409(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"])
with self.assertRaises(HTTPException) as exc:
mig.enqueue_evaluate(self.db, self.batch["id"])
with self.assertRaises(HTTPException) as exc_finish:
mig.enqueue_finish(self.db, self.batch["id"])
self.assertEqual(exc.exception.status_code, 409)
self.assertEqual(exc.exception.detail, "evaluation_in_progress")
self.assertEqual(exc_finish.exception.status_code, 409)
def test_failure_is_recorded_on_the_run_and_releases_the_project(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_evaluate(self.db, self.batch["id"])
with mock.patch.object(mig, "run_evaluate", side_effect=HTTPException(status_code=400, detail="boom")):
self.run_background()
self.db.expire_all()
run = self.db.get(BizMigrationRun, started["id"])
self.assertEqual((run.status, run.message), ("failed", "boom"))
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"]) # no longer blocked
def test_restart_cancels_orphaned_runs(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_evaluate(self.db, self.batch["id"])
self.assertEqual(mig.recover_interrupted_evaluations_on_startup(self.db), 1)
self.db.expire_all()
run = self.db.get(BizMigrationRun, started["id"])
self.assertEqual((run.status, run.message), ("cancelled", "interrupted_by_restart"))
# a cancelled claim must not be resurrected by a late worker
with self.assertRaises(HTTPException) as exc:
mig.run_evaluate(self.db, batch_id=self.batch["id"], _run_id=started["id"])
self.assertEqual(exc.exception.status_code, 409)
def test_background_evaluation_locks_the_project_only_for_the_final_write(self):
from sqlalchemy.orm import Query
calls: list[str] = []
original = Query.with_for_update
def spy(self_, *a, **k):
calls.append("lock")
return original(self_, *a, **k)
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"]) # claim = one short lock
with mock.patch.object(Query, "with_for_update", spy):
self.run_background()
self.assertEqual(len(calls), 1, "computation must be unlocked; only the write takes the project lock")
calls.clear()
with mock.patch.object(Query, "with_for_update", spy):
self.db.query(BizMigrationRun).delete()
self.db.commit()
mig.run_evaluate(self.db, batch_id=self.batch["id"]) # synchronous callers keep the old behaviour
self.assertGreaterEqual(len(calls), 1)
def test_finish_runs_in_the_background_and_closes_the_wave_only_on_success(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_finish(self.db, self.batch["id"])
self.assertEqual((started["status"], started["run"]["purpose"]), ("running", "acceptance"))
self.assertEqual(self.db.get(BizMigrationBatch, self.batch["id"]).status, "active")
self.run_background()
self.db.expire_all()
batch = self.db.get(BizMigrationBatch, self.batch["id"])
self.assertEqual(batch.status, "review")
self.assertEqual(batch.accept_run_id, started["run_id"])
self.assertEqual(self.db.get(BizMigrationRun, started["run_id"]).status, "success")
def test_finish_failure_keeps_the_wave_operable(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_finish(self.db, self.batch["id"])
with mock.patch.object(mig, "run_evaluate", side_effect=HTTPException(status_code=400, detail="x")):
self.run_background()
self.db.expire_all()
self.assertEqual(self.db.get(BizMigrationBatch, self.batch["id"]).status, "active")
self.assertEqual(self.db.get(BizMigrationRun, started["run_id"]).status, "failed")
def test_auto_monitor_stays_out_of_a_running_evaluation(self):
from netx_api.biz_migration import auto_monitor
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"])
with mock.patch.object(mig, "run_evaluate") as run_evaluate:
auto_monitor.try_auto_monitor_for_task(self.db, self.ctx.old_p.id)
run_evaluate.assert_not_called()
class ManualRouteAndDuplicateScopeTests(_Fixture):
"""Share the fixture, add route sheets / duplicate rows to the compare template."""
def _add_sheet(self, sheet):
self.ctx.ct.metrics_json = [*self.ctx.ct.metrics_json, sheet]
self.ctx.mt.collect_metric_ids_json = []
self.db.commit()
def test_uncollected_route_table_is_manual_pending_and_does_not_fail_acceptance(self):
self._add_sheet({"sheet_id": "bgp_route", "title": "routes", "metric_id": "bgp_route",
"key_fields": ["network"], "iface_fields": [], "compare_fields": [], "row_filters": [],
"field_rules": []})
out = mig.run_evaluate(self.db, batch_id=self.batch["id"], acceptance=True, purpose="acceptance")
card = next(c for c in out["summary"]["sheet_cards"] if c["metric_id"] == "bgp_route")
self.assertTrue(card["manual_pending"])
self.assertFalse(card.get("current_missing"))
self.assertIn("routes", out["summary"]["manual_pending_metrics"])
self.assertNotIn("routes", out["summary"]["current_missing_metrics"])
def test_duplicate_keys_outside_the_wave_are_warnings_not_failures(self):
c = self.ctx
for i, cmd in enumerate(("d1", "d2")):
self.db.add(BizStateMetricRow(id=f"dup-{i}", batch_id="bl_old", metric_id="interface_brief", seq=10 + i,
data_json={"interface": "GE-other", "admin": "up", "phy": "up", "prot": "up"}))
self.db.commit()
out = mig.run_evaluate(self.db, batch_id=self.batch["id"], acceptance=True, purpose="acceptance")
summary = out["summary"]
self.assertEqual(summary["duplicate_keys"], 0)
self.assertEqual(summary["duplicate_warnings"][0]["count"] >= 1, True)
def test_duplicate_keys_inside_the_wave_still_fail(self):
for i in range(2):
self.db.add(BizStateMetricRow(id=f"dup-{i}", batch_id="bl_old", metric_id="interface_brief", seq=10 + i,
data_json={"interface": "GE-old", "admin": "up", "phy": "up", "prot": "up"}))
self.db.commit()
out = mig.run_evaluate(self.db, batch_id=self.batch["id"], acceptance=True, purpose="acceptance")
self.assertGreaterEqual(out["summary"]["duplicate_keys"], 1)
self.assertFalse(out["summary"]["coverage_complete"])
class BaselineCacheTests(_Fixture):
def test_second_evaluation_does_not_read_baseline_rows_again(self):
mig.BASELINE_ROWS.invalidate()
real = mig._load_metric_rows
loaded: list[str] = []
def spy(db, *, batch_id, metric_id, **kw):
loaded.append(batch_id)
return real(db, batch_id=batch_id, metric_id=metric_id, **kw)
with mock.patch.object(mig, "_load_metric_rows", spy):
mig.run_evaluate(self.db, batch_id=self.batch["id"])
first = list(loaded)
loaded.clear()
mig.run_evaluate(self.db, batch_id=self.batch["id"])
self.assertIn("bl_old", first)
self.assertNotIn("bl_old", loaded)
self.assertNotIn("bl_new", loaded)
self.assertIn("cur_new", loaded) # current samples are always re-read
def test_lru_is_bounded_by_total_rows_and_can_be_invalidated(self):
cache = mig.BaselineRowCache(max_rows=5)
rows = lambda n: [{"i": i} for i in range(n)] # noqa: E731
cache.get_or_load("a", "m", lambda: rows(3))
cache.get_or_load("b", "m", lambda: rows(3)) # evicts a (3 + 3 > 5)
calls: list[str] = []
cache.get_or_load("a", "m", lambda: calls.append("a") or rows(3))
self.assertEqual(calls, ["a"])
cache.get_or_load("big", "m", lambda: rows(9)) # larger than the cap: never cached
cache.get_or_load("big", "m", lambda: calls.append("big") or rows(9))
self.assertEqual(calls, ["a", "big"])
cache.invalidate("a")
cache.get_or_load("a", "m", lambda: calls.append("a2") or rows(1))
self.assertEqual(calls[-1], "a2")
class RedTicketResolvableTests(_Fixture):
def _diff(self, run_id, key, color, verdict):
from netx_api.models import BizMigrationDiff
self.db.add(BizMigrationDiff(id=f"d-{run_id}-{key}", run_id=run_id, metric_id="interface_brief", color=color,
verdict=verdict, key_json={"match_old_key": key, "sheet_id": "interface_brief",
"key_str": key}))
def test_ticket_for_a_key_that_turned_green_is_flagged_not_closed(self):
from netx_api.models import BizMigrationRedTicket
pid = self.project_id
for key in ("gei-1", "gei-2"):
self.db.add(BizMigrationRedTicket(
id=f"t-{key}", project_id=pid, batch_id="old", run_id="r0", metric_id="interface_brief",
match_key_str=key, key_str=key, status="open", detail_json={"sheet_id": "interface_brief", "match_old_key": key}))
self.db.add(BizMigrationRun(id="r1", project_id=pid, batch_id=self.batch["id"], purpose="acceptance"))
self._diff("r1", "gei-1", "green", "migrated")
self._diff("r1", "gei-2", "red", "anomaly")
self.db.commit()
mig._persist_red_tickets_from_run(self.db, project_id=pid, batch_id=self.batch["id"], run_id="r1")
self.db.commit()
from netx_api.models import BizMigrationRedTicket as T
flagged = {t.id: mig._red_ticket_to_dict(t) for t in self.db.query(T)}
self.assertTrue(flagged["t-gei-1"]["resolvable"])
self.assertFalse(flagged["t-gei-2"]["resolvable"])
self.assertEqual(flagged["t-gei-1"]["status"], "open")
out = mig.resolve_resolvable_red_tickets(self.db, pid)
self.assertEqual(out["ids"], ["t-gei-1"])
self.assertEqual(self.db.get(T, "t-gei-1").status, "resolved")
self.assertEqual(self.db.get(T, "t-gei-2").status, "open")
class UncoveredSliceTests(_Fixture):
def test_a_slice_one_side_never_collected_is_not_collected_not_lost(self):
sheet = {"sheet_id": "bgp_peer.ipv4", "metric_id": "bgp_peer", "key_fields": ["neighbor"],
"iface_fields": [], "compare_fields": ["state"],
"row_filters": [{"op": "eq", "field": "afi", "value": "ipv4"}], "field_rules": []}
for batch, profile in (("cur_old", "zte.bgp_ipv4_summary"), ("cur_new", "zte.bgp_vpnv4_summary")):
self.db.add(BizStateBatchCommand(id=f"slice-{batch}", batch_id=batch, metric_id="bgp_peer",
profile_id=profile, parse_status="ok", row_count=0, raw_line_count=0))
self.db.commit()
self.assertEqual(mig._uncovered_slice_sides(self.db, sheet, "bgp_peer", "cur_old", "cur_new"), ["new"])
self.assertEqual(mig._uncovered_slice_sides(self.db, {**sheet, "row_filters": []}, "bgp_peer",
"cur_old", "cur_new"), [])