generated from coulomb/repo-seed
Renames the package, distribution, CLI alias, Makefile targets, and working directory from issue-facade to issue-core, signalling its role as the authoritative task lifecycle manager for the Coulomb org (peer to activity-core, rules-core, project-core). Adds POST /issues/ ingestion endpoint for activity-core's IssueSink, under a new optional [api] extra. The endpoint is served by `issue serve`, authenticates via the ISSUE_CORE_API_KEY env var (Bearer or X-API-Key header), and routes the TaskSpec payload to the configured default backend with full traceability metadata embedded in sync_metadata. - T01: Python package issue_tracker -> issue_core, dir rename - T02: registered in state hub under custodian domain - T03: INTENT.md (what it is, what it isn't, how it fits) - T04: SCOPE.md (in/out-of-scope, integration boundaries) - T05: POST /issues/ via FastAPI + Uvicorn, 9 unit tests - T06: docs/nats-task-ingestion.md design stub Closes ISSC-WP-0001. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
603 lines
21 KiB
Python
603 lines
21 KiB
Python
"""
|
|
Test suite for Core Domain Models.
|
|
|
|
These tests ensure the domain models (Issue, Label, User, etc.) work correctly,
|
|
including state management, validation, and business logic.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timezone
|
|
from issue_core.core.models import (
|
|
Issue, Label, User, Milestone, Comment,
|
|
IssueState, Priority, IssueType, LabelCategories
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueState:
|
|
"""Test IssueState enumeration."""
|
|
|
|
def test_from_string_standard_states(self):
|
|
"""Test converting standard strings to IssueState."""
|
|
assert IssueState.from_string("open") == IssueState.OPEN
|
|
assert IssueState.from_string("closed") == IssueState.CLOSED
|
|
assert IssueState.from_string("in_progress") == IssueState.IN_PROGRESS
|
|
assert IssueState.from_string("blocked") == IssueState.BLOCKED
|
|
|
|
def test_from_string_variants(self):
|
|
"""Test converting variant strings to IssueState."""
|
|
assert IssueState.from_string("in-progress") == IssueState.IN_PROGRESS
|
|
assert IssueState.from_string("progress") == IssueState.IN_PROGRESS
|
|
assert IssueState.from_string("OPEN") == IssueState.OPEN
|
|
|
|
def test_from_string_unknown_defaults_to_open(self):
|
|
"""Test unknown state strings default to OPEN."""
|
|
assert IssueState.from_string("unknown") == IssueState.OPEN
|
|
assert IssueState.from_string("") == IssueState.OPEN
|
|
|
|
def test_to_backend_string_gitea(self):
|
|
"""Test converting to Gitea backend string."""
|
|
assert IssueState.OPEN.to_backend_string("gitea") == "open"
|
|
assert IssueState.CLOSED.to_backend_string("gitea") == "closed"
|
|
assert IssueState.IN_PROGRESS.to_backend_string("gitea") == "open"
|
|
assert IssueState.BLOCKED.to_backend_string("gitea") == "open"
|
|
|
|
def test_to_backend_string_github(self):
|
|
"""Test converting to GitHub backend string."""
|
|
assert IssueState.OPEN.to_backend_string("github") == "open"
|
|
assert IssueState.CLOSED.to_backend_string("github") == "closed"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPriority:
|
|
"""Test Priority enumeration."""
|
|
|
|
def test_from_label_with_priority_prefix(self):
|
|
"""Test extracting priority from label."""
|
|
assert Priority.from_label("priority:low") == Priority.LOW
|
|
assert Priority.from_label("priority:medium") == Priority.MEDIUM
|
|
assert Priority.from_label("priority:high") == Priority.HIGH
|
|
assert Priority.from_label("priority:critical") == Priority.CRITICAL
|
|
|
|
def test_from_label_without_prefix(self):
|
|
"""Test non-priority labels return None."""
|
|
assert Priority.from_label("bug") is None
|
|
assert Priority.from_label("enhancement") is None
|
|
|
|
def test_from_label_invalid_priority(self):
|
|
"""Test invalid priority returns None."""
|
|
assert Priority.from_label("priority:invalid") is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueType:
|
|
"""Test IssueType enumeration."""
|
|
|
|
def test_from_label_standard_types(self):
|
|
"""Test extracting issue type from label."""
|
|
assert IssueType.from_label("bug") == IssueType.BUG
|
|
assert IssueType.from_label("feature") == IssueType.FEATURE
|
|
assert IssueType.from_label("enhancement") == IssueType.ENHANCEMENT
|
|
assert IssueType.from_label("task") == IssueType.TASK
|
|
assert IssueType.from_label("documentation") == IssueType.DOCUMENTATION
|
|
assert IssueType.from_label("question") == IssueType.QUESTION
|
|
|
|
def test_from_label_case_insensitive(self):
|
|
"""Test case insensitive type matching."""
|
|
assert IssueType.from_label("BUG") == IssueType.BUG
|
|
assert IssueType.from_label("Bug") == IssueType.BUG
|
|
|
|
def test_from_label_invalid_type(self):
|
|
"""Test invalid type returns None."""
|
|
assert IssueType.from_label("invalid") is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestLabel:
|
|
"""Test Label model."""
|
|
|
|
def test_label_creation(self):
|
|
"""Test creating a label."""
|
|
label = Label(name="bug", color="red", description="Bug reports")
|
|
assert label.name == "bug"
|
|
assert label.color == "red"
|
|
assert label.description == "Bug reports"
|
|
|
|
def test_label_category_priority(self):
|
|
"""Test priority label categorization."""
|
|
label = Label(name="priority:high")
|
|
assert label.category == "priority"
|
|
|
|
def test_label_category_status(self):
|
|
"""Test status label categorization."""
|
|
label = Label(name="status:in-progress")
|
|
assert label.category == "status"
|
|
|
|
def test_label_category_type_with_prefix(self):
|
|
"""Test type label with prefix categorization."""
|
|
label = Label(name="type:bug")
|
|
assert label.category == "type"
|
|
|
|
def test_label_category_type_without_prefix(self):
|
|
"""Test type label without prefix categorization."""
|
|
label = Label(name="bug")
|
|
assert label.category == "type"
|
|
label2 = Label(name="feature")
|
|
assert label2.category == "type"
|
|
|
|
def test_label_category_other(self):
|
|
"""Test other label categorization."""
|
|
label = Label(name="good-first-issue")
|
|
assert label.category == "other"
|
|
|
|
def test_label_priority_property(self):
|
|
"""Test extracting priority from label."""
|
|
label = Label(name="priority:high")
|
|
assert label.priority == Priority.HIGH
|
|
|
|
label2 = Label(name="bug")
|
|
assert label2.priority is None
|
|
|
|
def test_label_issue_type_property(self):
|
|
"""Test extracting issue type from label."""
|
|
label = Label(name="bug")
|
|
assert label.issue_type == IssueType.BUG
|
|
|
|
label2 = Label(name="priority:high")
|
|
assert label2.issue_type is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUser:
|
|
"""Test User model."""
|
|
|
|
def test_user_creation(self):
|
|
"""Test creating a user."""
|
|
user = User(
|
|
id="user123",
|
|
username="alice",
|
|
display_name="Alice Smith",
|
|
email="alice@example.com"
|
|
)
|
|
assert user.id == "user123"
|
|
assert user.username == "alice"
|
|
assert user.display_name == "Alice Smith"
|
|
assert user.email == "alice@example.com"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMilestone:
|
|
"""Test Milestone model."""
|
|
|
|
def test_milestone_creation(self):
|
|
"""Test creating a milestone."""
|
|
now = datetime.now(timezone.utc)
|
|
milestone = Milestone(
|
|
id="m1",
|
|
title="v1.0",
|
|
description="First release",
|
|
state="open",
|
|
due_date=now,
|
|
created_at=now,
|
|
updated_at=now
|
|
)
|
|
assert milestone.id == "m1"
|
|
assert milestone.title == "v1.0"
|
|
assert milestone.state == "open"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestComment:
|
|
"""Test Comment model."""
|
|
|
|
def test_comment_creation(self):
|
|
"""Test creating a comment."""
|
|
author = User(id="user1", username="alice")
|
|
now = datetime.now(timezone.utc)
|
|
comment = Comment(
|
|
id="c1",
|
|
body="Great issue!",
|
|
author=author,
|
|
created_at=now
|
|
)
|
|
assert comment.id == "c1"
|
|
assert comment.body == "Great issue!"
|
|
assert comment.author.username == "alice"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueBasics:
|
|
"""Test basic Issue model functionality."""
|
|
|
|
def test_issue_creation(self):
|
|
"""Test creating an issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1",
|
|
number=1,
|
|
title="Test Issue",
|
|
description="Test description",
|
|
state=IssueState.OPEN,
|
|
created_at=now,
|
|
updated_at=now
|
|
)
|
|
assert issue.id == "issue1"
|
|
assert issue.number == 1
|
|
assert issue.title == "Test Issue"
|
|
assert issue.state == IssueState.OPEN
|
|
|
|
def test_issue_with_labels(self):
|
|
"""Test creating issue with labels."""
|
|
now = datetime.now(timezone.utc)
|
|
labels = [
|
|
Label(name="bug", color="red"),
|
|
Label(name="priority:high", color="orange")
|
|
]
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Bug", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=labels
|
|
)
|
|
assert len(issue.labels) == 2
|
|
|
|
def test_issue_with_assignees(self):
|
|
"""Test creating issue with assignees."""
|
|
now = datetime.now(timezone.utc)
|
|
assignees = [User(id="u1", username="alice")]
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Task", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
assignees=assignees
|
|
)
|
|
assert len(issue.assignees) == 1
|
|
assert issue.primary_assignee.username == "alice"
|
|
|
|
def test_primary_assignee_when_none(self):
|
|
"""Test primary_assignee returns None when no assignees."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Task", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now
|
|
)
|
|
assert issue.primary_assignee is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueLabelCategorization:
|
|
"""Test Issue label categorization."""
|
|
|
|
def test_label_categories_property(self):
|
|
"""Test label_categories property organizes labels."""
|
|
now = datetime.now(timezone.utc)
|
|
labels = [
|
|
Label(name="bug"),
|
|
Label(name="priority:high"),
|
|
Label(name="status:in-review"),
|
|
Label(name="good-first-issue")
|
|
]
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=labels
|
|
)
|
|
|
|
categories = issue.label_categories
|
|
assert len(categories.type_labels) == 1
|
|
assert len(categories.priority_labels) == 1
|
|
assert len(categories.status_labels) == 1
|
|
assert len(categories.other_labels) == 1
|
|
|
|
def test_issue_priority_property(self):
|
|
"""Test issue.priority extracts priority from labels."""
|
|
now = datetime.now(timezone.utc)
|
|
labels = [Label(name="priority:high"), Label(name="bug")]
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=labels
|
|
)
|
|
assert issue.priority == Priority.HIGH
|
|
|
|
def test_issue_priority_none_when_no_priority_label(self):
|
|
"""Test issue.priority is None without priority label."""
|
|
now = datetime.now(timezone.utc)
|
|
labels = [Label(name="bug")]
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=labels
|
|
)
|
|
assert issue.priority is None
|
|
|
|
def test_issue_type_property(self):
|
|
"""Test issue.issue_type extracts type from labels."""
|
|
now = datetime.now(timezone.utc)
|
|
labels = [Label(name="bug"), Label(name="priority:high")]
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=labels
|
|
)
|
|
assert issue.issue_type == IssueType.BUG
|
|
|
|
def test_cache_invalidation(self):
|
|
"""Test label cache is invalidated when labels change."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=[Label(name="bug")]
|
|
)
|
|
|
|
# Access to populate cache
|
|
assert issue.issue_type == IssueType.BUG
|
|
|
|
# Manually modify labels
|
|
issue.labels = [Label(name="feature")]
|
|
issue.invalidate_cache()
|
|
|
|
# Should reflect new labels
|
|
assert issue.issue_type == IssueType.FEATURE
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueStateTransitions:
|
|
"""Test Issue state transition methods."""
|
|
|
|
def test_close_issue(self):
|
|
"""Test closing an open issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now
|
|
)
|
|
|
|
issue.close()
|
|
|
|
assert issue.state == IssueState.CLOSED
|
|
assert issue.closed_at is not None
|
|
|
|
def test_close_issue_with_custom_timestamp(self):
|
|
"""Test closing issue with custom timestamp."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now
|
|
)
|
|
|
|
custom_time = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
issue.close(closed_at=custom_time)
|
|
|
|
assert issue.closed_at == custom_time
|
|
|
|
def test_close_already_closed_issue_raises_error(self):
|
|
"""Test closing already closed issue raises error."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.CLOSED, created_at=now, updated_at=now,
|
|
closed_at=now
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="already closed"):
|
|
issue.close()
|
|
|
|
def test_reopen_issue(self):
|
|
"""Test reopening a closed issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.CLOSED, created_at=now, updated_at=now,
|
|
closed_at=now
|
|
)
|
|
|
|
issue.reopen()
|
|
|
|
assert issue.state == IssueState.OPEN
|
|
assert issue.closed_at is None
|
|
|
|
def test_reopen_open_issue_raises_error(self):
|
|
"""Test reopening non-closed issue raises error."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="not closed"):
|
|
issue.reopen()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueLabelMethods:
|
|
"""Test Issue label manipulation methods."""
|
|
|
|
def test_add_label(self):
|
|
"""Test adding a label to issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=[]
|
|
)
|
|
|
|
bug_label = Label(name="bug", color="red")
|
|
issue.add_label(bug_label)
|
|
|
|
assert len(issue.labels) == 1
|
|
assert issue.labels[0].name == "bug"
|
|
|
|
def test_add_duplicate_label_ignored(self):
|
|
"""Test adding duplicate label is ignored."""
|
|
now = datetime.now(timezone.utc)
|
|
bug_label = Label(name="bug", color="red")
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=[bug_label]
|
|
)
|
|
|
|
issue.add_label(bug_label)
|
|
|
|
assert len(issue.labels) == 1
|
|
|
|
def test_remove_label(self):
|
|
"""Test removing a label from issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=[Label(name="bug"), Label(name="feature")]
|
|
)
|
|
|
|
result = issue.remove_label("bug")
|
|
|
|
assert result is True
|
|
assert len(issue.labels) == 1
|
|
assert issue.labels[0].name == "feature"
|
|
|
|
def test_remove_nonexistent_label_returns_false(self):
|
|
"""Test removing non-existent label returns False."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=[Label(name="bug")]
|
|
)
|
|
|
|
result = issue.remove_label("nonexistent")
|
|
|
|
assert result is False
|
|
assert len(issue.labels) == 1
|
|
|
|
def test_has_label(self):
|
|
"""Test checking if issue has a label."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=[Label(name="bug"), Label(name="priority:high")]
|
|
)
|
|
|
|
assert issue.has_label("bug")
|
|
assert issue.has_label("priority:high")
|
|
assert not issue.has_label("feature")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueAssigneeMethods:
|
|
"""Test Issue assignee manipulation methods."""
|
|
|
|
def test_add_assignee(self):
|
|
"""Test adding an assignee to issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
assignees=[]
|
|
)
|
|
|
|
user = User(id="u1", username="alice")
|
|
issue.add_assignee(user)
|
|
|
|
assert len(issue.assignees) == 1
|
|
assert issue.assignees[0].username == "alice"
|
|
|
|
def test_add_duplicate_assignee_ignored(self):
|
|
"""Test adding duplicate assignee is ignored."""
|
|
now = datetime.now(timezone.utc)
|
|
user = User(id="u1", username="alice")
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
assignees=[user]
|
|
)
|
|
|
|
issue.add_assignee(user)
|
|
|
|
assert len(issue.assignees) == 1
|
|
|
|
def test_remove_assignee(self):
|
|
"""Test removing an assignee from issue."""
|
|
now = datetime.now(timezone.utc)
|
|
user1 = User(id="u1", username="alice")
|
|
user2 = User(id="u2", username="bob")
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
assignees=[user1, user2]
|
|
)
|
|
|
|
result = issue.remove_assignee("u1")
|
|
|
|
assert result is True
|
|
assert len(issue.assignees) == 1
|
|
assert issue.assignees[0].username == "bob"
|
|
|
|
def test_remove_nonexistent_assignee_returns_false(self):
|
|
"""Test removing non-existent assignee returns False."""
|
|
now = datetime.now(timezone.utc)
|
|
user = User(id="u1", username="alice")
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
assignees=[user]
|
|
)
|
|
|
|
result = issue.remove_assignee("nonexistent")
|
|
|
|
assert result is False
|
|
assert len(issue.assignees) == 1
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueCommentMethods:
|
|
"""Test Issue comment methods."""
|
|
|
|
def test_add_comment(self):
|
|
"""Test adding a comment to issue."""
|
|
now = datetime.now(timezone.utc)
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test", description="Desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
comments=[]
|
|
)
|
|
|
|
author = User(id="u1", username="alice")
|
|
comment = Comment(id="c1", body="Great!", author=author, created_at=now)
|
|
issue.add_comment(comment)
|
|
|
|
assert len(issue.comments) == 1
|
|
assert issue.comments[0].body == "Great!"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIssueSerialization:
|
|
"""Test Issue serialization."""
|
|
|
|
def test_to_dict(self):
|
|
"""Test converting issue to dictionary."""
|
|
now = datetime.now(timezone.utc)
|
|
labels = [Label(name="bug", color="red")]
|
|
assignees = [User(id="u1", username="alice", display_name="Alice")]
|
|
milestone = Milestone(id="m1", title="v1.0")
|
|
|
|
issue = Issue(
|
|
id="issue1", number=1, title="Test Issue", description="Test desc",
|
|
state=IssueState.OPEN, created_at=now, updated_at=now,
|
|
labels=labels, assignees=assignees, milestone=milestone,
|
|
backend_id="gitea-123", backend_type="gitea"
|
|
)
|
|
|
|
issue_dict = issue.to_dict()
|
|
|
|
assert issue_dict['id'] == "issue1"
|
|
assert issue_dict['number'] == 1
|
|
assert issue_dict['title'] == "Test Issue"
|
|
assert issue_dict['state'] == "open"
|
|
assert issue_dict['backend_id'] == "gitea-123"
|
|
assert issue_dict['backend_type'] == "gitea"
|
|
assert len(issue_dict['labels']) == 1
|
|
assert len(issue_dict['assignees']) == 1
|
|
assert issue_dict['milestone']['id'] == "m1"
|