Skip to content

Commit c6b6fc6

Browse files
test(wl_versions): cover revert_csv_pipeline (43% → 79%) — G3 batch 3b
Add TestRevertCsvPipeline class with 10 tests covering the 219-line revert_csv_pipeline function (lines 377-658 in bin/wl_versions.py): - Happy path: overwrites CSV with version contents + returns success - Version file not found: returns error dict without writing CSV - Audit event records added rows as restoredback_* - Audit event records removed rows as removedback_* - Audit event records edited rows as changedback_* - Audit event skips hidden columns (_ prefix) - Outer OSError → error dict + no partial write - Generic exception → error dict + logged - Source version removed from manifest after revert - Column position change recorded in moveback_* Mock target subtlety: revert_csv_pipeline imports post_audit_event inside its function body (line 434: `from wl_audit import post_audit_event`), not at module level. Patches must target wl_audit.post_audit_event (source module), not wl_versions (no such attribute exists on wl_versions at import time). Coverage: bin/wl_versions.py 43% → 79% (+36 percentage points, 55 missing lines remain — mostly _resolve_path_for_csv and small defensive checks). Tests: 716 passed, 1 skipped (Windows symlink skip pre-existing).
1 parent c9488d9 commit c6b6fc6

1 file changed

Lines changed: 342 additions & 0 deletions

File tree

tests/unit/test_versions.py

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
write_version_manifest,
3030
snapshot_version,
3131
get_versions_list,
32+
revert_csv_pipeline,
3233
)
34+
import wl_versions as wl_versions_module
35+
import wl_audit as wl_audit_module
36+
from wl_csv import write_csv as _write_csv_helper
3337

3438

3539
@pytest.mark.unit
@@ -641,3 +645,341 @@ def test_get_versions_list_filename_without_csv_extension(self, tmp_path):
641645
assert len(result) == 1
642646
# Version ID should be extracted even without .csv
643647
assert result[0]["version_id"] == "20260331_203045"
648+
649+
650+
# ═════════════════════════════════════════════════════════════════════════════
651+
# Test: revert_csv_pipeline — covers wl_versions.py:377-658 (G3 batch 3b)
652+
#
653+
# This 280+ line pipeline function was uncovered prior to G3. Tests use
654+
# tmp_path for real filesystem I/O (CSV reads/writes, manifest, version
655+
# snapshots) and mock post_audit_event so no actual Splunk REST call fires.
656+
# ═════════════════════════════════════════════════════════════════════════════
657+
658+
659+
def _setup_csv_with_version(tmp_path, current_rows, version_rows,
660+
headers=("name", "value")):
661+
"""Build a CSV + a version snapshot + a manifest in tmp_path.
662+
663+
Returns (csv_path, version_filename, version_display).
664+
"""
665+
csv_path = tmp_path / "test.csv"
666+
versions_dir = tmp_path / "_versions"
667+
versions_dir.mkdir()
668+
669+
_write_csv_helper(str(csv_path), list(headers), current_rows)
670+
671+
version_filename = "test_20260101_120000.csv"
672+
version_path = versions_dir / version_filename
673+
_write_csv_helper(str(version_path), list(headers), version_rows)
674+
675+
# Write a manifest that references the version snapshot
676+
manifest_path = versions_dir / "test_versions.json"
677+
manifest = {
678+
"versions": [
679+
{
680+
"timestamp": "2026-01-01T12:00:00Z",
681+
"display": "01-01-2026 12:00:00",
682+
"filename": version_filename,
683+
"analyst": "tester",
684+
"action": "save",
685+
"row_count": len(version_rows),
686+
"col_count": len(headers),
687+
}
688+
]
689+
}
690+
manifest_path.write_text(json.dumps(manifest))
691+
692+
return str(csv_path), version_filename, "01-01-2026 12:00:00"
693+
694+
695+
@pytest.mark.unit
696+
class TestRevertCsvPipeline:
697+
"""Cover revert_csv_pipeline at bin/wl_versions.py:377-658."""
698+
699+
def test_happy_path_overwrites_csv_and_returns_success(self, tmp_path):
700+
"""Current CSV is replaced by version content; audit event posted."""
701+
current = [{"name": "Alice", "value": "1"}, {"name": "Bob", "value": "2"}]
702+
version = [{"name": "Alice", "value": "1"}] # version had only Alice
703+
csv_path, version_filename, version_display = _setup_csv_with_version(
704+
tmp_path, current, version
705+
)
706+
707+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
708+
mock_post.return_value = (True, "")
709+
result = revert_csv_pipeline(
710+
csv_path=csv_path,
711+
version_filename=version_filename,
712+
version_display=version_display,
713+
revert_reason="oops, restore Alice-only state",
714+
analyst="tester",
715+
session_key="test_session",
716+
csv_file="test.csv",
717+
app_context="wl_manager",
718+
detection_rule="DR1",
719+
)
720+
721+
assert result["success"] is True
722+
assert result["error"] == ""
723+
# CSV was overwritten with version content (only Alice now)
724+
from wl_csv import read_csv
725+
new_headers, new_rows = read_csv(csv_path)
726+
assert len(new_rows) == 1
727+
assert new_rows[0]["name"] == "Alice"
728+
# Audit event posted
729+
assert mock_post.call_count == 1
730+
evt = mock_post.call_args.args[1]
731+
assert evt["action"] == "revert"
732+
assert evt["analyst"] == "tester"
733+
assert evt["reverted_to_version"] == version_display
734+
735+
def test_version_file_not_found_returns_error(self, tmp_path):
736+
"""Non-existent version filename returns error without touching CSV."""
737+
csv_path = tmp_path / "test.csv"
738+
(tmp_path / "_versions").mkdir()
739+
_write_csv_helper(str(csv_path), ["name"], [{"name": "Alice"}])
740+
original_content = csv_path.read_text()
741+
742+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
743+
result = revert_csv_pipeline(
744+
csv_path=str(csv_path),
745+
version_filename="nonexistent_99999999_000000.csv",
746+
version_display="bogus",
747+
revert_reason="testing missing version",
748+
analyst="tester",
749+
session_key="key",
750+
)
751+
752+
assert result["success"] is False
753+
assert "Version file not found" in result["error"]
754+
# CSV is unchanged
755+
assert csv_path.read_text() == original_content
756+
# No audit event posted (early return before audit)
757+
assert mock_post.call_count == 0
758+
759+
def test_audit_event_records_added_rows_as_restoredback(self, tmp_path):
760+
"""Rows present in version but not current → restoredback_ lines."""
761+
current = [{"name": "Alice", "value": "1"}]
762+
version = [
763+
{"name": "Alice", "value": "1"},
764+
{"name": "Bob", "value": "2"}, # added back by revert
765+
]
766+
csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version)
767+
768+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
769+
mock_post.return_value = (True, "")
770+
revert_csv_pipeline(
771+
csv_path=csv_path,
772+
version_filename=vf,
773+
version_display=vd,
774+
revert_reason="restore Bob",
775+
analyst="tester",
776+
session_key="key",
777+
)
778+
779+
evt = mock_post.call_args.args[1]
780+
value_lines = evt["value"]
781+
# Bob's row should appear in restoredback_ entries
782+
assert any("restoredback_name" in line and "Bob" in line for line in value_lines)
783+
assert evt["restoredback_row_count"] == 1
784+
assert evt["removedback_row_count"] == 0
785+
786+
def test_audit_event_records_removed_rows_as_removedback(self, tmp_path):
787+
"""Rows present in current but not version → removedback_ lines."""
788+
current = [
789+
{"name": "Alice", "value": "1"},
790+
{"name": "Bob", "value": "2"},
791+
]
792+
version = [{"name": "Alice", "value": "1"}] # version doesn't have Bob
793+
csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version)
794+
795+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
796+
mock_post.return_value = (True, "")
797+
revert_csv_pipeline(
798+
csv_path=csv_path,
799+
version_filename=vf,
800+
version_display=vd,
801+
revert_reason="remove Bob via revert",
802+
analyst="tester",
803+
session_key="key",
804+
)
805+
806+
evt = mock_post.call_args.args[1]
807+
value_lines = evt["value"]
808+
assert any("removedback_name" in line and "Bob" in line for line in value_lines)
809+
assert evt["removedback_row_count"] == 1
810+
assert evt["restoredback_row_count"] == 0
811+
812+
def test_audit_event_records_edited_rows_as_changedback(self, tmp_path):
813+
"""Same row keys but different values → changedback_ lines."""
814+
current = [{"name": "Alice", "value": "current_value"}]
815+
version = [{"name": "Alice", "value": "old_value"}]
816+
csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version)
817+
818+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
819+
mock_post.return_value = (True, "")
820+
revert_csv_pipeline(
821+
csv_path=csv_path,
822+
version_filename=vf,
823+
version_display=vd,
824+
revert_reason="restore old value",
825+
analyst="tester",
826+
session_key="key",
827+
)
828+
829+
evt = mock_post.call_args.args[1]
830+
value_lines = evt["value"]
831+
# Should contain a changedback_value line showing the field change
832+
assert any("changedback_value" in line for line in value_lines)
833+
assert evt["editedback_row_count"] == 1
834+
835+
def test_audit_event_skips_hidden_columns(self, tmp_path):
836+
"""Headers starting with `_` are not surfaced in value lines."""
837+
headers = ("name", "_internal_id", "value")
838+
current = [{"name": "Alice", "_internal_id": "x1", "value": "1"}]
839+
version = [{"name": "Alice", "_internal_id": "y1", "value": "1"},
840+
{"name": "Bob", "_internal_id": "y2", "value": "2"}]
841+
csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version, headers=headers)
842+
843+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
844+
mock_post.return_value = (True, "")
845+
revert_csv_pipeline(
846+
csv_path=csv_path,
847+
version_filename=vf,
848+
version_display=vd,
849+
revert_reason="restore Bob",
850+
analyst="tester",
851+
session_key="key",
852+
)
853+
854+
evt = mock_post.call_args.args[1]
855+
value_lines = evt["value"]
856+
# Hidden _internal_id must not appear in any audit value line
857+
assert not any("_internal_id" in line for line in value_lines), (
858+
"value_lines should skip _-prefixed columns: {}".format(value_lines)
859+
)
860+
# But the visible columns should appear
861+
assert any("name" in line for line in value_lines)
862+
863+
def test_outer_oserror_returns_error_dict(self, tmp_path):
864+
"""OSError during read_csv → returns error dict (lines 643-650)."""
865+
# Use a directory as csv_path → read_csv will raise (IsADirectoryError
866+
# / PermissionError depending on OS; both are OSError subclasses).
867+
target = tmp_path / "fake.csv"
868+
target.mkdir() # directory, not a file
869+
(tmp_path / "_versions").mkdir()
870+
871+
with patch.object(wl_audit_module, "post_audit_event"):
872+
result = revert_csv_pipeline(
873+
csv_path=str(target),
874+
version_filename="anything.csv",
875+
version_display="any",
876+
revert_reason="testing oserror path",
877+
analyst="tester",
878+
session_key="key",
879+
)
880+
881+
assert result["success"] is False
882+
# The OSError branch wraps the message with "Failed to revert CSV"
883+
# OR the generic Exception branch wraps with "Unexpected error".
884+
# Either way, an error is returned.
885+
assert result["error"] != ""
886+
assert result["data"] == {}
887+
888+
def test_generic_exception_returns_error_dict(self, tmp_path):
889+
"""Non-OSError exception in pipeline → generic-exception branch (651-658)."""
890+
current = [{"name": "Alice", "value": "1"}]
891+
version = [{"name": "Bob", "value": "2"}]
892+
csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version)
893+
894+
# Inject a RuntimeError by patching compute_diff (called inside pipeline)
895+
with patch.object(wl_versions_module, "compute_diff",
896+
side_effect=RuntimeError("synthetic failure")), \
897+
patch.object(wl_audit_module, "post_audit_event"):
898+
result = revert_csv_pipeline(
899+
csv_path=csv_path,
900+
version_filename=vf,
901+
version_display=vd,
902+
revert_reason="trigger generic exception",
903+
analyst="tester",
904+
session_key="key",
905+
)
906+
907+
assert result["success"] is False
908+
assert "Unexpected error during revert" in result["error"]
909+
assert "synthetic failure" in result["error"]
910+
911+
def test_revert_removes_source_version_from_manifest(self, tmp_path):
912+
"""After revert, the source version entry is removed from manifest
913+
(avoids duplicate when the revert snapshot is later added)."""
914+
current = [{"name": "Alice", "value": "1"}]
915+
version = [{"name": "Bob", "value": "2"}]
916+
csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version)
917+
918+
with patch.object(wl_audit_module, "post_audit_event"):
919+
revert_csv_pipeline(
920+
csv_path=csv_path,
921+
version_filename=vf,
922+
version_display=vd,
923+
revert_reason="cleanup test",
924+
analyst="tester",
925+
session_key="key",
926+
)
927+
928+
# Read the manifest post-revert: source version should be gone
929+
manifest, _ = read_version_manifest(csv_path)
930+
version_files = [
931+
v.get("filename", "") for v in manifest.get("versions", [])
932+
]
933+
assert vf not in version_files, (
934+
"Source version {} should be removed from manifest. Got: {}".format(
935+
vf, version_files
936+
)
937+
)
938+
939+
def test_revert_pipeline_records_column_position_changes(self, tmp_path):
940+
"""If column order differs between current and version, moveback_column lines appear."""
941+
# current has columns in order [name, value]; version has [value, name]
942+
current_headers = ("name", "value")
943+
version_headers = ("value", "name")
944+
current = [{"name": "Alice", "value": "1"}]
945+
version = [{"value": "1", "name": "Alice"}]
946+
947+
csv_path = tmp_path / "test.csv"
948+
versions_dir = tmp_path / "_versions"
949+
versions_dir.mkdir()
950+
_write_csv_helper(str(csv_path), list(current_headers), current)
951+
vf = "test_20260101_120000.csv"
952+
_write_csv_helper(str(versions_dir / vf), list(version_headers), version)
953+
manifest_path = versions_dir / "test_versions.json"
954+
manifest_path.write_text(json.dumps({
955+
"versions": [{
956+
"filename": vf,
957+
"display": "01-01-2026 12:00:00",
958+
"timestamp": "2026-01-01T12:00:00Z",
959+
"analyst": "t",
960+
"action": "save",
961+
"row_count": 1,
962+
"col_count": 2,
963+
}]
964+
}))
965+
966+
with patch.object(wl_audit_module, "post_audit_event") as mock_post:
967+
mock_post.return_value = (True, "")
968+
result = revert_csv_pipeline(
969+
csv_path=str(csv_path),
970+
version_filename=vf,
971+
version_display="01-01-2026 12:00:00",
972+
revert_reason="restore column order",
973+
analyst="tester",
974+
session_key="key",
975+
)
976+
977+
evt = mock_post.call_args.args[1]
978+
value_lines = evt["value"]
979+
# At least one moveback_column line should appear (columns swapped)
980+
moveback_col_lines = [l for l in value_lines if "moveback_column" in l]
981+
assert len(moveback_col_lines) > 0, (
982+
"expected moveback_column lines when column order differs; got: {}"
983+
.format(value_lines)
984+
)
985+
assert evt["moveback_column_count"] >= 1

0 commit comments

Comments
 (0)