diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/compare.py b/tests/e2e/compare.py new file mode 100644 index 00000000..eeb8de34 --- /dev/null +++ b/tests/e2e/compare.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Golden-file comparator for the SONiC config-generation E2E test. + +Compares exported SONiC ``config_db.json`` files against committed golden +files. config_db is a shallow TABLE -> entry -> attribute tree, so +structural difference paths are rendered as ``TABLE|entry.attribute``. + +Canonicalization sorts dictionary keys only; array order is preserved and +compared, since list order can be part of the generated contract. +""" + +import argparse +import difflib +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path + + +def _walk(golden, actual, path, depth, out): + """Collect difference paths between two parsed JSON values. + + ``depth`` counts dict levels already entered: children of the top level + (tables) attach with ``|``, everything deeper with ``.``. List indices + attach as ``[i]`` and stay at their parent's depth. + """ + if isinstance(golden, dict) and isinstance(actual, dict): + for key in sorted(golden.keys() | actual.keys()): + sep = "|" if depth == 1 else "." + child = f"{path}{sep}{key}" if path else str(key) + if key not in actual: + out.append(f"{child}: only in golden") + elif key not in golden: + out.append(f"{child}: only in actual") + else: + _walk(golden[key], actual[key], child, depth + 1, out) + elif isinstance(golden, list) and isinstance(actual, list): + for i in range(max(len(golden), len(actual))): + child = f"{path}[{i}]" + if i >= len(actual): + out.append(f"{child}: only in golden") + elif i >= len(golden): + out.append(f"{child}: only in actual") + else: + _walk(golden[i], actual[i], child, depth, out) + elif golden != actual: + out.append(f"{path}: golden {golden!r}, actual {actual!r}") + + +def diff_paths(golden, actual): + """Return sorted structural paths of the differences between two configs.""" + out = [] + _walk(golden, actual, "", 0, out) + return sorted(out) + + +def _canonical(config): + """Canonical JSON text: dict keys sorted, arrays untouched.""" + return json.dumps(config, indent=2, sort_keys=True) + "\n" + + +def unified_diff(golden, actual, name): + """Unified diff of the canonicalized golden and actual configs.""" + return "".join( + difflib.unified_diff( + _canonical(golden).splitlines(keepends=True), + _canonical(actual).splitlines(keepends=True), + fromfile=f"golden/{name}", + tofile=f"export/{name}", + ) + ) + + +@dataclass +class Mismatch: + """Content difference of one exported file against its golden file.""" + + paths: list + diff: str + + +@dataclass +class CompareResult: + """Outcome of comparing an export directory against the golden directory.""" + + missing: list = field(default_factory=list) + extra: list = field(default_factory=list) + mismatched: dict = field(default_factory=dict) + + @property + def ok(self): + return not (self.missing or self.extra or self.mismatched) + + def report(self): + lines = [] + for name in self.missing: + lines.append( + f"MISSING {name}: not exported " + "(generation or export failed - see generate log)" + ) + for name in self.extra: + lines.append(f"EXTRA {name}: unexpected extra export (no golden file)") + for name, mismatch in self.mismatched.items(): + lines.append(f"MISMATCH {name}:") + lines.extend(f" {path}" for path in mismatch.paths) + lines.append(mismatch.diff) + if not lines: + lines.append("OK: exports match the golden files") + return "\n".join(lines) + + +def compare_dirs(golden_dir, export_dir): + """Compare all golden ``*.json`` files against the exported ones. + + Requires exact file-set equality: a golden file without an export is + ``missing`` (generation or export failed), an export without a golden + file is ``extra``, and differing content is ``mismatched``. Non-JSON + files (e.g. firmware symlinks in the export directory) are ignored. + """ + golden_dir = Path(golden_dir) + export_dir = Path(export_dir) + golden_names = {p.name for p in golden_dir.glob("*.json")} + export_names = {p.name for p in export_dir.glob("*.json")} + + result = CompareResult( + missing=sorted(golden_names - export_names), + extra=sorted(export_names - golden_names), + ) + for name in sorted(golden_names & export_names): + golden = json.loads((golden_dir / name).read_text()) + actual = json.loads((export_dir / name).read_text()) + paths = diff_paths(golden, actual) + if paths: + result.mismatched[name] = Mismatch( + paths=paths, diff=unified_diff(golden, actual, name) + ) + return result + + +def regenerate(golden_dir, export_dir): + """Rewrite the golden directory from the export directory. + + Every exported ``*.json`` file is stored in canonical form (sorted dict + keys) so regenerated goldens produce minimal, reviewable git diffs; + stale golden files without a matching export are removed. Never run in + CI - regeneration is a deliberate local step after an intentional + generator change. + + Returns a list of entries describing coverage that was lost: either a + table that was populated in the previous golden and would become empty, + or a whole golden file that had no matching export at all and is being + removed. Regeneration is the only path by which coverage can silently + drop -- in the normal path a table going empty (or a whole device + disappearing) changes the golden and fails the comparison -- and either + is invisible inside a several-hundred-line JSON diff. The files are + still written (or removed); reporting is the caller's decision to act + on. + """ + golden_dir = Path(golden_dir) + export_dir = Path(export_dir) + golden_dir.mkdir(parents=True, exist_ok=True) + + def _populated(config): + return {t for t, v in config.items() if v} + + lost = [] + export_names = {p.name for p in export_dir.glob("*.json")} + for stale in golden_dir.glob("*.json"): + if stale.name not in export_names: + n = len(_populated(json.loads(stale.read_text()))) + if n: + lost.append(f"{stale.name}: file removed, had {n} populated tables") + stale.unlink() + + for name in sorted(export_names): + config = json.loads((export_dir / name).read_text()) + previous = golden_dir / name + if previous.exists(): + before = _populated(json.loads(previous.read_text())) + for table in sorted(before - _populated(config)): + lost.append(f"{name}: {table}") + previous.write_text(_canonical(config)) + return lost + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--golden", required=True, help="golden file directory") + parser.add_argument("--export", required=True, help="export directory to check") + parser.add_argument( + "--regenerate", + action="store_true", + help="rewrite the golden files from the export directory", + ) + parser.add_argument( + "--allow-coverage-loss", + action="store_true", + help="regenerate even when a previously populated table becomes empty", + ) + args = parser.parse_args(argv) + + if args.regenerate: + lost = regenerate(args.golden, args.export) + if lost and not args.allow_coverage_loss: + print("COVERAGE LOSS -- coverage was lost:") + for entry in lost: + print(f" {entry}") + print( + "Goldens were written. Review the diff, then either fix the " + "fixtures or re-run with --allow-coverage-loss to accept it." + ) + return 1 + return 0 + + result = compare_dirs(args.golden, args.export) + print(result.report()) + return 0 if result.ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/generate.py b/tests/e2e/generate.py new file mode 100644 index 00000000..0c29e3e2 --- /dev/null +++ b/tests/e2e/generate.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Generation driver for the SONiC config E2E test. + +Runs ``sync_sonic()`` against the seeded NetBox and asserts that generation +itself succeeded. ``sync_sonic`` returns only a ``{device: config}`` dict: +its internal rc is reported solely through the task layer when a ``task_id`` +is set, and per-device failures are logged and swallowed. Success is +therefore asserted here by + +- comparing the returned device set against the expectation derived from + the golden files (``--golden``), and +- failing on any ERROR-level loguru record captured during the run, which + covers every swallowed-exception path in ``sync.py``. + +All environment (NETBOX_API/NETBOX_TOKEN, SONIC_EXPORT_DIR, +SONIC_PORT_CONFIG_PATH, SONIC_EXPORT_IDENTIFIER=hostname) must be set +before this module is imported, since ``osism.settings`` reads it at import +time. The exported files are checked against the goldens by ``compare.py``. +""" + +import argparse +import os +import sys +from pathlib import Path + +from loguru import logger + + +def expected_devices(golden_dir, prefix, suffix): + """Derive the expected device names from the golden file names.""" + devices = set() + for path in Path(golden_dir).glob("*.json"): + if path.name.startswith(prefix) and path.name.endswith(suffix): + devices.add(path.name[len(prefix) : -len(suffix)]) + return devices + + +def run_generation(sync): + """Call the sync function, capturing ERROR-level loguru records. + + Returns ``(device_configs, errors)`` where ``errors`` is the list of + error messages logged during the run. + """ + errors = [] + sink_id = logger.add( + lambda message: errors.append(str(message).strip()), + level="ERROR", + format="{message}", + ) + try: + configs = sync() + finally: + logger.remove(sink_id) + return configs, errors + + +def main(argv=None, sync=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--golden", + help="golden file directory the expected device set is derived from", + ) + parser.add_argument( + "--no-expect", + action="store_true", + help="skip the device-set check (golden bootstrap / regeneration)", + ) + args = parser.parse_args(argv) + if not args.no_expect and not args.golden: + parser.error("either --golden or --no-expect is required") + + if sync is None: + missing_env = [ + name for name in ("NETBOX_API", "NETBOX_TOKEN") if not os.environ.get(name) + ] + if missing_env: + print(f"Missing required environment variables: {', '.join(missing_env)}") + return 2 + from osism.tasks.conductor.sonic.sync import sync_sonic + + sync = sync_sonic + + configs, errors = run_generation(sync) + + failed = False + for error in errors: + print(f"ERROR during generation: {error}") + failed = True + + empty = sorted(name for name, config in configs.items() if not config) + for name in empty: + print(f"EMPTY config generated for device: {name}") + failed = True + + if args.golden: + from osism import settings + + expected = expected_devices( + args.golden, settings.SONIC_EXPORT_PREFIX, settings.SONIC_EXPORT_SUFFIX + ) + for name in sorted(expected - set(configs)): + print(f"MISSING device (expected from goldens, not generated): {name}") + failed = True + for name in sorted(set(configs) - expected): + print(f"UNEXPECTED device (generated, no golden file): {name}") + failed = True + + print(f"Generated {len(configs)} SONiC configurations") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/e2e/__init__.py b/tests/unit/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/e2e/test_compare.py b/tests/unit/e2e/test_compare.py new file mode 100644 index 00000000..43a1e60d --- /dev/null +++ b/tests/unit/e2e/test_compare.py @@ -0,0 +1,362 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the E2E golden-file comparator. + +The comparator itself (tests/e2e/compare.py) is pure logic and independent +of any infrastructure, so it is tested here as part of the regular unit +suite. +""" + +import json + +from tests.e2e import compare +from tests.e2e.compare import compare_dirs, diff_paths, main, regenerate + + +class TestDiffPaths: + def test_equal_configs_yield_no_paths(self): + config = {"PORT": {"Ethernet0": {"speed": "100000"}}} + + assert diff_paths(config, config) == [] + + def test_value_mismatch_reports_table_entry_attribute_path(self): + golden = {"PORT": {"Ethernet4": {"speed": "100000"}}} + actual = {"PORT": {"Ethernet4": {"speed": "100"}}} + + assert diff_paths(golden, actual) == [ + "PORT|Ethernet4.speed: golden '100000', actual '100'" + ] + + def test_entry_only_in_golden(self): + golden = {"PORT": {"Ethernet4": {"speed": "100000"}}} + actual = {"PORT": {}} + + assert diff_paths(golden, actual) == ["PORT|Ethernet4: only in golden"] + + def test_entry_only_in_actual(self): + golden = {"PORT": {}} + actual = {"PORT": {"Ethernet4": {"speed": "100000"}}} + + assert diff_paths(golden, actual) == ["PORT|Ethernet4: only in actual"] + + def test_table_only_in_golden(self): + golden = {"VLAN": {"Vlan100": {}}} + actual = {} + + assert diff_paths(golden, actual) == ["VLAN: only in golden"] + + def test_array_order_is_significant(self): + golden = {"PORT": {"Ethernet0": {"valid_speeds": ["100000", "50000"]}}} + actual = {"PORT": {"Ethernet0": {"valid_speeds": ["50000", "100000"]}}} + + paths = diff_paths(golden, actual) + + assert paths == [ + "PORT|Ethernet0.valid_speeds[0]: golden '100000', actual '50000'", + "PORT|Ethernet0.valid_speeds[1]: golden '50000', actual '100000'", + ] + + def test_array_length_difference_reported(self): + golden = {"PORT": {"Ethernet0": {"valid_speeds": ["100000"]}}} + actual = {"PORT": {"Ethernet0": {"valid_speeds": ["100000", "50000"]}}} + + assert diff_paths(golden, actual) == [ + "PORT|Ethernet0.valid_speeds[1]: only in actual" + ] + + def test_type_mismatch_reported_as_value_difference(self): + golden = {"PORT": {"Ethernet0": {"mtu": "9100"}}} + actual = {"PORT": {"Ethernet0": {"mtu": 9100}}} + + assert diff_paths(golden, actual) == [ + "PORT|Ethernet0.mtu: golden '9100', actual 9100" + ] + + def test_multiple_differences_are_sorted_by_path(self): + golden = { + "PORT": {"Ethernet0": {"speed": "100000"}}, + "VLAN": {"Vlan100": {"vlanid": "100"}}, + } + actual = { + "PORT": {"Ethernet0": {"speed": "100"}}, + "VLAN": {"Vlan100": {"vlanid": "200"}}, + } + + paths = diff_paths(golden, actual) + + assert paths == sorted(paths) + assert len(paths) == 2 + + def test_deeply_nested_values_use_dot_separators(self): + golden = {"BGP_NEIGHBOR": {"10.0.0.1": {"af": {"ipv4": "on"}}}} + actual = {"BGP_NEIGHBOR": {"10.0.0.1": {"af": {"ipv4": "off"}}}} + + assert diff_paths(golden, actual) == [ + "BGP_NEIGHBOR|10.0.0.1.af.ipv4: golden 'on', actual 'off'" + ] + + +class TestCompareDirs: + @staticmethod + def _write(directory, name, config): + directory.mkdir(parents=True, exist_ok=True) + (directory / name).write_text(json.dumps(config)) + + def test_identical_dirs_are_ok(self, tmp_path): + config = {"PORT": {"Ethernet0": {"speed": "100000"}}} + self._write(tmp_path / "golden", "osism_sw1_config_db.json", config) + self._write(tmp_path / "export", "osism_sw1_config_db.json", config) + + result = compare_dirs(tmp_path / "golden", tmp_path / "export") + + assert result.ok + assert result.missing == [] + assert result.extra == [] + assert result.mismatched == {} + + def test_missing_export_is_reported(self, tmp_path): + self._write(tmp_path / "golden", "osism_sw1_config_db.json", {}) + (tmp_path / "export").mkdir() + + result = compare_dirs(tmp_path / "golden", tmp_path / "export") + + assert not result.ok + assert result.missing == ["osism_sw1_config_db.json"] + + def test_extra_export_is_reported(self, tmp_path): + (tmp_path / "golden").mkdir() + self._write(tmp_path / "export", "osism_sw2_config_db.json", {}) + + result = compare_dirs(tmp_path / "golden", tmp_path / "export") + + assert not result.ok + assert result.extra == ["osism_sw2_config_db.json"] + + def test_mismatch_collects_paths_and_unified_diff(self, tmp_path): + name = "osism_sw1_config_db.json" + self._write( + tmp_path / "golden", name, {"PORT": {"Ethernet4": {"speed": "100000"}}} + ) + self._write( + tmp_path / "export", name, {"PORT": {"Ethernet4": {"speed": "100"}}} + ) + + result = compare_dirs(tmp_path / "golden", tmp_path / "export") + + assert not result.ok + mismatch = result.mismatched[name] + assert mismatch.paths == ["PORT|Ethernet4.speed: golden '100000', actual '100'"] + assert '- "speed": "100000"' in mismatch.diff + assert '+ "speed": "100"' in mismatch.diff + + def test_non_json_files_are_ignored(self, tmp_path): + config = {"PORT": {}} + self._write(tmp_path / "golden", "osism_sw1_config_db.json", config) + self._write(tmp_path / "export", "osism_sw1_config_db.json", config) + (tmp_path / "export" / "firmware_sw1.bin").write_text("not json") + + result = compare_dirs(tmp_path / "golden", tmp_path / "export") + + assert result.ok + + def test_report_distinguishes_failure_categories(self, tmp_path): + self._write(tmp_path / "golden", "osism_sw1_config_db.json", {"A": {}}) + self._write(tmp_path / "golden", "osism_sw2_config_db.json", {}) + self._write(tmp_path / "export", "osism_sw1_config_db.json", {"B": {}}) + self._write(tmp_path / "export", "osism_sw3_config_db.json", {}) + + result = compare_dirs(tmp_path / "golden", tmp_path / "export") + report = result.report() + + assert "osism_sw2_config_db.json" in report + assert "generation or export failed" in report + assert "osism_sw3_config_db.json" in report + assert "unexpected extra" in report + assert "osism_sw1_config_db.json" in report + assert "A: only in golden" in report + + +class TestRegenerate: + def test_copies_exports_to_golden_canonically(self, tmp_path): + export = tmp_path / "export" + golden = tmp_path / "golden" + export.mkdir() + (export / "osism_sw1_config_db.json").write_text('{"B": {}, "A": {}}') + + regenerate(golden, export) + + content = (golden / "osism_sw1_config_db.json").read_text() + assert content == '{\n "A": {},\n "B": {}\n}\n' + + def test_removes_stale_golden_files(self, tmp_path): + export = tmp_path / "export" + golden = tmp_path / "golden" + export.mkdir() + golden.mkdir() + (export / "osism_sw1_config_db.json").write_text("{}") + (golden / "osism_gone_config_db.json").write_text("{}") + + regenerate(golden, export) + + assert not (golden / "osism_gone_config_db.json").exists() + assert (golden / "osism_sw1_config_db.json").exists() + + def test_ignores_non_json_exports(self, tmp_path): + export = tmp_path / "export" + golden = tmp_path / "golden" + export.mkdir() + (export / "firmware_sw1.bin").write_text("binary") + (export / "osism_sw1_config_db.json").write_text("{}") + + regenerate(golden, export) + + assert not (golden / "firmware_sw1.bin").exists() + + +class TestMain: + @staticmethod + def _dirs(tmp_path, golden_config, export_config): + golden = tmp_path / "golden" + export = tmp_path / "export" + golden.mkdir() + export.mkdir() + name = "osism_sw1_config_db.json" + (golden / name).write_text(json.dumps(golden_config)) + (export / name).write_text(json.dumps(export_config)) + return golden, export + + def test_matching_dirs_exit_zero(self, tmp_path, capsys): + golden, export = self._dirs(tmp_path, {"A": {}}, {"A": {}}) + + rc = main(["--golden", str(golden), "--export", str(export)]) + + assert rc == 0 + assert "OK" in capsys.readouterr().out + + def test_mismatch_exits_nonzero_and_prints_report(self, tmp_path, capsys): + golden, export = self._dirs(tmp_path, {"A": {}}, {"B": {}}) + + rc = main(["--golden", str(golden), "--export", str(export)]) + + assert rc == 1 + out = capsys.readouterr().out + assert "A: only in golden" in out + + def test_regenerate_rewrites_goldens_and_exits_zero(self, tmp_path): + golden, export = self._dirs(tmp_path, {"A": {}}, {"B": {}}) + + rc = main(["--golden", str(golden), "--export", str(export), "--regenerate"]) + + assert rc == 0 + name = "osism_sw1_config_db.json" + assert json.loads((golden / name).read_text()) == {"B": {}} + + def test_regenerate_coverage_loss_exits_nonzero_without_flag( + self, tmp_path, capsys + ): + """Without --allow-coverage-loss, main() reports the loss and fails.""" + golden, export = self._dirs(tmp_path, {"VLAN": {"Vlan100": {}}}, {"VLAN": {}}) + + rc = main(["--golden", str(golden), "--export", str(export), "--regenerate"]) + + assert rc == 1 + out = capsys.readouterr().out + assert "COVERAGE LOSS" in out + assert "osism_sw1_config_db.json: VLAN" in out + # Goldens are still written even though the run is reported failed. + name = "osism_sw1_config_db.json" + assert json.loads((golden / name).read_text()) == {"VLAN": {}} + + def test_regenerate_coverage_loss_exits_zero_with_flag(self, tmp_path, capsys): + """--allow-coverage-loss makes the same loss a successful regen.""" + golden, export = self._dirs(tmp_path, {"VLAN": {"Vlan100": {}}}, {"VLAN": {}}) + + rc = main( + [ + "--golden", + str(golden), + "--export", + str(export), + "--regenerate", + "--allow-coverage-loss", + ] + ) + + assert rc == 0 + out = capsys.readouterr().out + assert "COVERAGE LOSS" not in out + name = "osism_sw1_config_db.json" + assert json.loads((golden / name).read_text()) == {"VLAN": {}} + + +def test_regenerate_reports_emptied_table(tmp_path): + """A table that was populated and becomes empty is reported.""" + golden = tmp_path / "golden" + export = tmp_path / "export" + golden.mkdir() + export.mkdir() + (golden / "osism_sw_config_db.json").write_text( + json.dumps({"PORT": {"Ethernet0": {}}, "VLAN": {"Vlan100": {}}}) + ) + (export / "osism_sw_config_db.json").write_text( + json.dumps({"PORT": {"Ethernet0": {}}, "VLAN": {}}) + ) + + lost = compare.regenerate(golden, export) + + assert lost == ["osism_sw_config_db.json: VLAN"] + + +def test_regenerate_ignores_tables_empty_in_both(tmp_path): + """A table empty before and after is not a loss.""" + golden = tmp_path / "golden" + export = tmp_path / "export" + golden.mkdir() + export.mkdir() + (golden / "osism_sw_config_db.json").write_text(json.dumps({"NTP_SERVER": {}})) + (export / "osism_sw_config_db.json").write_text(json.dumps({"NTP_SERVER": {}})) + + assert compare.regenerate(golden, export) == [] + + +def test_regenerate_still_writes_when_coverage_lost(tmp_path): + """The guard reports; it does not refuse to write.""" + golden = tmp_path / "golden" + export = tmp_path / "export" + golden.mkdir() + export.mkdir() + (golden / "osism_sw_config_db.json").write_text( + json.dumps({"VLAN": {"Vlan100": {}}}) + ) + (export / "osism_sw_config_db.json").write_text(json.dumps({"VLAN": {}})) + + compare.regenerate(golden, export) + + assert json.loads((golden / "osism_sw_config_db.json").read_text()) == {"VLAN": {}} + + +def test_regenerate_ignores_new_file(tmp_path): + """A device with no previous golden cannot lose coverage.""" + golden = tmp_path / "golden" + export = tmp_path / "export" + golden.mkdir() + export.mkdir() + (export / "osism_new_config_db.json").write_text(json.dumps({"VLAN": {}})) + + assert compare.regenerate(golden, export) == [] + + +def test_regenerate_reports_removed_file_with_populated_tables(tmp_path): + """A device that stops being exported at all is the largest coverage loss.""" + golden = tmp_path / "golden" + export = tmp_path / "export" + golden.mkdir() + export.mkdir() + (golden / "osism_gone_config_db.json").write_text( + json.dumps({"PORT": {"Ethernet0": {}}, "VLAN": {"Vlan100": {}}}) + ) + + lost = compare.regenerate(golden, export) + + assert lost == ["osism_gone_config_db.json: file removed, had 2 populated tables"] + assert not (golden / "osism_gone_config_db.json").exists() diff --git a/tests/unit/e2e/test_generate.py b/tests/unit/e2e/test_generate.py new file mode 100644 index 00000000..2e3a831d --- /dev/null +++ b/tests/unit/e2e/test_generate.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the E2E generation driver. + +The driver's assertion logic (expected device set, loguru error capture) +is pure and tested here with an injected sync callable; only the real +``sync_sonic`` import is exercised in the actual E2E run. +""" + +from loguru import logger + +from tests.e2e.generate import expected_devices, main, run_generation + + +class TestExpectedDevices: + def test_strips_prefix_and_suffix(self, tmp_path): + (tmp_path / "osism_testbed-switch-0_config_db.json").write_text("{}") + (tmp_path / "osism_testbed-switch-1_config_db.json").write_text("{}") + + assert expected_devices(tmp_path, "osism_", "_config_db.json") == { + "testbed-switch-0", + "testbed-switch-1", + } + + def test_ignores_non_json_and_foreign_names(self, tmp_path): + (tmp_path / "osism_sw1_config_db.json").write_text("{}") + (tmp_path / "README.md").write_text("") + (tmp_path / "other_sw2.json").write_text("{}") + + assert expected_devices(tmp_path, "osism_", "_config_db.json") == {"sw1"} + + +class TestRunGeneration: + def test_returns_configs_and_no_errors_for_clean_sync(self): + configs, errors = run_generation(lambda: {"sw1": {"PORT": {}}}) + + assert configs == {"sw1": {"PORT": {}}} + assert errors == [] + + def test_captures_loguru_errors_during_sync(self): + def failing_sync(): + logger.error("Failed to sync SONiC configuration for device sw1: boom") + return {} + + configs, errors = run_generation(failing_sync) + + assert configs == {} + assert len(errors) == 1 + assert "boom" in errors[0] + + def test_does_not_capture_non_error_levels(self): + def chatty_sync(): + logger.info("processing") + logger.warning("odd but fine") + return {"sw1": {}} + + _, errors = run_generation(chatty_sync) + + assert errors == [] + + def test_sink_is_removed_after_run(self): + _, errors = run_generation(lambda: {}) + logger.error("logged after the run") + + assert errors == [] + + +class TestMain: + def test_success_returns_zero(self, tmp_path, capsys): + (tmp_path / "osism_sw1_config_db.json").write_text("{}") + + rc = main(["--golden", str(tmp_path)], sync=lambda: {"sw1": {"PORT": {}}}) + + assert rc == 0 + + def test_captured_errors_fail_the_run(self, tmp_path, capsys): + (tmp_path / "osism_sw1_config_db.json").write_text("{}") + + def failing_sync(): + logger.error("device sw1 exploded") + return {"sw1": {}} + + rc = main(["--golden", str(tmp_path)], sync=failing_sync) + + assert rc == 1 + assert "device sw1 exploded" in capsys.readouterr().out + + def test_device_set_mismatch_fails_and_names_devices(self, tmp_path, capsys): + (tmp_path / "osism_sw1_config_db.json").write_text("{}") + (tmp_path / "osism_sw2_config_db.json").write_text("{}") + + rc = main(["--golden", str(tmp_path)], sync=lambda: {"sw1": {"PORT": {}}}) + + assert rc == 1 + assert "sw2" in capsys.readouterr().out + + def test_empty_config_counts_as_failure(self, tmp_path, capsys): + (tmp_path / "osism_sw1_config_db.json").write_text("{}") + + rc = main(["--golden", str(tmp_path)], sync=lambda: {"sw1": {}}) + + assert rc == 1 + assert "sw1" in capsys.readouterr().out + + def test_no_expect_skips_device_set_check(self, capsys): + rc = main(["--no-expect"], sync=lambda: {"sw1": {"PORT": {}}}) + + assert rc == 0