Files
noteflow/tests/domain/test_project.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

669 lines
27 KiB
Python

"""Tests for Project entity and related domain objects."""
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
import pytest
from noteflow.domain.entities.project import (
ExportRules,
Project,
ProjectSettings,
TriggerRules,
)
from noteflow.domain.errors import CannotArchiveDefaultProjectError, ValidationError
from noteflow.domain.utils.time import utc_now
from noteflow.domain.value_objects import ExportFormat
class TestExportRules:
"""Tests for ExportRules dataclass."""
def test_export_rules_default_values(self) -> None:
"""Test ExportRules has None defaults for all fields."""
rules = ExportRules()
assert rules.default_format is None, "default_format should default to None"
assert rules.include_audio is None, "include_audio should default to None"
assert rules.include_timestamps is None, "include_timestamps should default to None"
assert rules.template_id is None, "template_id should default to None"
def test_export_rules_with_values(self) -> None:
"""Test ExportRules accepts all field values."""
template_id = uuid4()
rules = ExportRules(
default_format=ExportFormat.PDF,
include_audio=True,
include_timestamps=False,
template_id=template_id,
)
assert rules.default_format == ExportFormat.PDF, "default_format should match"
assert rules.include_audio is True, "include_audio should match"
assert rules.include_timestamps is False, "include_timestamps should match"
assert rules.template_id == template_id, "template_id should match"
def test_export_rules_is_frozen(self) -> None:
"""Test ExportRules is immutable."""
from dataclasses import FrozenInstanceError
rules = ExportRules()
with pytest.raises(FrozenInstanceError, match="cannot assign"):
rules.default_format = ExportFormat.HTML
class TestTriggerRules:
"""Tests for TriggerRules dataclass."""
def test_trigger_rules_default_values(self) -> None:
"""Test TriggerRules has None defaults for all fields."""
rules = TriggerRules()
assert rules.auto_start_enabled is None, "auto_start_enabled should default to None"
assert rules.calendar_match_patterns is None, (
"calendar_match_patterns should default to None"
)
assert rules.app_match_patterns is None, "app_match_patterns should default to None"
def test_trigger_rules_with_values(self) -> None:
"""Test TriggerRules accepts all field values."""
rules = TriggerRules(
auto_start_enabled=True,
calendar_match_patterns=["*standup*", "*meeting*"],
app_match_patterns=["Zoom", "Teams"],
)
assert rules.auto_start_enabled is True, "auto_start_enabled should match"
assert rules.calendar_match_patterns == ["*standup*", "*meeting*"], (
"calendar_match_patterns should match"
)
assert rules.app_match_patterns == ["Zoom", "Teams"], "app_match_patterns should match"
def test_empty_list_clears_patterns(self) -> None:
"""Test empty list explicitly clears inherited patterns."""
rules = TriggerRules(
calendar_match_patterns=[],
app_match_patterns=[],
)
assert rules.calendar_match_patterns == [], "calendar_match_patterns should be empty list"
assert rules.app_match_patterns == [], "app_match_patterns should be empty list"
def test_trigger_rules_is_frozen(self) -> None:
"""Test TriggerRules is immutable."""
from dataclasses import FrozenInstanceError
rules = TriggerRules()
with pytest.raises(FrozenInstanceError, match="cannot assign"):
rules.auto_start_enabled = True
class TestProjectSettings:
"""Tests for ProjectSettings dataclass."""
def test_project_settings_default_values(self) -> None:
"""Test ProjectSettings has None defaults for all fields."""
settings = ProjectSettings()
assert settings.export_rules is None, "export_rules should default to None"
assert settings.trigger_rules is None, "trigger_rules should default to None"
assert settings.rag_enabled is None, "rag_enabled should default to None"
assert settings.default_summarization_template is None, (
"default_summarization_template should default to None"
)
def test_with_nested_rules(self) -> None:
"""Test ProjectSettings accepts nested rule objects."""
export_rules = ExportRules(default_format=ExportFormat.MARKDOWN)
trigger_rules = TriggerRules(auto_start_enabled=True)
settings = ProjectSettings(
export_rules=export_rules,
trigger_rules=trigger_rules,
rag_enabled=True,
default_summarization_template="professional",
)
assert settings.export_rules == export_rules, "export_rules should match"
assert settings.trigger_rules == trigger_rules, "trigger_rules should match"
assert settings.rag_enabled is True, "rag_enabled should match"
assert settings.default_summarization_template == "professional", "template should match"
class TestProjectCreation:
"""Tests for Project creation and initialization."""
def test_create_with_required_fields(self) -> None:
"""Test Project creation with only required fields."""
project_id = uuid4()
workspace_id = uuid4()
project = Project(
id=project_id,
workspace_id=workspace_id,
name="Test Project",
)
assert project.id == project_id, "id should match"
assert project.workspace_id == workspace_id, "workspace_id should match"
assert project.name == "Test Project", "name should match"
@pytest.mark.parametrize(
("attr", "expected"),
[
("slug", None),
("description", None),
("is_default", False),
("archived_at", None),
],
)
def test_default_attribute_values(self, attr: str, expected: object) -> None:
"""Test Project has expected default values."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
actual = getattr(project, attr)
assert actual == expected, f"expected {attr}={expected!r}, got {actual!r}"
def test_default_settings_factory(self) -> None:
"""Test Project creates empty ProjectSettings by default."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert isinstance(project.settings, ProjectSettings), "settings should be ProjectSettings"
assert project.settings.export_rules is None, "export_rules should default to None"
assert project.settings.trigger_rules is None, "trigger_rules should default to None"
def test_default_metadata_factory(self) -> None:
"""Test Project creates empty metadata dict by default."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.metadata == {}, "metadata should default to empty dict"
assert isinstance(project.metadata, dict), "metadata should be a dict"
def test_timestamps_are_set(self) -> None:
"""Test created_at and updated_at are set on creation."""
before = utc_now()
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
after = utc_now()
assert project.created_at is not None, "created_at should be set"
assert project.updated_at is not None, "updated_at should be set"
assert before <= project.created_at <= after, "created_at should be within test window"
assert before <= project.updated_at <= after, "updated_at should be within test window"
class TestProjectSlugValidation:
"""Tests for Project slug validation."""
@pytest.mark.parametrize(
"valid_slug",
[
"my-project",
"project123",
"a",
"test-project-2024",
"123",
"a-b-c",
],
)
def test_valid_slug_patterns(self, valid_slug: str) -> None:
"""Test valid slug patterns are accepted."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Test",
slug=valid_slug,
)
assert project.slug == valid_slug, f"expected slug={valid_slug!r}, got {project.slug!r}"
@pytest.mark.parametrize(
"invalid_slug",
[
"My-Project", # uppercase
"my_project", # underscore
"my project", # space
"project!", # special char
"UPPERCASE", # all uppercase
"Project", # mixed case
"", # empty string
],
)
def test_invalid_slug_patterns_raise(self, invalid_slug: str) -> None:
"""Test invalid slug patterns raise ValidationError."""
with pytest.raises(ValidationError, match="slug"):
Project(
id=uuid4(),
workspace_id=uuid4(),
name="Test",
slug=invalid_slug,
)
def test_none_slug_allowed(self) -> None:
"""Test None slug is allowed (no validation)."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Test",
slug=None,
)
assert project.slug is None, f"expected slug=None, got {project.slug!r}"
class TestProjectArchiveRestore:
"""Tests for Project archive and restore operations."""
def test_archive_sets_timestamp(self) -> None:
"""Test archive() sets archived_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.archived_at is None, "archived_at should be None before archive"
before = utc_now()
project.archive()
after = utc_now()
assert project.archived_at is not None, "archived_at should be set"
assert before <= project.archived_at <= after, "archived_at should be within test window"
def test_archive_updates_timestamp(self) -> None:
"""Test archive() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
original_updated = project.updated_at
project.archive()
assert project.updated_at >= original_updated, (
"updated_at should be >= original after archive"
)
def test_archive_default_project_raises(self) -> None:
"""Test archiving default project raises CannotArchiveDefaultProjectError."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Default",
is_default=True,
)
with pytest.raises(CannotArchiveDefaultProjectError, match="Cannot archive"):
project.archive()
def test_restore_clears_archived_at(self) -> None:
"""Test restore() clears archived_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.archive()
assert project.archived_at is not None, "archived_at should be set after archive"
project.restore()
assert project.archived_at is None, "archived_at should be cleared"
def test_restore_updates_timestamp(self) -> None:
"""Test restore() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.archive()
archived_updated = project.updated_at
project.restore()
assert project.updated_at >= archived_updated, (
"updated_at should be >= archived_updated after restore"
)
class TestProjectProperties:
"""Tests for Project computed properties."""
def test_is_archived_false_by_default(self) -> None:
"""Test is_archived returns False for new project."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.is_archived is False, "is_archived should be False for new project"
def test_is_active_true_by_default(self) -> None:
"""Test is_active returns True for new project."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.is_active is True, "is_active should be True for new project"
@pytest.mark.parametrize(
("attribute", "expected"),
[
pytest.param("is_archived", True, id="archived_true"),
pytest.param("is_active", False, id="active_false"),
],
)
def test_project_properties_when_archived(self, attribute: str, expected: bool) -> None:
"""Test archived project properties."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.archive()
assert getattr(project, attribute) is expected
def test_is_archived_and_is_active_are_inverse(self) -> None:
"""Test is_archived and is_active are always inverse."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.is_archived != project.is_active, "should be inverse when new"
project.archive()
assert project.is_archived != project.is_active, "should be inverse when archived"
project.restore()
assert project.is_archived != project.is_active, "should be inverse when restored"
class TestProjectMutations:
"""Tests for Project mutation methods."""
def test_update_name(self) -> None:
"""Test update_name() changes name field."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Original")
project.update_name("Updated Name")
assert project.name == "Updated Name", f"expected name='Updated Name', got {project.name!r}"
def test_update_name_updates_timestamp(self) -> None:
"""Test update_name() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Original")
original_updated = project.updated_at
project.update_name("Updated")
assert project.updated_at >= original_updated, (
"updated_at should be >= original after update_name"
)
def test_update_description(self) -> None:
"""Test update_description() changes description field."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.update_description("New description")
assert project.description == "New description", (
f"expected description='New description', got {project.description!r}"
)
def test_update_description_to_none(self) -> None:
"""Test update_description() can set description to None."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Test",
description="Original",
)
project.update_description(None)
assert project.description is None, (
f"expected description=None, got {project.description!r}"
)
def test_update_description_updates_timestamp(self) -> None:
"""Test update_description() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
original_updated = project.updated_at
project.update_description("Description")
assert project.updated_at >= original_updated, (
"updated_at should be >= original after update_description"
)
def test_update_settings(self) -> None:
"""Test update_settings() replaces settings object."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
new_settings = ProjectSettings(rag_enabled=True)
project.update_settings(new_settings)
assert project.settings == new_settings, "settings should match new settings"
assert project.settings.rag_enabled is True, "rag_enabled should be True"
def test_update_settings_updates_timestamp(self) -> None:
"""Test update_settings() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
original_updated = project.updated_at
project.update_settings(ProjectSettings())
assert project.updated_at >= original_updated, (
"updated_at should be >= original after update_settings"
)
def test_update_slug_with_valid_pattern(self) -> None:
"""Test update_slug() accepts valid slug patterns."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.update_slug("new-valid-slug")
assert project.slug == "new-valid-slug", "Slug should be updated"
def test_update_slug_with_invalid_pattern_raises(self) -> None:
"""Test update_slug() raises ValidationError for invalid patterns."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
with pytest.raises(ValidationError, match="slug"):
project.update_slug("Invalid-Slug")
def test_update_slug_to_none_allowed(self) -> None:
"""Test update_slug() can set slug to None."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Test",
slug="original-slug",
)
project.update_slug(None)
assert project.slug is None, "Slug should be cleared to None"
def test_update_slug_updates_timestamp(self) -> None:
"""Test update_slug() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
original_updated = project.updated_at
project.update_slug("new-slug")
assert project.updated_at >= original_updated, "updated_at should be updated"
def test_set_metadata(self) -> None:
"""Test set_metadata() adds key-value pair."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.set_metadata("key", "value")
assert project.metadata["key"] == "value", (
f"expected metadata['key']='value', got {project.metadata.get('key')!r}"
)
def test_set_metadata_overwrites_existing(self) -> None:
"""Test set_metadata() overwrites existing key."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.set_metadata("key", "original")
project.set_metadata("key", "updated")
assert project.metadata["key"] == "updated", (
f"expected metadata['key']='updated', got {project.metadata.get('key')!r}"
)
def test_set_metadata_updates_timestamp(self) -> None:
"""Test set_metadata() updates updated_at timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
original_updated = project.updated_at
project.set_metadata("key", "value")
assert project.updated_at >= original_updated, (
"updated_at should be >= original after set_metadata"
)
class TestProjectEdgeCases:
"""Edge case tests for Project entity."""
def test_archive_already_archived_project(self) -> None:
"""Test archiving already archived project updates timestamp."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
project.archive()
first_archived = project.archived_at
assert first_archived is not None, "first_archived should be set"
# Small delay to ensure different timestamp
project.archive()
assert project.archived_at is not None, (
"archived_at should still be set after second archive"
)
assert project.archived_at >= first_archived, (
"archived_at should be >= first_archived after second archive"
)
def test_restore_non_archived_project(self) -> None:
"""Test restoring non-archived project is idempotent."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.archived_at is None, "archived_at should be None before restore"
project.restore()
assert project.archived_at is None, (
"archived_at should remain None after restore on non-archived project"
)
def test_metadata_complex_structures(self) -> None:
"""Test metadata can store complex nested structures."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
complex_value = {
"nested": {"key": "value"},
"list": [1, 2, 3],
"boolean": True,
}
project.set_metadata("complex", complex_value)
assert project.metadata["complex"] == complex_value, (
"complex nested structure should be stored in metadata"
)
def test_very_long_name(self) -> None:
"""Test project accepts very long names."""
long_name = "A" * 1000
project = Project(id=uuid4(), workspace_id=uuid4(), name=long_name)
assert len(project.name) == 1000, f"expected name length 1000, got {len(project.name)}"
def test_unicode_name_and_description(self) -> None:
"""Test project accepts unicode in name and description."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="项目名称", # Chinese for "project name"
description="プロジェクトの説明", # Japanese for "project description"
)
assert project.name == "项目名称", f"expected Chinese name, got {project.name!r}"
assert project.description == "プロジェクトの説明", (
f"expected Japanese description, got {project.description!r}"
)
def test_emoji_in_name(self) -> None:
"""Test project accepts emoji in name."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="My Project 🚀",
)
assert "🚀" in project.name, f"expected emoji in name, got {project.name!r}"
def test_settings_with_full_configuration(self) -> None:
"""Test project with fully configured settings."""
template_id = uuid4()
settings = ProjectSettings(
export_rules=ExportRules(
default_format=ExportFormat.PDF,
include_audio=True,
include_timestamps=True,
template_id=template_id,
),
trigger_rules=TriggerRules(
auto_start_enabled=True,
calendar_match_patterns=["*standup*"],
app_match_patterns=["Zoom"],
),
rag_enabled=True,
default_summarization_template="professional",
)
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Fully Configured",
settings=settings,
)
assert project.settings.export_rules is not None, "export_rules should not be None"
assert project.settings.export_rules.default_format == ExportFormat.PDF, (
"default_format should be PDF"
)
assert project.settings.trigger_rules is not None, "trigger_rules should not be None"
assert project.settings.trigger_rules.auto_start_enabled is True, (
"auto_start_enabled should be True"
)
class TestProjectInvariants:
"""Tests for Project domain invariants."""
def test_default_project_cannot_be_archived(self) -> None:
"""Test default project always rejects archive operation."""
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Default Project",
is_default=True,
)
# Multiple attempts should all fail
with pytest.raises(CannotArchiveDefaultProjectError, match="Cannot archive"):
project.archive()
# Project state should be unchanged
assert project.archived_at is None, "archived_at should remain None for default project"
assert project.is_active is True, "is_active should remain True for default project"
def test_updated_at_never_before_created_at(self) -> None:
"""Test updated_at is always >= created_at."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
assert project.updated_at >= project.created_at, (
"updated_at should be >= created_at on creation"
)
project.update_name("New Name")
assert project.updated_at >= project.created_at, (
"updated_at should be >= created_at after update_name"
)
project.archive()
assert project.updated_at >= project.created_at, (
"updated_at should be >= created_at after archive"
)
def test_explicit_timestamps_preserved(self) -> None:
"""Test explicit timestamps are preserved on creation."""
created = utc_now() - timedelta(days=30)
updated = utc_now() - timedelta(days=15)
project = Project(
id=uuid4(),
workspace_id=uuid4(),
name="Historical",
created_at=created,
updated_at=updated,
)
assert project.created_at == created, (
f"expected created_at={created}, got {project.created_at}"
)
assert project.updated_at == updated, (
f"expected updated_at={updated}, got {project.updated_at}"
)
def test_id_immutability(self) -> None:
"""Test project id cannot be changed after creation."""
project = Project(id=uuid4(), workspace_id=uuid4(), name="Test")
original_id = project.id
# Dataclass allows reassignment but domain logic should prevent it
# This documents current behavior - consider making id immutable
project.id = uuid4()
# Just verifying the field was changed (dataclass allows this)
assert project.id != original_id, (
"id should have been changed (dataclass allows reassignment)"
)
class TestCannotArchiveDefaultProjectError:
"""Tests for CannotArchiveDefaultProjectError."""
def test_error_message_includes_project_id(self) -> None:
"""Test error message includes the project ID."""
project_id = "test-project-123"
error = CannotArchiveDefaultProjectError(project_id)
# Use structured details instead of string comparison
assert error.details is not None, "error.details should not be None"
assert error.details.get("project_id") == project_id, (
f"expected project_id='{project_id}' in details"
)
def test_error_details_contain_project_id(self) -> None:
"""Test error details contain project_id key."""
project_id = "test-project-456"
error = CannotArchiveDefaultProjectError(project_id)
assert error.details is not None, "error.details should not be None"
assert error.details.get("project_id") == project_id, (
f"expected project_id='{project_id}' in details, got {error.details.get('project_id')!r}"
)