feat: edit and delete owned comments (Closes #575)
This commit is contained in:
1 parent
43f5833f7f
commit
8e357a097b
14 files changed
+634
-2
No files matched your search
@@ -45,7 +45,9 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications", "device-setup"}
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup"
|
||||
}
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
|
||||
@@ -8,6 +8,163 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/comments/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"body": "Original",
|
||||
"user": {"login": "timmy"},
|
||||
"issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/17",
|
||||
"html_url": f"{gitea_proxy.GITEA_URL}/stackchain/api/issues/17#issuecomment-42",
|
||||
})
|
||||
if request.method == "PATCH":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42, "body": "Corrected", "user": {"login": "timmy"},
|
||||
"html_url": "https://gitea.example/stackchain/api/issues/17#issuecomment-42",
|
||||
})
|
||||
return httpx.Response(204)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
edited = await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Corrected")
|
||||
deleted = await gitea_proxy.delete_owned_comment("stackchain/api", 17, 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert edited["body"] == "Corrected"
|
||||
assert deleted == {"id": 42, "deleted": True}
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/comments/42"),
|
||||
]
|
||||
assert json.loads(requests[2].content) == {"body": "Corrected"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"comment",
|
||||
[
|
||||
{"id": 42, "user": {"login": "alex"}, "issue_url": "https://gitea.example/api/v1/repos/stackchain/api/issues/17"},
|
||||
{"id": 42, "user": {"login": "timmy"}, "issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/99"},
|
||||
{"id": 42, "user": {"login": "timmy"}, "issue_url": "https://evil.example/api/v1/repos/stackchain/api/issues/17"},
|
||||
],
|
||||
)
|
||||
async def test_owned_comment_mutations_reject_other_author_or_thread_without_writing(comment):
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json=comment)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.CommentMutationForbiddenError):
|
||||
await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Nope")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [request.method for request in requests] == ["GET", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("base_path", "availability_call"),
|
||||
[
|
||||
("/api/v1/repos/stackchain/api/issues/17", "issue"),
|
||||
("/api/v1/repos/stackchain/api/pulls/17", "pull"),
|
||||
("/api/v1/notifications/91", "notification"),
|
||||
],
|
||||
)
|
||||
async def test_comment_mutation_routes_cover_issue_pull_and_unread_update(
|
||||
monkeypatch, base_path, availability_call
|
||||
):
|
||||
calls = []
|
||||
|
||||
async def available_issue(repository, number):
|
||||
calls.append(("issue", repository, number))
|
||||
return True
|
||||
|
||||
async def available_pull(repository, number):
|
||||
calls.append(("pull", repository, number))
|
||||
return True
|
||||
|
||||
async def notification_target(thread_id):
|
||||
calls.append(("notification", thread_id))
|
||||
return "stackchain/api", 17
|
||||
|
||||
async def edit(repository, number, comment_id, body):
|
||||
calls.append(("edit", repository, number, comment_id, body))
|
||||
return {"id": comment_id, "author": "timmy", "body": body}
|
||||
|
||||
async def delete(repository, number, comment_id):
|
||||
calls.append(("delete", repository, number, comment_id))
|
||||
return {"id": comment_id, "deleted": True}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available_issue)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", available_pull)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "notification_conversation_target", notification_target,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", edit)
|
||||
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
edited = await client.patch(base_path + "/comments/42", json={"body": " Corrected "})
|
||||
deleted = await client.delete(base_path + "/comments/42")
|
||||
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["body"] == "Corrected"
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"id": 42, "deleted": True}
|
||||
if availability_call == "notification":
|
||||
assert calls == [
|
||||
("notification", 91), ("edit", "stackchain/api", 17, 42, "Corrected"),
|
||||
("notification", 91), ("delete", "stackchain/api", 17, 42),
|
||||
]
|
||||
else:
|
||||
assert calls == [
|
||||
(availability_call, "stackchain/api", 17),
|
||||
("edit", "stackchain/api", 17, 42, "Corrected"),
|
||||
(availability_call, "stackchain/api", 17),
|
||||
("delete", "stackchain/api", 17, 42),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_comment_mutation_route_returns_forbidden_without_hiding_saved_edit(monkeypatch):
|
||||
async def available(repository, number):
|
||||
return True
|
||||
|
||||
async def forbidden(*args):
|
||||
raise gitea_proxy.CommentMutationForbiddenError("not owned")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available)
|
||||
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", forbidden)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/comments/42",
|
||||
json={"body": "Keep this draft"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "You can only change your own comments"
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
|
||||
@@ -18,6 +18,7 @@ ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
@@ -3862,6 +3863,112 @@ Promise.all([first, duplicate]).then(() => {{
|
||||
assert output["state"]["total"] == 4
|
||||
|
||||
|
||||
def test_owned_comment_actions_edit_delete_all_conversation_routes_and_preserve_failed_edit():
|
||||
script = f"""
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const createCommentActions = require({json.dumps(str(COMMENT_ACTIONS))});
|
||||
const requests = [];
|
||||
let shouldFail = false;
|
||||
const controller = createCommentActions({{
|
||||
getLogin: () => 'timmy',
|
||||
confirmDelete: () => true,
|
||||
fetchJson: async (url, options) => {{
|
||||
requests.push([url, options.method, options.body || null]);
|
||||
if (shouldFail) throw new Error('offline');
|
||||
if (options.method === 'DELETE') return {{id:42,deleted:true}};
|
||||
return {{id:42,author:'timmy',body:JSON.parse(options.body).body,created_at:'2026-08-11T10:00:00Z'}};
|
||||
}},
|
||||
}});
|
||||
const cancelController = createCommentActions({{
|
||||
getLogin: () => 'timmy', confirmDelete: () => false,
|
||||
fetchJson: async () => {{ throw new Error('delete should not run'); }},
|
||||
}});
|
||||
const contexts = [
|
||||
{{kind:'issue',item:{{repository:'stackchain/api',number:17}}}},
|
||||
{{kind:'pull',item:{{repository:'stackchain/api',number:17}}}},
|
||||
{{kind:'update',item:{{notification_id:91}}}},
|
||||
];
|
||||
(async () => {{
|
||||
const results = [];
|
||||
for (const context of contexts) {{
|
||||
const pager = createConversationPager({{loadPage:async()=>({{}})}});
|
||||
pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}},{{id:43,author:'alex',body:'Other'}}],total:2}});
|
||||
results.push(controller.isOwned(pager.snapshot().comments[0]));
|
||||
results.push(controller.isOwned(pager.snapshot().comments[1]));
|
||||
await controller.edit(context, pager, 42, 'Corrected');
|
||||
results.push(pager.snapshot().comments[0].body);
|
||||
await controller.remove(context, pager, 42);
|
||||
results.push([pager.snapshot().comments.map(item=>item.id), pager.snapshot().total]);
|
||||
}}
|
||||
const pager = createConversationPager({{loadPage:async()=>({{}})}});
|
||||
pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}}],total:1}});
|
||||
shouldFail = true;
|
||||
let failure = '';
|
||||
try {{ await controller.edit(contexts[0], pager, 42, 'Unsaved correction'); }} catch (error) {{ failure=error.message; }}
|
||||
const cancelled = await cancelController.remove(contexts[0], pager, 42);
|
||||
process.stdout.write(JSON.stringify({{requests,results,failure,cancelled,state:pager.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
assert [request[:2] for request in output["requests"][:6]] == [
|
||||
["api/v1/repos/stackchain/api/issues/17/comments/42", "PATCH"],
|
||||
["api/v1/repos/stackchain/api/issues/17/comments/42", "DELETE"],
|
||||
["api/v1/repos/stackchain/api/pulls/17/comments/42", "PATCH"],
|
||||
["api/v1/repos/stackchain/api/pulls/17/comments/42", "DELETE"],
|
||||
["api/v1/notifications/91/comments/42", "PATCH"],
|
||||
["api/v1/notifications/91/comments/42", "DELETE"],
|
||||
]
|
||||
assert output["results"] == [True, False, "Corrected", [[43], 1]] * 3
|
||||
assert output["failure"] == "offline"
|
||||
assert output["cancelled"] is None
|
||||
assert output["state"]["comments"][0]["body"] == "Original"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_actions_render_inline_for_mobile_in_all_conversations():
|
||||
html = await dashboard()
|
||||
dashboard_javascript = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
action_javascript = COMMENT_ACTIONS.read_text()
|
||||
javascript = dashboard_javascript + action_javascript
|
||||
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/comment-actions.js"></script>' in html
|
||||
assert html.index('static/comment-actions.js') < html.index('static/dashboard.js')
|
||||
assert "BASE + 'static/comment-actions.js'" in service_worker
|
||||
assert 'data-comment-action="edit"' in javascript
|
||||
assert 'data-comment-action="delete"' in javascript
|
||||
assert 'class="comment-edit-textarea"' in javascript
|
||||
assert "wireCommentActions('#issue-comments')" in javascript
|
||||
assert "wireCommentActions('#pull-comments')" in javascript
|
||||
assert "wireCommentActions('#update-comments')" in javascript
|
||||
assert ".comment-owned-actions button" in css and "min-height:44px" in css
|
||||
assert ".comment-edit-textarea" in css and "max-width:100%" in css
|
||||
assert ".issue-comment" in css and "overflow-wrap:anywhere" in css
|
||||
|
||||
|
||||
def test_notification_reader_exposes_current_comment_pager_only_while_open():
|
||||
script = f"""
|
||||
const {{createNotificationReader}} = require({json.dumps(str(MY_WORK))});
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const reader = createNotificationReader({{
|
||||
load: async () => ({{conversation:{{comments:[{{id:42,body:'Before'}}],total:1}}}}),
|
||||
markRead: async()=>{{}}, onOpen:()=>{{}}, onDetail:()=>{{}}, onItems:()=>{{}}, onStatus:()=>{{}}, onClose:()=>{{}},
|
||||
loadConversation: async()=>({{}}), createPager:createConversationPager,
|
||||
}});
|
||||
(async()=>{{
|
||||
const before = reader.commentPager();
|
||||
await reader.open({{notification_id:91}});
|
||||
const pager = reader.commentPager();
|
||||
pager.replace({{id:42,body:'After'}});
|
||||
process.stdout.write(JSON.stringify({{before:before===null,body:pager.snapshot().comments[0].body}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
assert json.loads(result.stdout) == {"before": True, "body": "After"}
|
||||
|
||||
|
||||
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
|
||||
script = f"""
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
|
||||
@@ -693,6 +693,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/comment-actions.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
|
||||
Reference in new issue
Block a user