Files
photoanalyzer/tests/unit/test_archive_journal_states.py

106 lines
4.5 KiB
Python

"""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