Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 25c9eda5ca
1800 changed files with 323575 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import pytest
from jira_mock.state import reset_state
@pytest.fixture(autouse=True)
def clean_state() -> None:
reset_state()
@@ -0,0 +1,233 @@
from __future__ import annotations
import json
from pathlib import Path
import jira_mock.state as state_mod
from jira_mock.state import (
load_state,
resolve_bundle_output_path,
resolve_bundle_state_path,
resolve_bundle_state_paths,
set_agent_workspace,
state_to_json,
)
def _flat_site(account_id: str) -> dict:
"""A minimal valid flat single-site seed keyed on a distinct user."""
return {
"currentUserAccountId": account_id,
"users": {
account_id: {
"accountId": account_id,
"accountType": "atlassian",
"emailAddress": f"{account_id}@example.com",
"displayName": f"User {account_id}",
"active": True,
"timeZone": "America/New_York",
}
},
}
def test_resolve_bundle_state_path_prefers_state_json(tmp_path: Path, monkeypatch) -> None:
"""state.json wins over a bare *.json sibling in the per-service subdir."""
service_dir = tmp_path / "services" / "jira"
service_dir.mkdir(parents=True)
(service_dir / "state.json").write_text("{}", encoding="utf-8")
(service_dir / "issues.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() == service_dir / "state.json"
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path: Path, monkeypatch) -> None:
"""The singular back-compat accessor returns the first *.json when there's
no state.json. (The loader itself reads the whole folder — see
resolve_bundle_state_paths.)"""
service_dir = tmp_path / "services" / "jira"
service_dir.mkdir(parents=True)
(service_dir / "b.json").write_text("{}", encoding="utf-8")
(service_dir / "a.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() == service_dir / "a.json"
def test_resolve_bundle_state_path_missing_subdir(tmp_path: Path, monkeypatch) -> None:
"""A partial bundle without this service's subdir resolves to None so the
loader falls back to INPUTDIR."""
(tmp_path / "services").mkdir()
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() is None
monkeypatch.delenv("BUNDLEDIR")
assert resolve_bundle_state_path() is None
def test_resolve_bundle_output_path(monkeypatch) -> None:
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", "/some/output/services/jira")
assert resolve_bundle_output_path() == Path("/some/output/services/jira/state.json")
monkeypatch.delenv("BUNDLE_OUTPUT_DIR")
assert resolve_bundle_output_path() is None
def _reset_loader(workspace: Path) -> None:
"""Force a clean reload from env the way load_state() guards on globals."""
state_mod._current_state = None
state_mod._sites.clear()
set_agent_workspace(str(workspace / "agent_workspace"))
def test_bundle_state_json_matches_inputdir(tmp_path: Path, monkeypatch) -> None:
"""Loading the same seed from <BUNDLEDIR>/services/jira/state.json yields the
same canonical state as loading it from INPUTDIR."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
seed = {
"currentUserAccountId": "user-1",
"users": {
"user-1": {
"accountId": "user-1",
"accountType": "atlassian",
"emailAddress": "user-1@example.com",
"displayName": "User 1",
"active": True,
"timeZone": "America/New_York",
}
},
}
seed_text = json.dumps(seed)
bundle_dir = tmp_path / "bundle"
bundle_state = bundle_dir / "services" / "jira" / "state.json"
bundle_state.parent.mkdir(parents=True)
bundle_state.write_text(seed_text, encoding="utf-8")
input_dir = tmp_path / "input"
input_dir.mkdir()
(input_dir / "jira.json").write_text(seed_text, encoding="utf-8")
# Load from bundle (INPUTDIR unset).
_reset_loader(tmp_path / "bundle_ws")
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
monkeypatch.delenv("INPUTDIR", raising=False)
load_state()
from_bundle = state_to_json()
# Load from INPUTDIR (BUNDLEDIR unset).
_reset_loader(tmp_path / "input_ws")
monkeypatch.delenv("BUNDLEDIR", raising=False)
monkeypatch.setenv("INPUTDIR", str(input_dir))
load_state()
from_inputdir = state_to_json()
assert from_bundle == from_inputdir
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path: Path, monkeypatch) -> None:
"""The plural resolver returns ALL sorted *.json when there's no state.json,
and just [state.json] when one is present."""
service_dir = tmp_path / "services" / "jira"
service_dir.mkdir(parents=True)
(service_dir / "b.json").write_text("{}", encoding="utf-8")
(service_dir / "a.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_paths() == [service_dir / "a.json", service_dir / "b.json"]
(service_dir / "state.json").write_text("{}", encoding="utf-8")
assert resolve_bundle_state_paths() == [service_dir / "state.json"]
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path: Path, monkeypatch) -> None:
"""A split multi-file bundle folder coalesces to the same canonical state as a
single consolidated multi-site state.json."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
site_a = _flat_site("user-a")
site_b = _flat_site("user-b")
# Make site B distinguishable beyond its current user via an extra user.
site_b["users"]["user-b-extra"] = {
"accountId": "user-b-extra",
"accountType": "atlassian",
"emailAddress": "user-b-extra@example.com",
"displayName": "User B Extra",
"active": True,
"timeZone": "America/New_York",
}
# (a) consolidated: one state.json with both sites.
consolidated = tmp_path / "consolidated"
consolidated_state = consolidated / "services" / "jira" / "state.json"
consolidated_state.parent.mkdir(parents=True)
consolidated_state.write_text(
json.dumps({"sites": {"default": site_a, "extra": site_b}}),
encoding="utf-8",
)
# (b) split: two {sites} wrapper files, no state.json — two distinct named
# sites across files (vs. the flat-file case, which merges into one site).
split = tmp_path / "split"
split_dir = split / "services" / "jira"
split_dir.mkdir(parents=True)
(split_dir / "a.json").write_text(json.dumps({"sites": {"default": site_a}}), encoding="utf-8")
(split_dir / "b.json").write_text(json.dumps({"sites": {"extra": site_b}}), encoding="utf-8")
# Load consolidated.
_reset_loader(tmp_path / "consolidated_ws")
monkeypatch.setenv("BUNDLEDIR", str(consolidated))
load_state()
from_consolidated = state_to_json()
# Load split.
_reset_loader(tmp_path / "split_ws")
monkeypatch.setenv("BUNDLEDIR", str(split))
load_state()
from_split = state_to_json()
assert from_consolidated == from_split
assert set(from_consolidated["sites"]) == {"default", "extra"}
assert set(from_split["sites"]) == {"default", "extra"}
def test_bundle_flat_files_merge_into_one_site(tmp_path: Path, monkeypatch) -> None:
"""the raw entities layout splits ONE site across per-entity files (no
{sites} wrapper). Flat files must merge into a single default site, not
fragment into a phantom site per file the server never activates."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
service_dir = tmp_path / "bundle" / "services" / "jira"
service_dir.mkdir(parents=True)
# Two halves of the SAME site: identity/users in one file, an extra user in
# another. Both must land in the single active (default) site.
(service_dir / "a_users.json").write_text(json.dumps(_flat_site("user-a")), encoding="utf-8")
(service_dir / "b_users.json").write_text(
json.dumps(
{
"users": {
"user-b": {
"accountId": "user-b",
"accountType": "atlassian",
"emailAddress": "user-b@example.com",
"displayName": "User B",
"active": True,
"timeZone": "America/New_York",
}
}
}
),
encoding="utf-8",
)
_reset_loader(tmp_path / "flat_ws")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
load_state()
merged = state_to_json()
# One site, and both per-file users present in it — nothing fragmented away.
assert set(merged.get("sites", {"default": merged}).keys()) == {"default"} or "users" in merged
state = merged["sites"]["default"] if "sites" in merged else merged
assert "user-a" in state["users"]
assert "user-b" in state["users"]
+479
View File
@@ -0,0 +1,479 @@
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())
@@ -0,0 +1,58 @@
from __future__ import annotations
import pytest
from jira_mock.server import create_issue, get_project_issues, list_sites, search, update_issue
from jira_mock.state import get_active_site_id, state_from_json, state_to_json
@pytest.mark.asyncio
async def test_site_selector_routes_reads_and_writes_independently() -> None:
state_from_json({"sites": {"default": {}, "acme": {}}})
default_issue = await create_issue("MOCK", "Default site issue", "Task", site_id="default")
acme_issue = await create_issue("MOCK", "Acme site issue", "Task", site_id="acme")
assert default_issue["key"] == "MOCK-1"
assert acme_issue["key"] == "MOCK-1"
listed = await list_sites()
assert listed["status"] == "success"
assert listed["total"] == 2
assert {site["site_id"] for site in listed["sites"]} == {"default", "acme"}
default_issues = await get_project_issues("MOCK", site_id="default")
acme_issues = await get_project_issues("MOCK", site_id="acme")
assert default_issues["issues"][0]["fields"]["summary"] == "Default site issue"
assert acme_issues["issues"][0]["fields"]["summary"] == "Acme site issue"
assert (await search('summary ~ "Acme"', site_id="acme"))["total"] == 1
assert (await search('summary ~ "Acme"', site_id="default"))["total"] == 0
exported = state_to_json()
assert set(exported["sites"]) == {"default", "acme"}
assert exported["sites"]["default"]["issues"]["MOCK-1"]["fields"]["summary"] == "Default site issue"
assert exported["sites"]["acme"]["issues"]["MOCK-1"]["fields"]["summary"] == "Acme site issue"
@pytest.mark.asyncio
async def test_failed_site_write_does_not_mutate_other_sites() -> None:
state_from_json({"sites": {"default": {}, "acme": {}}})
await create_issue("MOCK", "Default site issue", "Task", site_id="default")
before = state_to_json()
with pytest.raises(ValueError, match="Issue MISSING-1 not found"):
await update_issue("MISSING-1", "{}", site_id="acme")
assert state_to_json() == before
@pytest.mark.asyncio
async def test_state_import_resets_active_site_to_default() -> None:
state_from_json({"sites": {"default": {}, "acme": {}}})
await get_project_issues("MOCK", site_id="acme")
assert get_active_site_id() == "acme"
state_from_json({"sites": {"default": {}}})
assert get_active_site_id() == "default"
assert (await get_project_issues("MOCK"))["total"] == 0
+659
View File
@@ -0,0 +1,659 @@
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
import pytest
from jira_mock.models import (
JiraAttachment,
JiraComment,
JiraField,
JiraIssue,
JiraPriority,
JiraSprint,
JiraState,
JiraStatusCategory,
JiraWorklog,
)
from pydantic import ValidationError
REPO_ROOT = Path(__file__).resolve().parents[3]
def _issue_with_dates(created: str, updated: str) -> dict:
return {
"id": "10001",
"key": "MOCK-1",
"self": "https://api.atlassian.com/ex/jira/mock/rest/api/3/issue/MOCK-1",
"fields": {
"summary": "Schema check",
"description": None,
"issuetype": {"id": "10001", "name": "Task", "subtask": False},
"project": {"id": "10001", "key": "MOCK", "name": "Mock Project"},
"status": {"id": "10001", "name": "To Do"},
"created": created,
"updated": updated,
},
}
@pytest.mark.parametrize(
"timestamp",
[
"2026-05-08T14:30:00Z",
"2026-05-08T14:30:00.123Z",
"2026-05-08T14:30:00-04:00",
"2026-05-08T14:30:00.123-0400",
],
)
def test_jira_datetime_accepts_rest_api_timestamp_shapes(timestamp: str) -> None:
issue = JiraIssue.model_validate(_issue_with_dates(timestamp, timestamp))
assert issue.fields.created == timestamp
def test_jira_datetime_rejects_naive_timestamp() -> None:
with pytest.raises(ValidationError):
JiraIssue.model_validate(_issue_with_dates("2026-05-08T14:30:00", "2026-05-08T14:30:00Z"))
def test_issue_type_and_priority_names_allow_custom_values() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["issuetype"]["name"] = "Customer Escalation"
data["fields"]["priority"] = {"id": "7", "name": "Launch Blocker"}
issue = JiraIssue.model_validate(data)
assert issue.fields.issuetype.name == "Customer Escalation"
assert isinstance(issue.fields.priority, JiraPriority)
assert issue.fields.priority.name == "Launch Blocker"
def test_issue_type_and_priority_names_reject_empty_values() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["issuetype"]["name"] = " "
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["priority"] = {"id": "7", "name": ""}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_jira_user_account_type_is_constrained() -> None:
JiraState.model_validate(
{
"currentUserAccountId": "app-user",
"users": {"app-user": {"accountId": "app-user", "accountType": "app", "displayName": "App User"}},
}
)
with pytest.raises(ValidationError):
JiraState.model_validate(
{
"currentUserAccountId": "custom-user",
"users": {
"custom-user": {
"accountId": "custom-user",
"accountType": "external",
"displayName": "Custom User",
}
},
}
)
def test_stable_jira_enums_are_constrained() -> None:
JiraStatusCategory.model_validate({"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"})
JiraSprint.model_validate(
{
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
)
with pytest.raises(ValidationError):
JiraStatusCategory.model_validate({"id": 2, "key": "todo", "name": "To Do", "colorName": "blue-gray"})
with pytest.raises(ValidationError):
JiraSprint.model_validate(
{
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "completed",
"name": "Sprint 1",
"originBoardId": 1000,
}
)
def test_adf_node_types_are_constrained() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["description"] = {
"type": "doc",
"version": 1,
"content": [{"type": "unknownBlock", "content": [{"type": "text", "text": "hello"}]}],
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_state_keys_must_match_entity_ids() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
state = {"issues": {"OTHER-1": issue}}
with pytest.raises(ValidationError, match="issues key"):
JiraState.model_validate(state)
state = {"projects": {"OTHER": {"id": "10001", "key": "MOCK", "name": "Mock Project"}}}
with pytest.raises(ValidationError, match="projects key"):
JiraState.model_validate(state)
state = {"users": {"other-user": {"accountId": "user-1", "displayName": "User 1"}}}
with pytest.raises(ValidationError, match="users key"):
JiraState.model_validate(state)
state = {
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"boards": {"2": {"id": 1, "name": "Mock Board", "type": "scrum", "projectKey": "MOCK"}},
}
with pytest.raises(ValidationError, match="boards key"):
JiraState.model_validate(state)
state = {
"sprints": {
"2": {
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
}
}
with pytest.raises(ValidationError, match="sprints key"):
JiraState.model_validate(state)
def test_comments_and_worklogs_must_reference_existing_issue_keys() -> None:
comment = {
"id": "1",
"author": {"accountId": "commenter-001", "displayName": "User commenter-001"},
"body": {"type": "doc", "version": 1, "content": []},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
}
worklog = {
"id": "1",
"author": {"accountId": "worker-001", "displayName": "User worker-001"},
"updateAuthor": {"accountId": "worker-001", "displayName": "User worker-001"},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
"started": "2026-05-08T14:30:00Z",
"timeSpent": "1h",
"timeSpentSeconds": 3600,
}
with pytest.raises(ValidationError, match="comments key"):
JiraState.model_validate({"comments": {"MOCK-1": [comment]}})
with pytest.raises(ValidationError, match="worklogs key"):
JiraState.model_validate({"worklogs": {"MOCK-1": [worklog]}})
def test_issue_relationships_must_reference_existing_issue_keys() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
state = {
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
parent_issue = {
**issue,
"fields": {
**issue["fields"],
"parent": {"id": "10002", "key": "MOCK-2"},
},
}
with pytest.raises(ValidationError, match="parent references missing issue"):
JiraState.model_validate({**state, "issues": {"MOCK-1": parent_issue}})
linked_issue = {
**issue,
"fields": {
**issue["fields"],
"issuelinks": [
{
"id": "1",
"type": {"id": "10001", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
"inwardIssue": {"id": "10002", "key": "MOCK-2"},
}
],
},
}
with pytest.raises(ValidationError, match="inward link references missing issue"):
JiraState.model_validate({**state, "issues": {"MOCK-1": linked_issue}})
def test_issue_projects_must_match_state_projects() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
with pytest.raises(ValidationError, match="project references missing project"):
JiraState.model_validate({"issues": {"MOCK-1": issue}})
with pytest.raises(ValidationError, match="project id"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "99999", "key": "MOCK", "name": "Mock Project"}},
}
)
issue_with_wrong_project = deepcopy(issue)
issue_with_wrong_project["fields"]["project"] = {"id": "10002", "key": "TEST", "name": "Test Project"}
with pytest.raises(ValidationError, match="key prefix"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue_with_wrong_project},
"projects": {"TEST": {"id": "10002", "key": "TEST", "name": "Test Project"}},
}
)
def test_issue_statuses_must_reference_configured_statuses() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["status"] = {"id": "99999", "name": "to do"}
statuses = {
"10001": {
"id": "10001",
"name": "To Do",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
}
}
state = JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": []},
}
)
assert state.issues["MOCK-1"].fields.status.id == "10001"
assert state.issues["MOCK-1"].fields.status.name == "To Do"
assert state.issues["MOCK-1"].fields.status.statusCategory is not None
assert state.issues["MOCK-1"].fields.status.statusCategory.key == "new"
issue_with_missing_status = deepcopy(issue)
issue_with_missing_status["fields"]["status"] = {"id": "10005", "name": "Blocked"}
with pytest.raises(ValidationError, match="issue 'MOCK-1' status references missing status 'Blocked'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue_with_missing_status},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": []},
}
)
def test_sample_bundle_jira_state_is_loadable() -> None:
state = json.loads((REPO_ROOT / "fixtures/sample_bundle/services/jira.json").read_text())
JiraState.model_validate(state)
def test_workflows_must_reference_configured_statuses() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
statuses = {
"10001": {
"id": "10001",
"name": "To Do",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
},
"10002": {
"id": "10002",
"name": "In Progress",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress", "colorName": "yellow"},
},
}
state = JiraState.model_validate(
{
"defaultStatusValue": "to do",
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"to do": [{"id": "1", "name": "Start Progress", "to": "in progress"}]},
}
)
assert state.defaultStatusValue == "To Do"
assert list(state.workflow) == ["To Do"]
assert state.workflow["To Do"][0].to == "In Progress"
duplicate_status_names = deepcopy(statuses)
duplicate_status_names["10003"] = {
"id": "10003",
"name": "to do",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
}
with pytest.raises(ValidationError, match="duplicate status name 'to do'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": duplicate_status_names,
"workflow": {"To Do": []},
}
)
with pytest.raises(ValidationError, match="duplicate workflow entry for status 'To Do'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": [], "to do": []},
}
)
with pytest.raises(ValidationError, match="workflow key 'Blocked' references missing status 'Blocked'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": [], "Blocked": []},
}
)
with pytest.raises(
ValidationError, match="workflow transition '1' from 'To Do' references missing status 'Blocked'"
):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": [{"id": "1", "name": "Start Blocked", "to": "Blocked"}]},
}
)
def test_issue_user_references_must_match_state_users() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["assignee"] = {"accountId": "user-1", "displayName": "User 1"}
with pytest.raises(ValidationError, match="assignee references missing user"):
JiraState.model_validate(
{
"users": {},
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
JiraState.model_validate(
{
"users": {"user-1": {"accountId": "user-1", "displayName": "User 1"}},
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
def test_legacy_embedded_users_are_migrated_when_users_table_is_missing() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["assignee"] = {"accountId": "user-1", "displayName": "User 1"}
state = JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
assert state.users["user-1"].displayName == "User 1"
def test_embedded_users_and_projects_are_canonicalized_from_state() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["project"]["name"] = "Drifted Project"
issue["fields"]["assignee"] = {"accountId": "user-1", "displayName": "Drifted User"}
state = JiraState.model_validate(
{
"users": {"user-1": {"accountId": "user-1", "displayName": "Canonical User"}},
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Canonical Project"}},
}
)
assert state.issues["MOCK-1"].fields.project.name == "Canonical Project"
assert state.issues["MOCK-1"].fields.assignee is not None
assert state.issues["MOCK-1"].fields.assignee.displayName == "Canonical User"
def test_boards_and_sprints_must_reference_existing_entities() -> None:
with pytest.raises(ValidationError, match="board '1000' references missing project"):
JiraState.model_validate({"boards": {"1000": {"id": 1000, "name": "Mock Board", "projectKey": "MOCK"}}})
with pytest.raises(ValidationError, match="sprint '1' references missing board"):
JiraState.model_validate(
{
"sprints": {
"1": {
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
}
}
)
with pytest.raises(ValidationError, match="sprint '1' references non-scrum board"):
JiraState.model_validate(
{
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"boards": {"1000": {"id": 1000, "name": "Mock Kanban Board", "type": "kanban", "projectKey": "MOCK"}},
"sprints": {
"1": {
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
},
}
)
def test_state_entity_ids_must_be_unique() -> None:
issue_1 = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue_2 = deepcopy(issue_1)
issue_2["key"] = "MOCK-2"
with pytest.raises(ValidationError, match="duplicate issue id"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue_1, "MOCK-2": issue_2},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
with pytest.raises(ValidationError, match="duplicate project id"):
JiraState.model_validate(
{
"projects": {
"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"},
"TEST": {"id": "10001", "key": "TEST", "name": "Test Project"},
}
}
)
def test_issue_project_and_numeric_ids_are_constrained() -> None:
valid_issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
invalid_issue = {**valid_issue, "key": "mock-1"}
with pytest.raises(ValidationError):
JiraIssue.model_validate(invalid_issue)
invalid_issue = {**valid_issue, "id": "abc"}
with pytest.raises(ValidationError):
JiraIssue.model_validate(invalid_issue)
invalid_issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
invalid_issue["fields"]["project"]["key"] = "mock"
with pytest.raises(ValidationError):
JiraIssue.model_validate(invalid_issue)
with pytest.raises(ValidationError):
JiraComment.model_validate(
{
"id": "",
"author": {"accountId": "commenter-001", "displayName": "User commenter-001"},
"body": {"type": "doc", "version": 1, "content": []},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
}
)
with pytest.raises(ValidationError):
JiraWorklog.model_validate(
{
"id": "worklog-1",
"author": {"accountId": "worker-001", "displayName": "User worker-001"},
"updateAuthor": {"accountId": "worker-001", "displayName": "User worker-001"},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
"started": "2026-05-08T14:30:00Z",
"timeSpent": "1h",
"timeSpentSeconds": 3600,
}
)
with pytest.raises(ValidationError):
JiraAttachment.model_validate(
{
"id": "file-1",
"filename": "note.txt",
"author": {"accountId": "uploader-001", "displayName": "User uploader-001"},
"created": "2026-05-08T14:30:00Z",
"size": 4,
"mimeType": "text/plain",
"content": "aGVsbG8=",
}
)
def test_issue_nested_objects_are_typed() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["parent"] = {
"id": "10000",
"key": "mock-1",
"fields": {
"summary": "Parent",
"status": {"id": "10001", "name": "To Do"},
"issuetype": {"id": "10000", "name": "Epic"},
},
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["watches"] = {
"watchCount": 2,
"watchers": [{"accountId": "alice", "displayName": "Alice"}],
}
with pytest.raises(ValidationError, match="watchCount"):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["timetracking"] = {"timeSpent": "1h", "timeSpentSeconds": -1}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_issue_expanded_objects_are_typed() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["comment"] = {
"comments": [
{
"id": "1",
"author": {"accountId": "commenter-001", "displayName": "User commenter-001"},
"body": {"not": "adf"},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
}
],
"maxResults": 1,
"total": 1,
"startAt": 0,
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["changelog"] = {
"histories": [
{
"id": "history-1",
"created": "2026-05-08T14:30:00Z",
"items": [{"field": "status", "fromString": "To Do", "toString": "Done"}],
}
]
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["transitions"] = [{"id": "start", "name": "Start Progress", "to": {"id": "10002", "name": "In Progress"}}]
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_field_schema_is_typed() -> None:
field = JiraField.model_validate(
{
"id": "customfield_10001",
"key": "customfield_10001",
"name": "Story Points",
"custom": True,
"schema": {"type": "number", "customId": 10001},
}
)
assert field.schema_ is not None
assert field.schema_.type == "number"
with pytest.raises(ValidationError):
JiraField.model_validate(
{
"id": "customfield_10001",
"key": "customfield_10001",
"name": "Story Points",
"custom": True,
"schema": {"customId": -1},
}
)
@@ -0,0 +1,229 @@
from __future__ import annotations
import pytest
from jira_mock.server import (
add_comment,
add_worklog,
create_board,
create_issue,
create_sprint,
export_state,
get_boards,
get_sprint_issues,
get_sprints_from_board,
get_worklogs,
import_state,
update_estimate,
update_sprint,
)
from jira_mock.state import init_state, set_snapshot_paths, write_snapshots
from jira_mock.viewer import create_app
from starlette.testclient import TestClient
@pytest.mark.asyncio
async def test_sprints_and_sprint_issues() -> None:
await create_issue("MOCK", "Sprint item", "Story", additional_fields='{"labels":["seed"]}')
boards = await get_boards(project_key="MOCK")
assert boards["total"] == 1
assert boards["values"][0]["id"] == 1000
assert boards["values"][0]["projectKey"] == "MOCK"
sprint = await create_sprint("1000", "Sprint 1", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z", goal="Ship")
state = await export_state()
state["issues"]["MOCK-1"]["fields"]["customfield_10002"] = sprint["id"]
await import_state(state)
assert (await get_sprints_from_board("1000"))["values"][0]["name"] == "Sprint 1"
assert (await get_sprint_issues(str(sprint["id"])))["total"] == 1
filtered_issue = (await get_sprint_issues(str(sprint["id"]), fields="summary"))["issues"][0]
assert filtered_issue["fields"] == {"summary": "Sprint item"}
assert (await update_sprint(str(sprint["id"]), state="closed"))["state"] == "closed"
@pytest.mark.asyncio
async def test_sprints_are_scoped_to_boards() -> None:
mock_sprint = await create_sprint("1000", "Mock sprint", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z")
test_sprint = await create_sprint("1001", "Test sprint", "2026-02-01T00:00:00Z", "2026-02-15T00:00:00Z")
assert [sprint["id"] for sprint in (await get_sprints_from_board("1000"))["values"]] == [mock_sprint["id"]]
assert [sprint["id"] for sprint in (await get_sprints_from_board("1001"))["values"]] == [test_sprint["id"]]
@pytest.mark.asyncio
async def test_admin_can_create_boards() -> None:
with pytest.raises(PermissionError):
await create_board("MOCK", "Unauthorized Board")
state = await export_state()
state["is_admin"] = True
await import_state(state)
board = await create_board("MOCK", "Operations Scrum Board")
assert board["id"] == 1002
assert board["type"] == "scrum"
assert board["projectKey"] == "MOCK"
assert board["filterJql"] == "project = MOCK"
sprint = await create_sprint(str(board["id"]), "Operations sprint", "2026-03-01T00:00:00Z", "2026-03-15T00:00:00Z")
assert sprint["originBoardId"] == board["id"]
@pytest.mark.asyncio
async def test_create_board_recovers_seeded_board_counter() -> None:
state = await export_state()
state["is_admin"] = True
state["boards"]["2000"] = {
"id": 2000,
"name": "Seeded Scrum Board",
"type": "scrum",
"projectKey": "MOCK",
"filterJql": "project = MOCK",
}
await import_state(state)
assert (await create_board("MOCK", "Next Scrum Board"))["id"] == 2001
@pytest.mark.asyncio
async def test_sprints_require_existing_scrum_board() -> None:
with pytest.raises(ValueError, match="Board 9999 not found"):
await create_sprint("9999", "Missing board", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z")
state = await export_state()
state["boards"]["2000"] = {
"id": 2000,
"name": "Mock Kanban Board",
"type": "kanban",
"projectKey": "MOCK",
"filterJql": "project = MOCK",
}
await import_state(state)
with pytest.raises(ValueError, match="Sprints can only be created for Scrum boards"):
await create_sprint("2000", "Kanban sprint", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z")
@pytest.mark.asyncio
async def test_worklogs_update_timetracking() -> None:
await create_issue("MOCK", "Timed work", "Task")
await update_estimate("MOCK-1", "1d")
worklog = await add_worklog(
"MOCK-1",
"2h 30m",
comment="Investigated",
started="2026-05-08T14:30:00Z",
)
assert worklog["timeSpentSeconds"] == 9000
assert worklog["started"] == "2026-05-08T14:30:00Z"
summary = await get_worklogs("MOCK-1")
assert summary["totalTimeSpentSeconds"] == 9000
assert summary["timetracking"]["remainingEstimate"] == "5h 30m"
@pytest.mark.asyncio
async def test_state_round_trips() -> None:
await create_issue("MOCK", "Round trip", "Task")
state = await export_state()
state["issues"]["MOCK-1"]["fields"]["summary"] = "Changed outside tool"
assert (await import_state(state)) == {"ok": True}
assert (await export_state())["issues"]["MOCK-1"]["fields"]["summary"] == "Changed outside tool"
def test_snapshot_paths_support_partial_updates(tmp_path) -> None:
bundle_path = tmp_path / "bundle.json"
first_final_path = tmp_path / "first-final.json"
second_final_path = tmp_path / "second-final.json"
try:
set_snapshot_paths(final_path=first_final_path, bundle_state_path=bundle_path)
write_snapshots()
assert bundle_path.exists()
assert first_final_path.exists()
bundle_path.unlink()
set_snapshot_paths(final_path=second_final_path)
write_snapshots()
assert bundle_path.exists()
assert second_final_path.exists()
finally:
set_snapshot_paths(final_path=None, bundle_state_path=None)
def test_init_state_writes_initial_json_without_final_snapshot(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OUTPUTDIR", str(tmp_path))
try:
init_state()
assert (tmp_path / "initial.json").exists()
assert not (tmp_path / "final.json").exists()
finally:
set_snapshot_paths(final_path=None, bundle_state_path=None)
@pytest.mark.asyncio
async def test_viewer_api_smoke(monkeypatch) -> None:
await create_issue("MOCK", "Viewer issue", "Task", components="API,Frontend")
await add_comment("MOCK-1", "Viewer comment")
client = TestClient(create_app())
home = client.get("/")
assert home.status_code == 200
assert "list-view" in home.text
assert "kanban-view" in home.text
assert "detail-panel" in home.text
issues = client.get("/api/issues")
assert issues.status_code == 200
assert issues.json()["issues"][0]["key"] == "MOCK-1"
assert issues.json()["issues"][0]["status"] == "To Do"
assert client.get("/api/issues", params={"project": "MOCK"}).json()["total"] == 1
assert client.get("/api/issues", params={"type": "Bug"}).json()["total"] == 0
assert client.get("/api/projects").json()["projects"][0]["issueCount"] == 1
detail = client.get("/api/issues/MOCK-1").json()
assert detail["issue"]["components"] == ["API", "Frontend"]
assert detail["comments"][0]["body"] == "Viewer comment"
monkeypatch.setenv("MCP_PROXY_TOKEN", "secret")
authed_client = TestClient(create_app())
assert authed_client.get("/api/issues").status_code == 403
assert authed_client.get("/api/issues", headers={"x-proxy-token": "secret"}).status_code == 200
@pytest.mark.asyncio
async def test_viewer_shows_issue_attachments() -> None:
import base64
from jira_mock.models import JiraAttachment
from jira_mock.state import get_state
await create_issue("MOCK", "Issue with attachment", "Task")
state = get_state()
author = next(iter(state.users.values()))
state.issues["MOCK-1"].fields.attachment = [
JiraAttachment(
id="1",
filename="report.txt",
author=author,
created="2026-01-01T00:00:00Z",
size=11,
mimeType="text/plain",
content=base64.b64encode(b"hello world").decode(),
)
]
client = TestClient(create_app())
# Issue list signals attachment presence.
assert client.get("/api/issues").json()["issues"][0]["attachmentCount"] == 1
# Issue detail exposes attachment metadata, never the stored bytes.
detail = client.get("/api/issues/MOCK-1").json()["issue"]
assert len(detail["attachments"]) == 1
attachment = detail["attachments"][0]
assert attachment["filename"] == "report.txt"
assert attachment["mimeType"] == "text/plain"
assert attachment["size"] == 11
assert "content" not in attachment
@@ -0,0 +1,204 @@
from __future__ import annotations
import base64
import pytest
from jira_mock.server import (
add_attachment,
add_comment,
add_watcher,
create_issue,
create_status,
create_user,
export_state,
get_attachments,
get_issue,
get_transitions,
get_watchers,
import_state,
remove_watcher,
transition_issue,
upsert_workflow_transition,
)
@pytest.mark.asyncio
async def test_transitions_follow_default_workflow() -> None:
await create_issue("MOCK", "Workflow", "Task")
transitions = (await get_transitions("MOCK-1"))["transitions"]
assert transitions[0]["id"] == "1"
issue = await transition_issue("MOCK-1", "1")
assert issue["fields"]["status"]["name"] == "In Progress"
with pytest.raises(ValueError, match="not available"):
await transition_issue("MOCK-1", "1")
@pytest.mark.asyncio
async def test_empty_statuses_and_workflow_inherit_defaults() -> None:
state = await export_state()
state["statuses"] = {}
state["workflow"] = {}
state.pop("defaultStatusValue", None)
await import_state(state)
exported = await export_state()
assert exported["defaultStatusValue"] == "To Do"
assert exported["statuses"]["10001"]["name"] == "To Do"
assert "To Do" in exported["workflow"]
created = await create_issue("MOCK", "Default status after empty maps", "Task")
assert (await get_issue(created["key"], fields="status"))["fields"]["status"]["name"] == "To Do"
@pytest.mark.asyncio
async def test_empty_default_status_value_inherits_default() -> None:
state = await export_state()
state["defaultStatusValue"] = ""
await import_state(state)
assert (await export_state())["defaultStatusValue"] == "To Do"
@pytest.mark.asyncio
async def test_create_issue_uses_configured_default_status_value() -> None:
state = await export_state()
state["defaultStatusValue"] = "Backlog"
state["statuses"] = {
"20001": {
"id": "20001",
"name": "Backlog",
"description": "Ready for triage",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
},
"20002": {
"id": "20002",
"name": "Selected",
"description": "Ready to start",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress", "colorName": "yellow"},
},
}
state["workflow"] = {"Backlog": [{"id": "21", "name": "Select for Work", "to": "Selected"}], "Selected": []}
await import_state(state)
created = await create_issue("MOCK", "Starts in custom default", "Task")
assert (await get_issue(created["key"], fields="status"))["fields"]["status"]["name"] == "Backlog"
transitions = (await get_transitions(created["key"]))["transitions"]
assert transitions == [
{
"id": "21",
"name": "Select for Work",
"to": {
"id": "20002",
"name": "Selected",
"description": "Ready to start",
"iconUrl": None,
"statusCategory": {
"id": 4,
"key": "indeterminate",
"name": "In Progress",
"colorName": "yellow",
},
},
"hasScreen": False,
"isGlobal": False,
"isInitial": False,
"isAvailable": True,
"isConditional": False,
"isLooped": False,
}
]
@pytest.mark.asyncio
async def test_default_status_value_must_reference_status_and_workflow() -> None:
state = await export_state()
state["defaultStatusValue"] = "Backlog"
with pytest.raises(ValueError, match="defaultStatusValue 'Backlog' does not reference a configured status"):
await import_state(state)
state["statuses"]["20001"] = {
"id": "20001",
"name": "Backlog",
"description": "Ready for triage",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
}
with pytest.raises(ValueError, match="defaultStatusValue 'Backlog' does not have a workflow entry"):
await import_state(state)
@pytest.mark.asyncio
async def test_admin_can_create_custom_status_and_workflow_transition() -> None:
state = await export_state()
state["is_admin"] = True
await import_state(state)
await create_issue("MOCK", "Blocked work", "Task")
status = await create_status("10005", "Blocked", "indeterminate", description="Work is blocked")
transition = await upsert_workflow_transition("To Do", "8", "Mark Blocked", "Blocked")
assert status["name"] == "Blocked"
assert transition == {"id": "8", "name": "Mark Blocked", "to": "Blocked"}
assert any(item["id"] == "8" for item in (await get_transitions("MOCK-1"))["transitions"])
assert (await transition_issue("MOCK-1", "8"))["fields"]["status"]["name"] == "Blocked"
@pytest.mark.asyncio
async def test_workflow_transition_upsert_canonicalizes_status_names() -> None:
state = await export_state()
state["is_admin"] = True
await import_state(state)
await create_issue("MOCK", "Blocked work", "Task")
await create_status("10005", "Blocked", "indeterminate", description="Work is blocked")
transition = await upsert_workflow_transition("to do", "8", "Mark Blocked", "blocked")
assert transition == {"id": "8", "name": "Mark Blocked", "to": "Blocked"}
exported = await export_state()
assert "to do" not in exported["workflow"]
assert exported["workflow"]["To Do"][-1] == {"id": "8", "name": "Mark Blocked", "to": "Blocked"}
assert any(item["id"] == "8" for item in (await get_transitions("MOCK-1"))["transitions"])
assert (await transition_issue("MOCK-1", "8"))["fields"]["status"]["name"] == "Blocked"
@pytest.mark.asyncio
async def test_non_admin_cannot_configure_statuses_or_workflows() -> None:
with pytest.raises(PermissionError, match="Admin privileges"):
await create_status("10005", "Blocked", "indeterminate")
with pytest.raises(PermissionError, match="Admin privileges"):
await upsert_workflow_transition("To Do", "8", "Mark Blocked", "Blocked")
@pytest.mark.asyncio
async def test_comments_watchers_and_attachments() -> None:
await create_issue("MOCK", "Collaboration", "Task")
comment = await add_comment("MOCK-1", "Looks good")
assert comment["body"]["content"][0]["content"][0]["text"] == "Looks good"
assert (await get_issue("MOCK-1", fields="comment"))["fields"]["comment"]["total"] == 1
await add_comment("MOCK-1", "Second")
assert "comment" not in (await export_state())["issues"]["MOCK-1"]["fields"]
assert (await get_issue("MOCK-1", fields="comment"))["fields"]["comment"]["total"] == 2
state = await export_state()
state["is_admin"] = True
await import_state(state)
await create_user("alice", "Alice")
assert (await add_watcher("MOCK-1", "alice"))["message"] == "Added watcher alice to MOCK-1"
assert (await add_watcher("MOCK-1", "alice"))["message"] == "User alice is already watching MOCK-1"
assert (await get_watchers("MOCK-1"))["watchCount"] == 1
assert (await remove_watcher("MOCK-1", "alice"))["message"] == "Removed watcher alice from MOCK-1"
payload = base64.b64encode(b"hello").decode()
attachment = await add_attachment("MOCK-1", "note.txt", payload)
assert attachment["mimeType"] == "text/plain"
attachments = await get_attachments("MOCK-1")
assert attachments["total"] == 1
assert "content" not in attachments["attachments"][0]