480 lines
18 KiB
Python
480 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from copy import deepcopy
|
|
|
|
import pytest
|
|
from jira_mock.server import (
|
|
add_attachment,
|
|
add_comment,
|
|
add_worklog,
|
|
create_issue,
|
|
create_sprint,
|
|
create_user,
|
|
delete_issue,
|
|
export_state,
|
|
get_current_user,
|
|
get_epic_issues,
|
|
get_issue,
|
|
get_project_issues,
|
|
get_users,
|
|
import_state,
|
|
link_issues,
|
|
search,
|
|
search_fields,
|
|
update_issue,
|
|
)
|
|
from jira_mock.state import get_state
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_get_update_and_delete_issue() -> None:
|
|
created = await create_issue(
|
|
"MOCK", "Initial summary", "Task", description="Initial body", components="API, Frontend"
|
|
)
|
|
assert created["key"] == "MOCK-1"
|
|
|
|
issue = await get_issue("MOCK-1", fields="summary,description,components")
|
|
assert issue["fields"]["summary"] == "Initial summary"
|
|
assert issue["fields"]["description"]["content"][0]["content"][0]["text"] == "Initial body"
|
|
assert [component["name"] for component in issue["fields"]["components"]] == ["API", "Frontend"]
|
|
|
|
updated = await update_issue("MOCK-1", json.dumps({"summary": "Updated summary", "labels": ["ops", "urgent"]}))
|
|
assert updated["fields"]["summary"] == "Updated summary"
|
|
assert updated["fields"]["labels"] == ["ops", "urgent"]
|
|
|
|
assert (await delete_issue("MOCK-1")) == {"message": "Issue MOCK-1 deleted successfully"}
|
|
with pytest.raises(ValueError, match="Issue MOCK-1 not found"):
|
|
await get_issue("MOCK-1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_issue_rejects_malformed_additional_fields_and_rolls_back() -> None:
|
|
with pytest.raises(ValueError, match="Invalid JSON in `additional_fields` parameter"):
|
|
await create_issue("MOCK", "Bad additional fields", "Task", additional_fields="{not json")
|
|
|
|
assert (await search("project = MOCK", limit=10))["total"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_issue_uses_max_existing_issue_key_suffix() -> None:
|
|
await create_issue("MOCK", "First", "Task")
|
|
state = await export_state()
|
|
base_issue = state["issues"]["MOCK-1"]
|
|
|
|
for suffix in (3, 5):
|
|
issue = deepcopy(base_issue)
|
|
issue["id"] = str(10000 + suffix)
|
|
issue["key"] = f"MOCK-{suffix}"
|
|
issue["self"] = f"https://api.atlassian.com/ex/jira/mock/rest/api/3/issue/MOCK-{suffix}"
|
|
issue["fields"]["summary"] = f"Sparse issue {suffix}"
|
|
state["issues"][issue["key"]] = issue
|
|
|
|
await import_state(state)
|
|
|
|
created = await create_issue("MOCK", "After sparse keys", "Task")
|
|
assert created["key"] == "MOCK-6"
|
|
assert (await get_issue("MOCK-5"))["fields"]["summary"] == "Sparse issue 5"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seeded_state_recovers_numeric_counters() -> None:
|
|
await create_issue("MOCK", "Seed", "Task")
|
|
await create_issue("MOCK", "Seed link target", "Task")
|
|
await link_issues("MOCK-1", "MOCK-2", "Blocks")
|
|
await add_comment("MOCK-1", "seed comment")
|
|
await add_worklog("MOCK-1", "1h", started="2026-05-08T14:30:00Z")
|
|
await add_attachment("MOCK-1", "seed.txt", "aGk=")
|
|
await create_sprint("1000", "Seed sprint", "2026-05-01T00:00:00Z", "2026-05-15T00:00:00Z")
|
|
|
|
state = await export_state()
|
|
state["issues"]["MOCK-1"]["id"] = "10050"
|
|
state["issues"]["MOCK-1"]["fields"]["project"]["id"] = "10020"
|
|
state["issues"]["MOCK-2"]["fields"]["project"]["id"] = "10020"
|
|
state["projects"]["MOCK"]["id"] = "10020"
|
|
state["comments"]["MOCK-1"][0]["id"] = "42"
|
|
state["worklogs"]["MOCK-1"][0]["id"] = "24"
|
|
state["issues"]["MOCK-1"]["fields"]["attachment"][0]["id"] = "88"
|
|
state["issues"]["MOCK-1"]["fields"]["issuelinks"][0]["id"] = "78"
|
|
state["issues"]["MOCK-2"]["fields"]["issuelinks"][0]["id"] = "77"
|
|
|
|
[sprint] = state["sprints"].values()
|
|
sprint["id"] = 1007
|
|
sprint["self"] = "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1007"
|
|
state["sprints"] = {"1007": sprint}
|
|
state["counters"] = {
|
|
"issueId": 1,
|
|
"sprintId": 1,
|
|
"commentId": 1,
|
|
"worklogId": 1,
|
|
"attachmentId": 1,
|
|
"issueLinkId": 1,
|
|
}
|
|
|
|
await import_state(state)
|
|
|
|
assert (await create_issue("MOCK", "Next issue id", "Task"))["id"] == "10051"
|
|
assert (await add_comment("MOCK-1", "next comment"))["id"] == "43"
|
|
assert (await add_worklog("MOCK-1", "30m", started="2026-05-08T15:30:00Z"))["id"] == "25"
|
|
assert (await add_attachment("MOCK-1", "next.txt", "b2s="))["id"] == "89"
|
|
assert (await create_sprint("1000", "Next sprint", "2026-06-01T00:00:00Z", "2026-06-15T00:00:00Z"))["id"] == 1008
|
|
assert (await create_issue("ABC", "New project", "Task"))["key"] == "ABC-1"
|
|
assert (await get_issue("ABC-1", fields="project"))["fields"]["project"]["id"] == "10021"
|
|
assert (await link_issues("MOCK-1", "ABC-1", "Blocks"))["id"] == "79"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_state_small_id_counters_start_at_one() -> None:
|
|
await create_issue("MOCK", "First", "Task")
|
|
await create_issue("MOCK", "Second", "Task")
|
|
|
|
assert (await add_comment("MOCK-1", "first comment"))["id"] == "1"
|
|
assert (await add_worklog("MOCK-1", "1h", started="2026-05-08T14:30:00Z"))["id"] == "1"
|
|
assert (await add_attachment("MOCK-1", "first.txt", "aGk="))["id"] == "1"
|
|
assert (await link_issues("MOCK-1", "MOCK-2", "Blocks"))["id"] == "1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_rejects_direct_status_change() -> None:
|
|
await create_issue("MOCK", "Needs transition", "Task")
|
|
|
|
with pytest.raises(ValueError, match="Cannot change status directly"):
|
|
await update_issue("MOCK-1", json.dumps({"status": "Done"}))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_rejects_malformed_json_only_as_invalid_json() -> None:
|
|
await create_issue("MOCK", "Needs valid json", "Task")
|
|
|
|
with pytest.raises(ValueError, match="Invalid JSON"):
|
|
await update_issue("MOCK-1", "{not json")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_rejects_invalid_field_shapes_and_rolls_back() -> None:
|
|
await create_issue("MOCK", "Original summary", "Task")
|
|
|
|
with pytest.raises(ValueError):
|
|
await update_issue("MOCK-1", json.dumps({"summary": "Changed", "labels": "not-a-list"}))
|
|
|
|
issue = await get_issue("MOCK-1", fields="summary,labels")
|
|
assert issue["fields"]["summary"] == "Original summary"
|
|
assert issue["fields"]["labels"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_rejects_unsupported_fields_and_rolls_back() -> None:
|
|
await create_issue("MOCK", "Original summary", "Task")
|
|
|
|
with pytest.raises(ValueError, match="Unsupported update_issue field"):
|
|
await update_issue("MOCK-1", json.dumps({"summary": "Changed", "fixVersions": [{"name": "v1"}]}))
|
|
|
|
issue = await get_issue("MOCK-1", fields="summary,fixVersions")
|
|
assert issue["fields"]["summary"] == "Original summary"
|
|
assert issue["fields"]["fixVersions"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_can_clear_optional_fields() -> None:
|
|
(
|
|
await create_issue(
|
|
"MOCK",
|
|
"Clearable",
|
|
"Task",
|
|
description="Initial description",
|
|
assignee="user-1",
|
|
additional_fields=json.dumps({"labels": ["triage"], "priority": {"id": "2", "name": "High"}}),
|
|
)
|
|
)
|
|
|
|
updated = await update_issue(
|
|
"MOCK-1",
|
|
json.dumps({"description": "", "assignee": None, "priority": None, "labels": []}),
|
|
)
|
|
|
|
assert updated["fields"]["description"]["content"][0]["content"][0]["text"] == ""
|
|
assert updated["fields"].get("assignee") is None
|
|
assert updated["fields"].get("priority") is None
|
|
assert updated["fields"]["labels"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_users_are_state_level_identities() -> None:
|
|
users = await get_users(query="user-1")
|
|
assert users["total"] == 1
|
|
assert users["values"][0]["accountId"] == "user-1"
|
|
assert "self" not in users["values"][0]
|
|
assert (await get_current_user())["accountId"] == "reporter-001"
|
|
assert "self" not in (await get_current_user())
|
|
|
|
with pytest.raises(ValueError, match="User missing-user not found"):
|
|
await create_issue("MOCK", "Unknown assignee", "Task", assignee="missing-user")
|
|
|
|
state = await export_state()
|
|
state["is_admin"] = True
|
|
await import_state(state)
|
|
|
|
created_user = await create_user("pm-001", "Product Manager", "pm@example.com")
|
|
assert created_user["accountId"] == "pm-001"
|
|
assert "self" not in created_user
|
|
|
|
await create_issue("MOCK", "Assigned issue", "Task", assignee="pm-001")
|
|
issue = await get_issue("MOCK-1", fields="assignee,reporter,creator")
|
|
assert issue["fields"]["assignee"]["displayName"] == "Product Manager"
|
|
assert issue["fields"]["reporter"]["accountId"] == "reporter-001"
|
|
assert issue["fields"]["creator"]["accountId"] == "reporter-001"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_imported_users_replace_defaults_and_define_current_user() -> None:
|
|
state = await export_state()
|
|
state["users"] = {
|
|
"alice": {
|
|
"accountId": "alice",
|
|
"displayName": "Alice",
|
|
"emailAddress": "alice@example.com",
|
|
"timeZone": "America/Los_Angeles",
|
|
"active": True,
|
|
}
|
|
}
|
|
state.pop("currentUserAccountId", None)
|
|
await import_state(state)
|
|
|
|
assert (await get_users())["total"] == 1
|
|
assert (await get_current_user())["accountId"] == "alice"
|
|
created = await create_issue("MOCK", "Alice-authored issue", "Task")
|
|
issue = await get_issue(created["key"], fields="reporter,creator")
|
|
assert issue["fields"]["reporter"]["accountId"] == "alice"
|
|
assert issue["fields"]["creator"]["accountId"] == "alice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jql_current_user_uses_configured_current_user() -> None:
|
|
state = await export_state()
|
|
state["currentUserAccountId"] = "user-1"
|
|
await import_state(state)
|
|
|
|
await create_issue("MOCK", "Mine", "Task", assignee="user-1")
|
|
await create_issue("MOCK", "Unassigned", "Task")
|
|
|
|
result = await search("assignee = currentUser()", limit=10)
|
|
|
|
assert result["total"] == 1
|
|
assert result["issues"][0]["fields"]["summary"] == "Mine"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_jql_current_user_matches_nothing_when_current_user_is_missing() -> None:
|
|
await create_issue("MOCK", "Assigned issue", "Task", assignee="user-1")
|
|
get_state().currentUserAccountId = None
|
|
|
|
result = await search("assignee = currentUser()", limit=10)
|
|
|
|
assert result["total"] == 0
|
|
assert "currentUser() was not applied because no current Jira user is configured." in result["warningMessages"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_imported_state_requires_a_current_user() -> None:
|
|
state = await export_state()
|
|
state["users"] = {}
|
|
state.pop("currentUserAccountId", None)
|
|
|
|
with pytest.raises(ValueError, match="requires at least one user"):
|
|
await import_state(state)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_email_and_time_zone_are_validated() -> None:
|
|
state = await export_state()
|
|
state["is_admin"] = True
|
|
await import_state(state)
|
|
|
|
with pytest.raises(ValueError):
|
|
await create_user("bad-email", "Bad Email", "not-an-email")
|
|
with pytest.raises(ValueError):
|
|
await create_user("bad-zone", "Bad Zone", "zone@example.com", time_zone="New York")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_admin_cannot_create_users() -> None:
|
|
with pytest.raises(PermissionError):
|
|
await create_user("qa-001", "QA User")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_issue_rejects_empty_summary() -> None:
|
|
await create_issue("MOCK", "Original summary", "Task")
|
|
|
|
with pytest.raises(ValueError):
|
|
await update_issue("MOCK-1", json.dumps({"summary": ""}))
|
|
|
|
assert (await get_issue("MOCK-1"))["fields"]["summary"] == "Original summary"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_filters_text_project_labels_and_order() -> None:
|
|
first_mock = await create_issue("MOCK", "Fix checkout error", "Bug", description="Payment screen timeout")
|
|
await create_issue("TEST", "Write release plan", "Task", additional_fields=json.dumps({"labels": ["planning"]}))
|
|
second_mock = await create_issue(
|
|
"MOCK", "Checkout polish", "Task", additional_fields=json.dumps({"labels": ["frontend"]})
|
|
)
|
|
state = await export_state()
|
|
base_issue = deepcopy(state["issues"][first_mock["key"]])
|
|
for suffix in range(3, 11):
|
|
issue = deepcopy(base_issue)
|
|
issue["id"] = str(20000 + suffix)
|
|
issue["key"] = f"MOCK-{suffix}"
|
|
issue["fields"]["summary"] = f"Natural sort issue {suffix}"
|
|
state["issues"][issue["key"]] = issue
|
|
await import_state(state)
|
|
|
|
assert (await search('project = MOCK AND summary ~ "checkout"', limit=10))["total"] == 2
|
|
assert (await search("labels = planning", limit=10))["issues"][0]["key"] == "TEST-1"
|
|
by_key = await search("key = MOCK-1", limit=10)
|
|
assert by_key["warningMessages"] == []
|
|
assert by_key["total"] == 1
|
|
assert by_key["issues"][0]["key"] == "MOCK-1"
|
|
by_issuekey = await search("issuekey in (MOCK-1, TEST-1)", limit=10)
|
|
assert [issue["key"] for issue in by_issuekey["issues"]] == ["MOCK-1", "TEST-1"]
|
|
ordered = (await search("project in (MOCK, TEST) order by key desc", limit=20))["issues"]
|
|
assert [issue["key"] for issue in ordered] == [
|
|
"TEST-1",
|
|
"MOCK-10",
|
|
"MOCK-9",
|
|
"MOCK-8",
|
|
"MOCK-7",
|
|
"MOCK-6",
|
|
"MOCK-5",
|
|
"MOCK-4",
|
|
"MOCK-3",
|
|
second_mock["key"],
|
|
first_mock["key"],
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_issue_tools_honor_fields_filter() -> None:
|
|
await create_issue("MOCK", "Filtered", "Task", description="Hidden unless requested")
|
|
|
|
result = (await search("project = MOCK", fields="summary", limit=10))["issues"][0]
|
|
assert result["fields"] == {"summary": "Filtered"}
|
|
|
|
issue = await get_issue("MOCK-1", fields="summary,description")
|
|
assert set(issue["fields"]) == {"summary", "description"}
|
|
assert issue["fields"]["description"]["content"][0]["content"][0]["text"] == "Hidden unless requested"
|
|
|
|
full_issue = await get_issue("MOCK-1", fields="*all")
|
|
assert "status" in full_issue["fields"]
|
|
assert "project" in full_issue["fields"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_limit_warnings_are_compatible_with_ts_behavior() -> None:
|
|
await create_issue("MOCK", "One", "Task")
|
|
result = await search("project = MOCK", limit=-1, startAt=-2)
|
|
assert result["issues"] == []
|
|
assert "limit must be non-negative; using 0." in result["warningMessages"]
|
|
assert "startAt must be non-negative; using 0." in result["warningMessages"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_project_and_epic_issue_helpers() -> None:
|
|
epic = await create_issue("MOCK", "Epic", "Epic")
|
|
child = await create_issue("MOCK", "Story", "Story", additional_fields=json.dumps({"parent": epic["key"]}))
|
|
|
|
assert (await get_project_issues("MOCK"))["total"] == 2
|
|
epic_issues = await get_epic_issues(epic["key"])
|
|
assert epic_issues["total"] == 1
|
|
assert epic_issues["issues"][0]["key"] == child["key"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_link_issues_creates_bidirectional_links() -> None:
|
|
await create_issue("MOCK", "Blocked", "Task")
|
|
await create_issue("MOCK", "Blocker", "Task")
|
|
|
|
result = await link_issues("MOCK-1", "MOCK-2", "Blocks")
|
|
duplicate = await link_issues("MOCK-1", "MOCK-2", "Blocks")
|
|
reciprocal_duplicate = await link_issues("MOCK-2", "MOCK-1", "is blocked by")
|
|
|
|
assert duplicate["id"] == result["id"]
|
|
assert reciprocal_duplicate["id"] == result["id"]
|
|
assert result["type"]["name"] == "Blocks"
|
|
assert (await get_issue("MOCK-1", fields="issuelinks"))["fields"]["issuelinks"][0]["inwardIssue"]["key"] == "MOCK-2"
|
|
assert (await get_issue("MOCK-2", fields="issuelinks"))["fields"]["issuelinks"][0]["outwardIssue"][
|
|
"key"
|
|
] == "MOCK-1"
|
|
assert len((await get_issue("MOCK-1", fields="issuelinks"))["fields"]["issuelinks"]) == 1
|
|
assert len((await get_issue("MOCK-2", fields="issuelinks"))["fields"]["issuelinks"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_link_issues_rejects_self_links() -> None:
|
|
await create_issue("MOCK", "Circular", "Task")
|
|
|
|
with pytest.raises(ValueError, match="Cannot link an issue to itself"):
|
|
await link_issues("MOCK-1", "MOCK-1", "Blocks")
|
|
|
|
assert "issuelinks" not in (await get_issue("MOCK-1", fields="issuelinks"))["fields"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_issue_removes_dangling_issue_references() -> None:
|
|
parent = await create_issue("MOCK", "Parent", "Epic")
|
|
child = await create_issue("MOCK", "Child", "Story", additional_fields=json.dumps({"parent": parent["key"]}))
|
|
peer = await create_issue("MOCK", "Peer", "Task")
|
|
await link_issues(child["key"], peer["key"], "Blocks")
|
|
|
|
assert (await get_issue(child["key"], fields="parent"))["fields"]["parent"]["key"] == parent["key"]
|
|
assert (await get_issue(child["key"], fields="issuelinks"))["fields"]["issuelinks"][0]["inwardIssue"][
|
|
"key"
|
|
] == peer["key"]
|
|
|
|
await delete_issue(parent["key"])
|
|
assert "parent" not in (await get_issue(child["key"], fields="parent"))["fields"]
|
|
|
|
await delete_issue(peer["key"])
|
|
assert (await get_issue(child["key"], fields="issuelinks"))["fields"]["issuelinks"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_and_state_dump_use_jira_aliases() -> None:
|
|
await create_issue("MOCK", "Alias issue", "Task")
|
|
state = await export_state()
|
|
state["users"]["reporter-001"]["self"] = (
|
|
"https://api.atlassian.com/ex/jira/mock/rest/api/3/user?accountId=reporter-001"
|
|
)
|
|
state["projects"]["MOCK"]["self"] = "https://api.atlassian.com/ex/jira/mock/rest/api/3/project/MOCK"
|
|
state["issues"]["MOCK-1"]["self"] = "https://api.atlassian.com/ex/jira/mock/rest/api/3/issue/MOCK-1"
|
|
state["fields"].append(
|
|
{
|
|
"id": "customfield_10050",
|
|
"key": "customfield_10050",
|
|
"name": "Alias Field",
|
|
"custom": True,
|
|
"schema": {"type": "number", "customId": 10050},
|
|
}
|
|
)
|
|
state["issues"]["MOCK-1"]["changelog"] = {
|
|
"histories": [
|
|
{
|
|
"id": "1",
|
|
"created": "2026-05-08T14:30:00Z",
|
|
"items": [
|
|
{"field": "status", "from": "10001", "fromString": "To Do", "to": "10002", "toString": "Done"}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
await import_state(state)
|
|
|
|
field = (await search_fields("Alias Field"))[0]
|
|
assert field["schema"]["type"] == "number"
|
|
assert "schema_" not in field
|
|
|
|
exported_item = (await export_state())["issues"]["MOCK-1"]["changelog"]["histories"][0]["items"][0]
|
|
assert exported_item["from"] == "10001"
|
|
assert "from_" not in exported_item
|
|
assert '"self"' not in json.dumps(await export_state())
|