"""Archive journal state machine and evidence table (US06-02). The evidence table decides whether an original may be deleted, so every combination of journal state and disk reality is asserted here as a pure function — no database, no files. A wrong cell in this table is data loss. """ import pytest from photo_pipeline.services.archive_journal import ( ALLOWED_TRANSITIONS, FORWARD, MANUAL, RESUMABLE, TERMINAL_STATES, UNSAFE_STATES, ArchiveState, _classify, can_transition, ) pytestmark = pytest.mark.phase_f # ── state machine ──────────────────────────────────────────────────────────── def test_the_happy_path_is_the_only_way_forward(): assert can_transition(ArchiveState.PLANNED, ArchiveState.TRANSFERRING) assert can_transition(ArchiveState.TRANSFERRING, ArchiveState.VERIFIED) assert can_transition(ArchiveState.VERIFIED, ArchiveState.REMOVING) assert can_transition(ArchiveState.REMOVING, ArchiveState.COMPLETE) # No shortcut may skip verification before a source is removed. assert not can_transition(ArchiveState.TRANSFERRING, ArchiveState.REMOVING) assert not can_transition(ArchiveState.PLANNED, ArchiveState.VERIFIED) assert not can_transition(ArchiveState.VERIFIED, ArchiveState.COMPLETE) def test_removal_never_goes_backwards(): """Once the source may be gone, retrying the transfer would archive nothing and could overwrite the copy that is now the only one.""" assert ALLOWED_TRANSITIONS[ArchiveState.REMOVING] == { ArchiveState.COMPLETE, ArchiveState.FAILED, } assert not can_transition(ArchiveState.REMOVING, ArchiveState.TRANSFERRING) assert not can_transition(ArchiveState.REMOVING, ArchiveState.PLANNED) def test_complete_is_terminal_and_failed_can_be_retried(): assert ALLOWED_TRANSITIONS[ArchiveState.COMPLETE] == set() assert TERMINAL_STATES == {ArchiveState.COMPLETE} assert can_transition(ArchiveState.FAILED, ArchiveState.TRANSFERRING) assert can_transition(ArchiveState.FAILED, ArchiveState.PLANNED) def test_every_state_that_can_touch_the_disk_is_marked_unsafe(): assert UNSAFE_STATES == { ArchiveState.TRANSFERRING, ArchiveState.VERIFIED, ArchiveState.REMOVING, } assert ArchiveState.PLANNED not in UNSAFE_STATES # ── evidence table ─────────────────────────────────────────────────────────── @pytest.mark.parametrize( "state,source,destination,matches,expected", [ # Nothing published yet: the source is still the only copy. (ArchiveState.TRANSFERRING, True, False, False, RESUMABLE), (ArchiveState.FAILED, True, False, False, RESUMABLE), # The archive copy is durable and correct: finish the remaining steps. (ArchiveState.TRANSFERRING, True, True, True, FORWARD), (ArchiveState.TRANSFERRING, False, True, True, FORWARD), (ArchiveState.VERIFIED, True, True, True, FORWARD), (ArchiveState.REMOVING, False, True, True, FORWARD), (ArchiveState.FAILED, True, True, True, FORWARD), # Wrong bytes at the destination: never overwrite, never remove. (ArchiveState.TRANSFERRING, True, True, False, MANUAL), (ArchiveState.VERIFIED, True, True, False, MANUAL), (ArchiveState.REMOVING, False, True, False, MANUAL), # The journal claims an archived copy that is not there. (ArchiveState.VERIFIED, True, False, False, MANUAL), (ArchiveState.REMOVING, False, False, False, MANUAL), # Neither copy exists — never silently accepted as success. (ArchiveState.TRANSFERRING, False, False, False, MANUAL), (ArchiveState.FAILED, False, False, False, MANUAL), ], ) def test_classification_of_every_evidence_combination( state, source, destination, matches, expected ): classification, reason = _classify(state, source, destination, matches) assert classification == expected, reason def test_a_source_that_is_gone_without_an_archive_copy_is_never_called_recoverable(): """The one combination that must always stop: the original left active storage and nothing verifiable took its place.""" for state in (ArchiveState.TRANSFERRING, ArchiveState.VERIFIED, ArchiveState.REMOVING): assert _classify(state, False, False, False)[0] == MANUAL