US06-02: Transfer, Verify, and Remove Active Sources (#78)
This commit was merged in pull request #78.
This commit is contained in:
105
tests/unit/test_archive_journal_states.py
Normal file
105
tests/unit/test_archive_journal_states.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user