409 lines
13 KiB
Python
409 lines
13 KiB
Python
"""Tests for TaskService application service.
|
|
|
|
Tests cover:
|
|
- Task CRUD operations (get, create, update, delete)
|
|
- Task listing with filters (status, project, meeting)
|
|
- Task ingestion from summary action items
|
|
- Deduplication logic for task creation
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from noteflow.application.services.tasks import TaskService
|
|
from noteflow.domain.entities.summary import ActionItem
|
|
from noteflow.domain.entities.task import (
|
|
Task,
|
|
TaskListFilters,
|
|
TaskStatus,
|
|
normalize_task_text,
|
|
)
|
|
|
|
# Test constants
|
|
WORKSPACE_ID = uuid4()
|
|
"""Test workspace identifier."""
|
|
|
|
MEETING_ID = uuid4()
|
|
"""Test meeting identifier."""
|
|
|
|
TASK_TEXT = "Review the project proposal"
|
|
"""Sample task text for testing."""
|
|
|
|
TASK_TEXT_DUPLICATE = " Review THE Project Proposal! "
|
|
"""Duplicate task text with different casing and whitespace."""
|
|
|
|
|
|
@pytest.fixture
|
|
def task_uow_factory(mock_uow: MagicMock) -> MagicMock:
|
|
"""Create mock UoW factory for TaskService."""
|
|
factory = MagicMock(return_value=mock_uow)
|
|
return factory
|
|
|
|
|
|
@pytest.fixture
|
|
def task_service(task_uow_factory: MagicMock) -> TaskService:
|
|
"""Create TaskService with mock UoW factory."""
|
|
return TaskService(task_uow_factory)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_task() -> Task:
|
|
"""Create a sample task for testing."""
|
|
return Task(
|
|
id=uuid4(),
|
|
workspace_id=WORKSPACE_ID,
|
|
meeting_id=MEETING_ID,
|
|
action_item_id=1,
|
|
text=TASK_TEXT,
|
|
status=TaskStatus.OPEN,
|
|
priority=1,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_action_item() -> ActionItem:
|
|
"""Create a sample action item for testing."""
|
|
return ActionItem(
|
|
text=TASK_TEXT,
|
|
priority=1,
|
|
assignee="",
|
|
due_date=None,
|
|
segment_ids=[1, 2],
|
|
db_id=1,
|
|
)
|
|
|
|
|
|
class TestTaskServiceGet:
|
|
"""Tests for task retrieval by ID."""
|
|
|
|
async def test_get_task_found(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
sample_task: Task,
|
|
) -> None:
|
|
"""Test retrieving existing task returns the task."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.get = AsyncMock(return_value=sample_task)
|
|
|
|
result = await task_service.get(sample_task.id)
|
|
|
|
assert result is not None, "Expected task to be found"
|
|
assert result.id == sample_task.id, "Task ID should match"
|
|
assert result.text == TASK_TEXT, "Task text should match"
|
|
|
|
|
|
class TestTaskServiceMissing:
|
|
"""Tests for task operations when entity is missing."""
|
|
|
|
TaskMissingSetup = Callable[[MagicMock], None]
|
|
|
|
@staticmethod
|
|
def _set_missing_for_get(mock_uow: MagicMock) -> None:
|
|
mock_uow.tasks.get = AsyncMock(return_value=None)
|
|
|
|
@staticmethod
|
|
def _set_missing_for_delete(mock_uow: MagicMock) -> None:
|
|
mock_uow.tasks.delete = AsyncMock(return_value=False)
|
|
|
|
@pytest.mark.parametrize(
|
|
("method_name", "setup", "expected", "expected_commits"),
|
|
[
|
|
pytest.param("get", _set_missing_for_get, None, 0, id="get"),
|
|
pytest.param("delete", _set_missing_for_delete, False, 0, id="delete"),
|
|
],
|
|
)
|
|
async def test_missing_task_returns_expected(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
method_name: str,
|
|
setup: TaskMissingSetup,
|
|
expected: object,
|
|
expected_commits: int,
|
|
) -> None:
|
|
"""Missing task operations should return expected value and avoid commit."""
|
|
mock_uow = task_uow_factory.return_value
|
|
setup(mock_uow)
|
|
|
|
method = getattr(task_service, method_name)
|
|
result = await method(uuid4())
|
|
|
|
assert result is expected, f"Result for {method_name} should be {expected}"
|
|
assert mock_uow.commit.call_count == expected_commits, (
|
|
f"Commit count should be {expected_commits}"
|
|
)
|
|
|
|
|
|
class TestTaskServiceCreate:
|
|
"""Tests for task creation."""
|
|
|
|
async def test_create_task_success(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
sample_task: Task,
|
|
) -> None:
|
|
"""Test successful task creation commits and returns task."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.create = AsyncMock(return_value=sample_task)
|
|
|
|
result = await task_service.create(sample_task)
|
|
|
|
assert result.id == sample_task.id, "Created task ID should match"
|
|
mock_uow.tasks.create.assert_called_once_with(sample_task)
|
|
mock_uow.commit.assert_called_once()
|
|
|
|
|
|
class TestTaskServiceUpdate:
|
|
"""Tests for task updates."""
|
|
|
|
async def test_update_task_success(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
sample_task: Task,
|
|
) -> None:
|
|
"""Test successful task update commits and returns updated task."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.update = AsyncMock(return_value=sample_task)
|
|
|
|
result = await task_service.update(sample_task)
|
|
|
|
assert result.id == sample_task.id, "Updated task ID should match"
|
|
mock_uow.tasks.update.assert_called_once()
|
|
mock_uow.commit.assert_called_once()
|
|
|
|
async def test_update_task_to_done_sets_completed_at(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
) -> None:
|
|
"""Test marking task as done sets completed_at timestamp."""
|
|
task = Task(
|
|
id=uuid4(),
|
|
workspace_id=WORKSPACE_ID,
|
|
meeting_id=MEETING_ID,
|
|
action_item_id=1,
|
|
text=TASK_TEXT,
|
|
status=TaskStatus.DONE,
|
|
priority=1,
|
|
completed_at=None, # Not set initially
|
|
)
|
|
mock_uow = task_uow_factory.return_value
|
|
|
|
def return_task(t: Task) -> Task:
|
|
return t
|
|
|
|
mock_uow.tasks.update = AsyncMock(side_effect=return_task)
|
|
|
|
await task_service.update(task)
|
|
|
|
# Verify the task passed to update has completed_at set
|
|
call_args = mock_uow.tasks.update.call_args
|
|
updated_task = call_args[0][0]
|
|
assert updated_task.completed_at is not None, "completed_at should be set"
|
|
assert updated_task.status == TaskStatus.DONE, "Status should be DONE"
|
|
|
|
|
|
class TestTaskServiceDelete:
|
|
"""Tests for task deletion."""
|
|
|
|
async def test_delete_task_success(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
) -> None:
|
|
"""Test successful task deletion commits and returns True."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.delete = AsyncMock(return_value=True)
|
|
|
|
result = await task_service.delete(uuid4())
|
|
|
|
assert result is True, "Delete should return True on success"
|
|
mock_uow.commit.assert_called_once()
|
|
|
|
|
|
class TestTaskServiceListTasks:
|
|
"""Tests for task listing with filters."""
|
|
|
|
async def test_list_tasks_returns_tasks_and_count(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
sample_task: Task,
|
|
) -> None:
|
|
"""Test listing tasks returns tuple of tasks and total count."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.list_by_workspace = AsyncMock(return_value=([sample_task], 1))
|
|
|
|
tasks, total = await task_service.list_tasks(WORKSPACE_ID)
|
|
|
|
assert len(tasks) == 1, "Should return one task"
|
|
assert total == 1, "Total count should be 1"
|
|
assert tasks[0].id == sample_task.id, "Task ID should match"
|
|
|
|
async def test_list_tasks_with_status_filter(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
) -> None:
|
|
"""Test listing tasks filters by status."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.list_by_workspace = AsyncMock(return_value=([], 0))
|
|
|
|
await task_service.list_tasks(
|
|
WORKSPACE_ID,
|
|
filters=TaskListFilters(statuses=[TaskStatus.OPEN, TaskStatus.DONE]),
|
|
)
|
|
|
|
mock_uow.tasks.list_by_workspace.assert_called_once_with(
|
|
workspace_id=WORKSPACE_ID,
|
|
filters=TaskListFilters(statuses=[TaskStatus.OPEN, TaskStatus.DONE]),
|
|
)
|
|
|
|
async def test_list_tasks_with_pagination(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
) -> None:
|
|
"""Test listing tasks respects pagination parameters."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.list_by_workspace = AsyncMock(return_value=([], 50))
|
|
|
|
await task_service.list_tasks(
|
|
WORKSPACE_ID,
|
|
filters=TaskListFilters(limit=10, offset=20),
|
|
)
|
|
|
|
mock_uow.tasks.list_by_workspace.assert_called_once_with(
|
|
workspace_id=WORKSPACE_ID,
|
|
filters=TaskListFilters(limit=10, offset=20),
|
|
)
|
|
|
|
|
|
class TestTaskServiceIngestion:
|
|
"""Tests for task ingestion from summary action items."""
|
|
|
|
async def test_ingest_from_summary_creates_tasks(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
sample_action_item: ActionItem,
|
|
) -> None:
|
|
"""Test ingesting action items creates tasks."""
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.list_by_workspace = AsyncMock(return_value=([], 0))
|
|
|
|
def return_task(t: Task) -> Task:
|
|
return t
|
|
|
|
mock_uow.tasks.create = AsyncMock(side_effect=return_task)
|
|
|
|
result = await task_service.ingest_from_summary(
|
|
workspace_id=WORKSPACE_ID,
|
|
meeting_id=MEETING_ID,
|
|
action_items=[sample_action_item],
|
|
)
|
|
|
|
assert result.created_count == 1, "Should create one task"
|
|
assert result.skipped_count == 0, "Should skip no tasks"
|
|
assert result.total_action_items == 1, "Total should be 1"
|
|
mock_uow.tasks.create.assert_called_once()
|
|
mock_uow.commit.assert_called_once()
|
|
|
|
async def test_ingest_from_summary_empty_list(
|
|
self,
|
|
task_service: TaskService,
|
|
) -> None:
|
|
"""Test ingesting empty action items list returns zero counts."""
|
|
result = await task_service.ingest_from_summary(
|
|
workspace_id=WORKSPACE_ID,
|
|
meeting_id=MEETING_ID,
|
|
action_items=[],
|
|
)
|
|
|
|
assert result.created_count == 0, "Should create no tasks"
|
|
assert result.skipped_count == 0, "Should skip no tasks"
|
|
assert result.total_action_items == 0, "Total should be 0"
|
|
|
|
async def test_ingest_from_summary_deduplicates_existing(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
sample_task: Task,
|
|
sample_action_item: ActionItem,
|
|
) -> None:
|
|
"""Test ingestion skips duplicate tasks based on normalized text."""
|
|
mock_uow = task_uow_factory.return_value
|
|
# Existing task with same normalized text
|
|
mock_uow.tasks.list_by_workspace = AsyncMock(return_value=([sample_task], 1))
|
|
|
|
def return_task(t: Task) -> Task:
|
|
return t
|
|
|
|
mock_uow.tasks.create = AsyncMock(side_effect=return_task)
|
|
|
|
result = await task_service.ingest_from_summary(
|
|
workspace_id=WORKSPACE_ID,
|
|
meeting_id=MEETING_ID,
|
|
action_items=[sample_action_item],
|
|
)
|
|
|
|
assert result.created_count == 0, "Should create no tasks (duplicate)"
|
|
assert result.skipped_count == 1, "Should skip one task"
|
|
mock_uow.tasks.create.assert_not_called()
|
|
|
|
async def test_ingest_from_summary_multiple_items(
|
|
self,
|
|
task_service: TaskService,
|
|
task_uow_factory: MagicMock,
|
|
) -> None:
|
|
"""Test ingesting multiple action items creates multiple tasks."""
|
|
action_items = [
|
|
ActionItem(text="Task one", priority=1, segment_ids=[1], db_id=1),
|
|
ActionItem(text="Task two", priority=2, segment_ids=[2], db_id=2),
|
|
ActionItem(text="Task three", priority=3, segment_ids=[3], db_id=3),
|
|
]
|
|
mock_uow = task_uow_factory.return_value
|
|
mock_uow.tasks.list_by_workspace = AsyncMock(return_value=([], 0))
|
|
|
|
def return_task(t: Task) -> Task:
|
|
return t
|
|
|
|
mock_uow.tasks.create = AsyncMock(side_effect=return_task)
|
|
|
|
result = await task_service.ingest_from_summary(
|
|
workspace_id=WORKSPACE_ID,
|
|
meeting_id=MEETING_ID,
|
|
action_items=action_items,
|
|
)
|
|
|
|
assert result.created_count == 3, "Should create three tasks"
|
|
assert result.skipped_count == 0, "Should skip no tasks"
|
|
assert mock_uow.tasks.create.call_count == 3, "Should call create 3 times"
|
|
|
|
|
|
class TestNormalizeText:
|
|
"""Tests for text normalization used in deduplication."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_text", "expected"),
|
|
[
|
|
pytest.param("Hello World", "hello world", id="lowercase"),
|
|
pytest.param(" spaced ", "spaced", id="trim_whitespace"),
|
|
pytest.param("Hello, World!", "hello world", id="remove_punctuation"),
|
|
pytest.param("UPPER lower", "upper lower", id="mixed_case"),
|
|
pytest.param(" Multiple Spaces ", "multiple spaces", id="collapse_spaces"),
|
|
],
|
|
)
|
|
def test_task_normalize_text(self, input_text: str, expected: str) -> None:
|
|
"""Test text normalization for deduplication key generation."""
|
|
result = normalize_task_text(input_text)
|
|
assert result == expected, f"normalize_task_text('{input_text}') should be '{expected}'"
|