Files
noteflow/tests/application/test_task_service.py
Travis Vasceannie d8090a98e8
Some checks failed
CI / test-typescript (push) Has been cancelled
CI / test-rust (push) Has been cancelled
CI / test-python (push) Has been cancelled
ci/cd fixes
2026-01-26 00:28:15 +00:00

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}'"