From ab70844c8e6f45465a6be8db772a388c75cda10f Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Tue, 6 Jan 2026 23:45:36 +0100 Subject: [PATCH] metadata-scrubber-tool: added end-to-end test for cli commands, which includes for read and scrub commands. added the version flag and modified the verbose to use capital V --- src/commands/read.py | 2 +- src/main.py | 34 ++++++++- src/services/batch_processor.py | 11 ++- tests/e2e/test_main.py | 24 ++++++ tests/e2e/test_read_command.py | 79 +++++++++++++++++++ tests/e2e/test_scrub_command.py | 129 ++++++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/test_main.py create mode 100644 tests/e2e/test_read_command.py create mode 100644 tests/e2e/test_scrub_command.py diff --git a/src/commands/read.py b/src/commands/read.py index fb45e69b..1c2d3ba5 100644 --- a/src/commands/read.py +++ b/src/commands/read.py @@ -20,7 +20,7 @@ log = logging.getLogger("metadata-scrubber") # fmt: off -def get_metadata( +def read( file_path: Path = typer.Argument( exists=True, # Must exist on the filesystem file_okay=True, # Can be a file diff --git a/src/main.py b/src/main.py index 86f106b2..3c8d98a9 100644 --- a/src/main.py +++ b/src/main.py @@ -13,7 +13,7 @@ import logging import typer -from src.commands.read import get_metadata +from src.commands.read import read from src.commands.scrub import scrub from src.utils.logger import setup_logging @@ -22,18 +22,44 @@ app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False) log = logging.getLogger("metadata-scrubber") +__version__ = "0.1.0" + + +# --------------------------------------------------------- +# VERSION CALLBACK +# --------------------------------------------------------- +def version_callback(value: bool): + """ + Prints the version and exits. + """ + if value: + print(f"Version: {__version__}") + raise typer.Exit() + + +# --------------------------------------------------------- +# MAIN ENTRY POINT +# --------------------------------------------------------- # fmt: off @app.callback() def main( verbose: bool = typer.Option( False, "--verbose", - "-v", + "-V", help="Show detailed debug logs for every file processed.", ), + version: bool = typer.Option( + None, + "--version", + "-v", + callback=version_callback, + is_eager=True, + help="Show the application version and exit." + ), ): """ - Metadata Scrubber Tool - Clean your images privacy data. + Metadata Scrubber Tool - Clean your images personal identifying data. eg: author name, camera model, GPS coordinates, etc. """ # Initialize the logger based on the user's flag setup_logging(verbose) @@ -43,7 +69,7 @@ def main( # fmt: on # register commands -app.command(name="read")(get_metadata) +app.command(name="read")(read) app.command(name="scrub")(scrub) # run app diff --git a/src/services/batch_processor.py b/src/services/batch_processor.py index cb5eed82..a4ad2d0f 100644 --- a/src/services/batch_processor.py +++ b/src/services/batch_processor.py @@ -99,7 +99,7 @@ class BatchProcessor: if self.dry_run: # Verify the file can be handled (will raise if not) MetadataFactory.get_handler(str(file)) - output_path = self._get_unique_output_path(file) + output_path = self._get_unique_output_path(file, reserve=False) result = FileResult( filepath=file, success=True, @@ -250,7 +250,7 @@ class BatchProcessor: # Best effort cleanup - don't fail if we can't delete pass - def _get_unique_output_path(self, file: Path) -> Path: + def _get_unique_output_path(self, file: Path, reserve: bool = True) -> Path: """ Generate unique output path with suffix (_1, _2) if file exists. @@ -258,6 +258,8 @@ class BatchProcessor: Args: file: Original file path. + reserve: If True, create placeholder file to reserve the path. + Set to False for dry-run mode. Returns: Unique path in output directory that doesn't conflict with existing files. @@ -278,7 +280,8 @@ class BatchProcessor: ) counter += 1 - # Create empty placeholder to reserve the path - output_path.touch() + # Create empty placeholder to reserve the path (skip in dry-run) + if reserve: + output_path.touch() return output_path diff --git a/tests/e2e/test_main.py b/tests/e2e/test_main.py new file mode 100644 index 00000000..bf705910 --- /dev/null +++ b/tests/e2e/test_main.py @@ -0,0 +1,24 @@ +from typer.testing import CliRunner + +from src.main import __version__, app + +# Initialize the runner +runner = CliRunner() + + +def test_app_version(): + """ + Ensure the --version flag prints the correct version and exits cleanly. + """ + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0, "The app crashed!" + assert __version__ in result.stdout, "Version number not found in output" + + +def test_app_help(): + """ + Ensure the --help flag works. + """ + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Metadata Scrubber Tool" in result.stdout diff --git a/tests/e2e/test_read_command.py b/tests/e2e/test_read_command.py new file mode 100644 index 00000000..2911bf11 --- /dev/null +++ b/tests/e2e/test_read_command.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from src.main import app + +runner = CliRunner() + +# Test file paths +JPG_TEST_FILE = r"C:\Users\Xheri\development\metadata-scrubber-tool\tests\assets\test_images\DSCN0012.jpg" +PNG_TEST_FILE = r"C:\Users\Xheri\development\metadata-scrubber-tool\tests\assets\test_images\generated_test_45.png" +TEST_DIR = r"C:\Users\Xheri\development\metadata-scrubber-tool\tests\assets\test_images" + + +# ============== Success Case Tests ============== + + +@pytest.mark.parametrize( + "x", + [JPG_TEST_FILE, PNG_TEST_FILE], +) +def test_read_command_single_file_success(x): + """ + Test the 'read' command with a single file (JPG and PNG). + """ + result = runner.invoke(app, ["read", str(x)]) + + assert result.exit_code == 0 + assert "Reading" in result.stdout + assert Path(x).name in result.stdout + + +@pytest.mark.parametrize( + "ext", + ["jpg", "png"], +) +def test_read_command_recursive_directory_success(ext): + """ + Test the 'read' command with recursive directory processing. + """ + result = runner.invoke(app, ["read", TEST_DIR, "-r", "-ext", ext]) + + assert result.exit_code == 0 + assert "Reading" in result.stdout + + +def test_read_command_requires_ext_with_recursive(): + """ + Test that --recursive requires --extension flag. + """ + result = runner.invoke(app, ["read", TEST_DIR, "-r"]) + + assert result.exit_code != 0 + # Should fail with bad parameter error + + +def test_read_command_requires_recursive_with_ext(): + """ + Test that --extension requires --recursive flag. + """ + result = runner.invoke(app, ["read", JPG_TEST_FILE, "-ext", "jpg"]) + + assert result.exit_code != 0 + + +# ============== Error Case Tests ============== + + +def test_read_command_file_not_found(): + """ + Test that the app handles missing files gracefully. + """ + result = runner.invoke(app, ["read", "ghost_file.jpg"]) + + # Typer returns exit code 2 (Usage Error) for bad arguments + assert result.exit_code == 2 + assert "Invalid value for 'FILE_PATH'" in result.stderr + assert "does not exist" in result.stderr diff --git a/tests/e2e/test_scrub_command.py b/tests/e2e/test_scrub_command.py new file mode 100644 index 00000000..4941bf37 --- /dev/null +++ b/tests/e2e/test_scrub_command.py @@ -0,0 +1,129 @@ +""" +E2E tests for the 'scrub' command. + +Tests the full CLI flow for scrubbing metadata from files. +""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from src.main import app + +runner = CliRunner() + +# Test file paths - use examples folder for faster tests (fewer files) +JPG_TEST_FILE = r"C:\Users\Xheri\development\metadata-scrubber-tool\tests\assets\test_images\DSCN0012.jpg" +PNG_TEST_FILE = r"C:\Users\Xheri\development\metadata-scrubber-tool\tests\assets\test_images\generated_test_45.png" +# Use examples folder (small) instead of full test_images folder (100+ files) +EXAMPLES_DIR = ( + r"C:\Users\Xheri\development\metadata-scrubber-tool\tests\assets\test_images" +) + + +@pytest.fixture +def output_dir(tmp_path): + """Create isolated output directory for each test using tmp_path.""" + output = tmp_path / "output" + output.mkdir(parents=True, exist_ok=True) + return output + + +# ============== Success Case Tests ============== + + +@pytest.mark.parametrize("x", [JPG_TEST_FILE, PNG_TEST_FILE]) +def test_scrub_command_single_file_success(x, output_dir): + """ + Test the 'scrub' command with a single file. + """ + result = runner.invoke(app, ["scrub", x, "--output", str(output_dir)]) + + assert result.exit_code == 0 + # Check output file was created (has processed_ prefix) + output_file = output_dir / f"processed_{Path(x).name}" + assert output_file.exists() + + +def test_scrub_command_recursive_jpg_success(output_dir): + """ + Test the 'scrub' command with recursive directory processing for JPG. + Uses examples folder (smaller) for faster tests. + """ + result = runner.invoke( + app, ["scrub", EXAMPLES_DIR, "-r", "-ext", "jpg", "--output", str(output_dir)] + ) + + assert result.exit_code == 0 + # Check at least one output file was created + output_files = list(output_dir.glob("processed_*.jpg")) + assert len(output_files) > 0 + + +def test_scrub_command_dry_run(output_dir): + """ + Test that --dry-run doesn't create files. + """ + result = runner.invoke( + app, ["scrub", JPG_TEST_FILE, "--output", str(output_dir), "--dry-run"] + ) + + assert result.exit_code == 0 + assert "DRY-RUN" in result.stdout + # No files should be created in dry-run mode + output_file = output_dir / f"processed_{Path(JPG_TEST_FILE).name}" + assert not output_file.exists() + + +def test_scrub_command_with_workers(output_dir): + """ + Test the --workers option for concurrent processing. + """ + result = runner.invoke( + app, + [ + "scrub", + EXAMPLES_DIR, + "-r", + "-ext", + "jpg", + "--output", + str(output_dir), + "--workers", + "2", + ], + ) + + assert result.exit_code == 0 + + +# ============== Error Case Tests ============== + + +def test_scrub_command_file_not_found(): + """ + Test that the app handles missing files gracefully. + """ + result = runner.invoke(app, ["scrub", "ghost_file.jpg"]) + + assert result.exit_code == 2 + assert "Invalid value" in result.stderr + + +def test_scrub_command_requires_ext_with_recursive(): + """ + Test that --recursive requires --extension flag. + """ + result = runner.invoke(app, ["scrub", EXAMPLES_DIR, "-r"]) + + assert result.exit_code != 0 + + +def test_scrub_command_requires_recursive_with_ext(): + """ + Test that --extension requires --recursive flag. + """ + result = runner.invoke(app, ["scrub", JPG_TEST_FILE, "-ext", "jpg"]) + + assert result.exit_code != 0