coulomb-social/tests/test_import_tree.py
tegwick 789e69902d
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m5s
Harden original-date migration and verify Coulomb recovery admission
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a17f16-d99c-7ea3-95f0-8212897fba6b
2026-10-27 23:51:27 +01:00

263 lines
9.8 KiB
Python

"""Import ADR-0004 tree into content plane (CSOC-WP-0001-T04 rehearsal path)."""
from pathlib import Path
import pytest
from django.core.management import call_command
from coulomb_social.apps.members.models import Member, User
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.content import load_space_page
from coulomb_social.apps.spaces.import_tree import import_space_tree
from coulomb_social.apps.spaces.models import Space, SpaceMembership
@pytest.fixture
def content_root(tmp_path, settings):
root = tmp_path / "content"
settings.CONTENT_ROOT = str(root)
return root
def _write_sample_tree(root: Path) -> Path:
tree = root / "sample-space"
pages = tree / "pages"
assets = tree / "assets"
pages.mkdir(parents=True)
assets.mkdir(parents=True)
(pages / "index.md").write_text(
"---\n"
'title: "Sample Space"\n'
'abstractor: "From export"\n'
'visual: ""\n'
'space: "sample-space"\n'
"bubble_type: space\n"
'source_created_at: "2001-01-02T03:04:05Z"\n'
"---\n\n"
"# Sample Space\n\nLanding.\n",
encoding="utf-8",
)
(pages / "notes.md").write_text(
"---\n"
'title: "Notes"\n'
'abstractor: "Scratch"\n'
'visual: "assets/notes-visual.png"\n'
'space: "sample-space"\n'
"bubble_type: article\n"
'source_created_at: "2001-01-03T04:05:06Z"\n'
"---\n\n"
"# Notes\n\nBody text.\n",
encoding="utf-8",
)
(assets / "notes-visual.png").write_bytes(b"\x89PNG\r\n")
return tree
@pytest.mark.django_db
def test_import_space_tree(content_root, tmp_path):
tree = _write_sample_tree(tmp_path / "export")
user = User.objects.create_user(username="imp")
member = Member.objects.create(
tenant_id="tenant:a",
user=user,
user_engine_user_id="usr_imp",
issuer="https://local.dev/issuer",
subject="imp-sub",
display_name="Importer",
)
report = import_space_tree(
tree,
tenant_id="tenant:a",
member=member,
)
assert report.pages_copied == 2
assert report.assets_copied == 1
assert report.space_created
space = Space.objects.get(tenant_id="tenant:a", slug="sample-space")
assert space.title == "Sample Space"
assert space.description == "From export"
assert SpaceMembership.objects.filter(space=space, member=member).exists()
notes = pageops.read_page("sample-space", "notes")
assert notes.exists
assert notes.title == "Notes"
assert "Body text" in notes.body
assert (pageops.space_dir("sample-space") / "assets" / "notes-visual.png").is_file()
rendered = load_space_page(space, "notes")
assert rendered.error is None
assert rendered.source == "content-plane"
assert "Body text" in rendered.html
@pytest.mark.django_db
def test_import_refuses_overwrite_without_replace(content_root, tmp_path):
tree = _write_sample_tree(tmp_path / "export")
import_space_tree(tree, tenant_id="tenant:a")
with pytest.raises(pageops.PageOpsError):
import_space_tree(tree, tenant_id="tenant:a", replace=False)
report = import_space_tree(tree, tenant_id="tenant:a", replace=True)
assert report.space_updated or report.space_created
assert report.pages_copied == 2
@pytest.mark.django_db
def test_import_content_tree_command(content_root, tmp_path, capsys):
tree = _write_sample_tree(tmp_path / "export")
call_command("import_content_tree", str(tree), "--tenant", "tenant:cmd", "--dry-run")
out = capsys.readouterr().out
assert "Dry run" in out
assert not Space.objects.filter(slug="sample-space").exists()
call_command(
"import_content_tree",
str(tree),
"--tenant",
"tenant:cmd",
"--slug",
"cmd-space",
)
space = Space.objects.get(slug="cmd-space", tenant_id="tenant:cmd")
assert space.title == "Sample Space"
assert pageops.read_page("cmd-space", "index").exists
@pytest.mark.django_db
def test_import_rejects_other_tenant_before_writes(content_root, tmp_path):
tree = _write_sample_tree(tmp_path / "export")
member = Member.objects.create(
tenant_id="tenant:b", user=User.objects.create_user(username="other"),
user_engine_user_id="other", issuer="https://issuer.test", subject="other",
)
with pytest.raises(pageops.PageOpsError, match="another tenant"):
import_space_tree(tree, tenant_id="tenant:a", member=member)
assert not content_root.exists()
assert not Space.objects.exists()
@pytest.mark.django_db
def test_import_rejects_cross_tenant_slug(content_root, tmp_path):
tree = _write_sample_tree(tmp_path / "export")
import_space_tree(tree, tenant_id="tenant:a")
before = pageops.page_file("sample-space", "index").read_bytes()
with pytest.raises(pageops.PageOpsError, match="another tenant"):
import_space_tree(tree, tenant_id="tenant:b", replace=True)
assert pageops.page_file("sample-space", "index").read_bytes() == before
assert not Space.objects.filter(tenant_id="tenant:b").exists()
@pytest.mark.django_db
def test_import_rejects_symlink_in_dry_run(content_root, tmp_path):
from django.core.management.base import CommandError
tree = _write_sample_tree(tmp_path / "export")
(tree / "assets" / "outside").symlink_to(tmp_path)
with pytest.raises(CommandError, match="symlink"):
call_command("import_content_tree", str(tree), "--dry-run")
assert not content_root.exists()
@pytest.mark.django_db
def test_import_rolls_back_content_on_publish_error(content_root, tmp_path, monkeypatch):
tree = _write_sample_tree(tmp_path / "export")
import_space_tree(tree, tenant_id="tenant:a")
path = pageops.page_file("sample-space", "index")
original = path.read_bytes()
meta, _ = pageops.parse_frontmatter((tree / "pages" / "index.md").read_text())
meta["title"] = "Changed"
(tree / "pages" / "index.md").write_text(pageops.dump_frontmatter(meta, '# Changed\n'))
real_rename = Path.rename
def fail_publish(self, target):
if self.name == "staged":
raise OSError("simulated publish failure")
return real_rename(self, target)
monkeypatch.setattr(Path, "rename", fail_publish)
with pytest.raises(OSError, match="simulated"):
import_space_tree(tree, tenant_id="tenant:a", replace=True)
assert path.read_bytes() == original
assert Space.objects.get(slug="sample-space").title == "Sample Space"
@pytest.mark.django_db
def test_single_import_subject_cannot_fall_back_to_other_tenant(content_root, tmp_path):
from django.core.management.base import CommandError
tree = _write_sample_tree(tmp_path / "export")
Member.objects.create(
tenant_id="tenant:b", user=User.objects.create_user(username="foreign"),
user_engine_user_id="foreign", issuer="https://issuer.test", subject="foreign",
)
with pytest.raises(CommandError, match="exactly one"):
call_command("import_content_tree", str(tree), tenant="tenant:a",
member_subject="foreign", dry_run=True)
assert not content_root.exists()
@pytest.mark.django_db(transaction=True)
def test_import_restores_tree_when_database_commit_fails(content_root, tmp_path, monkeypatch):
from django.db import DatabaseError, connection
tree = _write_sample_tree(tmp_path / "export")
import_space_tree(tree, tenant_id="tenant:a")
path = pageops.page_file("sample-space", "index")
original = path.read_bytes()
meta, _ = pageops.parse_frontmatter((tree / "pages" / "index.md").read_text())
meta["title"] = "Changed"
(tree / "pages" / "index.md").write_text(pageops.dump_frontmatter(meta, '# Changed\n'))
def fail_commit():
raise DatabaseError("simulated database commit failure")
monkeypatch.setattr(connection, "commit", fail_commit)
with pytest.raises(DatabaseError):
import_space_tree(tree, tenant_id="tenant:a", replace=True)
assert path.read_bytes() == original
assert Space.objects.get(slug="sample-space").title == "Sample Space"
@pytest.mark.django_db
@pytest.mark.parametrize("failure", ["overlap", "page-name", "metadata"])
def test_shared_validation_rejects_bad_trees(content_root, tmp_path, failure):
from django.core.management.base import CommandError
tree = _write_sample_tree(tmp_path / "export")
if failure == "overlap":
import shutil
content_root.mkdir()
shutil.copytree(tree, content_root / "source")
tree = content_root / "source"
elif failure == "page-name":
(tree / "pages" / "notes.md").rename(tree / "pages" / "Bad Name.md")
else:
(tree / "pages" / "index.md").write_text(
'---\ntitle: "' + ('x' * 256) + '"\n---\n\nbody\n'
)
with pytest.raises(CommandError):
call_command("import_content_tree", str(tree), dry_run=True)
assert not Space.objects.exists()
assert not (content_root / "spaces").exists()
@pytest.mark.django_db
def test_failed_restore_preserves_previous_tree(content_root, tmp_path, monkeypatch):
tree = _write_sample_tree(tmp_path / "export")
import_space_tree(tree, tenant_id="tenant:a")
original = pageops.page_file("sample-space", "index").read_bytes()
real_rename = Path.rename
def fail_publish_and_restore(self, target):
if self.name in {"staged", "previous"}:
raise OSError("simulated filesystem failure")
return real_rename(self, target)
monkeypatch.setattr(Path, "rename", fail_publish_and_restore)
with pytest.raises(pageops.PageOpsError, match="retained"):
import_space_tree(tree, tenant_id="tenant:a", replace=True)
saved = list((content_root / "spaces").glob(".import-*/previous/pages/index.md"))
assert len(saved) == 1
assert saved[0].read_bytes() == original
assert Space.objects.get(slug="sample-space").title == "Sample Space"