From 3f1afa4db166025232b9d3cd6c0a0d7da63d8c97 Mon Sep 17 00:00:00 2001 From: Sergei Selivanov Date: Tue, 3 Mar 2026 10:25:35 +0200 Subject: [PATCH] Add tests exposing 4 critical bugs --- tests/test_create.py | 63 ++++++++++++++++++++++++++ tests/test_hasher.py | 24 ++++++++++ tests/test_nested.py | 102 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) diff --git a/tests/test_create.py b/tests/test_create.py index 4abbd560..bfb9e059 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -8,12 +8,15 @@ """ import os +import datetime from freezegun import freeze_time from click.testing import CliRunner from .conftest import path_conversion_tests from .conftest import abspath_conversion_tests from ascmhl import utils +from ascmhl.generator import MHLGenerationCreationSession +from ascmhl.hashlist import MHLHashEntry from ascmhl.history import MHLHistory import ascmhl.commands @@ -376,3 +379,63 @@ def test_create_mulitple_hashformats_double_hashformat(fs, simple_mhl_history): print(result.output) assert result.exit_code == 0 + + +@freeze_time("2020-01-15 13:00:00") +def test_multiple_format_hash_entries_are_instances(fs): + """Bug: generator.py:75 — hash_entries = [MHLHashEntry] includes the class + itself as a list element. Every hash entry must be an instance, not the class.""" + fs.create_file("/root/Stuff.txt", contents="stuff\n") + + runner = CliRunner() + result = runner.invoke(ascmhl.commands.create, [abspath_conversion_tests("/root"), "-h", "xxh64"]) + assert result.exit_code == 0 + + history = MHLHistory.load_from_path(abspath_conversion_tests("/root")) + session = MHLGenerationCreationSession(history) + + file_path = abspath_conversion_tests("/root/Stuff.txt") + file_size = os.path.getsize(file_path) + modification_date = datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) + + hash_lookup = {"xxh64": "aabbccdd11223344", "md5": "d41d8cd98f00b204e9800998ecf8427e"} + session.append_multiple_format_file_hashes(file_path, file_size, hash_lookup, modification_date) + + new_hash_list = session.new_hash_lists[history] + media_hash = new_hash_list.find_media_hash_for_path("Stuff.txt") + assert media_hash is not None + + for entry in media_hash.hash_entries: + assert isinstance(entry, MHLHashEntry), ( + f"Expected an instance of MHLHashEntry, got {entry!r}. " + f"The MHLHashEntry class itself was inserted into hash_entries." + ) + + +@freeze_time("2020-01-15 13:00:00") +def test_multiple_format_hash_entries_correct_count(fs): + """Bug: generator.py:75 — hash_entries = [MHLHashEntry] causes 3 entries + instead of 2 when appending 2 hash formats.""" + fs.create_file("/root/File.txt", contents="data\n") + + runner = CliRunner() + result = runner.invoke(ascmhl.commands.create, [abspath_conversion_tests("/root"), "-h", "xxh64"]) + assert result.exit_code == 0 + + history = MHLHistory.load_from_path(abspath_conversion_tests("/root")) + session = MHLGenerationCreationSession(history) + + file_path = abspath_conversion_tests("/root/File.txt") + file_size = os.path.getsize(file_path) + modification_date = datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) + + hash_lookup = {"xxh64": "aabbccdd11223344", "md5": "d41d8cd98f00b204e9800998ecf8427e"} + session.append_multiple_format_file_hashes(file_path, file_size, hash_lookup, modification_date) + + new_hash_list = session.new_hash_lists[history] + media_hash = new_hash_list.find_media_hash_for_path("File.txt") + assert media_hash is not None + assert len(media_hash.hash_entries) == 2, ( + f"Expected 2 hash entries (one per format), got {len(media_hash.hash_entries)}. " + f"Extra entry is likely the MHLHashEntry class from `hash_entries = [MHLHashEntry]`." + ) diff --git a/tests/test_hasher.py b/tests/test_hasher.py index 62ca4f65..7b87768b 100644 --- a/tests/test_hasher.py +++ b/tests/test_hasher.py @@ -9,6 +9,7 @@ import pytest from ascmhl.hasher import * +from ascmhl.hasher import AggregateHasher def test_cannot_instantiate_abstract_classes(): @@ -149,3 +150,26 @@ def test_hash_list_concat_equals_chunking(): concat = hasher.bytes_from_string_digest("".join(hash_list)) h2 = hasher.hash_data(concat) assert h1 == h2 # assert that sequentially feeding hashes is same as concat then feeding hashes + + +def test_aggregate_hasher_can_be_instantiated(): + """Bug: hasher.py:250 — hasher_lookup = Dict[str, str] assigns the type + instead of creating an empty dict, crashing on item assignment.""" + try: + hasher = AggregateHasher(["xxh64", "md5"]) + except TypeError as e: + pytest.fail( + f"AggregateHasher.__init__ crashed: {e}. " + f"hasher_lookup = Dict[str, str] assigns the type, not an empty dict." + ) + + +def test_aggregate_hasher_has_correct_lookups(): + """Bug: hasher.py:250 — after instantiation, hasher_lookup should be a + real dict keyed by format name.""" + hasher = AggregateHasher(["xxh64"]) + assert isinstance(hasher.hasher_lookup, dict), ( + f"hasher_lookup should be a dict, got {type(hasher.hasher_lookup)}. " + f"It was assigned Dict[str, str] (a type) instead of an empty dict." + ) + assert "xxh64" in hasher.hasher_lookup diff --git a/tests/test_nested.py b/tests/test_nested.py index 69ad474b..5ec00b07 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -8,6 +8,7 @@ """ import os +import pytest from .conftest import abspath_conversion_tests from .conftest import path_conversion_tests @@ -15,6 +16,8 @@ from freezegun import freeze_time import ascmhl +from ascmhl.hashlist import MHLHashList, MHLHashListReference, MHLProcessInfo +from ascmhl.history import MHLHistory @freeze_time("2020-01-16 09:15:00") @@ -197,3 +200,102 @@ def test_create_nested_mhl_chain_missing(fs): result = runner.invoke(ascmhl.commands.diff, [abspath_conversion_tests("/root")]) assert result.exception assert result.exit_code == 32 + + +def test_nested_history_no_crash_when_child_has_no_ignore_patterns(): + """Bug: history.py:105 — latest_ignore_pattern_from_nested_histories iterates + over latest_ignore_patterns() which can return None, causing TypeError.""" + parent = MHLHistory() + parent.asc_mhl_path = "/root/ascmhl" + + child = MHLHistory() + child.asc_mhl_path = "/root/sub/ascmhl" + child.parent_history = parent + # child has NO hash_lists, so latest_ignore_patterns() returns None + + parent.child_histories.append(child) + parent.child_history_mappings["sub"] = child + + try: + parent.latest_ignore_pattern_from_nested_histories() + except TypeError as e: + pytest.fail( + f"latest_ignore_pattern_from_nested_histories crashed with TypeError: {e}. " + f"latest_ignore_patterns() returned None for a child with no hash lists." + ) + + +def test_nested_history_no_crash_when_child_has_none_ignore_spec(): + """Bug: history.py:105 — same crash when child has hash lists but + the latest has ignore_spec set to None.""" + parent = MHLHistory() + parent.asc_mhl_path = "/root/ascmhl" + + child = MHLHistory() + child.asc_mhl_path = "/root/sub/ascmhl" + child.parent_history = parent + + hash_list = MHLHashList() + hash_list.process_info = MHLProcessInfo() + hash_list.process_info.ignore_spec = None + child.hash_lists.append(hash_list) + + parent.child_histories.append(child) + parent.child_history_mappings["sub"] = child + + try: + parent.latest_ignore_pattern_from_nested_histories() + except TypeError as e: + pytest.fail( + f"latest_ignore_pattern_from_nested_histories crashed with TypeError: {e}. " + f"latest_ignore_patterns() returned None for a child with None ignore_spec." + ) + + +def test_resolve_references_continues_after_missing_child(): + """Bug: history.py:370 — _resolve_hash_list_references uses `return` + instead of `continue` when a reference path is not found, aborting + resolution of all remaining references.""" + history = MHLHistory() + history.asc_mhl_path = "/root/ascmhl" + + # Create a child history that can be found + child = MHLHistory() + child.asc_mhl_path = "/root/child_b/ascmhl" + child.parent_history = history + + child_hash_list = MHLHashList() + child_hash_list.file_path = "/root/child_b/ascmhl/0001_child_b.mhl" + child_hash_list.generation_number = 1 + child.hash_lists.append(child_hash_list) + + history.child_histories.append(child) + history.child_history_mappings["child_b"] = child + + # Hash list with two references: first missing, second resolvable + hash_list = MHLHashList() + hash_list.file_path = "/root/ascmhl/0001_root.mhl" + + ref_missing = MHLHashListReference() + ref_missing.path = "child_a/ascmhl/0001_child_a.mhl" + ref_missing.reference_hash = "fake_hash_a" + + ref_found = MHLHashListReference() + ref_found.path = "child_b/ascmhl/0001_child_b.mhl" + child_hash_list.generate_reference_hash = lambda: "valid_hash_b" + ref_found.reference_hash = "valid_hash_b" + + hash_list.hash_list_references.append(ref_missing) + hash_list.hash_list_references.append(ref_found) + history.hash_lists.append(hash_list) + + history._resolve_hash_list_references() + + assert len(hash_list.referenced_hash_lists) > 0, ( + "No references were resolved. The method returned early when it " + "encountered the first missing reference instead of continuing." + ) + assert child_hash_list in hash_list.referenced_hash_lists, ( + "The known child_b reference was not resolved. The method " + "aborted at the missing child_a reference (return vs continue)." + )