feat: attach photos from Search preview replies (Closes #955)
CI / lint (pull_request) Successful in 2m0s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-16 09:30:18 +00:00
1 parent 053f32c428
commit d3a57f222e
10 files changed
+320 -4

No files matched your search

+64
View File
@@ -325,6 +325,70 @@ async def test_global_search_preview_reply_rejects_a_mismatched_target_before_co
assert comments == []
@pytest.mark.anyio
async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_target(monkeypatch):
main._idempotency_ledger.clear()
previews = []
uploads = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return {"repository": repository, "kind": kind, "number": number, "commentable": True}
async def upload(repository, number, filename, content_type, content):
uploads.append((repository, number, filename, content_type, content))
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "search-photo-pull-42"}
image = b"\x89PNG\r\n\x1a\nsafe-pixels"
files = {"file": ("photo.png", image, "image/png")}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
files=files,
headers=headers,
)
replay = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
files=files,
headers=headers,
)
assert first.status_code == replay.status_code == 201
assert replay.json() == first.json()
assert first.json()["markdown"] == "![photo.png](<https://forge.example/evidence/photo.png>)"
assert previews == [("stackchain/api", "pull", 42)]
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
@pytest.mark.anyio
async def test_global_search_preview_attachment_rejects_kind_mismatch_before_upload(monkeypatch):
main._idempotency_ledger.clear()
uploads = []
async def preview(repository, kind, number):
return {"repository": repository, "kind": "issue", "number": number}
async def upload(*args):
uploads.append(args)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
files={"file": ("photo.png", b"\x89PNG\r\n\x1a\nsafe-pixels", "image/png")},
headers={"Idempotency-Key": "search-photo-mismatch-42"},
)
assert response.status_code == 404
assert uploads == []
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
+54
View File
@@ -113,6 +113,60 @@ def test_mobile_conversation_composers_accept_five_ordered_photos():
assert "maxFiles: 5" in mount.group(1), controller
def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'for="take-search-reply-photo">Take photo</label>' in html
assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
assert 'for="search-reply-attachment">Choose existing</label>' in html
assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
mount = re.search(
r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n \}\);",
dashboard,
re.DOTALL,
)
assert mount
assert "maxFiles: 5" in mount.group(1)
assert "'/preview/attachments?kind='" in mount.group(1)
assert ".search-preview-reply .conversation-photo-actions" in css
def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment():
search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js"
script = f"""
const create = require({json.dumps(str(search_preview))});
const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true;
const item={{kind:'issue',repository:'stackchain/api',number:42}};
const next={{kind:'pull',repository:'stackchain/web',number:9}};
const api=create({{
fetchJson:async value=>({{...value,commentable:true}}),
postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}},
prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '![photo](<safe-url>)';}},
hasAttachments:()=>attached,
clearAttachments:()=>{{cleared += 1;attached=false;}},
storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}},
createOperationId:()=> 'reply-photo-42',
getSession:()=>({{items:[item,next],more:false}}),
onNavigate:value=>navigated.push(value.number),
onState:()=>{{}},
}});
(async()=>{{
await api.open(item);
await api.reply({{advance:true}});
process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"posted": [[42, "![photo](<safe-url>)", "reply-photo-42"]],
"prepared": [[42, ""]],
"cleared": 1,
"navigated": [9],
}
def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()