feat: edit assigned issue content from mobile (#189)
This commit is contained in:
1 parent
c0eabbf865
commit
937df5d20b
7 files changed
+479
-1
No files matched your search
@@ -6,6 +6,142 @@ import pytest
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, title, body, expected_updated_at):
|
||||
calls.append((repository, number, title, body, expected_updated_at))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_assigned_issue", update, raising=False
|
||||
)
|
||||
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/content",
|
||||
json={
|
||||
"title": " Clarified scope ",
|
||||
"body": " Updated acceptance criteria ",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["title"] == "Clarified scope"
|
||||
assert calls == [(
|
||||
"stackchain/api", 17, "Clarified scope", "Updated acceptance criteria",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||
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={
|
||||
"number": 17,
|
||||
"title": "Changed upstream",
|
||||
"body": "Newer body",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:02:00Z",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"pull_request": None,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueEditConflictError):
|
||||
await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "My draft", "Draft body",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
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":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Old", "body": "Old body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"assignees": [{"login": "timmy"}], "pull_request": None,
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Clarified", "body": "New body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "Clarified", "New body", "2026-08-07T10:00:00Z"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert requests[2].content == b'{"title":"Clarified","body":"New body"}'
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "Clarified",
|
||||
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(monkeypatch):
|
||||
async def update(*_args):
|
||||
raise gitea_proxy.IssueEditConflictError("changed")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue", update)
|
||||
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/content",
|
||||
json={
|
||||
"title": "My draft",
|
||||
"body": "Still safe",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_issue_creation_operations():
|
||||
main._issue_creation_operations.clear()
|
||||
@@ -749,6 +885,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
@@ -771,6 +908,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
|
||||
@@ -110,6 +110,34 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_issue_content_updates_my_work_without_mutating_snapshot():
|
||||
payload = {
|
||||
"issues": [{"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body"}],
|
||||
"pull_requests": [],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = {json.dumps(payload)};
|
||||
const updated = buildMyWork.replaceIssueContent(
|
||||
original, 'stackchain/api', 17,
|
||||
{{title:'Clarified',body:'New body',updated_at:'2026-08-07T10:01:00Z'}}
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:original.issues[0]}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == {
|
||||
"updated": {
|
||||
"number": 17, "repository": "stackchain/api", "title": "Clarified",
|
||||
"body": "New body", "updated_at": "2026-08-07T10:01:00Z",
|
||||
},
|
||||
"original": {
|
||||
"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -155,6 +183,57 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
||||
assert result.stdout == "Issue release was not confirmed."
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage,
|
||||
fetchJson:(url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const draft = {{title:'Clarified scope', body:'Updated body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}};
|
||||
controller.saveEditDraft(item, draft);
|
||||
const first = controller.updateContent(item, draft);
|
||||
const duplicate = controller.updateContent(item, draft);
|
||||
const during = controller.loadEditDraft(item);
|
||||
finish({{repository:'stackchain/api',number:17,title:'Clarified scope',body:'Updated body',updated_at:'2026-08-07T10:01:00Z'}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate, during, after:controller.loadEditDraft(item), results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"title": "Clarified scope", "body": "Updated body",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
}]
|
||||
assert output["same"] is True
|
||||
assert output["during"] == {
|
||||
"title": "Clarified scope", "body": "Updated body",
|
||||
"expectedUpdatedAt": "2026-08-07T10:00:00Z",
|
||||
}
|
||||
assert output["after"] is None
|
||||
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
@@ -415,6 +494,22 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="edit-issue-content"' in html
|
||||
assert 'id="issue-edit-form"' in html
|
||||
assert 'id="issue-edit-title" type="text" maxlength="255"' in html
|
||||
assert 'id="issue-edit-body" maxlength="10000"' in html
|
||||
assert 'id="save-issue-content"' in html
|
||||
assert 'id="cancel-issue-content"' in html
|
||||
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
|
||||
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
|
||||
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
|
||||
assert 'buildMyWork.replaceIssueContent(' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
||||
html = await dashboard()
|
||||
|
||||
Reference in new issue
Block a user