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