mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 01:50:46 +08:00
Fix business compare history, search, sampling and exports
This commit is contained in:
parent
afa8e06b2d
commit
4d5101ec3a
19 changed files with 1598 additions and 274 deletions
87
tests/test_biz_state_compare_export.py
Normal file
87
tests/test_biz_state_compare_export.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Export must page lazily, keep tied sequence rows, and produce valid UTF-8 CSV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import unittest
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from netx_api.biz_state.compare_service import _iter_sheet_diffs, export_run_zip
|
||||
from netx_api.db import Base
|
||||
from netx_api.models import BizCompareDiff, BizCompareRun
|
||||
|
||||
|
||||
class CompareExportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.db = Session(self.engine)
|
||||
self.sheet = {"sheet_id": "sheet", "metric_id": "test", "key_fields": ["id"],
|
||||
"compare_fields": ["note"], "display_fields": ["id", "note"],
|
||||
"summary": {"unchanged": 7}, "status": "done"}
|
||||
self.run = BizCompareRun(id="run", metric_id="test", status="success",
|
||||
summary_json={"sheets": [self.sheet]})
|
||||
self.db.add(self.run)
|
||||
for seq, ids in ((0, "cba"), (1, "ed"), (2, "gf")):
|
||||
for rid in ids:
|
||||
note = '中文, "quoted"\nsecond line'
|
||||
self.db.add(BizCompareDiff(id=rid, run_id="run", metric_id="sheet", seq=seq,
|
||||
kind="unchanged", key_json={"id": rid},
|
||||
before_json={"id": rid, "note": note},
|
||||
after_json={"id": rid, "note": note}))
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_cursor_handles_tied_sequence_numbers_across_chunks(self):
|
||||
statements = []
|
||||
def record(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
if "ORDER BY biz_compare_diff.seq" in statement:
|
||||
statements.append(statement)
|
||||
event.listen(self.engine, "before_cursor_execute", record)
|
||||
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
|
||||
iterator = _iter_sheet_diffs(self.db, "run", "sheet")
|
||||
self.assertEqual(statements, [])
|
||||
self.assertEqual(next(iterator)["key"]["id"], "a")
|
||||
self.assertEqual(next(iterator)["key"]["id"], "b")
|
||||
self.assertEqual(len(statements), 1, "do not load the next chunk early")
|
||||
self.assertEqual([d["key"]["id"] for d in iterator], list("cdefg"))
|
||||
self.assertEqual(len(statements), 4)
|
||||
self.assertTrue(all("biz_compare_diff.seq >" in q and "biz_compare_diff.id >" in q
|
||||
for q in statements[1:]))
|
||||
|
||||
def test_export_csv_escapes_unicode_quotes_commas_and_newlines(self):
|
||||
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
|
||||
archive_bytes = export_run_zip(self.db, "run")
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
||||
payload = archive.read("tables/sheet.csv")
|
||||
self.assertTrue(payload.startswith(b"\xef\xbb\xbf"))
|
||||
rows = list(csv.DictReader(io.StringIO(payload.decode("utf-8-sig"))))
|
||||
self.assertEqual([r["id"] for r in rows], list("abcdefg"))
|
||||
self.assertTrue(all(r["note__pre"] == '中文, "quoted"\nsecond line' for r in rows))
|
||||
self.assertTrue(all(r["note__pre"] == r["note__post"] for r in rows))
|
||||
|
||||
def test_other_sheets_do_not_leak_into_export(self):
|
||||
self.db.add(BizCompareDiff(id="other", run_id="run", metric_id="another", seq=0,
|
||||
kind="added", key_json={"id": "other"}, after_json={"note": "other"}))
|
||||
self.db.commit()
|
||||
with patch("netx_api.biz_state.compare_service._DIFF_CHUNK", 2):
|
||||
self.assertEqual([d["key"]["id"] for d in _iter_sheet_diffs(self.db, "run", "sheet")], list("abcdefg"))
|
||||
|
||||
def test_legacy_inline_and_empty_exports(self):
|
||||
self.db.query(BizCompareDiff).delete()
|
||||
inline = {"kind": "added", "key": {"id": "legacy"}, "after": {"note": "value"}}
|
||||
self.run.summary_json = {"sheets": [{**self.sheet, "diffs": [inline]}]}
|
||||
self.db.commit()
|
||||
self.assertEqual(list(_iter_sheet_diffs(self.db, "run", "sheet")), [inline])
|
||||
self.run.summary_json = {"sheets": [self.sheet]}
|
||||
self.db.commit()
|
||||
with zipfile.ZipFile(io.BytesIO(export_run_zip(self.db, "run"))) as archive:
|
||||
self.assertEqual(archive.read("tables/sheet.csv").decode("utf-8-sig"), "kind,id,note__pre,note__post\n")
|
||||
103
tests/test_biz_state_compare_memory.py
Normal file
103
tests/test_biz_state_compare_memory.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Bounded sampling regressions and an opt-in comparison memory benchmark."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
import tracemalloc
|
||||
import types
|
||||
from collections import defaultdict, deque
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from netx_api.biz_state.compare_engine import _StratifiedSample, compare_rows, stratify_take
|
||||
|
||||
|
||||
def reference_sample(items, limit):
|
||||
if len(items) <= limit:
|
||||
return list(items)
|
||||
buckets = defaultdict(deque)
|
||||
for item in items:
|
||||
buckets[item[0]].append(item)
|
||||
out = []
|
||||
while buckets and len(out) < limit:
|
||||
for key in list(buckets):
|
||||
out.append(buckets[key].popleft())
|
||||
if not buckets[key]:
|
||||
del buckets[key]
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def test_streaming_sample_preserves_round_robin_and_small_input_order():
|
||||
rng = random.Random(42)
|
||||
for _ in range(100):
|
||||
items = [(str(rng.randrange(20)), i) for i in range(rng.randrange(100))]
|
||||
for limit in (0, 1, 5, 20, 100):
|
||||
expected = reference_sample(items, limit)
|
||||
assert stratify_take(iter(items), limit, key_fn=lambda item: item[0]) == expected
|
||||
|
||||
|
||||
def test_sample_storage_is_bounded_for_both_many_rows_and_many_strata():
|
||||
sample = _StratifiedSample(50)
|
||||
for i in range(20_000):
|
||||
sample.add(i, str(i) if i >= 10_000 else "one-large-peer")
|
||||
assert len(sample.heap) == 50
|
||||
assert len(sample.strata) == 50
|
||||
assert sample.picked() == [0, *range(10_000, 10_049)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iface_fields,rules,pmap", [
|
||||
([], [], {}),
|
||||
(["interface"], [], {}),
|
||||
(["interface"], [{"from": "GE", "to": "gei"}], {"gei-1": "gei-2"}),
|
||||
])
|
||||
def test_compare_does_not_mutate_inputs_when_reusing_rows(iface_fields, rules, pmap):
|
||||
before = [{"id": "1", "interface": "GE-1", "state": "up"}]
|
||||
after = [{"id": "1", "interface": "GE-2", "state": "up"}]
|
||||
original = deepcopy((before, after))
|
||||
compare_rows(before_rows=before, after_rows=after, key_fields=["id"],
|
||||
iface_fields=iface_fields, compare_fields=["state"], port_map=pmap,
|
||||
iface_normalize_rules=rules, include_unchanged=True, unchanged_limit=1)
|
||||
assert (before, after) == original
|
||||
|
||||
|
||||
def test_memory_benchmark_against_git_baseline():
|
||||
"""Run explicitly with NETX_BENCH_ROWS=1000000; not part of routine CI."""
|
||||
n = int(os.environ.get("NETX_BENCH_ROWS", "0"))
|
||||
if not n:
|
||||
pytest.skip("opt-in large synthetic benchmark")
|
||||
baseline_ref = os.environ.get("NETX_BENCH_REF", "HEAD")
|
||||
source = subprocess.run(["git", "show", f"{baseline_ref}:netx_api/biz_state/compare_engine.py"],
|
||||
check=True, capture_output=True, text=True, encoding="utf-8").stdout
|
||||
baseline = types.ModuleType("netx_api.biz_state._benchmark_baseline")
|
||||
baseline.__package__ = "netx_api.biz_state"
|
||||
exec(compile(source, "baseline_compare_engine.py", "exec"), baseline.__dict__)
|
||||
before = [{"id": str(i), "neighbor": str(i % 50), "state": "up", "afi": "vpnv4"} for i in range(n)]
|
||||
after = [dict(row) for row in before]
|
||||
records = []
|
||||
digests = []
|
||||
for name, compare in (("baseline", baseline.compare_rows), ("optimized", compare_rows)):
|
||||
gc.collect()
|
||||
tracemalloc.start()
|
||||
started = time.perf_counter()
|
||||
result = compare(before_rows=before, after_rows=after, key_fields=["id"], iface_fields=[],
|
||||
compare_fields=["state"], include_unchanged=True, unchanged_limit=5000,
|
||||
compact_unchanged=True)
|
||||
elapsed = time.perf_counter() - started
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
tracemalloc.stop()
|
||||
digests.append(hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest())
|
||||
records.append({"engine": name, "rows_per_side": n, "peak_engine_mib": round(peak / 2**20, 2),
|
||||
"seconds_with_tracemalloc": round(elapsed, 3)})
|
||||
del result
|
||||
assert digests[0] == digests[1], "large-sheet results and sample order must match"
|
||||
print("\n" + json.dumps({"baseline_ref": baseline_ref, "input_allocations_included": False,
|
||||
"results": records}, indent=2))
|
||||
186
tests/test_biz_state_compare_search.py
Normal file
186
tests/test_biz_state_compare_search.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Regression tests: searching a pair must not change its comparison verdict."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from netx_api.biz_state.compare_engine import compare_rows
|
||||
from netx_api.biz_state.compare_service import (
|
||||
_load_metric_rows_for_search,
|
||||
_literal_search_pattern,
|
||||
_persist_sheet_diffs,
|
||||
_search_stored_diffs,
|
||||
list_run_diffs,
|
||||
)
|
||||
from netx_api.db import Base
|
||||
from netx_api.models import (
|
||||
BizCompareDiff, BizCompareRun, BizCompareTemplate, BizPortMappingRow,
|
||||
BizStateLldpNeighbor, BizStateMetricRow,
|
||||
)
|
||||
|
||||
|
||||
class CompareSearchTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=[m.__table__ for m in (
|
||||
BizCompareDiff, BizCompareRun, BizCompareTemplate, BizPortMappingRow,
|
||||
BizStateLldpNeighbor, BizStateMetricRow,
|
||||
)])
|
||||
self.db = Session(self.engine)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def seed(self, before, after, *, metric="test", keys=None, fields=None,
|
||||
iface=None, mapping=None, sample=False, compact=False, filters=None):
|
||||
keys = keys or ["id"]
|
||||
iface = iface or []
|
||||
fields = fields or ["state"]
|
||||
for side, rows in (("before", before), ("after", after)):
|
||||
for seq, row in enumerate(rows):
|
||||
rid = f"{side}-{seq}"
|
||||
if metric == "lldp_neighbor":
|
||||
self.db.add(BizStateLldpNeighbor(id=rid, batch_id=side, **row))
|
||||
else:
|
||||
self.db.add(BizStateMetricRow(id=rid, batch_id=side, metric_id=metric,
|
||||
seq=seq, data_json=dict(row)))
|
||||
row["_netx"] = {"row_id": rid}
|
||||
if mapping:
|
||||
for b, a in mapping.items():
|
||||
self.db.add(BizPortMappingRow(mapping_id="map", before_if=b, after_if=a))
|
||||
result = compare_rows(before_rows=before, after_rows=after, key_fields=keys,
|
||||
compare_fields=fields, iface_fields=iface, port_map=mapping or {},
|
||||
include_unchanged=True, compact_unchanged=compact)
|
||||
sheet = {"sheet_id": "sheet", "metric_id": metric, "key_fields": keys,
|
||||
"compare_fields": fields, "iface_fields": iface,
|
||||
"summary": {**result["summary"], "unchanged_listed": 0 if sample
|
||||
else result["summary"]["unchanged"]},
|
||||
"row_filters": filters or []}
|
||||
self.db.add(BizCompareRun(id="run", status="success", before_batch_id="before",
|
||||
after_batch_id="after", mapping_id="map" if mapping else "",
|
||||
summary_json={"sheets": [sheet]}))
|
||||
self.db.commit()
|
||||
_persist_sheet_diffs(self.db, run_id="run", metric_id="sheet",
|
||||
diffs=[d for d in result["diffs"]
|
||||
if not sample or d["kind"] != "unchanged"])
|
||||
return result
|
||||
|
||||
def search(self, **kwargs):
|
||||
return list_run_diffs(self.db, "run", metric_id="sheet", **kwargs)
|
||||
|
||||
def test_search_before_value_preserves_changed_verdict(self):
|
||||
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "down"}])
|
||||
with patch("netx_api.biz_state.compare_service.compare_rows") as compare:
|
||||
result = self.search(kw="up", kind="changed")
|
||||
compare.assert_not_called()
|
||||
self.assertEqual(result["total"], 1)
|
||||
self.assertEqual(result["items"][0]["after"]["state"], "down")
|
||||
self.assertEqual(self.search(kw="up", kind="removed")["total"], 0)
|
||||
|
||||
def test_field_filter_matches_after_value_on_compact_diff(self):
|
||||
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "down"}], compact=True)
|
||||
result = self.search(qf={"state": "down"}, kind="changed")
|
||||
self.assertEqual(result["total"], 1)
|
||||
self.assertEqual(result["items"][0]["before"]["state"], "up")
|
||||
|
||||
def test_sample_lookup_completes_pair_matching_only_one_side(self):
|
||||
self.seed([{"id": "1", "state": "up", "age": "old"}],
|
||||
[{"id": "1", "state": "up", "age": "new"}], sample=True)
|
||||
result = self.search(kw="old", kind="unchanged")
|
||||
self.assertEqual(result["source"], "live")
|
||||
self.assertEqual(result["total"], 1)
|
||||
self.assertEqual(result["items"][0]["after"]["age"], "new")
|
||||
|
||||
def test_sample_lookup_does_not_turn_changed_into_removed(self):
|
||||
self.seed([{"id": "1", "state": "up"}, {"id": "2", "state": "up"}],
|
||||
[{"id": "1", "state": "down"}, {"id": "2", "state": "up"}], sample=True)
|
||||
result = self.search(qf={"id": "1", "state": "up"}, kind="all")
|
||||
self.assertEqual([d["kind"] for d in result["items"]], ["changed"])
|
||||
|
||||
def test_sample_lookup_keeps_duplicate_group_pairing(self):
|
||||
self.seed([{"id": "1", "state": "up", "note": "first"},
|
||||
{"id": "1", "state": "down", "note": "second"}],
|
||||
[{"id": "1", "state": "up", "note": "other"},
|
||||
{"id": "1", "state": "down", "note": "other"}], sample=True)
|
||||
result = self.search(kw="second", kind="all")
|
||||
self.assertEqual([d["kind"] for d in result["items"]], ["unchanged"])
|
||||
self.assertEqual(result["items"][0]["after"]["state"], "down")
|
||||
|
||||
def test_sample_search_is_not_limited_by_persisted_search_text_size(self):
|
||||
self.seed([{"id": "1", "state": "up", "note": "x" * 5000 + "needle"}],
|
||||
[{"id": "1", "state": "up", "note": "other"}], sample=True)
|
||||
self.assertEqual(self.search(kw="needle", kind="unchanged")["total"], 1)
|
||||
|
||||
def test_sample_lookup_applies_port_mapping_before_matching(self):
|
||||
self.seed([{"id": "1", "interface": "old", "state": "up"}],
|
||||
[{"id": "1", "interface": "new", "state": "up"}],
|
||||
keys=["id", "interface"], iface=["interface"], mapping={"old": "new"}, sample=True)
|
||||
result = self.search(qf={"interface": "old"}, kind="unchanged")
|
||||
self.assertEqual(result["total"], 1)
|
||||
self.assertEqual(result["items"][0]["after"]["interface"], "new")
|
||||
|
||||
def test_lldp_source_search(self):
|
||||
self.seed([{"local_if": "gei-1", "remote_sys": "peer", "remote_if": "port", "remote_ip": "1"}],
|
||||
[{"local_if": "gei-1", "remote_sys": "peer", "remote_if": "port", "remote_ip": "1"}],
|
||||
metric="lldp_neighbor", keys=["local_if", "remote_sys"], fields=["remote_ip"],
|
||||
iface=["local_if"], sample=True)
|
||||
self.assertEqual(self.search(kw="peer", kind="unchanged")["total"], 1)
|
||||
|
||||
def test_lldp_compact_diff_field_search(self):
|
||||
self.seed([{"local_if": "gei-1", "remote_sys": "peer", "remote_ip": "1"}],
|
||||
[{"local_if": "gei-1", "remote_sys": "peer", "remote_ip": "2"}],
|
||||
metric="lldp_neighbor", keys=["local_if", "remote_sys"], fields=["remote_ip"],
|
||||
iface=["local_if"], compact=True)
|
||||
self.assertEqual(self.search(qf={"remote_ip": "2"}, kind="changed")["total"], 1)
|
||||
|
||||
def test_exact_load_cap_is_not_truncated(self):
|
||||
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "up"}])
|
||||
rows, truncated = _load_metric_rows_for_search(self.db, batch_id="before", metric_id="test",
|
||||
row_filters=[], key_fields=["id"], kw="up", cap=1)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertFalse(truncated)
|
||||
|
||||
def test_incomplete_duplicate_groups_are_omitted(self):
|
||||
self.seed([{"id": "1", "state": "up"}, {"id": "1", "state": "down"}],
|
||||
[{"id": "1", "state": "up"}, {"id": "1", "state": "down"}], sample=True)
|
||||
with patch("netx_api.biz_state.compare_service._LIVE_SEARCH_GROUP_LOAD_CAP", 1):
|
||||
result = self.search(kw="up", kind="all")
|
||||
self.assertEqual(result["items"], [])
|
||||
self.assertTrue(result["truncated"])
|
||||
|
||||
def test_postgres_search_pattern_treats_wildcards_literally(self):
|
||||
self.assertEqual(_literal_search_pattern("a_10%"), "%a\\_10\\%%")
|
||||
|
||||
def test_postgres_unsupported_filter_uses_python(self):
|
||||
self.seed([{"id": "1", "state": "up", "age": "H"}],
|
||||
[{"id": "1", "state": "up", "age": "H"}])
|
||||
with patch("netx_api.biz_state.compare_sql._dialect_is_postgres", return_value=True):
|
||||
rows, _ = _load_metric_rows_for_search(self.db, batch_id="before", metric_id="test",
|
||||
row_filters=[{"field": "age", "op": "age_timer"}], key_fields=["id"], kw="up")
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_unknown_sheet_is_rejected(self):
|
||||
self.seed([{"id": "1", "state": "up"}], [{"id": "1", "state": "up"}])
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
list_run_diffs(self.db, "run", metric_id="missing")
|
||||
self.assertEqual(error.exception.detail, "sheet_not_found")
|
||||
|
||||
def test_stored_search_escapes_like_wildcards(self):
|
||||
self.seed([{"id": "x_1", "state": "up"}, {"id": "xy1", "state": "up"}],
|
||||
[{"id": "x_1", "state": "down"}, {"id": "xy1", "state": "down"}])
|
||||
self.assertEqual(self.search(qf={"id": "x_1"}, kind="changed")["total"], 1)
|
||||
query = _search_stored_diffs(self.db.query(BizCompareDiff), metric_id="test",
|
||||
kw="up", field_q={"id": "x_1"})
|
||||
compiled = query.statement.compile(dialect=postgresql.dialect())
|
||||
self.assertIn("LEFT OUTER JOIN", str(compiled))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
170
tests/test_biz_state_compare_snapshot.py
Normal file
170
tests/test_biz_state_compare_snapshot.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Historical comparisons must use the settings captured when they were queued."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import zipfile
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from netx_api.biz_state.compare_service import (
|
||||
_create_running_run, _execute_compare_into_run, cancel_compare_run,
|
||||
export_run_zip, get_run, list_run_diffs, template_metrics,
|
||||
)
|
||||
from netx_api.db import Base
|
||||
from netx_api.models import (
|
||||
BizCompareDiff, BizCompareJob, BizCompareTemplate, BizPortMapping,
|
||||
BizPortMappingRow, BizStateMetricRow,
|
||||
)
|
||||
|
||||
|
||||
class CompareSnapshotTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.factory = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
self.db = self.factory()
|
||||
# Progress uses a separate session in production; keep every test write local.
|
||||
self.session_patch = patch("netx_api.db.SessionLocal", self.factory)
|
||||
self.session_patch.start()
|
||||
self.sheet = {
|
||||
"sheet_id": "sheet", "metric_id": "test", "title": "Original sheet",
|
||||
"key_fields": ["id", "interface"], "iface_fields": ["interface"],
|
||||
"compare_fields": ["state"], "display_fields": ["id", "interface", "state"],
|
||||
"row_filters": [], "ignore_port_changes": False,
|
||||
}
|
||||
self.tpl = BizCompareTemplate(
|
||||
id="tpl", name="Original template", metrics_json=[deepcopy(self.sheet)],
|
||||
iface_normalize_json=[{"from": "GE", "to": "gei"}],
|
||||
)
|
||||
self.job = BizCompareJob(id="job", name="Original job", template_id="tpl",
|
||||
mapping_id="map", store_unchanged="sample")
|
||||
self.mapping = BizPortMapping(id="map", name="Original mapping")
|
||||
self.pair = BizPortMappingRow(mapping_id="map", before_if="gei-1", after_if="gei-2")
|
||||
self.db.add_all([self.tpl, self.job, self.mapping, self.pair])
|
||||
for side, iface, note in (("before", "GE-1.100", "old"), ("after", "GE-2.100", "new")):
|
||||
self.db.add(BizStateMetricRow(id=side, batch_id=side, metric_id="test", seq=0,
|
||||
data_json={"id": "1", "interface": iface,
|
||||
"state": "up", "note": note}))
|
||||
self.db.commit()
|
||||
self.run = _create_running_run(
|
||||
self.db, job=self.job, tpl=self.tpl, before_batch_id="before",
|
||||
after_batch_id="after", sheets_cfg=template_metrics(self.tpl),
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.session_patch.stop()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def edit_current_settings(self):
|
||||
changed = {**self.sheet, "title": "Edited sheet", "compare_fields": ["note"],
|
||||
"row_filters": [{"field": "state", "op": "eq", "value": "down"}]}
|
||||
self.tpl.name = "Edited template"
|
||||
self.tpl.metrics_json = [changed]
|
||||
self.tpl.iface_normalize_json = [{"from": "GE", "to": "other"}]
|
||||
self.pair.after_if = "gei-99"
|
||||
self.job.name = "Edited job"
|
||||
self.job.store_unchanged = "never"
|
||||
self.job.enabled_sheet_ids = ["different-sheet"]
|
||||
self.mapping.name = "Edited mapping"
|
||||
self.db.commit()
|
||||
|
||||
def execute(self):
|
||||
return _execute_compare_into_run(self.db, self.run.id)
|
||||
|
||||
def test_queued_run_uses_original_sheet_rules_mapping_and_storage(self):
|
||||
original = deepcopy(self.run.summary_json["config_snapshot"])
|
||||
self.edit_current_settings()
|
||||
detail = self.execute()
|
||||
self.assertEqual(detail["status"], "success")
|
||||
self.assertEqual(detail["summary"]["unchanged"], 1)
|
||||
self.assertEqual(detail["summary"]["unchanged_listed"], 1)
|
||||
self.assertEqual(detail["summary"]["store_unchanged"], "sample")
|
||||
self.assertEqual(detail["sheets"][0]["compare_fields"], ["state"])
|
||||
self.assertEqual(self.run.summary_json["config_snapshot"], original)
|
||||
|
||||
def test_compact_hydration_restores_original_normalized_values(self):
|
||||
self.execute()
|
||||
self.edit_current_settings()
|
||||
diff = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["items"][0]
|
||||
self.assertEqual(diff["before"]["interface"], "GE-1.100")
|
||||
self.assertEqual(diff["mapped_before"]["interface"], "gei-2.100")
|
||||
self.assertEqual(diff["after"]["interface"], "gei-2.100")
|
||||
|
||||
def test_live_search_does_not_inherit_new_filters_or_transforms(self):
|
||||
with patch("netx_api.biz_state.compare_service._UNCHANGED_SAMPLE_MAX", 0):
|
||||
self.execute()
|
||||
self.edit_current_settings()
|
||||
result = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged", kw="old")
|
||||
self.assertEqual(result["source"], "live")
|
||||
self.assertEqual(result["total"], 1)
|
||||
self.assertEqual(result["items"][0]["mapped_before"]["interface"], "gei-2.100")
|
||||
|
||||
def test_snapshot_can_be_read_after_mapping_and_template_are_deleted(self):
|
||||
self.execute()
|
||||
self.db.query(BizPortMappingRow).delete()
|
||||
self.db.delete(self.mapping)
|
||||
self.db.delete(self.tpl)
|
||||
self.db.commit()
|
||||
detail = get_run(self.db, self.run.id)
|
||||
self.assertEqual(detail["template_name"], "Original template")
|
||||
self.assertEqual(detail["mapping_name"], "Original mapping")
|
||||
diff = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["items"][0]
|
||||
self.assertEqual(diff["mapped_before"]["interface"], "gei-2.100")
|
||||
|
||||
def test_queued_run_can_execute_after_its_template_and_mapping_are_deleted(self):
|
||||
self.db.query(BizPortMappingRow).delete()
|
||||
self.db.delete(self.mapping)
|
||||
self.db.delete(self.tpl)
|
||||
self.db.commit()
|
||||
detail = self.execute()
|
||||
self.assertEqual(detail["status"], "success")
|
||||
self.assertEqual(detail["summary"]["unchanged"], 1)
|
||||
|
||||
def test_detail_returns_original_names_and_independent_template_copy(self):
|
||||
self.edit_current_settings()
|
||||
detail = get_run(self.db, self.run.id)
|
||||
self.assertEqual(detail["job_name"], "Original job")
|
||||
self.assertEqual(detail["config_snapshot_version"], 1)
|
||||
self.assertEqual(detail["template"]["iface_normalize_rules"], [{"from": "GE", "to": "gei"}])
|
||||
self.assertNotIn("config_snapshot", detail["summary"])
|
||||
detail["template"]["iface_normalize_rules"].clear()
|
||||
self.assertTrue(self.run.summary_json["config_snapshot"]["template"]["iface_normalize_rules"])
|
||||
|
||||
def test_export_contains_original_config_and_hydrated_csv(self):
|
||||
self.execute()
|
||||
self.edit_current_settings()
|
||||
with zipfile.ZipFile(io.BytesIO(export_run_zip(self.db, self.run.id))) as archive:
|
||||
config = json.loads(archive.read("config_snapshot.json"))
|
||||
self.assertEqual(config["port_map"], {"gei-1": "gei-2"})
|
||||
self.assertEqual(config["template"]["name"], "Original template")
|
||||
rows = list(csv.DictReader(io.StringIO(archive.read("tables/sheet.csv").decode("utf-8-sig"))))
|
||||
self.assertEqual(rows[0]["interface"], "gei-2.100")
|
||||
|
||||
def test_failed_and_cancelled_runs_keep_snapshot(self):
|
||||
original = deepcopy(self.run.summary_json["config_snapshot"])
|
||||
with patch("netx_api.biz_state.compare_service._run_sheet", side_effect=RuntimeError("test-failure")):
|
||||
with self.assertRaisesRegex(RuntimeError, "test-failure"):
|
||||
self.execute()
|
||||
self.assertEqual(self.run.summary_json["config_snapshot"], original)
|
||||
self.run.status = "running"
|
||||
self.db.commit()
|
||||
cancel_compare_run(self.db, self.run.id)
|
||||
self.assertEqual(self.run.summary_json["config_snapshot"], original)
|
||||
|
||||
def test_legacy_runs_without_snapshot_remain_readable(self):
|
||||
self.execute()
|
||||
self.run.summary_json = {k: v for k, v in self.run.summary_json.items() if k != "config_snapshot"}
|
||||
self.db.commit()
|
||||
detail = get_run(self.db, self.run.id)
|
||||
self.assertEqual(detail["status"], "success")
|
||||
self.assertIsNone(detail["config_snapshot_version"])
|
||||
self.assertEqual(list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["total"], 1)
|
||||
self.assertEqual(self.db.query(BizCompareDiff).count(), 1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue