From 04850a8f204bfdaf536c235abb120032295dc759 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 28 Dec 2025 11:34:35 +0100 Subject: [PATCH] test: add unit tests and CI workflow for dockreap - Add unittest-based unit tests for core volume logic - Add Makefile with isolated venv-based test runner - Add GitHub Actions CI workflow - Automatically mark SemVer-tagged commits as stable https://chatgpt.com/share/695107c4-f320-800f-b4ce-da953de9bb86 --- .github/workflows/ci.yml | 43 +++++++++++ Makefile | 21 ++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 213 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 5848 bytes tests/unit/__init__.py | 0 .../test_dockreap_main.cpython-313.pyc | Bin 0 -> 4548 bytes tests/unit/test_dockreap_main.py | 69 ++++++++++++++++++ 7 files changed, 133 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Makefile create mode 100644 src/dockreap/__pycache__/__init__.cpython-313.pyc create mode 100644 src/dockreap/__pycache__/__main__.cpython-313.pyc create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/__pycache__/test_dockreap_main.cpython-313.pyc create mode 100644 tests/unit/test_dockreap_main.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c668674 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI (unit tests, stable tag) + +on: + push: + branches: ["**"] + tags: ["v*.*.*"] + pull_request: + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed to create/move tags reliably + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package (editable) + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run unit tests + run: make test + + - name: Mark commit as stable (move stable tag) + if: startsWith(github.ref, 'refs/tags/v') + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Move/create 'stable' tag to the same commit as the pushed SemVer tag + git tag -f stable "${GITHUB_SHA}" + git push -f origin stable diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..473ee35 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.ONESHELL: +SHELL := /bin/bash +.SHELLFLAGS := -euo pipefail -c + +VENV ?= .venv +PY := $(VENV)/bin/python +PIP := $(VENV)/bin/pip + +.PHONY: venv test clean + +venv: + test -x "$(PY)" || python3 -m venv "$(VENV)" + "$(PIP)" install -U pip + "$(PIP)" install -e . + +test: venv + "$(PY)" -m unittest discover -s tests/unit -p "test_*.py" -v + +clean: + rm -rf "$(VENV)" .pytest_cache .coverage + find . -type d -name "__pycache__" -print0 | xargs -0r rm -rf diff --git a/src/dockreap/__pycache__/__init__.cpython-313.pyc b/src/dockreap/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d270e86d927d69f94b41275711bf208e21a63f4 GIT binary patch literal 213 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 literal 0 HcmV?d00001 diff --git a/tests/unit/test_dockreap_main.py b/tests/unit/test_dockreap_main.py new file mode 100644 index 0000000..d5ae020 --- /dev/null +++ b/tests/unit/test_dockreap_main.py @@ -0,0 +1,69 @@ +import unittest +from unittest.mock import patch, MagicMock + +import dockreap.__main__ as dockreap_main + + +class TestDockreapMain(unittest.TestCase): + def test_get_anonymous_volumes_filters_64char_hex(self): + fake_stdout = "\n".join( + [ + "short", + "nothex" * 10, + "a" * 64, # valid + "A" * 64, # invalid (uppercase not matched) + "g" * 64, # invalid (not hex) + "b" * 63, # invalid (too short) + "c" * 65, # invalid (too long) + "deadbeef" * 8, # valid 64-char hex + ] + ) + + completed = MagicMock() + completed.stdout = fake_stdout + + with patch.object(dockreap_main.subprocess, "run", return_value=completed) as run_mock: + vols = dockreap_main.get_anonymous_volumes() + + # Ensure docker command was called correctly + run_mock.assert_called_with( + ["docker", "volume", "ls", "--format", "{{.Name}}"], + stdout=dockreap_main.subprocess.PIPE, + text=True, + ) + + self.assertEqual(vols, ["a" * 64, "deadbeef" * 8]) + + def test_is_volume_used_true_when_docker_returns_container_ids(self): + completed = MagicMock() + completed.stdout = "abc123\n" + + with patch.object(dockreap_main.subprocess, "run", return_value=completed): + self.assertTrue(dockreap_main.is_volume_used("x" * 64)) + + def test_is_volume_used_false_when_no_container_ids(self): + completed = MagicMock() + completed.stdout = "\n" + + with patch.object(dockreap_main.subprocess, "run", return_value=completed): + self.assertFalse(dockreap_main.is_volume_used("x" * 64)) + + def test_cleanup_symlink_does_nothing_when_not_symlink(self): + # We don't want to touch the real filesystem, so we patch the Path object + fake_data_path = MagicMock() + fake_data_path.is_symlink.return_value = False + + fake_volume_dir = MagicMock() + fake_volume_dir.__truediv__.side_effect = lambda x: fake_data_path if x == "_data" else MagicMock() + + with patch.object(dockreap_main, "VOLUME_BASE_PATH") as base_path_mock: + base_path_mock.__truediv__.return_value = fake_volume_dir + + # Should not raise; should not try to unlink/rmtree + dockreap_main.cleanup_symlink("a" * 64) + + fake_data_path.unlink.assert_not_called() + + +if __name__ == "__main__": + unittest.main()