Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Page saves keep every revision. An explicit purge drops only inactive revisions older than a timezone-aware cutoff, keeps live bytes and deleted page history, and stops if a stored hash does not match. Interrupted copies and moves resume from a journal without deleting a changed source or granting destination access. Private corpus review remains open. Assistant: grok Assistant-Session: 01a12cc2-e3b2-77a3-8aef-9cc60e577fee
158 lines
6.7 KiB
Python
158 lines
6.7 KiB
Python
"""Revision retention and crash recovery for copy/move. Synthetic content only."""
|
|
|
|
import os
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from django.urls import reverse
|
|
|
|
from coulomb_social.apps.spaces import pageops
|
|
from coulomb_social.apps.spaces.services import create_space, set_space_access
|
|
from tests.test_space_permissions import make_member
|
|
|
|
|
|
@pytest.fixture
|
|
def trees(tmp_path, settings):
|
|
settings.CONTENT_ROOT = str(tmp_path / "content")
|
|
for slug in ("source", "destination"):
|
|
pageops.init_space_content(slug, title=slug)
|
|
return tmp_path / "content"
|
|
|
|
|
|
def _notes(body="Retained content"):
|
|
asset = pageops.assets_dir("source") / "diagram.png"
|
|
asset.write_bytes(b"synthetic image")
|
|
return pageops.write_page(
|
|
"source", "notes", title="Notes", body=f"\n\n{body}\n",
|
|
)
|
|
|
|
|
|
def test_restart_drops_unconfirmed_destination_and_keeps_source(trees):
|
|
source = _notes()
|
|
before = Path(source.path).read_bytes()
|
|
partial = pageops.assets_dir("destination") / "notes-diagram.png"
|
|
partial.write_bytes(b"synthetic image")
|
|
record = pageops._start_record(
|
|
"transfer", "source", "notes", "destination", "notes", source.revision,
|
|
)
|
|
pageops._note_asset(record, "notes-diagram.png")
|
|
pageops.write_page("source", "other", title="Other", body="Unrelated")
|
|
assert not partial.exists()
|
|
assert Path(source.path).read_bytes() == before
|
|
assert pageops.page_aliases("source") == {}
|
|
assert not pageops.read_page("destination", "notes").exists
|
|
assert not list((trees / ".recovery").glob("*.json"))
|
|
|
|
|
|
def test_restart_finishes_a_published_move_without_changing_bytes(trees):
|
|
source = _notes()
|
|
copied = pageops.copy_page(
|
|
"source", "notes", "destination", "notes", title="Notes", journal=True,
|
|
)
|
|
record = pageops._start_record(
|
|
"transfer", "source", "notes", "destination", "notes", source.revision,
|
|
)
|
|
record["dest_revision"] = copied.revision
|
|
record["phase"] = "published"
|
|
pageops._write_record(record)
|
|
result = pageops.recover_interrupted_content()
|
|
assert result == {"cleared": 1, "held": 0}
|
|
assert not Path(source.path).exists()
|
|
assert pageops.page_aliases("source")["notes"] == ["destination", "notes"]
|
|
assert pageops.read_page("destination", "notes").revision == copied.revision
|
|
assert not list((trees / ".recovery").glob("*.json"))
|
|
|
|
|
|
def test_restart_keeps_a_source_that_changed_and_drops_its_alias(trees):
|
|
source = _notes()
|
|
copied = pageops.copy_page(
|
|
"source", "notes", "destination", "notes", title="Notes", journal=True,
|
|
)
|
|
pageops.remember_page_address("source", "notes", "destination", "notes")
|
|
record = pageops._start_record(
|
|
"transfer", "source", "notes", "destination", "notes", source.revision,
|
|
)
|
|
record["dest_revision"] = copied.revision
|
|
record["phase"] = "aliased"
|
|
pageops._write_record(record)
|
|
Path(source.path).write_bytes(b"changed after the crash window\n")
|
|
pageops.recover_interrupted_content()
|
|
assert Path(source.path).read_bytes() == b"changed after the crash window\n"
|
|
assert pageops.page_aliases("source") == {}
|
|
assert pageops.read_page("destination", "notes").revision == copied.revision
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_recovered_move_still_requires_the_destination_grant(trees, client):
|
|
owner, reader = make_member("owner"), make_member("reader")
|
|
source = create_space(owner, title="Shared", slug="source")
|
|
target = create_space(owner, title="Private", slug="private")
|
|
set_space_access(owner, source, reader.pk, "viewer")
|
|
doc = pageops.write_page(source.slug, "notes", title="Notes", body="Retained content")
|
|
copied = pageops.copy_page(
|
|
source.slug, "notes", target.slug, "moved", title="Notes", journal=True,
|
|
)
|
|
record = pageops._start_record(
|
|
"transfer", source.slug, "notes", target.slug, "moved", doc.revision,
|
|
)
|
|
record["dest_revision"] = copied.revision
|
|
record["phase"] = "published"
|
|
pageops._write_record(record)
|
|
pageops.recover_interrupted_content()
|
|
client.force_login(reader.user)
|
|
old_url = reverse("spaces:detail", args=[source.slug]) + "?page=notes"
|
|
assert client.get(old_url).status_code == 404
|
|
set_space_access(owner, target, reader.pk, "viewer")
|
|
response = client.get(old_url, follow=True)
|
|
assert response.status_code == 200
|
|
assert b"Retained content" in response.content
|
|
|
|
|
|
def test_existing_destination_is_refused_without_a_journal(trees):
|
|
source = _notes()
|
|
pageops.write_page("destination", "notes", title="Keep", body="Already here")
|
|
before = pageops.page_file("destination", "notes").read_bytes()
|
|
with pytest.raises(pageops.PageOpsError, match="already exists"):
|
|
pageops.transfer_page("source", "notes", "destination", "notes")
|
|
assert pageops.page_file("destination", "notes").read_bytes() == before
|
|
assert Path(source.path).read_bytes()
|
|
assert not list((trees / ".recovery").glob("*.json"))
|
|
|
|
|
|
def test_purge_keeps_live_bytes_and_deleted_page_history(trees):
|
|
first = pageops.write_page("source", "notes", title="Notes", body="First")
|
|
pageops.write_page(
|
|
"source", "notes", title="Notes", body="Second", expected_revision=first.revision,
|
|
)
|
|
removed = pageops.write_page("source", "gone", title="Gone", body="Recover me")
|
|
pageops.delete_page("source", "gone")
|
|
revision_dir = trees / ".revisions" / "source" / "notes"
|
|
old = next(path for path in revision_dir.glob("*.md") if path.stem == first.revision)
|
|
live = next(path for path in revision_dir.glob("*.md") if path.stem != first.revision)
|
|
deleted = next((trees / ".revisions" / "source" / "gone").glob("*.md"))
|
|
past = (datetime.now(UTC) - timedelta(days=30)).timestamp()
|
|
for path in (old, live, deleted):
|
|
os.utime(path, (past, past))
|
|
removed_count = pageops.purge_revisions(
|
|
older_than=datetime.now(UTC) - timedelta(days=7),
|
|
)
|
|
assert removed_count == 1
|
|
assert not old.exists()
|
|
assert live.exists()
|
|
assert deleted.exists()
|
|
assert pageops.read_page("source", "notes").body == "Second\n"
|
|
assert removed.body == "Recover me\n"
|
|
|
|
|
|
def test_purge_stops_before_deleting_when_a_revision_hash_does_not_match(trees):
|
|
doc = pageops.write_page("source", "notes", title="Notes", body="First")
|
|
pageops.write_page(
|
|
"source", "notes", title="Notes", body="Second", expected_revision=doc.revision,
|
|
)
|
|
stored = list((trees / ".revisions" / "source" / "notes").glob("*.md"))
|
|
stored[0].write_bytes(b"tampered")
|
|
past = datetime.now(UTC) - timedelta(days=1)
|
|
with pytest.raises(pageops.PageOpsError, match="integrity"):
|
|
pageops.purge_revisions(older_than=past)
|
|
assert len(list((trees / ".revisions" / "source" / "notes").glob("*.md"))) == 2
|