Land CSOC-WP-0006 PageOps: member space/page CRUD, copy, transfer

Thin CONTENT_ROOT content plane (ADR-0003/0004) with space visual field,
product UI for Title/Abstract/Visual, page list sidebar, and tests. Update
capability model and smoke checklist; mark WP-0006 finished.
This commit is contained in:
tegwick 2026-08-13 12:46:39 +02:00
parent 00dbb86c93
commit affed64839
24 changed files with 1812 additions and 151 deletions

View file

@ -17,11 +17,13 @@ def test_render_markdown_strips_script():
@pytest.mark.django_db
def test_load_space_page_unbound():
def test_load_space_page_unbound(tmp_path, settings):
settings.CONTENT_ROOT = str(tmp_path / "empty-content")
space = Space(tenant_id="t", slug="x", title="X")
page = load_space_page(space)
assert page.error
assert "not bound" in page.error.lower()
err = page.error.lower()
assert "content plane" in err or "not bound" in err or "forgejo" in err
@pytest.mark.django_db

87
tests/test_pageops.py Normal file
View file

@ -0,0 +1,87 @@
"""PageOps content-plane unit tests (ADR-0003 / ADR-0004)."""
from pathlib import Path
import pytest
from coulomb_social.apps.spaces import pageops
@pytest.fixture
def content_root(tmp_path, settings):
root = tmp_path / "content"
settings.CONTENT_ROOT = str(root)
return root
def test_write_read_list_page(content_root):
pageops.init_space_content("lab", title="Lab", abstractor="Research", visual="assets/c.jpg")
doc = pageops.write_page(
"lab",
"notes",
title="Notes",
abstractor="Scratch",
visual="assets/n.jpg",
body="# Notes\n\nHello.\n",
)
assert doc.exists
assert doc.title == "Notes"
assert doc.abstractor == "Scratch"
assert "Hello" in doc.body
pages = pageops.list_pages("lab")
slugs = [p.slug for p in pages]
assert slugs[0] == "index"
assert "notes" in slugs
path = Path(doc.path)
assert path.is_file()
text = path.read_text(encoding="utf-8")
assert text.startswith("---\n")
assert 'title: "Notes"' in text or "title: Notes" in text or 'title: "Notes"' in text
def test_copy_page_same_space(content_root):
pageops.init_space_content("lab", title="Lab")
pageops.write_page("lab", "alpha", title="Alpha", body="Body A\n")
copy = pageops.copy_page("lab", "alpha", "lab", "alpha-copy")
assert copy.slug == "alpha-copy"
assert copy.title == "Alpha (copy)"
assert "Body A" in copy.body
assert pageops.read_page("lab", "alpha").exists
def test_transfer_page(content_root):
pageops.init_space_content("src", title="Src")
pageops.init_space_content("dst", title="Dst")
pageops.write_page("src", "moved", title="Moved", abstractor="A", body="X\n")
doc = pageops.transfer_page("src", "moved", "dst", "moved")
assert doc.exists
assert doc.title == "Moved"
assert not pageops.read_page("src", "moved").exists
assert pageops.read_page("dst", "moved").exists
def test_cannot_delete_or_transfer_index(content_root):
pageops.init_space_content("lab", title="Lab")
with pytest.raises(pageops.PageOpsError):
pageops.delete_page("lab", "index")
with pytest.raises(pageops.PageOpsError):
pageops.transfer_page("lab", "index", "other")
def test_copy_relative_visual_asset(content_root):
pageops.init_space_content("lab", title="Lab")
assets = pageops.assets_dir("lab")
assets.mkdir(parents=True, exist_ok=True)
(assets / "cover.png").write_bytes(b"\x89PNG\r\n")
pageops.write_page(
"lab",
"with-pic",
title="Pic",
visual="assets/cover.png",
body="pic\n",
)
copy = pageops.copy_page("lab", "with-pic", "lab", "with-pic-copy")
assert copy.visual.startswith("assets/")
assert (pageops.space_dir("lab") / copy.visual).is_file()

229
tests/test_space_crud.py Normal file
View file

@ -0,0 +1,229 @@
"""Member-facing space/page CRUD, copy, transfer (CSOC-WP-0006)."""
from pathlib import Path
import pytest
from django.urls import reverse
from coulomb_social.apps.members.models import Member
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.content import load_space_page
from coulomb_social.apps.spaces.models import Space
def _login(client, settings, *, subject: str = "u1", tenant: str = "tenant:a", name: str = "User"):
settings.DEBUG = True
settings.OIDC_ENABLED = False
settings.DEFAULT_TENANT_ID = tenant
return client.post(
reverse("identity:dev_login"),
{
"subject": subject,
"issuer": "https://local.dev/issuer",
"name": name,
"tenant": tenant,
},
)
@pytest.fixture
def content_root(tmp_path, settings):
root = tmp_path / "content"
settings.CONTENT_ROOT = str(root)
return root
@pytest.mark.django_db
def test_create_space_writes_index_and_lists(client, settings, content_root):
_login(client, settings)
r = client.post(
reverse("spaces:create"),
{
"title": "Garden",
"abstractor": "Grow ideas",
"slug": "garden",
"visual": "assets/cover.jpg",
},
)
assert r.status_code == 302
assert r.url.endswith("/app/spaces/garden/")
space = Space.objects.get(slug="garden")
assert space.title == "Garden"
assert space.description == "Grow ideas"
assert space.visual == "assets/cover.jpg"
assert space.abstractor == "Grow ideas"
index = pageops.read_page("garden", "index")
assert index.exists
assert index.title == "Garden"
home = client.get(reverse("core:app_home"))
assert home.status_code == 200
assert b"Garden" in home.content
assert b"Grow ideas" in home.content
@pytest.mark.django_db
def test_edit_space_updates_index_frontmatter(client, settings, content_root):
_login(client, settings)
client.post(
reverse("spaces:create"),
{"title": "Lab", "slug": "lab", "abstractor": "Old", "visual": ""},
)
r = client.post(
reverse("spaces:edit", kwargs={"slug": "lab"}),
{"title": "Lab 2", "abstractor": "New abstract", "visual": "assets/v.jpg"},
)
assert r.status_code == 302
space = Space.objects.get(slug="lab")
assert space.title == "Lab 2"
assert space.description == "New abstract"
assert space.visual == "assets/v.jpg"
index = pageops.read_page("lab", "index")
assert index.title == "Lab 2"
assert index.abstractor == "New abstract"
@pytest.mark.django_db
def test_archive_space_hides_from_list(client, settings, content_root):
_login(client, settings)
client.post(
reverse("spaces:create"),
{"title": "Temp", "slug": "temp", "abstractor": "", "visual": ""},
)
r = client.post(reverse("spaces:archive", kwargs={"slug": "temp"}))
assert r.status_code == 302
assert not Space.objects.get(slug="temp").is_active
home = client.get(reverse("core:app_home"))
assert home.status_code == 200
# Flash message may mention the title; the space must not remain linked.
assert b"/app/spaces/temp/" not in home.content
assert b"No spaces yet" in home.content
@pytest.mark.django_db
def test_page_crud_copy_transfer(client, settings, content_root):
_login(client, settings)
client.post(
reverse("spaces:create"),
{"title": "Alpha", "slug": "alpha", "abstractor": "", "visual": ""},
)
client.post(
reverse("spaces:create"),
{"title": "Beta", "slug": "beta", "abstractor": "", "visual": ""},
)
# create page
r = client.post(
reverse("spaces:page_new", kwargs={"slug": "alpha"}),
{
"title": "Notes",
"slug": "notes",
"abstractor": "Scratch pad",
"visual": "",
"body": "# Notes\n\nHello body.\n",
},
)
assert r.status_code == 302
assert "page=notes" in r.url
doc = pageops.read_page("alpha", "notes")
assert doc.exists
assert doc.abstractor == "Scratch pad"
# render from content plane
space = Space.objects.get(slug="alpha")
rendered = load_space_page(space, "notes")
assert rendered.error is None
assert rendered.source == "content-plane"
assert "Hello body" in rendered.html
detail = client.get(reverse("spaces:detail", kwargs={"slug": "alpha"}) + "?page=notes")
assert detail.status_code == 200
assert b"Hello body" in detail.content
assert b"Notes" in detail.content
# edit
r = client.post(
reverse("spaces:page_edit", kwargs={"slug": "alpha", "page_slug": "notes"}),
{
"title": "Notes v2",
"slug": "notes",
"abstractor": "Updated",
"visual": "assets/n.jpg",
"body": "# Notes v2\n\nUpdated body.\n",
},
)
assert r.status_code == 302
doc = pageops.read_page("alpha", "notes")
assert doc.title == "Notes v2"
assert "Updated body" in doc.body
# copy within space
r = client.post(
reverse("spaces:page_copy", kwargs={"slug": "alpha", "page_slug": "notes"}),
{
"dest_space": "alpha",
"dest_slug": "notes-copy",
"title": "",
},
)
assert r.status_code == 302
copy = pageops.read_page("alpha", "notes-copy")
assert copy.exists
assert copy.title == "Notes v2 (copy)"
# transfer to beta
r = client.post(
reverse("spaces:page_transfer", kwargs={"slug": "alpha", "page_slug": "notes-copy"}),
{
"dest_space": "beta",
"dest_slug": "notes-copy",
},
)
assert r.status_code == 302
assert not pageops.read_page("alpha", "notes-copy").exists
moved = pageops.read_page("beta", "notes-copy")
assert moved.exists
assert moved.title == "Notes v2 (copy)"
# delete remaining notes
r = client.post(
reverse("spaces:page_delete", kwargs={"slug": "alpha", "page_slug": "notes"})
)
assert r.status_code == 302
assert not pageops.read_page("alpha", "notes").exists
@pytest.mark.django_db
def test_page_ops_require_membership_isolation(client, settings, content_root):
_login(client, settings, subject="u1", tenant="tenant:a")
client.post(
reverse("spaces:create"),
{"title": "Mine", "slug": "mine", "abstractor": "", "visual": ""},
)
# other tenant cannot see
client.logout()
_login(client, settings, subject="u2", tenant="tenant:b", name="Other")
r = client.get(reverse("spaces:detail", kwargs={"slug": "mine"}))
assert r.status_code == 404
r = client.post(
reverse("spaces:page_new", kwargs={"slug": "mine"}),
{"title": "X", "slug": "x", "body": "nope"},
)
assert r.status_code == 404
@pytest.mark.django_db
def test_cannot_delete_index_via_view(client, settings, content_root):
_login(client, settings)
client.post(
reverse("spaces:create"),
{"title": "Keep", "slug": "keep", "abstractor": "", "visual": ""},
)
# URL for delete index would work but pageops rejects
r = client.post(
reverse("spaces:page_delete", kwargs={"slug": "keep", "page_slug": "index"})
)
assert r.status_code == 302
assert pageops.read_page("keep", "index").exists