diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..29f7955 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,102 @@ +"""Pytest fixtures for tracker tests.""" + +import json +import tempfile +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional + +import pytest + +from tracker.models import Project, Session, Note, NoteType, Change + + +@pytest.fixture +def tmp_project_dir(tmp_path): + """Create a temporary directory for project tests.""" + projects_root = tmp_path / "projects" + projects_root.mkdir() + return projects_root + + +@pytest.fixture +def sample_project_data(): + """Sample project data for testing.""" + return { + "id": "test-project-id-123", + "name": "Test Project", + "slug": "test-project", + "description": "A test project for unit testing", + "type": "code", + "status": "active", + "tags": ["python", "testing"], + "root_path": "/path/to/projects/test-project", + "repo_path": "/path/to/repos/test-project", + "created_at": datetime(2024, 1, 15, 10, 0, 0), + "updated_at": datetime(2024, 1, 15, 10, 0, 0), + "last_session_at": None, + } + + +@pytest.fixture +def sample_project(sample_project_data): + """Create a sample Project instance.""" + return Project(**sample_project_data) + + +@pytest.fixture +def mock_session(sample_project): + """Create a mock session for testing.""" + return Session( + id="session-123", + project_slug=sample_project.slug, + started_at=datetime(2024, 1, 15, 10, 0, 0), + ended_at=datetime(2024, 1, 15, 11, 30, 0), + duration_minutes=90, + objective="Complete initial implementation", + summary="Worked on core features", + work_done=["Implemented feature A", "Fixed bug B"], + changes=["Added new endpoint"], + decisions=["Use JSON for storage"], + blockers=[], + next_steps=["Add tests", "Write documentation"], + references=["https://example.com/docs"], + raw_notes=[ + {"type": "work", "text": "Working on feature A", "timestamp": "2024-01-15T10:15:00"}, + {"type": "idea", "text": "Consider using caching", "timestamp": "2024-01-15T10:30:00"}, + ], + ) + + +@pytest.fixture +def sample_note(): + """Create a sample note for testing.""" + return Note( + type=NoteType.WORK, + text="Completed the implementation of feature X", + created_at=datetime(2024, 1, 15, 10, 30, 0), + ) + + +@pytest.fixture +def sample_change(): + """Create a sample change for testing.""" + return Change( + date=datetime(2024, 1, 15).date(), + type="code", + title="Added user authentication", + impact="Improved security", + references=["#123"], + ) + + +@pytest.fixture +def tmp_project_with_structure(tmp_project_dir, sample_project_data): + """Create a temporary project with directory structure.""" + slug = sample_project_data["slug"] + project_root = tmp_project_dir / slug + (project_root / "sessions").mkdir(parents=True) + (project_root / "docs").mkdir(parents=True) + (project_root / "assets").mkdir(parents=True) + (project_root / "meta").mkdir(parents=True) + return project_root diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..1f8549f --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,337 @@ +"""Tests for complete flow: init -> start -> note -> stop -> show.""" + +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +from tracker.models import Session, Note, NoteType +from tracker.services import ( + create_project, + get_project, + ensure_project_structure, + set_active_session, + get_active_session, + clear_active_session, + validate_no_other_active_session, + add_note, + consolidate_notes, + get_projects_root, +) +from tracker.storage import FileStorage + + +class TestInitProjectFlow: + """Tests for project initialization flow.""" + + def test_init_project_creates_structure(self, tmp_path, sample_project_data): + """Test that project initialization creates required directory structure.""" + slug = sample_project_data["slug"] + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + project = create_project( + name=sample_project_data["name"], + slug=slug, + description=sample_project_data["description"], + type=sample_project_data["type"], + ) + + ensure_project_structure(slug) + + project_root = tmp_path / slug + assert (project_root / "sessions").is_dir() + assert (project_root / "docs").is_dir() + assert (project_root / "assets").is_dir() + assert (project_root / "meta").is_dir() + + def test_init_project_creates_meta_file(self, tmp_path, sample_project_data): + """Test that project initialization creates meta/project.yaml.""" + slug = sample_project_data["slug"] + storage = FileStorage(tmp_path) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + project = create_project( + name=sample_project_data["name"], + slug=slug, + description=sample_project_data["description"], + type=sample_project_data["type"], + ) + + # Write the project meta (simulating what storage would do) + meta_data = { + "id": project.id, + "name": project.name, + "slug": project.slug, + "description": project.description, + "type": project.type, + "status": project.status, + "tags": project.tags, + "created_at": project.created_at.isoformat(), + "updated_at": project.updated_at.isoformat(), + } + storage.write_project_meta(slug, meta_data) + + meta_path = tmp_path / slug / "meta" / "project.yaml" + assert meta_path.exists() + + with open(meta_path, "r") as f: + data = yaml.safe_load(f) + + assert data["name"] == sample_project_data["name"] + assert data["slug"] == slug + + +class TestStartSessionFlow: + """Tests for starting a session.""" + + def test_start_session(self, tmp_path, sample_project_data, monkeypatch): + """Test starting a session creates active session.""" + slug = sample_project_data["slug"] + + # Create a fake active session path + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + project = create_project( + name=sample_project_data["name"], + slug=slug, + ) + ensure_project_structure(slug) + + session = Session( + id="session-1", + project_slug=slug, + started_at=datetime.now(), + objective="Test objective", + ) + + set_active_session(session) + + active = get_active_session() + assert active is not None + assert active.id == "session-1" + assert active.project_slug == slug + + def test_start_session_fails_if_other_active(self, tmp_path, sample_project_data, monkeypatch): + """Test that starting a session fails if another project has an active session.""" + slug1 = "project-1" + slug2 = "project-2" + + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + session1 = Session( + id="session-1", + project_slug=slug1, + started_at=datetime.now(), + ) + set_active_session(session1) + + is_valid = True + if not validate_no_other_active_session(slug2): + is_valid = False + + assert is_valid is False + + +class TestAddNoteFlow: + """Tests for adding notes during a session.""" + + def test_add_note(self, tmp_path, sample_project_data, monkeypatch): + """Test adding a note during a session.""" + slug = sample_project_data["slug"] + + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + session = Session( + id="session-1", + project_slug=slug, + started_at=datetime.now(), + ) + set_active_session(session) + + add_note(session, "work", "Test work note") + + assert len(session.raw_notes) == 1 + assert session.raw_notes[0]["type"] == "work" + assert session.raw_notes[0]["text"] == "Test work note" + + def test_add_multiple_notes(self, tmp_path, sample_project_data, monkeypatch): + """Test adding multiple notes during a session.""" + slug = sample_project_data["slug"] + + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + session = Session( + id="session-1", + project_slug=slug, + started_at=datetime.now(), + ) + + add_note(session, "work", "Work note") + add_note(session, "idea", "Idea note") + add_note(session, "blocker", "Blocker note") + + assert len(session.raw_notes) == 3 + assert session.raw_notes[0]["type"] == "work" + assert session.raw_notes[1]["type"] == "idea" + assert session.raw_notes[2]["type"] == "blocker" + + +class TestStopSessionFlow: + """Tests for stopping a session.""" + + def test_stop_session(self, tmp_path, sample_project_data, monkeypatch): + """Test stopping a session clears active session.""" + slug = sample_project_data["slug"] + + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + session = Session( + id="session-1", + project_slug=slug, + started_at=datetime.now(), + ) + set_active_session(session) + + session.ended_at = datetime.now() + session.duration_minutes = 30 + + clear_active_session() + + active = get_active_session() + assert active is None + + +class TestShowProjectFlow: + """Tests for showing project information.""" + + def test_show_project(self, tmp_path, sample_project_data): + """Test showing project information.""" + slug = sample_project_data["slug"] + storage = FileStorage(tmp_path) + + meta_data = { + "id": "test-id", + "name": sample_project_data["name"], + "slug": slug, + "description": sample_project_data["description"], + "type": sample_project_data["type"], + "status": sample_project_data["status"], + "tags": sample_project_data["tags"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } + storage.write_project_meta(slug, meta_data) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + project = get_project(slug) + assert project is not None + assert project.name == sample_project_data["name"] + assert project.slug == slug + + +class TestConsolidateNotes: + """Tests for consolidating notes after session.""" + + def test_consolidate_notes_categorizes_work(self): + """Test that consolidate_notes categorizes work notes.""" + raw_notes = [ + {"type": "work", "text": "Implemented feature A", "timestamp": "2024-01-15T10:00:00"}, + {"type": "work", "text": "Wrote tests", "timestamp": "2024-01-15T10:30:00"}, + ] + + consolidated = consolidate_notes(raw_notes) + + assert len(consolidated["work_done"]) == 2 + assert "Implemented feature A" in consolidated["work_done"] + assert "Wrote tests" in consolidated["work_done"] + + def test_consolidate_notes_categorizes_blockers(self): + """Test that consolidate_notes categorizes blockers.""" + raw_notes = [ + {"type": "blocker", "text": "Waiting for API access", "timestamp": "2024-01-15T10:00:00"}, + ] + + consolidated = consolidate_notes(raw_notes) + + assert len(consolidated["blockers"]) == 1 + assert "Waiting for API access" in consolidated["blockers"] + + def test_consolidate_notes_categorizes_decisions(self): + """Test that consolidate_notes categorizes decisions.""" + raw_notes = [ + {"type": "decision", "text": "Use PostgreSQL for storage", "timestamp": "2024-01-15T10:00:00"}, + ] + + consolidated = consolidate_notes(raw_notes) + + assert len(consolidated["decisions"]) == 1 + assert "Use PostgreSQL for storage" in consolidated["decisions"] + + def test_consolidate_notes_categorizes_references(self): + """Test that consolidate_notes categorizes references.""" + raw_notes = [ + {"type": "reference", "text": "https://docs.example.com", "timestamp": "2024-01-15T10:00:00"}, + ] + + consolidated = consolidate_notes(raw_notes) + + assert len(consolidated["references"]) == 1 + assert "https://docs.example.com" in consolidated["references"] + + def test_consolidate_notes_categorizes_changes(self): + """Test that consolidate_notes categorizes changes.""" + raw_notes = [ + {"type": "change", "text": "Refactored authentication module", "timestamp": "2024-01-15T10:00:00"}, + ] + + consolidated = consolidate_notes(raw_notes) + + assert len(consolidated["changes"]) == 1 + assert "Refactored authentication module" in consolidated["changes"] diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..77841a3 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,220 @@ +"""Tests for data models.""" + +from datetime import datetime, date + +import pytest + +from tracker.models import Project, Session, Note, NoteType, Change + + +class TestProjectCreation: + """Tests for Project model creation.""" + + def test_project_creation_with_all_fields(self, sample_project_data): + """Test creating a project with all fields specified.""" + project = Project(**sample_project_data) + + assert project.id == sample_project_data["id"] + assert project.name == sample_project_data["name"] + assert project.slug == sample_project_data["slug"] + assert project.description == sample_project_data["description"] + assert project.type == sample_project_data["type"] + assert project.status == sample_project_data["status"] + assert project.tags == sample_project_data["tags"] + assert project.root_path == sample_project_data["root_path"] + assert project.repo_path == sample_project_data["repo_path"] + assert project.created_at == sample_project_data["created_at"] + assert project.updated_at == sample_project_data["updated_at"] + assert project.last_session_at is None + + def test_project_creation_with_defaults(self): + """Test creating a project with default values.""" + now = datetime.now() + project = Project( + id="test-id", + name="Test", + slug="test", + created_at=now, + updated_at=now, + ) + + assert project.description == "" + assert project.type == "misc" + assert project.status == "inbox" + assert project.tags == [] + assert project.root_path == "" + assert project.repo_path is None + assert project.last_session_at is None + + def test_project_status_validation(self): + """Test that project status can be set to valid values.""" + valid_statuses = ["inbox", "next", "active", "blocked", "waiting", "done", "archived"] + + for status in valid_statuses: + project = Project( + id="test-id", + name="Test", + slug="test", + created_at=datetime.now(), + updated_at=datetime.now(), + status=status, + ) + assert project.status == status + + def test_project_type_validation(self): + """Test that project type can be set to valid values.""" + valid_types = ["code", "homelab", "automation", "agent", "research", "misc"] + + for project_type in valid_types: + project = Project( + id="test-id", + name="Test", + slug="test", + created_at=datetime.now(), + updated_at=datetime.now(), + type=project_type, + ) + assert project.type == project_type + + +class TestSessionCreation: + """Tests for Session model creation.""" + + def test_session_creation_with_all_fields(self, mock_session): + """Test creating a session with all fields specified.""" + assert mock_session.id == "session-123" + assert mock_session.project_slug == "test-project" + assert mock_session.started_at == datetime(2024, 1, 15, 10, 0, 0) + assert mock_session.ended_at == datetime(2024, 1, 15, 11, 30, 0) + assert mock_session.duration_minutes == 90 + assert mock_session.objective == "Complete initial implementation" + assert mock_session.summary == "Worked on core features" + assert len(mock_session.work_done) == 2 + assert len(mock_session.changes) == 1 + assert len(mock_session.decisions) == 1 + assert len(mock_session.blockers) == 0 + assert len(mock_session.next_steps) == 2 + assert len(mock_session.references) == 1 + assert len(mock_session.raw_notes) == 2 + + def test_session_creation_with_defaults(self): + """Test creating a session with required fields only.""" + session = Session( + id="session-1", + project_slug="test-project", + started_at=datetime.now(), + ) + + assert session.ended_at is None + assert session.duration_minutes is None + assert session.objective == "" + assert session.summary == "" + assert session.work_done == [] + assert session.changes == [] + assert session.decisions == [] + assert session.blockers == [] + assert session.next_steps == [] + assert session.references == [] + assert session.raw_notes == [] + + def test_session_with_optional_datetime_fields(self): + """Test session with ended_at but no duration_minutes.""" + session = Session( + id="session-1", + project_slug="test-project", + started_at=datetime(2024, 1, 15, 10, 0, 0), + ended_at=datetime(2024, 1, 15, 11, 0, 0), + ) + + assert session.ended_at is not None + assert session.duration_minutes is None + + +class TestNoteTypes: + """Tests for Note and NoteType models.""" + + def test_note_creation(self, sample_note): + """Test creating a note.""" + assert sample_note.type == NoteType.WORK + assert sample_note.text == "Completed the implementation of feature X" + assert sample_note.created_at == datetime(2024, 1, 15, 10, 30, 0) + + def test_note_type_enum_values(self): + """Test that NoteType enum has expected values.""" + assert NoteType.WORK.value == "work" + assert NoteType.CHANGE.value == "change" + assert NoteType.BLOCKER.value == "blocker" + assert NoteType.DECISION.value == "decision" + assert NoteType.IDEA.value == "idea" + assert NoteType.REFERENCE.value == "reference" + + def test_note_type_assignment(self): + """Test assigning different note types to a note.""" + for note_type in NoteType: + note = Note(type=note_type, text="Test note") + assert note.type == note_type + + def test_note_default_created_at(self): + """Test that note has default created_at timestamp.""" + note = Note(type=NoteType.WORK, text="Test note") + assert note.created_at is not None + assert isinstance(note.created_at, datetime) + + def test_note_raw_notes_structure(self, mock_session): + """Test that raw_notes in session have expected structure.""" + assert len(mock_session.raw_notes) == 2 + assert mock_session.raw_notes[0]["type"] == "work" + assert mock_session.raw_notes[0]["text"] == "Working on feature A" + assert "timestamp" in mock_session.raw_notes[0] + + +class TestChangeValidation: + """Tests for Change model validation.""" + + def test_change_creation(self, sample_change): + """Test creating a change.""" + assert sample_change.date == date(2024, 1, 15) + assert sample_change.type == "code" + assert sample_change.title == "Added user authentication" + assert sample_change.impact == "Improved security" + assert sample_change.references == ["#123"] + + def test_change_creation_with_defaults(self): + """Test creating a change with default values.""" + change = Change( + date=date(2024, 1, 15), + type="docs", + title="Updated documentation", + ) + + assert change.impact == "" + assert change.references == [] + + def test_change_date_as_date_not_datetime(self, sample_change): + """Test that change date is stored as date object.""" + assert isinstance(sample_change.date, date) + assert not isinstance(sample_change.date, datetime) + + def test_change_types(self): + """Test valid change types.""" + valid_types = ["code", "infra", "config", "docs", "automation", "decision"] + + for change_type in valid_types: + change = Change( + date=date.today(), + type=change_type, + title=f"Test {change_type}", + ) + assert change.type == change_type + + def test_change_with_multiple_references(self): + """Test change with multiple references.""" + change = Change( + date=date.today(), + type="code", + title="Major refactor", + references=["#100", "#101", "#102"], + ) + assert len(change.references) == 3 + assert "#100" in change.references + assert "#102" in change.references diff --git a/tests/test_project_service.py b/tests/test_project_service.py new file mode 100644 index 0000000..c478f26 --- /dev/null +++ b/tests/test_project_service.py @@ -0,0 +1,209 @@ +"""Tests for ProjectService.""" + +import tempfile +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +from tracker.models import Project +from tracker.services import ( + create_project, + get_project, + update_project, + list_projects, + get_projects_root, + ensure_project_structure, +) + + +class TestCreateProject: + """Tests for create_project function.""" + + def test_create_project_returns_project_instance(self): + """Test that create_project returns a Project instance.""" + project = create_project( + name="Test Project", + slug="test-project", + description="A test project", + type="code", + ) + + assert isinstance(project, Project) + assert project.name == "Test Project" + assert project.slug == "test-project" + assert project.description == "A test project" + assert project.type == "code" + + def test_create_project_generates_id(self): + """Test that create_project generates a UUID.""" + project = create_project(name="Test", slug="test") + assert project.id is not None + assert len(project.id) > 0 + + def test_create_project_sets_default_status(self): + """Test that create_project sets default status to inbox.""" + project = create_project(name="Test", slug="test") + assert project.status == "inbox" + + def test_create_project_sets_timestamps(self): + """Test that create_project sets created_at and updated_at.""" + before = datetime.now() + project = create_project(name="Test", slug="test") + after = datetime.now() + + assert before <= project.created_at <= after + assert before <= project.updated_at <= after + # created_at and updated_at should be very close (within 1 second) + time_diff = abs((project.updated_at - project.created_at).total_seconds()) + assert time_diff < 1 + + def test_create_project_with_tags(self): + """Test creating a project with tags.""" + tags = ["python", "testing", "cli"] + project = create_project(name="Test", slug="test", tags=tags) + assert project.tags == tags + + def test_create_project_with_repo_path(self): + """Test creating a project with a repo path.""" + repo_path = Path("/path/to/repo") + project = create_project(name="Test", slug="test", repo_path=repo_path) + assert project.repo_path == str(repo_path) + + def test_create_project_without_repo_path(self): + """Test creating a project without repo path.""" + project = create_project(name="Test", slug="test") + assert project.repo_path is None + + +class TestGetProject: + """Tests for get_project function.""" + + def test_get_project_returns_none_for_nonexistent(self, tmp_path): + """Test that get_project returns None for nonexistent project.""" + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + result = get_project("nonexistent") + assert result is None + + def test_get_project_returns_project_when_exists(self, tmp_path, sample_project_data): + """Test that get_project returns Project when it exists.""" + slug = sample_project_data["slug"] + project_dir = tmp_path / slug / "meta" + project_dir.mkdir(parents=True) + + meta_path = project_dir / "project.yaml" + with open(meta_path, "w") as f: + yaml.safe_dump(sample_project_data, f) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + project = get_project(slug) + assert project is not None + assert project.slug == slug + assert project.name == sample_project_data["name"] + + def test_get_project_handles_invalid_yaml(self, tmp_path): + """Test that get_project handles invalid YAML gracefully.""" + slug = "test-project" + project_dir = tmp_path / slug / "meta" + project_dir.mkdir(parents=True) + + meta_path = project_dir / "project.yaml" + with open(meta_path, "w") as f: + f.write("invalid: yaml: content:") + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + result = get_project(slug) + assert result is None + + +class TestListProjects: + """Tests for list_projects function.""" + + def test_list_projects_returns_empty_list_when_no_projects(self, tmp_path): + """Test that list_projects returns empty list when no projects exist.""" + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + projects = list_projects() + assert projects == [] + + def test_list_projects_returns_all_projects(self, tmp_path, sample_project_data): + """Test that list_projects returns all existing projects.""" + project1_data = sample_project_data.copy() + project1_data["slug"] = "project-1" + project1_data["name"] = "Project 1" + + project2_data = sample_project_data.copy() + project2_data["slug"] = "project-2" + project2_data["name"] = "Project 2" + + for project_data in [project1_data, project2_data]: + slug = project_data["slug"] + project_dir = tmp_path / slug / "meta" + project_dir.mkdir(parents=True) + meta_path = project_dir / "project.yaml" + with open(meta_path, "w") as f: + yaml.safe_dump(project_data, f) + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + projects = list_projects() + assert len(projects) == 2 + slugs = [p.slug for p in projects] + assert "project-1" in slugs + assert "project-2" in slugs + + def test_list_projects_ignores_hidden_directories(self, tmp_path, sample_project_data): + """Test that list_projects ignores hidden directories.""" + slug = sample_project_data["slug"] + project_dir = tmp_path / slug / "meta" + project_dir.mkdir(parents=True) + meta_path = project_dir / "project.yaml" + with open(meta_path, "w") as f: + yaml.safe_dump(sample_project_data, f) + + hidden_dir = tmp_path / ".hidden" + hidden_dir.mkdir() + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + projects = list_projects() + assert len(projects) == 1 + assert projects[0].slug == slug + + +class TestEnsureProjectStructure: + """Tests for ensure_project_structure function.""" + + def test_ensure_project_structure_creates_directories(self, tmp_path): + """Test that ensure_project_structure creates required directories.""" + slug = "test-project" + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + ensure_project_structure(slug) + + project_root = tmp_path / slug + assert (project_root / "sessions").exists() + assert (project_root / "docs").exists() + assert (project_root / "assets").exists() + assert (project_root / "meta").exists() + + def test_ensure_project_structure_is_idempotent(self, tmp_path): + """Test that ensure_project_structure can be called multiple times safely.""" + slug = "test-project" + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + ensure_project_structure(slug) + ensure_project_structure(slug) + + project_root = tmp_path / slug + assert (project_root / "sessions").exists() + assert (project_root / "docs").exists() + + def test_ensure_project_structure_creates_nested_directories(self, tmp_path): + """Test that ensure_project_structure creates parent directories if needed.""" + slug = "nested/project" + + with patch("tracker.services.project_service._PROJECTS_ROOT", tmp_path): + ensure_project_structure(slug) + + project_root = tmp_path / slug + assert (project_root / "sessions").exists() diff --git a/tests/test_session_service.py b/tests/test_session_service.py new file mode 100644 index 0000000..d9cd3c3 --- /dev/null +++ b/tests/test_session_service.py @@ -0,0 +1,241 @@ +"""Tests for SessionService.""" + +import json +from datetime import datetime +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from tracker.models import Session +from tracker.services import ( + get_active_session, + set_active_session, + clear_active_session, + get_active_session_path, + validate_no_other_active_session, +) + + +@pytest.fixture +def mock_active_session_path(tmp_path, monkeypatch): + """Mock the active session path to use tmp_path.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + return fake_path + + +class TestSetAndGetActiveSession: + """Tests for set and get active session functions.""" + + def test_set_and_get_active_session(self, tmp_path, mock_session, monkeypatch): + """Test setting and getting an active session.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + retrieved = get_active_session() + + assert retrieved is not None + assert retrieved.id == mock_session.id + assert retrieved.project_slug == mock_session.project_slug + assert retrieved.started_at == mock_session.started_at + + def test_get_active_session_returns_none_when_no_session(self, tmp_path, monkeypatch): + """Test that get_active_session returns None when no session exists.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + result = get_active_session() + assert result is None + + def test_set_active_session_creates_parent_directories(self, tmp_path, mock_session, monkeypatch): + """Test that set_active_session creates parent directories if needed.""" + fake_path = tmp_path / "subdir" / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + assert fake_path.exists() + + def test_active_session_contains_all_fields(self, tmp_path, mock_session, monkeypatch): + """Test that active session file contains all session fields.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + + with open(fake_path, "r") as f: + data = json.load(f) + + assert data["id"] == mock_session.id + assert data["project_slug"] == mock_session.project_slug + assert "started_at" in data + assert "work_done" in data + assert data["work_done"] == mock_session.work_done + + +class TestClearActiveSession: + """Tests for clear_active_session function.""" + + def test_clear_active_session_removes_file(self, tmp_path, mock_session, monkeypatch): + """Test that clear_active_session removes the active session file.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + assert fake_path.exists() + + clear_active_session() + assert not fake_path.exists() + + def test_clear_active_session_when_no_session(self, tmp_path, monkeypatch): + """Test that clear_active_session does not error when no session exists.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + clear_active_session() + assert not fake_path.exists() + + def test_get_active_session_after_clear(self, tmp_path, mock_session, monkeypatch): + """Test that get_active_session returns None after clearing.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + clear_active_session() + + result = get_active_session() + assert result is None + + +class TestValidateNoOtherActiveSession: + """Tests for validate_no_other_active_session function.""" + + def test_validate_no_other_active_session_returns_true_when_none(self, tmp_path, monkeypatch): + """Test validation returns True when no active session exists.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + result = validate_no_other_active_session("any-project") + assert result is True + + def test_validate_no_other_active_session_returns_true_for_same_project( + self, tmp_path, mock_session, monkeypatch + ): + """Test validation returns True for same project's session.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + + result = validate_no_other_active_session(mock_session.project_slug) + assert result is True + + def test_validate_no_other_active_session_returns_false_for_different_project( + self, tmp_path, mock_session, monkeypatch + ): + """Test validation returns False for different project's session.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + + result = validate_no_other_active_session("different-project") + assert result is False + + def test_validate_no_other_active_session_with_no_active_after_clear( + self, tmp_path, mock_session, monkeypatch + ): + """Test validation returns True after clearing session.""" + fake_path = tmp_path / ".active_session.json" + + def mock_get_active_session_path(): + return fake_path + + monkeypatch.setattr( + "tracker.services.session_service.get_active_session_path", + mock_get_active_session_path + ) + + set_active_session(mock_session) + clear_active_session() + + result = validate_no_other_active_session("any-project") + assert result is True diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..c57bb86 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,193 @@ +"""Tests for FileStorage.""" + +import json +from datetime import datetime +from pathlib import Path + +import pytest +import yaml + +from tracker.models import Session +from tracker.storage import FileStorage + + +class TestReadWriteProjectMeta: + """Tests for read/write project meta operations.""" + + def test_write_and_read_project_meta(self, tmp_project_dir): + """Test writing and reading project meta.""" + storage = FileStorage(tmp_project_dir) + slug = "test-project" + + meta_data = { + "id": "test-id", + "name": "Test Project", + "slug": slug, + "description": "A test project", + "type": "code", + "status": "active", + "tags": ["python"], + "created_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(), + "updated_at": datetime(2024, 1, 15, 10, 0, 0).isoformat(), + } + + storage.write_project_meta(slug, meta_data) + + result = storage.read_project_meta(slug) + assert result["name"] == "Test Project" + assert result["slug"] == slug + assert result["type"] == "code" + + def test_read_project_meta_creates_parent_directories(self, tmp_project_dir): + """Test that write_project_meta creates parent directories.""" + storage = FileStorage(tmp_project_dir) + slug = "new-project" + + meta_data = {"id": "test-id", "name": "Test", "slug": slug} + storage.write_project_meta(slug, meta_data) + + meta_path = tmp_project_dir / slug / "meta" / "project.yaml" + assert meta_path.exists() + + def test_read_project_meta_raises_for_nonexistent(self, tmp_project_dir): + """Test that reading nonexistent project raises error.""" + storage = FileStorage(tmp_project_dir) + + with pytest.raises(FileNotFoundError): + storage.read_project_meta("nonexistent") + + +class TestAppendToLog: + """Tests for append_to_log operations.""" + + def test_append_to_log_creates_log_file(self, tmp_project_dir): + """Test that append_to_log creates LOG.md if it doesn't exist.""" + storage = FileStorage(tmp_project_dir) + slug = "test-project" + + # Create project directory first + (tmp_project_dir / slug).mkdir(parents=True) + + storage.append_to_log(slug, "# Test Log Entry\n") + + log_path = tmp_project_dir / slug / "LOG.md" + assert log_path.exists() + + def test_append_to_log_appends_content(self, tmp_project_dir): + """Test that append_to_log appends content to LOG.md.""" + storage = FileStorage(tmp_project_dir) + slug = "test-project" + + # Create project directory first + (tmp_project_dir / slug).mkdir(parents=True) + + storage.append_to_log(slug, "# First Entry\n") + storage.append_to_log(slug, "# Second Entry\n") + + content = storage.read_log(slug) + assert "# First Entry" in content + assert "# Second Entry" in content + + def test_read_log_returns_empty_string_for_nonexistent(self, tmp_project_dir): + """Test that read_log returns empty string for nonexistent log.""" + storage = FileStorage(tmp_project_dir) + + result = storage.read_log("nonexistent") + assert result == "" + + +class TestActiveSessionStorage: + """Tests for active session storage operations.""" + + def test_write_and_read_active_session(self, tmp_project_dir, mock_session): + """Test writing and reading active session.""" + storage = FileStorage(tmp_project_dir) + + session_data = mock_session.model_dump(mode="json") + session_data["started_at"] = mock_session.started_at.isoformat() + if mock_session.ended_at: + session_data["ended_at"] = mock_session.ended_at.isoformat() + + storage.write_active_session(session_data) + + result = storage.read_active_session() + assert result is not None + assert result["id"] == mock_session.id + + def test_read_active_session_returns_none_when_not_exists(self, tmp_project_dir): + """Test that read_active_session returns None when file doesn't exist.""" + storage = FileStorage(tmp_project_dir) + + result = storage.read_active_session() + assert result is None + + def test_delete_active_session(self, tmp_project_dir, mock_session): + """Test deleting active session.""" + storage = FileStorage(tmp_project_dir) + + session_data = mock_session.model_dump(mode="json") + session_data["started_at"] = mock_session.started_at.isoformat() + storage.write_active_session(session_data) + + storage.delete_active_session() + + result = storage.read_active_session() + assert result is None + + def test_delete_active_session_when_not_exists(self, tmp_project_dir): + """Test deleting active session when it doesn't exist doesn't error.""" + storage = FileStorage(tmp_project_dir) + + storage.delete_active_session() + + +class TestProjectExistence: + """Tests for project existence checks.""" + + def test_project_exists_returns_true_for_existing_project(self, tmp_project_dir): + """Test that project_exists returns True for existing project.""" + storage = FileStorage(tmp_project_dir) + slug = "test-project" + + (tmp_project_dir / slug).mkdir() + + assert storage.project_exists(slug) is True + + def test_project_exists_returns_false_for_nonexistent(self, tmp_project_dir): + """Test that project_exists returns False for nonexistent project.""" + storage = FileStorage(tmp_project_dir) + + assert storage.project_exists("nonexistent") is False + + +class TestListProjects: + """Tests for listing projects.""" + + def test_list_projects_returns_all_projects(self, tmp_project_dir): + """Test that list_projects returns all project slugs.""" + storage = FileStorage(tmp_project_dir) + + (tmp_project_dir / "project-1").mkdir() + (tmp_project_dir / "project-2").mkdir() + + projects = storage.list_projects() + assert "project-1" in projects + assert "project-2" in projects + + def test_list_projects_excludes_hidden_directories(self, tmp_project_dir): + """Test that list_projects excludes hidden directories.""" + storage = FileStorage(tmp_project_dir) + + (tmp_project_dir / "project-1").mkdir() + (tmp_project_dir / ".hidden").mkdir() + + projects = storage.list_projects() + assert "project-1" in projects + assert ".hidden" not in projects + + def test_list_projects_returns_empty_list_when_no_projects(self, tmp_project_dir): + """Test that list_projects returns empty list when no projects exist.""" + storage = FileStorage(tmp_project_dir) + + projects = storage.list_projects() + assert projects == []