feat(taskflow): add core task API, storage persistence, csv export, stats page, and test coverage

This commit is contained in:
Alexander Kalinovsky
2026-04-01 17:56:03 +03:00
commit 19d659df6b
31 changed files with 4197 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.storage import StoredTask
from app.storage.repository import JsonFileTaskRepository, StorageTaskNotFoundError
def make_task(
*,
title: str = "Task",
started: bool = False,
done: bool = False,
) -> StoredTask:
now = datetime.now(UTC)
started_at = now + timedelta(seconds=1) if started else None
done_at = now + timedelta(seconds=2) if done else None
return StoredTask(
id=uuid4(),
title=title,
created_at=now,
started_at=started_at,
done_at=done_at,
)
def make_repo(tmp_path) -> JsonFileTaskRepository:
return JsonFileTaskRepository(tmp_path / "tasks.json")
def test_create_task_in_empty_storage(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="First task")
created = repo.create_task(task)
tasks = repo.list_tasks()
assert created == task
assert len(tasks) == 1
assert tasks[0] == task
def test_create_task_sequentially_preserves_insertion_order(tmp_path):
repo = make_repo(tmp_path)
task_1 = make_task(title="First")
task_2 = make_task(title="Second")
repo.create_task(task_1)
repo.create_task(task_2)
tasks = repo.list_tasks()
assert [task.id for task in tasks] == [task_1.id, task_2.id]
def test_create_task_invalid_model_rejected_before_repository_call(tmp_path):
repo = make_repo(tmp_path)
with pytest.raises(ValidationError):
StoredTask(
id=uuid4(),
title="",
created_at=datetime.now(UTC),
)
assert repo.list_tasks() == []
def test_list_tasks_returns_empty_list_for_fresh_storage(tmp_path):
repo = make_repo(tmp_path)
assert repo.list_tasks() == []
def test_list_tasks_returns_persisted_tasks(tmp_path):
repo = make_repo(tmp_path)
task_1 = make_task(title="One")
task_2 = make_task(title="Two")
repo.create_task(task_1)
repo.create_task(task_2)
tasks = repo.list_tasks()
assert tasks == [task_1, task_2]
def test_get_task_returns_existing_task(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Lookup me")
repo.create_task(task)
found = repo.get_task(task.id)
assert found == task
def test_get_task_returns_none_for_missing_id(tmp_path):
repo = make_repo(tmp_path)
repo.create_task(make_task(title="Other task"))
found = repo.get_task(uuid4())
assert found is None
def test_get_task_with_wrong_runtime_type_returns_none_and_does_not_change_storage(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Type safety")
repo.create_task(task)
found = repo.get_task("not-a-uuid") # type: ignore[arg-type]
assert found is None
assert repo.list_tasks() == [task]
def test_update_task_replaces_existing_fields(tmp_path):
repo = make_repo(tmp_path)
original = make_task(title="Old title")
repo.create_task(original)
updated = StoredTask(
id=original.id,
title="New title",
created_at=original.created_at,
started_at=original.started_at,
done_at=original.done_at,
)
result = repo.update_task(updated)
assert result == updated
assert repo.get_task(original.id) == updated
def test_update_task_persists_transition_related_fields(tmp_path):
repo = make_repo(tmp_path)
original = make_task(title="Workflow")
repo.create_task(original)
now = datetime.now(UTC)
updated = StoredTask(
id=original.id,
title=original.title,
created_at=original.created_at,
started_at=now,
done_at=now + timedelta(minutes=5),
)
repo.update_task(updated)
reloaded = repo.get_task(original.id)
assert reloaded is not None
assert reloaded.started_at == updated.started_at
assert reloaded.done_at == updated.done_at
def test_update_task_raises_for_missing_task(tmp_path):
repo = make_repo(tmp_path)
missing = make_task(title="Missing")
with pytest.raises(StorageTaskNotFoundError):
repo.update_task(missing)
assert repo.list_tasks() == []
def test_delete_task_removes_existing_task(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Delete me")
repo.create_task(task)
deleted = repo.delete_task(task.id)
assert deleted is True
assert repo.list_tasks() == []
def test_delete_task_only_affects_requested_task(tmp_path):
repo = make_repo(tmp_path)
task_1 = make_task(title="Keep me")
task_2 = make_task(title="Delete me")
repo.create_task(task_1)
repo.create_task(task_2)
deleted = repo.delete_task(task_2.id)
assert deleted is True
assert repo.list_tasks() == [task_1]
def test_delete_task_returns_false_for_missing_task(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Existing")
repo.create_task(task)
deleted = repo.delete_task(uuid4())
assert deleted is False
assert repo.list_tasks() == [task]