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>
72 lines
2.9 KiB
Python
72 lines
2.9 KiB
Python
"""Phase 4 exit: a feature with a prototype image has a pinned thread; an implementation shows
|
|
one feature shipped and one behind. Driven through the views."""
|
|
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.urls import reverse
|
|
|
|
from intent.comments.models import CommentThread, ThreadKind
|
|
from intent.features.models import Feature
|
|
|
|
P = {"org_slug": "catbriar", "project_slug": "intent"}
|
|
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
|
|
|
|
|
def test_prototype_pin_and_implementation_status(client, open_project, editor, outsider):
|
|
client.force_login(editor)
|
|
for title in ("Checkout", "Receipts"):
|
|
client.post(reverse("features:create", kwargs=P), {"title": title, "note": ""})
|
|
checkout, receipts = Feature.objects.order_by("number")
|
|
|
|
# A prototype image on the feature.
|
|
client.post(
|
|
reverse("files:add", kwargs={**P, "feature_key": checkout.key}),
|
|
{
|
|
"kind": "prototype",
|
|
"upload": SimpleUploadedFile("checkout.png", PNG),
|
|
"note": "First mockup",
|
|
},
|
|
)
|
|
file = checkout.files.get()
|
|
|
|
# Someone pins a comment to a point on it.
|
|
client.force_login(outsider)
|
|
client.post(
|
|
reverse(
|
|
"files:thread_create", kwargs={**P, "feature_key": checkout.key, "file_id": file.pk}
|
|
),
|
|
{"body": "Pay button is hard to find", "across": "80", "down": "90"},
|
|
)
|
|
pin = CommentThread.objects.get(kind=ThreadKind.IMAGE)
|
|
file_page = client.get(
|
|
reverse("files:detail", kwargs={**P, "feature_key": checkout.key, "file_id": file.pk})
|
|
).content.decode()
|
|
assert '<circle cx="80.0%" cy="90.0%"' in file_page
|
|
assert f'href="#thread-{pin.pk}"' in file_page
|
|
|
|
# An implementation ships both features, then one feature changes.
|
|
client.force_login(editor)
|
|
client.post(
|
|
reverse("implementations:create", kwargs=P),
|
|
{"name": "Storefront", "url": "https://forge.example/shop/storefront"},
|
|
)
|
|
impl = open_project.implementations.get()
|
|
statuses = reverse("implementations:statuses", kwargs={**P, "impl_id": impl.pk})
|
|
client.post(statuses, {f"status-{checkout.pk}": "shipped", f"status-{receipts.pk}": "shipped"})
|
|
client.post(
|
|
reverse("features:edit", kwargs={**P, "feature_key": receipts.key}),
|
|
{
|
|
"title": "Receipts",
|
|
"description": "Email a receipt.",
|
|
"status": "active",
|
|
"note": "Email",
|
|
},
|
|
)
|
|
|
|
page = client.get(
|
|
reverse("implementations:detail", kwargs={**P, "impl_id": impl.pk})
|
|
).content.decode()
|
|
checkout_row = page[page.index(f'id="{checkout.key}"') : page.index(f'id="{receipts.key}"')]
|
|
receipts_row = page[page.index(f'id="{receipts.key}"') :]
|
|
assert "behind" not in checkout_row
|
|
assert "<strong>behind</strong> (now 2)" in receipts_row
|
|
assert "Shipped: 2" in page and "Behind the current revision: 1" in page
|