intent/tests/test_features.py
Alec Perkins b9378b8397 Add projects and features with revision history (Phase 2)
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>
2026-10-27 07:58:16 -04:00

148 lines
5.8 KiB
Python

import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError
from django.urls import reverse
from intent.features import services
from intent.features.models import Feature, FeatureStatus
def furl(name, feature=None, project=None, **kwargs):
project = project or feature.project
kwargs = {"org_slug": project.org.slug, "project_slug": project.slug, **kwargs}
if feature:
kwargs["feature_key"] = feature.key
return reverse(f"features:{name}", kwargs=kwargs)
@pytest.fixture
def feature(project, owner):
return services.create_feature(project, owner, title="Sign in", description="Line one\n")
def test_numbers_are_sequential_and_first_revision_recorded(project, owner, feature):
second = services.create_feature(project, owner, title="Second")
assert (feature.key, second.key) == ("INT-1", "INT-2")
revision = feature.revisions.get()
assert (revision.revision, revision.note) == (1, "Created.")
def test_update_records_revision_with_note(owner, feature):
revision = services.update_feature(feature, owner, note="Clarify", description="Line uno\n")
assert revision.revision == feature.current_revision == 2
assert revision.note == "Clarify"
assert feature.revisions.get(revision=1).description == "Line one\n"
def test_update_needs_a_note_and_a_change(owner, feature):
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note=" ", title="New")
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="No-op", title="Sign in")
def test_cannot_create_a_cycle(project, owner, feature):
child = services.create_feature(project, owner, title="Child", parent=feature)
grandchild = services.create_feature(project, owner, title="Grandchild", parent=child)
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="Loop", parent=grandchild)
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="Self", parent=feature)
def test_superseded_needs_replacement(project, owner, feature):
with pytest.raises(ValidationError):
services.update_feature(feature, owner, note="Gone", status=FeatureStatus.SUPERSEDED)
replacement = services.create_feature(project, owner, title="Sign in v2")
services.update_feature(
feature, owner, note="Replaced", status=FeatureStatus.SUPERSEDED, superseded_by=replacement
)
services.update_feature(feature, owner, note="Back", status=FeatureStatus.ACTIVE)
feature.refresh_from_db()
assert feature.superseded_by is None
def test_database_enforces_superseded_by(feature):
with pytest.raises(IntegrityError):
Feature.objects.filter(pk=feature.pk).update(status=FeatureStatus.SUPERSEDED)
def test_collaborator_without_project_role_cannot_edit(collaborator, project, feature):
with pytest.raises(PermissionDenied):
services.create_feature(project, collaborator, title="Nope")
with pytest.raises(PermissionDenied):
services.update_feature(feature, collaborator, note="x", title="Nope")
def test_create_via_form_with_parent_prefill(client, editor, project, feature):
client.force_login(editor)
response = client.get(furl("create", project=project) + "?parent=int-1")
assert b"selected>INT-1 Sign in" in response.content
response = client.post(
furl("create", project=project),
{"title": "Child", "description": "", "parent": feature.pk, "note": ""},
)
child = Feature.objects.get(title="Child")
assert response["Location"] == furl("detail", child)
assert child.parent == feature
def test_preview_does_not_save(client, owner, feature):
client.force_login(owner)
response = client.post(
furl("edit", feature),
{
"title": "Sign in",
"description": "# Draft",
"status": "active",
"note": "x",
"preview": "1",
},
)
assert b"<h2" in response.content and b"Draft" in response.content
feature.refresh_from_db()
assert feature.current_revision == 1
def test_edit_via_form(client, owner, feature):
client.force_login(owner)
client.post(
furl("edit", feature),
{"title": "Sign in", "description": "Line uno\n", "status": "active", "note": "Spanish"},
)
feature.refresh_from_db()
assert feature.current_revision == 2
def test_feature_pages_render(client, owner, project, feature):
services.update_feature(feature, owner, note="Edit", description="Line uno\n", title="Login")
client.force_login(owner)
for path in [
furl("list", project=project),
reverse("projects:home", kwargs={"org_slug": "catbriar", "project_slug": "intent"}),
furl("detail", feature),
furl("history", feature),
furl("revision", feature, revision=1),
furl("compare", feature),
]:
assert client.get(path).status_code == 200, path
compare = client.get(furl("compare", feature) + "?old=1&new=2").content.decode()
assert "Line one" in compare and "Line uno" in compare and "Removed: " in compare
assert client.get(furl("revision", feature, revision=2)).status_code == 302
def test_feature_key_lookup(client, owner, feature):
client.force_login(owner)
base = furl("list", project=feature.project)
assert client.get(f"{base}int-1/").status_code == 200
assert client.get(f"{base}NOPE-1/").status_code == 404
assert client.get(f"{base}INT-99/").status_code == 404
def test_viewer_cannot_open_edit_page(client, make_user, project, feature):
project.visibility = "public"
project.save()
assert client.get(furl("detail", feature)).status_code == 200
client.force_login(make_user())
assert client.get(furl("edit", feature)).status_code == 403