660 lines
23 KiB
Python
660 lines
23 KiB
Python
|
|
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},
|
||
|
|
}
|
||
|
|
)
|