From 8ee2a9f5e9b0ab9a35d38aae8cbf9769b77d101e Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:18:42 +0100 Subject: [PATCH 01/15] feat: add tests for the new track command --- tests/conftest.py | 57 ++++++++++++++++ tests/test_track.py | 162 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_track.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7b2692b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import MagicMock +import os +import tempfile +from pathlib import Path +from gittask.database import DBManager +from gittask.config import ConfigManager + +@pytest.fixture +def mock_db(tmp_path): + """ + Fixture for a temporary database. + """ + db_path = tmp_path / "db.json" + db = DBManager(str(db_path)) + return db + +@pytest.fixture +def mock_asana(mocker): + """ + Fixture for mocking AsanaClient. + """ + mock_client = MagicMock() + mocker.patch("gittask.commands.track.AsanaClient", return_value=mock_client) + mocker.patch("gittask.commands.checkout.AsanaClient", return_value=mock_client) + return mock_client + +@pytest.fixture +def mock_git(mocker): + """ + Fixture for mocking GitHandler. + """ + mock_git_handler = MagicMock() + mocker.patch("gittask.commands.checkout.GitHandler", return_value=mock_git_handler) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git_handler) + + # Default behavior + mock_git_handler.get_current_branch.return_value = "main" + mock_git_handler.get_repo_root.return_value = "/tmp/mock_repo" + + return mock_git_handler + +@pytest.fixture +def mock_config(mocker, mock_db): + """ + Fixture for mocking ConfigManager. + """ + mock_config_manager = MagicMock() + mocker.patch("gittask.commands.track.ConfigManager", return_value=mock_config_manager) + mocker.patch("gittask.commands.checkout.ConfigManager", return_value=mock_config_manager) + + # Default behavior + mock_config_manager.get_api_token.return_value = "mock_token" + mock_config_manager.get_default_workspace.return_value = "mock_workspace_gid" + mock_config_manager.get_default_project.return_value = "mock_project_gid" + + return mock_config_manager diff --git a/tests/test_track.py b/tests/test_track.py new file mode 100644 index 0000000..6fa87db --- /dev/null +++ b/tests/test_track.py @@ -0,0 +1,162 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from gittask.database import DBManager +from unittest.mock import MagicMock + +runner = CliRunner() + +def test_track_creates_global_session(mock_db, mock_asana, mock_config, mocker): + """ + Test that 'gittask track' creates a global session. + """ + mocker.patch("gittask.commands.track.DBManager", return_value=mock_db) + + # Mock Asana search to return a task + mock_asana.__enter__.return_value.search_tasks.return_value = [ + {'gid': '123', 'name': 'Planning'} + ] + + result = runner.invoke(app, ["track", "Planning"]) + + assert result.exit_code == 0 + assert "Started tracking time for 'Planning' (Global)" in result.stdout + + # Verify session in DB + sessions = mock_db.time_sessions.all() + assert len(sessions) == 1 + assert sessions[0]['branch'] == "@global:Planning" + assert sessions[0]['repo_path'] == "GLOBAL" + assert sessions[0]['task_gid'] == '123' + +def test_track_stops_existing_session(mock_db, mock_asana, mock_config, mocker): + """ + Test that 'gittask track' stops an existing session. + """ + mocker.patch("gittask.commands.track.DBManager", return_value=mock_db) + + # Start an existing session + mock_db.start_session("feature-branch", "/tmp/repo", "old_task_gid") + + # Mock Asana search + mock_asana.__enter__.return_value.search_tasks.return_value = [ + {'gid': '456', 'name': 'Meeting'} + ] + + result = runner.invoke(app, ["track", "Meeting"]) + + assert result.exit_code == 0 + + # Verify old session stopped + old_session = mock_db.time_sessions.get(doc_id=1) + assert old_session['end_time'] is not None + + # Verify new session started + new_session = mock_db.time_sessions.get(doc_id=2) + assert new_session['branch'] == "@global:Meeting" + assert new_session['end_time'] is None + +def test_track_no_auth(mock_db, mock_asana, mock_config, mocker): + """ + Test that 'gittask track' fails if not authenticated. + """ + mock_config.get_api_token.return_value = None + result = runner.invoke(app, ["track", "Task"]) + assert result.exit_code == 1 + assert "Not authenticated" in result.stdout + +def test_track_no_workspace(mock_db, mock_asana, mock_config, mocker): + """ + Test that 'gittask track' fails if no default workspace is set. + """ + mock_config.get_default_workspace.return_value = None + result = runner.invoke(app, ["track", "Task"]) + assert result.exit_code == 1 + assert "No default workspace" in result.stdout + +def test_track_task_not_found_create(mock_db, mock_asana, mock_config, mocker): + """ + Test creating a new task when search returns no results. + """ + mocker.patch("gittask.commands.track.DBManager", return_value=mock_db) + mock_asana.__enter__.return_value.search_tasks.return_value = [] + + # Mock questionary + mock_questionary = mocker.patch("gittask.commands.track.questionary") + mock_questionary.confirm.return_value.ask.return_value = True # Create? Yes + + mock_asana.__enter__.return_value.create_task.return_value = {'gid': 'new_gid', 'name': 'New Task'} + + result = runner.invoke(app, ["track", "New Task"]) + + assert result.exit_code == 0 + assert "Started tracking time for 'New Task' (Global)" in result.stdout + + sessions = mock_db.time_sessions.all() + assert len(sessions) == 1 + assert sessions[0]['task_gid'] == 'new_gid' + +def test_track_task_not_found_cancel(mock_db, mock_asana, mock_config, mocker): + """ + Test cancelling when task is not found. + """ + mocker.patch("gittask.commands.track.DBManager", return_value=mock_db) + mock_asana.__enter__.return_value.search_tasks.return_value = [] + + mock_questionary = mocker.patch("gittask.commands.track.questionary") + mock_questionary.confirm.return_value.ask.return_value = False # Create? No + + result = runner.invoke(app, ["track", "New Task"]) + + assert result.exit_code == 0 + assert "Tracking cancelled" in result.stdout + assert len(mock_db.time_sessions.all()) == 0 + +def test_track_multiple_matches(mock_db, mock_asana, mock_config, mocker): + """ + Test selecting from multiple matching tasks. + """ + mocker.patch("gittask.commands.track.DBManager", return_value=mock_db) + mock_asana.__enter__.return_value.search_tasks.return_value = [ + {'gid': '1', 'name': 'Task A'}, + {'gid': '2', 'name': 'Task B'} + ] + + mock_questionary = mocker.patch("gittask.commands.track.questionary") + mock_questionary.select.return_value.ask.return_value = 'Task B' + + result = runner.invoke(app, ["track", "Task"]) + + assert result.exit_code == 0 + assert "Started tracking time for 'Task B' (Global)" in result.stdout + + sessions = mock_db.time_sessions.all() + assert len(sessions) == 1 + assert sessions[0]['task_gid'] == '2' + +def test_track_interactive_create(mock_db, mock_asana, mock_config, mocker): + """ + Test interactive mode (no arg) where user creates a new task. + """ + mocker.patch("gittask.commands.track.DBManager", return_value=mock_db) + + # Mock project tasks fetch + mock_asana.__enter__.return_value.get_project_tasks.return_value = [] + + mock_questionary = mocker.patch("gittask.commands.track.questionary") + mock_questionary.text.return_value.ask.return_value = "Interactive Task" + mock_questionary.confirm.return_value.ask.return_value = True # Create? Yes + + # Mock select_and_create_tags to return empty list + mocker.patch("gittask.commands.track.select_and_create_tags", return_value=[]) + + mock_asana.__enter__.return_value.create_task.return_value = {'gid': 'int_gid', 'name': 'Interactive Task'} + + result = runner.invoke(app, ["track"]) # No args + + assert result.exit_code == 0 + assert "Started tracking time for 'Interactive Task' (Global)" in result.stdout + + sessions = mock_db.time_sessions.all() + assert len(sessions) == 1 + assert sessions[0]['task_gid'] == 'int_gid' From 74744cfc58223ee2defb96d9f1ca1b09f076bdd9 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:19:28 +0100 Subject: [PATCH 02/15] feat: add tests for checkout command --- tests/test_checkout.py | 117 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/test_checkout.py diff --git a/tests/test_checkout.py b/tests/test_checkout.py new file mode 100644 index 0000000..ec6f3d4 --- /dev/null +++ b/tests/test_checkout.py @@ -0,0 +1,117 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock + +runner = CliRunner() + +def test_checkout_new_branch_create_task(mock_db, mock_asana, mock_config, mock_git, mocker): + """ + Test checking out a new branch and creating a new Asana task. + """ + mocker.patch("gittask.commands.checkout.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.checkout.GitHandler", return_value=mock_git) + + # Mock git behavior + mock_git.get_current_branch.return_value = "main" + + # Mock Asana behavior + mock_asana.__enter__.return_value.get_project_tasks.return_value = [] + mock_asana.__enter__.return_value.create_task.return_value = {'gid': 'new_task_gid', 'name': 'feature-branch'} + + # Mock user input + mock_questionary = mocker.patch("gittask.commands.checkout.questionary") + mock_questionary.text.return_value.ask.return_value = "feature-branch" # Task name input + mock_questionary.confirm.return_value.ask.return_value = True # Create task? Yes + + # Mock tag selection + mocker.patch("gittask.commands.checkout.select_and_create_tags", return_value=[]) + + result = runner.invoke(app, ["checkout", "-b", "feature-branch"]) + + assert result.exit_code == 0 + + # Verify git checkout called + mock_git.checkout_branch.assert_called_with("feature-branch", create_new=True) + + # Verify task created + mock_asana.__enter__.return_value.create_task.assert_called() + + # Verify DB link + task_info = mock_db.get_task_for_branch("feature-branch", "/tmp/mock_repo") + assert task_info is not None + assert task_info['asana_task_gid'] == 'new_task_gid' + + # Verify session started + sessions = mock_db.time_sessions.all() + assert len(sessions) == 1 + assert sessions[0]['branch'] == "feature-branch" + +def test_checkout_existing_branch_already_linked(mock_db, mock_asana, mock_config, mock_git, mocker): + """ + Test checking out an existing branch that is already linked. + """ + mocker.patch("gittask.commands.checkout.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.checkout.GitHandler", return_value=mock_git) + + # Pre-link the branch + mock_db.link_branch_to_task("existing-branch", "/tmp/mock_repo", "task_gid", "Task Name", "proj", "work") + + mock_git.get_current_branch.return_value = "main" + + result = runner.invoke(app, ["checkout", "existing-branch"]) + + assert result.exit_code == 0 + assert "Switched to branch existing-branch" in result.stdout + assert "Started tracking time" in result.stdout + + # Verify session started + sessions = mock_db.time_sessions.all() + assert len(sessions) == 1 + assert sessions[0]['branch'] == "existing-branch" + +def test_checkout_main_skipped(mock_db, mock_asana, mock_config, mock_git, mocker): + """ + Test that checking out main skips Asana linking. + """ + mocker.patch("gittask.commands.checkout.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.checkout.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.return_value = "feature" + + result = runner.invoke(app, ["checkout", "main"]) + + assert result.exit_code == 0 + assert "skipped for Asana linking" in result.stdout + + # Verify NO session started + sessions = mock_db.time_sessions.all() + assert len(sessions) == 0 + +def test_checkout_stops_current_session(mock_db, mock_asana, mock_config, mock_git, mocker): + """ + Test that checkout stops the current session before switching. + """ + mocker.patch("gittask.commands.checkout.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.checkout.GitHandler", return_value=mock_git) + + # Start a session for current branch + mock_db.start_session("current-branch", "/tmp/mock_repo", "gid") + + mock_git.get_current_branch.return_value = "current-branch" + + # Pre-link target branch + mock_db.link_branch_to_task("target-branch", "/tmp/mock_repo", "gid2", "Task 2", "proj", "work") + + result = runner.invoke(app, ["checkout", "target-branch"]) + + assert result.exit_code == 0 + + # Verify old session stopped + old_session = mock_db.time_sessions.get(doc_id=1) + assert old_session['end_time'] is not None + + # Verify new session started + new_session = mock_db.time_sessions.get(doc_id=2) + assert new_session['branch'] == "target-branch" + assert new_session['end_time'] is None From 5248068d8142532bcfb0454511bc36bd960f1810 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:19:59 +0100 Subject: [PATCH 03/15] chore: add pytest to pyproject.toml --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ad02916..52162cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,12 @@ dependencies = [ "PyGithub", ] +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-mock", +] + [project.scripts] gittask = "gittask.main:app" gt = "gittask.main:app" From 452f25f5d13dfcf1e5b39045609f038df7ba342a Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:25:43 +0100 Subject: [PATCH 04/15] feat: add tests for auth command and updated auth --- gittask/commands/auth.py | 4 ++- tests/test_auth.py | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/test_auth.py diff --git a/gittask/commands/auth.py b/gittask/commands/auth.py index 8f12d2e..8953518 100644 --- a/gittask/commands/auth.py +++ b/gittask/commands/auth.py @@ -8,7 +8,7 @@ @app.command() def login( - token: str = typer.Option(None, prompt=True, hide_input=True, help="Asana Personal Access Token"), + token: str = typer.Option(None, help="Asana Personal Access Token"), github: bool = typer.Option(False, "--github", help="Login with GitHub Token") ): """ @@ -20,6 +20,8 @@ def login( config.set_github_token(github_token) console.print("[green]Successfully logged in to GitHub![/green]") else: + if not token: + token = typer.prompt("Asana Personal Access Token", hide_input=True) config.set_api_token(token) console.print("[green]Successfully logged in to Asana![/green]") diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f35c7e6 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,60 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock + +runner = CliRunner() + +def test_auth_login_asana(mock_config, mocker): + """ + Test logging in with Asana token. + """ + mocker.patch("gittask.commands.auth.ConfigManager", return_value=mock_config) + + result = runner.invoke(app, ["auth", "login"], input="fake_asana_token\n") + + assert result.exit_code == 0 + assert "Successfully logged in to Asana" in result.stdout + mock_config.set_api_token.assert_called_with("fake_asana_token") + +def test_auth_login_github(mock_config, mocker): + """ + Test logging in with GitHub token. + """ + mocker.patch("gittask.commands.auth.ConfigManager", return_value=mock_config) + + result = runner.invoke(app, ["auth", "login", "--github"], input="fake_github_token\n") + + assert result.exit_code == 0 + assert "Successfully logged in to GitHub" in result.stdout + mock_config.set_github_token.assert_called_with("fake_github_token") + +def test_auth_logout(mock_config, mocker): + """ + Test logging out. + """ + mocker.patch("gittask.commands.auth.ConfigManager", return_value=mock_config) + + result = runner.invoke(app, ["auth", "logout"]) + + assert result.exit_code == 0 + assert "Logged out" in result.stdout + mock_config.logout.assert_called() + mock_config.set_api_token.assert_called_with("") + +def test_auth_login_github_preserves_asana_token(mock_config, mocker): + """ + Test that logging in to GitHub does not prompt for or change Asana token. + """ + mocker.patch("gittask.commands.auth.ConfigManager", return_value=mock_config) + + # Run with --github + result = runner.invoke(app, ["auth", "login", "--github"], input="new_github_token\n") + + assert result.exit_code == 0 + + # Verify GitHub token set + mock_config.set_github_token.assert_called_with("new_github_token") + + # Verify Asana token NOT set/changed + mock_config.set_api_token.assert_not_called() From 09b5a48f48f1a9278631a52085bc861bffaf69b3 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:34:15 +0100 Subject: [PATCH 05/15] refac: remove duplicate code --- gittask/commands/commit.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/gittask/commands/commit.py b/gittask/commands/commit.py index 1a62483..edabbf2 100644 --- a/gittask/commands/commit.py +++ b/gittask/commands/commit.py @@ -34,11 +34,6 @@ def commit( # Git commit failed (e.g., nothing to commit) raise typer.Exit(code=1) - try: - subprocess.run(cmd, check=True) - console.print("[green]Commit successful.[/green]") - except subprocess.CalledProcessError: - # Git commit failed (e.g., nothing to commit) - raise typer.Exit(code=1) + # Asana posting moved to 'push' command From c8f44332b01bfd14d19d70f4f833668ccc2c4165 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:34:31 +0100 Subject: [PATCH 06/15] feat: add tests for finsih and commit --- tests/test_commit.py | 56 ++++++++++++++++ tests/test_finish.py | 156 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 tests/test_commit.py create mode 100644 tests/test_finish.py diff --git a/tests/test_commit.py b/tests/test_commit.py new file mode 100644 index 0000000..771c9d2 --- /dev/null +++ b/tests/test_commit.py @@ -0,0 +1,56 @@ +import pytest +from typer.testing import CliRunner +from gittask.commands.commit import commit +import typer +import subprocess + +runner = CliRunner() + +# We need to wrap the function in a Typer app to test it with CliRunner +app = typer.Typer() +app.command()(commit) + +def test_commit_success(mock_db, mock_git, mocker): + """ + Test successful commit. + """ + mocker.patch("gittask.commands.commit.db", mock_db) + mocker.patch("gittask.commands.commit.git", mock_git) + + mock_subprocess = mocker.patch("gittask.commands.commit.subprocess.run") + + result = runner.invoke(app, ["-m", "test commit"]) + + assert result.exit_code == 0 + assert "Commit successful" in result.stdout + + mock_subprocess.assert_called_with(["git", "commit", "-m", "test commit"], check=True) + +def test_commit_all_files(mock_db, mock_git, mocker): + """ + Test commit with -a flag. + """ + mocker.patch("gittask.commands.commit.db", mock_db) + mocker.patch("gittask.commands.commit.git", mock_git) + + mock_subprocess = mocker.patch("gittask.commands.commit.subprocess.run") + + result = runner.invoke(app, ["-m", "test commit", "-a"]) + + assert result.exit_code == 0 + + mock_subprocess.assert_called_with(["git", "commit", "-a", "-m", "test commit"], check=True) + +def test_commit_failure(mock_db, mock_git, mocker): + """ + Test commit failure (e.g. git error). + """ + mocker.patch("gittask.commands.commit.db", mock_db) + mocker.patch("gittask.commands.commit.git", mock_git) + + mock_subprocess = mocker.patch("gittask.commands.commit.subprocess.run") + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "git commit") + + result = runner.invoke(app, ["-m", "test commit"]) + + assert result.exit_code == 1 diff --git a/tests/test_finish.py b/tests/test_finish.py new file mode 100644 index 0000000..e71cc44 --- /dev/null +++ b/tests/test_finish.py @@ -0,0 +1,156 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock, call + +runner = CliRunner() + +def test_finish_full_flow(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test the happy path: linked task, active session, open PR, cleanup. + """ + # Mocks + mocker.patch("gittask.commands.finish.db", mock_db) + mocker.patch("gittask.commands.finish.git", mock_git) + mocker.patch("gittask.commands.finish.config", mock_config) + mocker.patch("gittask.commands.finish.AsanaClient", return_value=mock_asana) + + # Mock GitHub + mock_gh_client = MagicMock() + mock_repo = MagicMock() + mock_pr = MagicMock() + mock_pr.number = 1 + mock_pr.title = "Test PR" + mock_repo.owner.login = "owner" + mock_repo.get_pulls.return_value = MagicMock(totalCount=1, __getitem__=lambda s, i: mock_pr) + + mocker.patch("gittask.commands.finish.get_github_client", return_value=mock_gh_client) + mocker.patch("gittask.commands.finish.get_github_repo", return_value=mock_repo) + + # Mock subprocess + mock_subprocess = mocker.patch("gittask.commands.finish.subprocess.run") + + # Mock questionary + mock_questionary = mocker.patch("gittask.commands.finish.questionary") + mock_questionary.confirm.return_value.ask.return_value = True # Say yes to everything + + # Setup Data + mock_git.get_current_branch.return_value = "feature-branch" + mock_config.get_paid_plan_status.return_value = False + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + mocker.patch.object(mock_db, 'stop_current_session', return_value={'duration_seconds': 3600}) + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[ + {'id': 1, 'task_gid': 'task123', 'duration_seconds': 3600, 'branch': 'feature-branch', 'end_time': 1234567890} + ]) + mocker.patch.object(mock_db, 'mark_session_synced') + + # Run + result = runner.invoke(app, ["finish"]) + + assert result.exit_code == 0 + + # Verifications + # 1. Stop Timer + mock_db.stop_current_session.assert_called_once() + + # 1.5 Sync Time + mock_asana.__enter__.return_value.log_time_comment.assert_called() # Default free plan + mock_db.mark_session_synced.assert_called_with(1) + + # 2. Merge PR + mock_pr.merge.assert_called_once() + + # 3. Close Asana Task + mock_asana.__enter__.return_value.complete_task.assert_called_with('task123') + + # 4. Cleanup + mock_git.checkout_branch.assert_called_with("main") + mock_subprocess.assert_has_calls([ + call(["git", "pull"], check=True), + call(["git", "branch", "-d", "feature-branch"], check=True) + ]) + +def test_finish_no_task_abort(mock_db, mock_git, mocker): + """ + Test aborting when no task is linked. + """ + mocker.patch("gittask.commands.finish.db", mock_db) + mocker.patch("gittask.commands.finish.git", mock_git) + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value=None) + mocker.patch.object(mock_db, 'stop_current_session') # Should not be called, but good to mock + + mock_questionary = mocker.patch("gittask.commands.finish.questionary") + mock_questionary.confirm.return_value.ask.return_value = False # Abort + + result = runner.invoke(app, ["finish"]) + + assert result.exit_code == 0 + assert "Current branch is not linked" in result.stdout + + # Verify we didn't proceed to stop timer + mock_db.stop_current_session.assert_not_called() + +def test_finish_no_pr_found(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test flow when no PR is found. + """ + mocker.patch("gittask.commands.finish.db", mock_db) + mocker.patch("gittask.commands.finish.git", mock_git) + mocker.patch("gittask.commands.finish.config", mock_config) + mocker.patch("gittask.commands.finish.AsanaClient", return_value=mock_asana) + + mock_gh_client = MagicMock() + mock_repo = MagicMock() + mock_repo.owner.login = "owner" + mock_repo.get_pulls.return_value = MagicMock(totalCount=0) # No PRs + + mocker.patch("gittask.commands.finish.get_github_client", return_value=mock_gh_client) + mocker.patch("gittask.commands.finish.get_github_repo", return_value=mock_repo) + + mock_questionary = mocker.patch("gittask.commands.finish.questionary") + mock_questionary.confirm.return_value.ask.return_value = True + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={'asana_task_gid': '123', 'asana_task_name': 'Task'}) + mocker.patch.object(mock_db, 'stop_current_session', return_value={'duration_seconds': 100}) + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[]) + + result = runner.invoke(app, ["finish"]) + + assert result.exit_code == 0 + assert "No open PR found" in result.stdout + +def test_finish_cleanup_failure(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test graceful handling of cleanup failure. + """ + mocker.patch("gittask.commands.finish.db", mock_db) + mocker.patch("gittask.commands.finish.git", mock_git) + mocker.patch("gittask.commands.finish.config", mock_config) + mocker.patch("gittask.commands.finish.AsanaClient", return_value=mock_asana) + + # Mock GitHub to return no PRs to skip that part + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = MagicMock(totalCount=0) + mocker.patch("gittask.commands.finish.get_github_repo", return_value=mock_repo) + mocker.patch("gittask.commands.finish.get_github_client") + + mock_questionary = mocker.patch("gittask.commands.finish.questionary") + mock_questionary.confirm.return_value.ask.return_value = True + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={'asana_task_gid': '123', 'asana_task_name': 'Task'}) + mocker.patch.object(mock_db, 'stop_current_session', return_value={'duration_seconds': 100}) + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[]) + + # Mock subprocess failure during cleanup + mock_subprocess = mocker.patch("gittask.commands.finish.subprocess.run") + mock_subprocess.side_effect = Exception("Git error") + + result = runner.invoke(app, ["finish"]) + + assert result.exit_code == 0 + assert "Cleanup failed: Git error" in result.stdout From aa33ae64925989fb7ae333e835fb7a6e2fd3a903 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:36:07 +0100 Subject: [PATCH 07/15] feat: add tests for init --- tests/test_init.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/test_init.py diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..cf9c4d2 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,66 @@ +import pytest +from typer.testing import CliRunner +from gittask.commands.init import init +import typer +from unittest.mock import MagicMock + +runner = CliRunner() +app = typer.Typer() +app.command()(init) + +def test_init_success(mock_config, mock_asana, mocker): + """ + Test successful initialization. + """ + mocker.patch("gittask.commands.init.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.init.AsanaClient", return_value=mock_asana) + + mock_questionary = mocker.patch("gittask.commands.init.questionary") + + # Mock Asana data + mock_asana.__enter__.return_value.me = {'name': 'Test User'} + mock_asana.__enter__.return_value.get_workspaces.return_value = [{'gid': 'ws1', 'name': 'Workspace 1'}] + mock_asana.__enter__.return_value.get_projects.return_value = [{'gid': 'p1', 'name': 'Project 1'}] + + # Mock user input + # 1. Select Workspace + mock_questionary.select.return_value.ask.side_effect = ['ws1', 'p1'] + # 2. Paid plan confirm + mock_questionary.confirm.return_value.ask.return_value = True + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Hello, Test User!" in result.stdout + assert "Configuration saved!" in result.stdout + + mock_config.set_default_workspace.assert_called_with('ws1') + mock_config.set_paid_plan_status.assert_called_with(True) + mock_config.set_default_project.assert_called_with('p1') + +def test_init_not_authenticated(mock_config, mocker): + """ + Test init fails if not authenticated. + """ + mocker.patch("gittask.commands.init.ConfigManager", return_value=mock_config) + mock_config.get_api_token.return_value = None + + result = runner.invoke(app, []) + + assert result.exit_code == 1 + assert "Not authenticated" in result.stdout + +def test_init_no_workspaces(mock_config, mock_asana, mocker): + """ + Test init fails if no workspaces found. + """ + mocker.patch("gittask.commands.init.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.init.AsanaClient", return_value=mock_asana) + + mock_asana.__enter__.return_value.me = {'name': 'Test User'} + mock_asana.__enter__.return_value.get_workspaces.return_value = [] + + result = runner.invoke(app, []) + + assert result.exit_code == 1 + assert "No workspaces found" in result.stdout From 9bce1238355e502cb03a81fc24908edc73886f66 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:38:06 +0100 Subject: [PATCH 08/15] feat: add tests for push --- tests/test_push.py | 144 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/test_push.py diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..93c425e --- /dev/null +++ b/tests/test_push.py @@ -0,0 +1,144 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock, call +import subprocess + +runner = CliRunner() + +def test_push_success_with_upstream(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test successful push when upstream exists. + """ + mocker.patch("gittask.commands.push.db", mock_db) + mocker.patch("gittask.commands.push.git", mock_git) + mocker.patch("gittask.commands.push.config", mock_config) + mocker.patch("gittask.commands.push.AsanaClient", return_value=mock_asana) + + # Mock subprocess + mock_subprocess = mocker.patch("gittask.commands.push.subprocess") + mock_subprocess.CalledProcessError = subprocess.CalledProcessError + mock_subprocess.DEVNULL = subprocess.DEVNULL + mock_subprocess.CalledProcessError = subprocess.CalledProcessError + mock_subprocess.DEVNULL = subprocess.DEVNULL + + # Mock git behavior + mock_git.get_current_branch.return_value = "feature-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + mock_git.get_remote_url.return_value = "https://github.com/owner/repo.git" + + # Mock upstream check (success) + mock_subprocess.run.return_value.returncode = 0 + + # Mock log output + mock_subprocess.check_output.return_value = "hash1|Commit 1\nhash2|Commit 2" + + # Mock DB task + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + + result = runner.invoke(app, ["push"]) + + assert result.exit_code == 0 + assert "Pushing to origin/feature-branch" in result.stdout + assert "Push successful" in result.stdout + assert "Posted push summary" in result.stdout + + # Verify push command + mock_subprocess.run.assert_any_call(["git", "push", "origin", "feature-branch"], check=True) + + # Verify Asana comment + mock_asana.__enter__.return_value.post_comment.assert_called_once() + args, _ = mock_asana.__enter__.return_value.post_comment.call_args + assert args[0] == 'task123' + assert "Commit 1" in args[1] + assert "Commit 2" in args[1] + +def test_push_success_no_upstream(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test successful push when upstream does not exist (sets upstream). + """ + mocker.patch("gittask.commands.push.db", mock_db) + mocker.patch("gittask.commands.push.git", mock_git) + mocker.patch("gittask.commands.push.config", mock_config) + mocker.patch("gittask.commands.push.AsanaClient", return_value=mock_asana) + + mock_subprocess = mocker.patch("gittask.commands.push.subprocess") + mock_subprocess.CalledProcessError = subprocess.CalledProcessError + mock_subprocess.DEVNULL = subprocess.DEVNULL + + mock_git.get_current_branch.return_value = "new-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + mock_git.get_remote_url.return_value = "git@github.com:owner/repo.git" + + # Mock upstream check failure (no upstream) + def run_side_effect(cmd, **kwargs): + if "rev-parse" in cmd: + raise subprocess.CalledProcessError(1, cmd) + return MagicMock(returncode=0) + + mock_subprocess.run.side_effect = run_side_effect + + # Mock log output + mock_subprocess.check_output.return_value = "hash1|Init" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Task' + }) + + result = runner.invoke(app, ["push"]) + + assert result.exit_code == 0 + + # Verify push with --set-upstream + mock_subprocess.run.assert_any_call(["git", "push", "--set-upstream", "origin", "new-branch"], check=True) + +def test_push_failure(mock_db, mock_git, mocker): + """ + Test push failure. + """ + mocker.patch("gittask.commands.push.db", mock_db) + mocker.patch("gittask.commands.push.git", mock_git) + + mock_subprocess = mocker.patch("gittask.commands.push.subprocess") + + mock_git.get_current_branch.return_value = "main" + + # Mock push failure + def run_side_effect(cmd, **kwargs): + if "git" in cmd and "push" in cmd: + raise subprocess.CalledProcessError(1, cmd) + return MagicMock(returncode=0) + + mock_subprocess.run.side_effect = run_side_effect + + result = runner.invoke(app, ["push"]) + + assert result.exit_code == 1 + +def test_push_no_task_linked(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test push when no task is linked (skips comment). + """ + mocker.patch("gittask.commands.push.db", mock_db) + mocker.patch("gittask.commands.push.git", mock_git) + mocker.patch("gittask.commands.push.config", mock_config) + mocker.patch("gittask.commands.push.AsanaClient", return_value=mock_asana) + + mock_subprocess = mocker.patch("gittask.commands.push.subprocess") + mock_subprocess.check_output.return_value = "hash|msg" + + mock_git.get_current_branch.return_value = "branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value=None) + + result = runner.invoke(app, ["push"]) + + assert result.exit_code == 0 + assert "Branch not linked to Asana task" in result.stdout + + mock_asana.__enter__.return_value.post_comment.assert_not_called() From 764ed9f36b896a1ebaee45ce7cfe53acd4a627f5 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:39:12 +0100 Subject: [PATCH 09/15] feat: add tests for pr --- tests/test_pr.py | 116 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_push.py | 1 + 2 files changed, 117 insertions(+) create mode 100644 tests/test_pr.py diff --git a/tests/test_pr.py b/tests/test_pr.py new file mode 100644 index 0000000..26a15b0 --- /dev/null +++ b/tests/test_pr.py @@ -0,0 +1,116 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock +import subprocess + +runner = CliRunner() + +def test_pr_create_success(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test successful PR creation. + """ + mocker.patch("gittask.commands.pr.db", mock_db) + mocker.patch("gittask.commands.pr.git", mock_git) + mocker.patch("gittask.commands.pr.config", mock_config) + mocker.patch("gittask.commands.pr.AsanaClient", return_value=mock_asana) + + # Mock GitHub + mock_gh_client = MagicMock() + mock_repo = MagicMock() + mock_pr = MagicMock() + mock_pr.html_url = "http://github.com/owner/repo/pull/1" + mock_pr.title = "PR Title" + mock_pr.number = 1 + + mock_repo.create_pull.return_value = mock_pr + + mocker.patch("gittask.commands.pr.get_github_client", return_value=mock_gh_client) + mocker.patch("gittask.commands.pr.get_github_repo", return_value=mock_repo) + + # Mock subprocess (push) + mock_subprocess = mocker.patch("gittask.commands.pr.subprocess") + mock_subprocess.run.return_value.returncode = 0 + + mock_git.get_current_branch.return_value = "feature-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + + result = runner.invoke(app, ["pr", "create"]) + + assert result.exit_code == 0 + assert "PR Created Successfully" in result.stdout + assert "Posted PR link" in result.stdout + + mock_repo.create_pull.assert_called_once() + mock_asana.__enter__.return_value.post_comment.assert_called_once() + +def test_pr_create_already_exists(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test PR creation when it already exists. + """ + mocker.patch("gittask.commands.pr.db", mock_db) + mocker.patch("gittask.commands.pr.git", mock_git) + mocker.patch("gittask.commands.pr.config", mock_config) + mocker.patch("gittask.commands.pr.AsanaClient", return_value=mock_asana) + + mock_gh_client = MagicMock() + mock_repo = MagicMock() + mock_repo.owner.login = "owner" + + # Simulate exception on create + mock_repo.create_pull.side_effect = Exception("A pull request already exists") + + # Simulate finding existing PR + existing_pr = MagicMock() + existing_pr.html_url = "http://github.com/owner/repo/pull/1" + mock_repo.get_pulls.return_value = MagicMock(totalCount=1, __getitem__=lambda s, i: existing_pr) + + mocker.patch("gittask.commands.pr.get_github_client", return_value=mock_gh_client) + mocker.patch("gittask.commands.pr.get_github_repo", return_value=mock_repo) + + mock_subprocess = mocker.patch("gittask.commands.pr.subprocess") + + mock_git.get_current_branch.return_value = "feature-branch" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + + result = runner.invoke(app, ["pr", "create"]) + + assert result.exit_code == 0 + assert "A pull request already exists" in result.stdout + assert "http://github.com/owner/repo/pull/1" in result.stdout + +def test_pr_list(mock_git, mocker): + """ + Test listing PRs. + """ + mocker.patch("gittask.commands.pr.git", mock_git) + + mock_gh_client = MagicMock() + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + + pr1 = MagicMock() + pr1.number = 1 + pr1.title = "PR 1" + pr1.user.login = "user1" + pr1.html_url = "url1" + + mock_repo.get_pulls.return_value = [pr1] + + mocker.patch("gittask.commands.pr.get_github_client", return_value=mock_gh_client) + mocker.patch("gittask.commands.pr.get_github_repo", return_value=mock_repo) + + result = runner.invoke(app, ["pr", "list"]) + + assert result.exit_code == 0 + assert "Open PRs for owner/repo" in result.stdout + assert "PR 1" in result.stdout diff --git a/tests/test_push.py b/tests/test_push.py index 93c425e..8ac7d68 100644 --- a/tests/test_push.py +++ b/tests/test_push.py @@ -104,6 +104,7 @@ def test_push_failure(mock_db, mock_git, mocker): mocker.patch("gittask.commands.push.git", mock_git) mock_subprocess = mocker.patch("gittask.commands.push.subprocess") + mock_subprocess.CalledProcessError = subprocess.CalledProcessError mock_git.get_current_branch.return_value = "main" From ccc0780607b21965f6d91a21e973fd59fa7eddff Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:41:12 +0100 Subject: [PATCH 10/15] feat: add tests for session start and stop --- tests/test_session.py | 148 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/test_session.py diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..ab763d2 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,148 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock + +runner = CliRunner() + +def test_session_stop_branch(mock_db, mock_git, mocker): + """ + Test stopping a branch session. + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.return_value = "feature-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'stop_current_session', return_value={'duration_seconds': 120, 'branch': 'feature-branch'}) + + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 0 + assert "Stopped tracking time for 'feature-branch' (2m)" in result.stdout + mock_db.stop_current_session.assert_called_with("feature-branch", "/tmp/repo") + +def test_session_stop_global(mock_db, mock_git, mocker): + """ + Test stopping a global session (when not in git or no branch session). + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + # Simulate not in git repo + mock_git.get_current_branch.side_effect = Exception("Not a git repo") + + mocker.patch.object(mock_db, 'stop_any_active_session', return_value={'duration_seconds': 300, 'branch': '@global:Task'}) + + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 0 + assert "Stopped tracking time for 'Task (Global)' (5m)" in result.stdout + mock_db.stop_any_active_session.assert_called_once() + +def test_session_stop_no_active(mock_db, mock_git, mocker): + """ + Test stopping when no session is active. + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.return_value = "main" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'stop_current_session', return_value=None) + mocker.patch.object(mock_db, 'stop_any_active_session', return_value=None) + + result = runner.invoke(app, ["stop"]) + + assert result.exit_code == 0 + assert "No active session found" in result.stdout + +def test_session_start_success(mock_db, mock_git, mocker): + """ + Test starting a session for a linked branch. + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.return_value = "feature-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + + # Ensure no open sessions (real DB is empty by default) + # mock_db.time_sessions.search.return_value = [] # REMOVED + + mocker.patch.object(mock_db, 'start_session') + + result = runner.invoke(app, ["start"]) + + assert result.exit_code == 0 + assert "Started tracking time for 'feature-branch'" in result.stdout + mock_db.start_session.assert_called_with("feature-branch", "/tmp/repo", "task123") + +def test_session_start_not_linked(mock_db, mock_git, mocker): + """ + Test starting a session for an unlinked branch. + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.return_value = "feature-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value=None) + + result = runner.invoke(app, ["start"]) + + assert result.exit_code == 1 + assert "not linked to an Asana task" in result.stdout + +def test_session_start_already_tracking(mock_db, mock_git, mocker): + """ + Test starting a session when already tracking the same branch. + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.return_value = "feature-branch" + mock_git.get_repo_root.return_value = "/tmp/repo" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + + # Insert existing session into real DB + mock_db.time_sessions.insert({ + 'branch': 'feature-branch', + 'repo_path': '/tmp/repo', + 'end_time': None, + 'start_time': '2023-01-01T00:00:00' + }) + + mocker.patch.object(mock_db, 'start_session') + + result = runner.invoke(app, ["start"]) + + assert result.exit_code == 0 + assert "Already tracking time for 'feature-branch'" in result.stdout + mock_db.start_session.assert_not_called() + +def test_session_start_not_git(mock_db, mock_git, mocker): + """ + Test starting a session when not in a git repo. + """ + mocker.patch("gittask.commands.session.db", mock_db) + mocker.patch("gittask.commands.session.GitHandler", return_value=mock_git) + + mock_git.get_current_branch.side_effect = Exception("Not a git repo") + + result = runner.invoke(app, ["start"]) + + assert result.exit_code == 1 + assert "Not in a git repository" in result.stdout From e94dbf5a975af67d89650764f028e1e54b3d2387 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:43:10 +0100 Subject: [PATCH 11/15] feat: add tests for status --- tests/test_status.py | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_status.py diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..b4e431c --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,110 @@ +import pytest +from typer.testing import CliRunner +from gittask.commands.status import status +import typer +from unittest.mock import MagicMock +import time + +runner = CliRunner() +app = typer.Typer() +app.command()(status) + +def test_status_active_branch_session(mock_db, mocker): + """ + Test status with an active branch session. + """ + mocker.patch("gittask.commands.status.DBManager", return_value=mock_db) + + # Mock active session + start_time = time.time() - 3665 # 1h 1m 5s ago + mock_session = { + 'branch': 'feature-branch', + 'start_time': start_time, + 'end_time': None, + 'repo_path': '/tmp/repo' + } + # Use real DB insert instead of mocking search + mock_db.time_sessions.insert(mock_session) + + # Mock task info + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_name': 'Feature Task' + }) + + # Mock unsynced + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[]) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Currently tracking: feature-branch" in result.stdout + assert "Task: Feature Task" in result.stdout + assert "Duration: 1h 1m" in result.stdout + assert "All sessions synced!" in result.stdout + +def test_status_active_global_session(mock_db, mocker): + """ + Test status with an active global session. + """ + mocker.patch("gittask.commands.status.DBManager", return_value=mock_db) + + # Mock active session + start_time = time.time() - 120 # 2m ago + mock_session = { + 'branch': '@global:Global Task', + 'start_time': start_time, + 'end_time': None, + 'repo_path': 'GLOBAL' + } + mock_db.time_sessions.insert(mock_session) + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_name': 'Global Task' + }) + + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[]) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Currently tracking: Global Task (Global)" in result.stdout + assert "Duration: 0h 2m" in result.stdout + +def test_status_no_active_session(mock_db, mocker): + """ + Test status with no active session. + """ + mocker.patch("gittask.commands.status.DBManager", return_value=mock_db) + + # No insert needed, DB is empty + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[]) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "No active time tracking session" in result.stdout + +def test_status_unsynced_sessions(mock_db, mocker): + """ + Test status with unsynced sessions. + """ + mocker.patch("gittask.commands.status.DBManager", return_value=mock_db) + + # No active session + + unsynced = [ + { + 'branch': 'branch-1', + 'duration_seconds': 3600, + 'start_time': 1672531200, # 2023-01-01 00:00:00 + 'end_time': 1672534800 + } + ] + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=unsynced) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Unsynced Sessions" in result.stdout + assert "branch-1" in result.stdout + assert "1h 0m" in result.stdout From 4e76896bae9353ff76dded3f156490583b278cd3 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:45:08 +0100 Subject: [PATCH 12/15] feat: add tests for sync --- tests/test_sync.py | 144 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/test_sync.py diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..1dd8581 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,144 @@ +import pytest +from typer.testing import CliRunner +from gittask.commands.sync import sync +import typer +from unittest.mock import MagicMock, call + +runner = CliRunner() +app = typer.Typer() +app.command()(sync) + +def test_sync_success_paid_plan(mock_db, mock_config, mock_asana, mocker): + """ + Test successful sync with paid plan (add_time_entry). + """ + mocker.patch("gittask.commands.sync.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.sync.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.sync.AsanaClient", return_value=mock_asana) + + # Mock unsynced sessions + sessions = [ + {'id': 1, 'task_gid': 't1', 'duration_seconds': 3600, 'end_time': 123, 'branch': 'b1'}, + {'id': 2, 'task_gid': 't2', 'duration_seconds': 1800, 'end_time': 456, 'branch': 'b2'} + ] + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=sessions) + mocker.patch.object(mock_db, 'mark_session_synced') + + # Mock paid plan + mock_config.get_paid_plan_status.return_value = True + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Syncing 2 sessions..." in result.stdout + assert "Sync complete!" in result.stdout + + # Verify Asana calls + mock_asana.__enter__.return_value.add_time_entry.assert_has_calls([ + call('t1', 3600), + call('t2', 1800) + ]) + + # Verify DB calls + mock_db.mark_session_synced.assert_has_calls([ + call(1), + call(2) + ]) + +def test_sync_success_free_plan(mock_db, mock_config, mock_asana, mocker): + """ + Test successful sync with free plan (log_time_comment). + """ + mocker.patch("gittask.commands.sync.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.sync.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.sync.AsanaClient", return_value=mock_asana) + + sessions = [ + {'id': 1, 'task_gid': 't1', 'duration_seconds': 3600, 'end_time': 123, 'branch': 'b1'} + ] + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=sessions) + mocker.patch.object(mock_db, 'mark_session_synced') + + mock_config.get_paid_plan_status.return_value = False + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Sync complete!" in result.stdout + + mock_asana.__enter__.return_value.log_time_comment.assert_called_with('t1', 3600, 'b1') + mock_db.mark_session_synced.assert_called_with(1) + +def test_sync_no_token(mock_config, mocker): + """ + Test sync fails if not authenticated. + """ + mocker.patch("gittask.commands.sync.ConfigManager", return_value=mock_config) + mock_config.get_api_token.return_value = None + + result = runner.invoke(app, []) + + assert result.exit_code == 1 + assert "Not authenticated" in result.stdout + +def test_sync_nothing_to_sync(mock_db, mock_config, mock_asana, mocker): + """ + Test sync when there are no unsynced sessions. + """ + mocker.patch("gittask.commands.sync.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.sync.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.sync.AsanaClient", return_value=mock_asana) + + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=[]) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Nothing to sync" in result.stdout + +def test_sync_only_active_sessions(mock_db, mock_config, mock_asana, mocker): + """ + Test sync when only active sessions exist (no end_time). + """ + mocker.patch("gittask.commands.sync.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.sync.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.sync.AsanaClient", return_value=mock_asana) + + sessions = [ + {'id': 1, 'end_time': None} + ] + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=sessions) + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Only active sessions found" in result.stdout + +def test_sync_failure(mock_db, mock_config, mock_asana, mocker): + """ + Test sync handles individual session failures gracefully. + """ + mocker.patch("gittask.commands.sync.DBManager", return_value=mock_db) + mocker.patch("gittask.commands.sync.ConfigManager", return_value=mock_config) + mocker.patch("gittask.commands.sync.AsanaClient", return_value=mock_asana) + + sessions = [ + {'id': 1, 'task_gid': 't1', 'duration_seconds': 3600, 'end_time': 123, 'branch': 'b1'}, + {'id': 2, 'task_gid': 't2', 'duration_seconds': 1800, 'end_time': 456, 'branch': 'b2'} + ] + mocker.patch.object(mock_db, 'get_unsynced_sessions', return_value=sessions) + mocker.patch.object(mock_db, 'mark_session_synced') + + mock_config.get_paid_plan_status.return_value = True + + # Fail first, succeed second + mock_asana.__enter__.return_value.add_time_entry.side_effect = [Exception("API Error"), None] + + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert "Failed to sync session 1: API Error" in result.stdout + assert "Sync complete!" in result.stdout + + # Verify only second session marked synced + mock_db.mark_session_synced.assert_called_once_with(2) From aa2fc7adccbfc4c7a8dfd4bf04a3f5a4720e807c Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:45:50 +0100 Subject: [PATCH 13/15] feat: add tests for tags --- tests/test_tags.py | 126 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_tags.py diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..e914c9e --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,126 @@ +import pytest +from typer.testing import CliRunner +from gittask.main import app +from unittest.mock import MagicMock + +runner = CliRunner() + +def test_tags_list_success(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test listing tags for a task. + """ + mocker.patch("gittask.commands.tags.db", mock_db) + mocker.patch("gittask.commands.tags.git", mock_git) + mocker.patch("gittask.commands.tags.config", mock_config) + mocker.patch("gittask.commands.tags.AsanaClient", return_value=mock_asana) + + mock_git.get_current_branch.return_value = "feature-branch" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123', + 'asana_task_name': 'Feature Task' + }) + + # Mock Asana task details + mock_task = { + 'name': 'Feature Task', + 'tags': [ + {'name': 'Tag1', 'color': 'red'}, + {'name': 'Tag2', 'color': None} + ] + } + mock_asana.__enter__.return_value.tasks_api.get_task.return_value = mock_task + + result = runner.invoke(app, ["tags"]) + + assert result.exit_code == 0 + assert "Tags for task: Feature Task" in result.stdout + assert "Tag1" in result.stdout + assert "red" in result.stdout + assert "Tag2" in result.stdout + assert "default" in result.stdout + +def test_tags_list_no_tags(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test listing tags when task has no tags. + """ + mocker.patch("gittask.commands.tags.db", mock_db) + mocker.patch("gittask.commands.tags.git", mock_git) + mocker.patch("gittask.commands.tags.config", mock_config) + mocker.patch("gittask.commands.tags.AsanaClient", return_value=mock_asana) + + mock_git.get_current_branch.return_value = "feature-branch" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123' + }) + + mock_task = { + 'name': 'Feature Task', + 'tags': [] + } + mock_asana.__enter__.return_value.tasks_api.get_task.return_value = mock_task + + result = runner.invoke(app, ["tags"]) + + assert result.exit_code == 0 + assert "No tags" in result.stdout + +def test_tags_list_not_linked(mock_db, mock_git, mocker): + """ + Test listing tags when branch is not linked. + """ + mocker.patch("gittask.commands.tags.db", mock_db) + mocker.patch("gittask.commands.tags.git", mock_git) + + mock_git.get_current_branch.return_value = "feature-branch" + mocker.patch.object(mock_db, 'get_task_for_branch', return_value=None) + + result = runner.invoke(app, ["tags"]) + + assert result.exit_code == 1 + assert "not linked to an Asana task" in result.stdout + +def test_tags_add_success(mock_db, mock_git, mock_config, mock_asana, mocker): + """ + Test adding tags to a task. + """ + mocker.patch("gittask.commands.tags.db", mock_db) + mocker.patch("gittask.commands.tags.git", mock_git) + mocker.patch("gittask.commands.tags.config", mock_config) + mocker.patch("gittask.commands.tags.AsanaClient", return_value=mock_asana) + + mock_git.get_current_branch.return_value = "feature-branch" + + mocker.patch.object(mock_db, 'get_task_for_branch', return_value={ + 'asana_task_gid': 'task123' + }) + + # Mock select_and_create_tags + mocker.patch("gittask.commands.tags.select_and_create_tags", return_value=['tag1', 'tag2']) + + result = runner.invoke(app, ["tags", "add"]) + + assert result.exit_code == 0 + assert "Applying 2 tags..." in result.stdout + assert "Tags added successfully" in result.stdout + + mock_asana.__enter__.return_value.add_tag_to_task.assert_has_calls([ + mocker.call('task123', 'tag1'), + mocker.call('task123', 'tag2') + ]) + +def test_tags_add_not_linked(mock_db, mock_git, mocker): + """ + Test adding tags when branch is not linked. + """ + mocker.patch("gittask.commands.tags.db", mock_db) + mocker.patch("gittask.commands.tags.git", mock_git) + + mock_git.get_current_branch.return_value = "feature-branch" + mocker.patch.object(mock_db, 'get_task_for_branch', return_value=None) + + result = runner.invoke(app, ["tags", "add"]) + + assert result.exit_code == 1 + assert "not linked to an Asana task" in result.stdout From 619e89d4a889eab4329befd90fb9e9e0cf286b14 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:48:01 +0100 Subject: [PATCH 14/15] feat: add tests for asana client and database client --- tests/test_asana_client.py | 288 +++++++++++++++++++++++++++++++++++++ tests/test_database.py | 157 ++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 tests/test_asana_client.py create mode 100644 tests/test_database.py diff --git a/tests/test_asana_client.py b/tests/test_asana_client.py new file mode 100644 index 0000000..6453a71 --- /dev/null +++ b/tests/test_asana_client.py @@ -0,0 +1,288 @@ +import pytest +from unittest.mock import MagicMock, call +import datetime +from gittask.asana_client import AsanaClient + +@pytest.fixture +def mock_asana_lib(mocker): + return mocker.patch("gittask.asana_client.asana") + +@pytest.fixture +def client(mock_asana_lib): + # Setup default mocks for init + mock_users_api = MagicMock() + mock_asana_lib.UsersApi.return_value = mock_users_api + mock_users_api.get_user.return_value = {'gid': 'user123', 'name': 'Test User'} + + return AsanaClient("fake_token") + +def test_init(mock_asana_lib): + mock_users_api = MagicMock() + mock_asana_lib.UsersApi.return_value = mock_users_api + mock_users_api.get_user.return_value = {'gid': 'user123', 'name': 'Test User'} + + client = AsanaClient("fake_token") + + assert client.me == {'gid': 'user123', 'name': 'Test User'} + mock_asana_lib.Configuration.assert_called() + mock_asana_lib.ApiClient.assert_called() + mock_users_api.get_user.assert_called_with("me", opts={}) + +def test_context_manager(mock_asana_lib): + mock_users_api = MagicMock() + mock_asana_lib.UsersApi.return_value = mock_users_api + mock_users_api.get_user.return_value = {'gid': 'user123'} + + # Mock pool on api_client + mock_api_client = MagicMock() + mock_asana_lib.ApiClient.return_value = mock_api_client + mock_pool = MagicMock() + mock_api_client.pool = mock_pool + + with AsanaClient("token") as client: + assert client.me['gid'] == 'user123' + + mock_pool.close.assert_called_once() + mock_pool.join.assert_called_once() + +def test_get_user_gid(client): + assert client.get_user_gid() == 'user123' + +def test_search_tasks(client, mock_asana_lib): + mock_typeahead = MagicMock() + mock_asana_lib.TypeaheadApi.return_value = mock_typeahead + # Re-init to attach mock + client.typeahead_api = mock_typeahead + + mock_typeahead.typeahead_for_workspace.return_value = [{'gid': 't1', 'name': 'Task 1'}] + + results = client.search_tasks('ws1', 'query') + + assert results == [{'gid': 't1', 'name': 'Task 1'}] + mock_typeahead.typeahead_for_workspace.assert_called_with( + 'ws1', 'task', {'query': 'query', 'opt_fields': 'name,gid,completed'} + ) + +def test_create_task(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + mock_tasks.create_task.return_value = {'gid': 'new_task'} + + # Test with project + result = client.create_task('ws1', 'p1', 'New Task') + + assert result == {'gid': 'new_task'} + expected_body = { + "data": { + "workspace": "ws1", + "name": "New Task", + "assignee": "user123", + "projects": ["p1"] + } + } + mock_tasks.create_task.assert_called_with(expected_body, opts={}) + + # Test without project + client.create_task('ws1', None, 'New Task 2') + expected_body_no_proj = { + "data": { + "workspace": "ws1", + "name": "New Task 2", + "assignee": "user123" + } + } + mock_tasks.create_task.assert_called_with(expected_body_no_proj, opts={}) + +def test_log_time_comment(client, mock_asana_lib): + mock_stories = MagicMock() + mock_asana_lib.StoriesApi.return_value = mock_stories + client.stories_api = mock_stories + + # 1h 30m + client.log_time_comment('t1', 5400, 'feature') + + args, kwargs = mock_stories.create_story_for_task.call_args + body = args[0] + text = body['data']['html_text'] + + assert "1h 30m" in text + assert "feature" in text + assert "gittask cli tool" in text + +def test_log_time_comment_less_than_minute(client, mock_asana_lib): + mock_stories = MagicMock() + mock_asana_lib.StoriesApi.return_value = mock_stories + client.stories_api = mock_stories + + client.log_time_comment('t1', 30, 'feature') + + args, kwargs = mock_stories.create_story_for_task.call_args + text = args[0]['data']['html_text'] + assert "< 1m" in text + +def test_post_comment(client, mock_asana_lib): + mock_stories = MagicMock() + mock_asana_lib.StoriesApi.return_value = mock_stories + client.stories_api = mock_stories + + client.post_comment('t1', 'Hello') + + args, kwargs = mock_stories.create_story_for_task.call_args + text = args[0]['data']['html_text'] + assert "Hello" in text + assert "gittask cli tool" in text + +def test_complete_task(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + client.complete_task('t1') + + mock_tasks.update_task.assert_called_with( + {"data": {"completed": True}}, 't1', opts={} + ) + +def test_get_workspaces(client, mock_asana_lib): + mock_ws = MagicMock() + mock_asana_lib.WorkspacesApi.return_value = mock_ws + client.workspaces_api = mock_ws + + mock_ws.get_workspaces.return_value = [{'gid': 'ws1'}] + + assert client.get_workspaces() == [{'gid': 'ws1'}] + +def test_get_workspace_by_gid(client, mock_asana_lib): + mock_ws = MagicMock() + mock_asana_lib.WorkspacesApi.return_value = mock_ws + client.workspaces_api = mock_ws + + mock_ws.get_workspace.return_value = {'gid': 'ws1', 'name': 'WS'} + + assert client.get_workspace_by_gid('ws1') == {'gid': 'ws1', 'name': 'WS'} + +def test_get_projects(client, mock_asana_lib): + mock_projects = MagicMock() + mock_asana_lib.ProjectsApi.return_value = mock_projects + client.projects_api = mock_projects + + mock_projects.get_projects_for_workspace.return_value = [{'gid': 'p1'}] + + assert client.get_projects('ws1') == [{'gid': 'p1'}] + +def test_get_tags(client, mock_asana_lib): + mock_tags = MagicMock() + mock_asana_lib.TagsApi.return_value = mock_tags + client.tags_api = mock_tags + + mock_tags.get_tags_for_workspace.return_value = [{'gid': 'tag1'}] + + assert client.get_tags('ws1') == [{'gid': 'tag1'}] + mock_tags.get_tags_for_workspace.assert_called_with('ws1', opts={'opt_fields': 'name,gid'}) + +def test_create_tag(client, mock_asana_lib): + mock_tags = MagicMock() + mock_asana_lib.TagsApi.return_value = mock_tags + client.tags_api = mock_tags + + client.create_tag('ws1', 'Tag Name', 'red') + + expected_body = {"data": {"workspace": "ws1", "name": "Tag Name", "color": "red"}} + mock_tags.create_tag.assert_called_with(expected_body, opts={}) + +def test_add_tag_to_task(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + client.add_tag_to_task('t1', 'tag1') + + mock_tasks.add_tag_for_task.assert_called_with({"data": {"tag": "tag1"}}, 't1') + +def test_get_project_tasks(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + mock_tasks.get_tasks.return_value = [{'gid': 't1'}] + + assert client.get_project_tasks('p1') == [{'gid': 't1'}] + + args, kwargs = mock_tasks.get_tasks.call_args + opts = kwargs['opts'] + assert opts['project'] == 'p1' + assert opts['completed_since'] == 'now' + +def test_assign_task(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + client.assign_task('t1', 'user2') + + mock_tasks.update_task.assert_called_with( + {"data": {"assignee": "user2"}}, 't1', opts={} + ) + +def test_get_custom_fields(client, mock_asana_lib): + mock_cf = MagicMock() + mock_asana_lib.CustomFieldsApi.return_value = mock_cf + client.custom_fields_api = mock_cf + + mock_cf.get_custom_fields_for_workspace.return_value = [{'gid': 'cf1'}] + + assert client.get_custom_fields('ws1') == [{'gid': 'cf1'}] + +def test_get_task_with_fields(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + client.get_task_with_fields('t1') + + args, kwargs = mock_tasks.get_task.call_args + assert 'custom_fields' in kwargs['opts']['opt_fields'] + +def test_get_actual_time(client, mock_asana_lib): + mock_tasks = MagicMock() + mock_asana_lib.TasksApi.return_value = mock_tasks + client.tasks_api = mock_tasks + + mock_tasks.get_task.return_value = {'actual_time_minutes': 120} + + assert client.get_actual_time('t1') == 120 + +def test_add_time_entry(client, mock_asana_lib): + mock_time = MagicMock() + mock_asana_lib.TimeTrackingEntriesApi.return_value = mock_time + client.time_tracking_api = mock_time + + # Test with default date (today) + client.add_time_entry('t1', 3600) + + args, kwargs = mock_time.create_time_tracking_entry.call_args + body = args[0] + assert body['data']['duration_minutes'] == 60 + assert body['data']['entered_on'] == datetime.date.today().isoformat() + + # Test with specific date + date = datetime.date(2023, 1, 1) + client.add_time_entry('t1', 120, entered_on=date) + + args, kwargs = mock_time.create_time_tracking_entry.call_args + body = args[0] + assert body['data']['duration_minutes'] == 2 # 120 seconds = 2 mins + assert body['data']['entered_on'] == '2023-01-01' + +def test_add_time_entry_rounding(client, mock_asana_lib): + mock_time = MagicMock() + mock_asana_lib.TimeTrackingEntriesApi.return_value = mock_time + client.time_tracking_api = mock_time + + # 30 seconds -> 1 minute + client.add_time_entry('t1', 30) + + args, kwargs = mock_time.create_time_tracking_entry.call_args + assert args[0]['data']['duration_minutes'] == 1 diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..8974a76 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,157 @@ +import pytest +from gittask.database import DBManager +import time +import os + +@pytest.fixture +def db(tmp_path): + """ + Fixture for a DBManager with a temporary database file. + """ + db_path = tmp_path / "test_db.json" + return DBManager(str(db_path)) + +def test_init_default_path(mocker): + """ + Test initialization with default path. + """ + mock_path = mocker.patch("gittask.database.Path") + mock_home = mock_path.home.return_value + mock_config_dir = mock_home / ".gittask" + + DBManager() + + mock_config_dir.mkdir.assert_called_with(parents=True, exist_ok=True) + # We can't easily assert the TinyDB init path here without mocking TinyDB, + # but the directory creation confirms the logic path. + +def test_cache_tags(db): + tags = [{'gid': '1', 'name': 'Tag1'}, {'gid': '2', 'name': 'Tag2'}] + db.cache_tags(tags) + + cached = db.get_cached_tags() + assert len(cached) == 2 + assert cached == tags + + # Test truncate and replace + new_tags = [{'gid': '3', 'name': 'Tag3'}] + db.cache_tags(new_tags) + + cached = db.get_cached_tags() + assert len(cached) == 1 + assert cached == new_tags + +def test_branch_map_operations(db): + # Link branch + db.link_branch_to_task( + branch_name="feature", + repo_path="/repo", + task_gid="t1", + task_name="Task 1", + project_gid="p1", + workspace_gid="w1" + ) + + # Get task + task = db.get_task_for_branch("feature", "/repo") + assert task is not None + assert task['asana_task_gid'] == "t1" + assert task['asana_task_name'] == "Task 1" + + # Update link (upsert) + db.link_branch_to_task( + branch_name="feature", + repo_path="/repo", + task_gid="t2", + task_name="Task 2", + project_gid="p1", + workspace_gid="w1" + ) + + task = db.get_task_for_branch("feature", "/repo") + assert task['asana_task_gid'] == "t2" + assert task['asana_task_name'] == "Task 2" + + # Get non-existent + assert db.get_task_for_branch("other", "/repo") is None + assert db.get_task_for_branch("feature", "/other_repo") is None + +def test_session_start_stop(db): + # Start session + session_id = db.start_session("feature", "/repo", "t1") + assert session_id is not None + + # Verify active + active = db.get_active_session() + assert active is not None + assert active['id'] == session_id + assert active['branch'] == "feature" + assert active['end_time'] is None + + # Stop session + stopped = db.stop_current_session("feature", "/repo") + assert stopped is not None + assert stopped['id'] == session_id + assert stopped['end_time'] is not None + assert stopped['duration_seconds'] >= 0 + + # Verify no active + assert db.get_active_session() is None + +def test_single_active_session_enforcement(db): + # Start first session + id1 = db.start_session("feature1", "/repo", "t1") + + # Start second session (should stop first) + id2 = db.start_session("feature2", "/repo", "t2") + + # Verify first is stopped + sessions = db.time_sessions.all() + s1 = next(s for s in sessions if s['id'] == id1) + s2 = next(s for s in sessions if s['id'] == id2) + + assert s1['end_time'] is not None + assert s2['end_time'] is None + + # Verify active is s2 + active = db.get_active_session() + assert active['id'] == id2 + +def test_stop_any_active_session(db): + db.start_session("feature", "/repo", "t1") + + stopped = db.stop_any_active_session() + assert stopped is not None + assert stopped['branch'] == "feature" + assert stopped['end_time'] is not None + + # Test when none active + assert db.stop_any_active_session() is None + +def test_stop_current_session_specific(db): + # Start session for branch A + db.start_session("branchA", "/repo", "t1") + + # Try to stop branch B (should fail) + assert db.stop_current_session("branchB", "/repo") is None + + # Try to stop branch A in different repo (should fail) + assert db.stop_current_session("branchA", "/other") is None + + # Stop branch A + assert db.stop_current_session("branchA", "/repo") is not None + +def test_unsynced_sessions(db): + # Create a session and stop it + db.start_session("feature", "/repo", "t1") + db.stop_any_active_session() + + unsynced = db.get_unsynced_sessions() + assert len(unsynced) == 1 + assert unsynced[0]['synced_to_asana'] is False + + # Mark synced + db.mark_session_synced(unsynced[0]['id']) + + unsynced = db.get_unsynced_sessions() + assert len(unsynced) == 0 From 00130e315503f06ffb04b9b827617460bf8342b4 Mon Sep 17 00:00:00 2001 From: Andreas Fiehn Date: Sat, 10 Jan 2026 16:49:43 +0100 Subject: [PATCH 15/15] feat: git workflow to run tests on pr to main --- .github/workflows/tests.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..15188d4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv venv + uv pip install .[dev] + + - name: Run tests + run: | + uv run pytest