claire/tests/test_pm_service.py
Natalie 53921b4e94 refactor(domain): rename ChatRole persisted value clare → claire (migration 0010)
ChatRole.CLAIRE now persists as "claire" everywhere. Migration 0010
rewrites both the chat_messages.role column and the chat_message_posted
event payloads in one transaction so a future replay reconstructs the
same projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:29:30 -06:00

207 lines
8.2 KiB
Python

"""Service-layer tests for PM expansion.
Covers state-machine enforcement, identity uniqueness, idempotent tagging,
and the color-resolution chain.
"""
from __future__ import annotations
import uuid as _uuid
import pytest
from claire.db import migrate, open_db
from claire.domain import EpicStatus, TASK_TRANSITIONS, TaskStatus, TaskType
from claire.hlc import HLCGenerator
from claire.read import resolve_task_color
from claire.web import service
def _setup() -> tuple:
conn = open_db(":memory:")
migrate(conn)
gen = HLCGenerator("test-machine")
return conn, gen
def test_create_org_unique() -> None:
conn, gen = _setup()
service.create_org(conn, gen, name="acme")
with pytest.raises(service.Conflict):
service.create_org(conn, gen, name="acme")
def test_create_person_validates_handle_prefix() -> None:
conn, gen = _setup()
with pytest.raises(service.InvalidInput):
service.create_person(conn, gen, handle="quinn", display_name="Q")
service.create_person(conn, gen, handle="@quinn", display_name="Q")
with pytest.raises(service.Conflict):
service.create_person(conn, gen, handle="@quinn", display_name="Q2")
def test_create_epic_requires_project() -> None:
conn, gen = _setup()
with pytest.raises(service.NotFound):
service.create_epic(conn, gen, project="nope", title="t")
service.create_project(conn, gen, name="proj")
epic = service.create_epic(conn, gen, project="proj", title="E1")
assert epic.title == "E1" and epic.status == EpicStatus.OPEN
def test_archive_epic_already_archived() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
epic = service.create_epic(conn, gen, project="p", title="e")
service.archive_epic(conn, gen, epic_id=epic.id)
with pytest.raises(service.Conflict):
service.archive_epic(conn, gen, epic_id=epic.id)
def test_transition_rejects_illegal() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
# todo → claire_review is illegal — a worker only enters review from
# in_progress, never straight from todo.
with pytest.raises(service.InvalidInput):
service.transition_task_state(
conn, gen, task_id=task.id, to_state=TaskStatus.CLAIRE_REVIEW,
)
def test_transition_in_progress_to_done_is_illegal() -> None:
"""A worked task may NOT skip the two review gates straight to done."""
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
service.transition_task_state(
conn, gen, task_id=task.id, to_state=TaskStatus.IN_PROGRESS,
)
with pytest.raises(service.InvalidInput):
service.transition_task_state(
conn, gen, task_id=task.id, to_state=TaskStatus.DONE,
)
def test_transition_two_gate_review_happy_path() -> None:
"""Full happy path: in_progress → claire_review → user_review → done."""
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
for to_state in (
TaskStatus.IN_PROGRESS,
TaskStatus.CLAIRE_REVIEW,
TaskStatus.USER_REVIEW,
TaskStatus.DONE,
):
updated = service.transition_task_state(
conn, gen, task_id=task.id, to_state=to_state,
)
assert updated.status == to_state
def test_transition_review_rejection_edges() -> None:
"""Both review gates can bounce a task back to the worker."""
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
# Claire rejects: claire_review → in_progress.
service.transition_task_state(conn, gen, task_id=task.id, to_state=TaskStatus.IN_PROGRESS)
service.transition_task_state(conn, gen, task_id=task.id, to_state=TaskStatus.CLAIRE_REVIEW)
bounced = service.transition_task_state(
conn, gen, task_id=task.id, to_state=TaskStatus.IN_PROGRESS,
)
assert bounced.status == TaskStatus.IN_PROGRESS
# User rejects: user_review → in_progress.
service.transition_task_state(conn, gen, task_id=task.id, to_state=TaskStatus.CLAIRE_REVIEW)
service.transition_task_state(conn, gen, task_id=task.id, to_state=TaskStatus.USER_REVIEW)
bounced2 = service.transition_task_state(
conn, gen, task_id=task.id, to_state=TaskStatus.IN_PROGRESS,
)
assert bounced2.status == TaskStatus.IN_PROGRESS
def test_transition_all_legal_pairs() -> None:
"""Iterate every TASK_TRANSITIONS pair: each must be accepted."""
for from_state, to_state in TASK_TRANSITIONS:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
# Manually seed the task into `from_state` via direct UPDATE — we're
# testing the transition_task_state validator, not the path to reach it.
conn.execute(
"UPDATE tasks SET status = ? WHERE id = ?",
(from_state.value, str(task.id)),
)
updated = service.transition_task_state(
conn, gen, task_id=task.id, to_state=to_state,
)
assert updated.status == to_state
def test_tag_task_idempotent_and_untag() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
tag = service.create_tag(conn, gen, name="urgent")
service.tag_task(conn, gen, task_id=task.id, tag_id=tag.id)
# Second tag: idempotent (no Conflict)
service.tag_task(conn, gen, task_id=task.id, tag_id=tag.id)
# Untag, untag again: also a no-op.
service.untag_task(conn, gen, task_id=task.id, tag_id=tag.id)
service.untag_task(conn, gen, task_id=task.id, tag_id=tag.id)
def test_get_or_create_tag() -> None:
conn, gen = _setup()
t1 = service.get_or_create_tag(conn, gen, name="bug")
t2 = service.get_or_create_tag(conn, gen, name="bug")
assert t1.id == t2.id
def test_set_task_owner_unknown_person() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
with pytest.raises(service.NotFound):
service.set_task_owner(conn, gen, task_id=task.id, owner_person_id=_uuid.uuid4())
def test_set_task_meta_noop_when_all_none() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
# All-None call returns the task unchanged and emits no event.
before_count = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
service.set_task_meta(conn, gen, task_id=task.id)
after_count = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
assert before_count == after_count
def test_resolve_task_color_priority_chain() -> None:
conn, gen = _setup()
service.create_project(conn, gen, name="p")
task = service.add_task(conn, gen, project="p", title="t")
# Default: state color for "todo"
assert resolve_task_color(conn, task) == "#94a3b8"
# Type wins over state
task = service.set_task_type(conn, gen, task_id=task.id, task_type=TaskType.BUG)
assert resolve_task_color(conn, task) == "#ef4444"
# Category wins over type
cat = service.create_category(conn, gen, name="urgent", color="#abcdef")
task = service.set_task_category(conn, gen, task_id=task.id, category_id=cat.id)
assert resolve_task_color(conn, task) == "#abcdef"
# Tag wins over category
tag = service.create_tag(conn, gen, name="critical", color="#123456")
service.tag_task(conn, gen, task_id=task.id, tag_id=tag.id)
from claire.read import get_task as _get
task = _get(conn, task.id)
assert task is not None
assert resolve_task_color(conn, task) == "#123456"
# Explicit color wins over everything
task = service.set_task_meta(conn, gen, task_id=task.id, color="#deadbe")
assert resolve_task_color(conn, task) == "#deadbe"
# apply_color_rule=False with no explicit color returns None
task = service.set_task_meta(conn, gen, task_id=task.id, color="", apply_color_rule=False)
assert resolve_task_color(conn, task) is None