Add comprehensive test suite for MVP-1 Personal Tracker CLI

Implements 72 tests covering:
- Model tests (Project, Session, Note, Change)
- ProjectService tests (create, get, list, ensure structure)
- SessionService tests (active session management)
- FileStorage tests (read/write operations)
- Complete flow tests (init -> start -> note -> stop -> show)
- Note consolidation tests

Uses pytest with tmp_path fixtures for isolated testing.
This commit is contained in:
2026-03-23 09:40:46 -03:00
parent 4e67062c99
commit 2735562b65
6 changed files with 1302 additions and 0 deletions

102
tests/conftest.py Normal file
View File

@@ -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

337
tests/test_flow.py Normal file
View File

@@ -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"]

220
tests/test_models.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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

193
tests/test_storage.py Normal file
View File

@@ -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 == []