Projects under organizations with fixed URL name and feature ID prefix, three visibility levels, open contribution, per-project roles, settings and typed-confirmation delete. Hidden projects 404. Leaving an org drops its project roles. Project access rules live in intent/access.py alongside org access. Features numbered per project (INT-12) in a tree. Every change is a revision with a required note; history, old revisions, and side-by-side field and line diffs between any two. Cycles and superseded-without-replacement are rejected. Markdown renders safely with source-line attributes for Phase 3 line comments and headings shifted below the page h1. Server-side preview works without JS. Structural accessibility check runs on every page in tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
import pytest
|
|
from django.core.exceptions import PermissionDenied, ValidationError
|
|
from django.urls import reverse
|
|
|
|
from intent.features.models import Feature, FeatureStatus
|
|
from intent.features.services import create_feature, update_feature
|
|
from intent.orgs.models import OrgMembership
|
|
from intent.orgs.services import remove_member as remove_org_member
|
|
from intent.projects import services
|
|
from intent.projects.models import Project, ProjectMembership
|
|
|
|
|
|
def url(name, project, **kwargs):
|
|
return reverse(
|
|
f"projects:{name}",
|
|
kwargs={"org_slug": project.org.slug, "project_slug": project.slug, **kwargs},
|
|
)
|
|
|
|
|
|
def test_create_requires_grant(collaborator, org):
|
|
with pytest.raises(PermissionDenied):
|
|
services.create_project(
|
|
org,
|
|
collaborator,
|
|
slug="x",
|
|
name="X",
|
|
feature_prefix="XX",
|
|
visibility="private",
|
|
open_contribution=False,
|
|
)
|
|
|
|
|
|
def test_granted_collaborator_creates_and_owns_project(client, collaborator, org):
|
|
OrgMembership.objects.filter(user=collaborator).update(can_create_projects=True)
|
|
client.force_login(collaborator)
|
|
response = client.post(
|
|
reverse("project_create", kwargs={"org_slug": org.slug}),
|
|
{"name": "Docs", "slug": "docs", "feature_prefix": "doc", "visibility": "public"},
|
|
)
|
|
project = Project.objects.get(slug="docs")
|
|
assert response.status_code == 302
|
|
assert project.feature_prefix == "DOC"
|
|
assert project.memberships.get(user=collaborator).role == "owner"
|
|
|
|
|
|
def test_duplicate_prefix_is_a_form_error(client, owner, org, project):
|
|
client.force_login(owner)
|
|
response = client.post(
|
|
reverse("project_create", kwargs={"org_slug": org.slug}),
|
|
{"name": "Other", "slug": "other", "feature_prefix": "INT", "visibility": "private"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert b"already exists" in response.content
|
|
|
|
|
|
def test_reserved_slug_rejected(owner, org):
|
|
with pytest.raises(ValidationError):
|
|
services.create_project(
|
|
org,
|
|
owner,
|
|
slug="members",
|
|
name="M",
|
|
feature_prefix="MM",
|
|
visibility="private",
|
|
open_contribution=False,
|
|
)
|
|
|
|
|
|
def test_private_project_hidden_from_outsiders(client, make_user, project):
|
|
response = client.get(url("home", project))
|
|
assert response.status_code == 302 and "/accounts/login/" in response["Location"]
|
|
client.force_login(make_user())
|
|
assert client.get(url("home", project)).status_code == 404
|
|
|
|
|
|
def test_public_project_visible_to_anonymous(client, project):
|
|
project.visibility = "public"
|
|
project.save()
|
|
assert client.get(url("home", project)).status_code == 200
|
|
|
|
|
|
def test_settings_require_admin(client, editor, owner, project):
|
|
client.force_login(editor)
|
|
assert client.get(url("settings", project)).status_code == 403
|
|
client.force_login(owner)
|
|
client.post(url("settings", project), {"name": "Renamed", "visibility": "public"})
|
|
project.refresh_from_db()
|
|
assert project.name == "Renamed" and project.visibility == "public"
|
|
|
|
|
|
def test_delete_needs_typed_slug_and_cascades_features(client, owner, project):
|
|
parent = create_feature(project, owner, title="Parent")
|
|
child = create_feature(project, owner, title="Child", parent=parent)
|
|
update_feature(
|
|
parent, owner, note="Replaced", status=FeatureStatus.SUPERSEDED, superseded_by=child
|
|
)
|
|
client.force_login(owner)
|
|
client.post(url("delete", project), {"confirm": "wrong"})
|
|
assert Project.objects.filter(pk=project.pk).exists()
|
|
client.post(url("delete", project), {"confirm": "intent"})
|
|
assert not Project.objects.filter(pk=project.pk).exists()
|
|
assert not Feature.objects.exists()
|
|
|
|
|
|
def test_project_members_must_be_on_the_org_team(owner, make_user, project):
|
|
with pytest.raises(ValidationError):
|
|
services.add_member(project, owner, user=make_user())
|
|
|
|
|
|
def test_leaving_org_drops_project_roles(editor, org, project):
|
|
remove_org_member(org.memberships.get(user=editor), editor)
|
|
assert not ProjectMembership.objects.filter(user=editor).exists()
|
|
|
|
|
|
def test_admin_adds_member_via_page(client, owner, collaborator, project):
|
|
client.force_login(owner)
|
|
client.post(url("members", project), {"user": collaborator.pk, "role": "collaborator"})
|
|
assert project.memberships.filter(user=collaborator).exists()
|
|
|
|
|
|
def test_org_page_lists_visible_projects(client, owner, collaborator, org, project):
|
|
org_page = reverse("orgs:detail", kwargs={"org_slug": org.slug})
|
|
link = f'href="{url("home", project)}"'
|
|
client.force_login(owner)
|
|
assert link in client.get(org_page).content.decode()
|
|
client.force_login(collaborator)
|
|
assert link not in client.get(org_page).content.decode()
|