106 lines
4.5 KiB
Python
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
|