Files: prototype and documentation files on features, with versions. Adding, replacing or removing a file is a feature revision with a change note. Types come from the extension; uploads are capped at 25 MB and stored under opaque paths. Every file URL checks project access, then redirects to a short-lived signed URL on the bucket, or streams with nosniff and a CSP sandbox when no bucket is configured. HTML prototypes run in a sandboxed iframe without same-origin access. Threads: whole-file threads and pins at a point on an image, drawn as an SVG overlay in percentages. Pins are placed with number fields, or by clicking the image with JS. Implementations: name, link and status, plus a per-feature status grid stamped with the feature revision, marked behind when the feature changes, and confirmable without changing the status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
5 KiB
Python
128 lines
5 KiB
Python
import pytest
|
|
from django.core.exceptions import PermissionDenied, ValidationError
|
|
from django.urls import reverse
|
|
|
|
from intent.features.services import create_feature, update_feature
|
|
from intent.implementations import services
|
|
from intent.implementations.models import FeatureImplStatus, FeatureStatus
|
|
|
|
P = {"org_slug": "catbriar", "project_slug": "intent"}
|
|
|
|
|
|
def iurl(name, impl=None):
|
|
kwargs = dict(P)
|
|
if impl is not None:
|
|
kwargs["impl_id"] = impl.pk
|
|
return reverse(f"implementations:{name}", kwargs=kwargs)
|
|
|
|
|
|
@pytest.fixture
|
|
def impl(open_project, owner):
|
|
return services.create_implementation(
|
|
open_project, owner, name="Web app", url="https://forge.example/acme/web"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def features(open_project, owner):
|
|
return [create_feature(open_project, owner, title=t) for t in ("Sign in", "Sign out")]
|
|
|
|
|
|
def test_only_editors_create_and_urls_must_be_http(open_project, collaborator, owner, impl):
|
|
with pytest.raises(PermissionDenied):
|
|
services.create_implementation(
|
|
open_project, collaborator, name="X", url="https://x.example"
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
services.create_implementation(open_project, owner, name="X", url="javascript:alert(1)")
|
|
with pytest.raises(ValidationError, match="already"):
|
|
services.create_implementation(open_project, owner, name="web APP", url="https://x.example")
|
|
|
|
|
|
def test_status_is_stamped_and_goes_behind(impl, features, owner, editor):
|
|
sign_in, sign_out = features
|
|
changed = services.set_statuses(
|
|
impl,
|
|
editor,
|
|
{sign_in: (FeatureStatus.SHIPPED, False), sign_out: (FeatureStatus.NOT_STARTED, False)},
|
|
)
|
|
assert changed == 1 # "Not started" is the default, so no row is written for it.
|
|
row = FeatureImplStatus.objects.get()
|
|
assert (row.status, row.feature_revision, row.is_behind) == ("shipped", 1, False)
|
|
|
|
update_feature(sign_in, owner, note="Add 2FA", description="Now with 2FA")
|
|
row.refresh_from_db()
|
|
assert row.is_behind
|
|
|
|
# Unchanged and unconfirmed: nothing happens. Confirmed: re-stamped at the current revision.
|
|
sign_in.refresh_from_db()
|
|
assert services.set_statuses(impl, editor, {sign_in: ("shipped", False)}) == 0
|
|
assert services.set_statuses(impl, editor, {sign_in: ("shipped", True)}) == 1
|
|
row.refresh_from_db()
|
|
assert (row.feature_revision, row.is_behind) == (2, False)
|
|
|
|
|
|
def test_outsiders_cannot_set_statuses(impl, features, outsider):
|
|
with pytest.raises(PermissionDenied):
|
|
services.set_statuses(impl, outsider, {features[0]: ("shipped", False)})
|
|
|
|
|
|
# Views
|
|
|
|
|
|
def test_status_grid_via_form(client, impl, features, editor):
|
|
sign_in, sign_out = features
|
|
client.force_login(editor)
|
|
client.post(
|
|
iurl("statuses", impl),
|
|
{f"status-{sign_in.pk}": "shipped", f"status-{sign_out.pk}": "in_progress"},
|
|
)
|
|
statuses = dict(impl.feature_statuses.values_list("feature__number", "status"))
|
|
assert statuses == {1: "shipped", 2: "in_progress"}
|
|
|
|
|
|
def test_bad_status_value_is_refused(client, impl, features, editor):
|
|
client.force_login(editor)
|
|
client.post(iurl("statuses", impl), {f"status-{features[0].pk}": "done"})
|
|
assert not impl.feature_statuses.exists()
|
|
|
|
|
|
def test_outsider_sees_grid_read_only(client, impl, features, outsider):
|
|
client.force_login(outsider)
|
|
page = client.get(iurl("detail", impl)).content.decode()
|
|
assert "Sign in" in page and 'id="status-' not in page
|
|
assert client.post(iurl("statuses", impl), {}).status_code == 403
|
|
assert client.get(iurl("create")).status_code == 403
|
|
|
|
|
|
def test_create_edit_archive_delete(client, open_project, owner, editor):
|
|
client.force_login(editor)
|
|
client.post(iurl("create"), {"name": "CLI", "url": "https://forge.example/acme/cli"})
|
|
impl = open_project.implementations.get()
|
|
client.post(
|
|
iurl("edit", impl),
|
|
{"name": "CLI", "url": "https://forge.example/acme/cli", "status": "archived"},
|
|
)
|
|
impl.refresh_from_db()
|
|
assert impl.status == "archived"
|
|
assert "Archived" in client.get(iurl("list")).content.decode()
|
|
assert client.post(iurl("delete", impl), {"confirm": "CLI"}).status_code == 403
|
|
client.force_login(owner)
|
|
client.post(iurl("delete", impl), {"confirm": "cli"})
|
|
assert open_project.implementations.exists()
|
|
client.post(iurl("delete", impl), {"confirm": "CLI"})
|
|
assert not open_project.implementations.exists()
|
|
|
|
|
|
def test_feature_page_shows_each_implementation(client, impl, features, owner, editor):
|
|
services.set_statuses(impl, editor, {features[0]: ("shipped", False)})
|
|
update_feature(features[0], owner, note="Change", title="Sign in v2")
|
|
client.force_login(editor)
|
|
page = client.get(
|
|
reverse("features:detail", kwargs={**P, "feature_key": "INT-1"})
|
|
).content.decode()
|
|
assert "Web app" in page and "Shipped" in page and "<strong>behind</strong>" in page
|
|
other = client.get(
|
|
reverse("features:detail", kwargs={**P, "feature_key": "INT-2"})
|
|
).content.decode()
|
|
assert "Not started" in other
|