Add Handbook.md benchmark tasks
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user