|
#!/usr/bin/env python3 |
|
""" |
|
Unit tests for IMAP Organizer - Behavior-driven testing |
|
|
|
Methodology: INSTRUCTIONS.md |
|
- Test behaviors (contracts), NOT implementation details |
|
- Naming: test_<unit>__<scenario>__<expected>() |
|
- Isolation: tmp_path as CWD, controlled env, no network |
|
- Parametrize variants |
|
- Real CLI tests with CliRunner |
|
- <25 lines per test |
|
|
|
Run: pytest test_imap_organizer.py -v |
|
Coverage: pytest --cov=imap_organizer test_imap_organizer.py |
|
""" |
|
|
|
import pytest |
|
from unittest.mock import Mock, patch, MagicMock |
|
from datetime import datetime |
|
from pathlib import Path |
|
import os |
|
|
|
from typer.testing import CliRunner |
|
|
|
# Import code under test |
|
from imap_organizer import ( |
|
Config, |
|
get_env_file_from_config, |
|
load_env_for_config, |
|
setup_logging, |
|
EmailOrganizer, |
|
app, |
|
) |
|
|
|
# ================================================================== |
|
# HARNESS & FIXTURES |
|
# ================================================================== |
|
|
|
@pytest.fixture |
|
def tmp_env(tmp_path, monkeypatch): |
|
"""Isolated environment: tmp CWD, clean env, isolated HALT file""" |
|
# Change to tmp directory |
|
monkeypatch.chdir(tmp_path) |
|
|
|
# Isolate HALT file to tmp directory |
|
halt_file = tmp_path / ".imap-organizer.HALT" |
|
monkeypatch.setattr("imap_organizer.EmailOrganizer.HALT_FLAG_FILE", halt_file) |
|
|
|
# Clean environment |
|
for key in ["IMAP_HOST", "IMAP_PORT", "IMAP_USER", "IMAP_PASSWORD"]: |
|
monkeypatch.delenv(key, raising=False) |
|
|
|
return tmp_path |
|
|
|
@pytest.fixture |
|
def mock_imap_client(): |
|
"""Controllable fake IMAP client""" |
|
client = Mock() |
|
|
|
# Connection |
|
client.login.return_value = None |
|
client.capabilities.return_value = [b'IMAP4REV1', b'MOVE', b'UIDPLUS'] |
|
|
|
# Folders |
|
client.list_folders.return_value = [ |
|
([b'\\HasNoChildren'], b'/', 'INBOX'), |
|
([b'\\HasNoChildren'], b'/', 'Einkauf'), |
|
] |
|
client.folder_status.side_effect = Exception("Folder not found") |
|
client.create_folder.return_value = None |
|
client.subscribe_folder.return_value = None |
|
|
|
# Messages |
|
client.select_folder.return_value = (b'OK', [b'3 messages']) |
|
client.search.return_value = [101, 102, 103] |
|
client.fetch.return_value = { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
102: {b'INTERNALDATE': datetime(2025, 3, 20)}, |
|
103: {b'INTERNALDATE': datetime(2024, 12, 1)}, |
|
} |
|
|
|
# Move |
|
client.move.return_value = None |
|
client.copy.return_value = None |
|
client.delete_messages.return_value = None |
|
client.expunge.return_value = None |
|
|
|
# Cleanup |
|
client.logout.return_value = None |
|
|
|
return client |
|
|
|
@pytest.fixture |
|
def message_sets(): |
|
"""Test data: message sets with various date distributions |
|
|
|
Uses fixed year 2025 to avoid year-boundary flakes. |
|
""" |
|
return { |
|
"current_year": { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
102: {b'INTERNALDATE': datetime(2025, 6, 20)}, |
|
}, |
|
"mixed_years": { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
102: {b'INTERNALDATE': datetime(2024, 6, 1)}, |
|
103: {b'INTERNALDATE': datetime(2023, 12, 15)}, |
|
}, |
|
"with_missing": { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
102: {}, # Missing date |
|
103: {b'INTERNALDATE': datetime(2024, 6, 1)}, |
|
}, |
|
} |
|
|
|
@pytest.fixture |
|
def runner(): |
|
"""Shared CliRunner for CLI tests |
|
|
|
Note: Typer's CliRunner doesn't support mix_stderr parameter. |
|
Rich console stderr output is not well-captured by CliRunner, |
|
so CLI error tests focus on exit codes rather than error messages. |
|
""" |
|
return CliRunner() |
|
|
|
# ================================================================== |
|
# CONFIG & ENV LOADING |
|
# ================================================================== |
|
|
|
def test_config__env_overrides_toml__precedence(tmp_env, monkeypatch): |
|
"""Ensures ENV variables override TOML config values""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "toml.local"\nport = 143\n') |
|
|
|
monkeypatch.setenv("IMAP_HOST", "env.local") |
|
|
|
config = Config(config_file=config_file) |
|
|
|
assert config.get("connection", "host") == "env.local" # ENV wins |
|
assert config.get("connection", "port") == 143 # TOML preserved |
|
|
|
@pytest.mark.parametrize("profile,expected_env", [ |
|
("dev", ".env-dev"), |
|
("sandbox", ".env-sandbox"), |
|
("prod", ".env"), # .imap-organizer.toml → .env |
|
]) |
|
def test_config__paired_env__loads(tmp_env, monkeypatch, profile, expected_env): |
|
"""Ensures config filename pairs with correct .env file""" |
|
if profile == "prod": |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
else: |
|
config_file = tmp_env / f".imap-organizer-{profile}.toml" |
|
|
|
config_file.write_text('[connection]\nhost = "test.local"\n') |
|
|
|
env_file = tmp_env / expected_env |
|
env_file.write_text(f"IMAP_PASSWORD={profile}-password\n") |
|
|
|
# Clean env |
|
monkeypatch.delenv("IMAP_PASSWORD", raising=False) |
|
|
|
# Load env for config |
|
load_env_for_config(config_file) |
|
|
|
assert os.getenv("IMAP_PASSWORD") == f"{profile}-password" |
|
|
|
def test_config__paired_env_missing__falls_back_to_default(tmp_env, monkeypatch): |
|
"""Ensures fallback to .env when paired env file missing""" |
|
config_file = tmp_env / ".imap-organizer-dev.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\n') |
|
|
|
# Only default .env exists |
|
default_env = tmp_env / ".env" |
|
default_env.write_text("IMAP_PASSWORD=fallback-password\n") |
|
|
|
monkeypatch.delenv("IMAP_PASSWORD", raising=False) |
|
|
|
load_env_for_config(config_file) |
|
|
|
assert os.getenv("IMAP_PASSWORD") == "fallback-password" |
|
|
|
def test_config__discovery__finds_cwd_then_home(tmp_env, monkeypatch): |
|
"""Ensures config discovery: CWD first, then home""" |
|
# No CWD config, create home config |
|
home_dir = tmp_env / "home" |
|
home_dir.mkdir() |
|
(home_dir / ".config" / "imap-organizer").mkdir(parents=True) |
|
home_config = home_dir / ".config" / "imap-organizer" / "config.toml" |
|
home_config.write_text('[connection]\nhost = "home.local"\n') |
|
|
|
monkeypatch.setattr(Path, "home", lambda: home_dir) |
|
|
|
config = Config() |
|
|
|
assert config.config_file == home_config |
|
assert config.get("connection", "host") == "home.local" |
|
|
|
def test_config__bad_toml__raises_runtime_error(tmp_env): |
|
"""Ensures malformed TOML raises RuntimeError with clear message""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('bad toml syntax [[[') |
|
|
|
with pytest.raises(RuntimeError, match="Failed to load config"): |
|
Config(config_file=config_file) |
|
|
|
def test_config__explicit_missing__raises_not_found(tmp_env): |
|
"""Ensures explicit missing config path raises 'not found' error""" |
|
missing_file = tmp_env / ".imap-organizer-nonexistent.toml" |
|
|
|
with pytest.raises(RuntimeError, match="Config file not found"): |
|
Config(config_file=missing_file) |
|
|
|
def test_config__no_config_loads_default_env(tmp_env, monkeypatch): |
|
"""Ensures load_env_for_config(None) loads default .env from CWD""" |
|
# Create default .env in CWD |
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=default_password\n") |
|
|
|
monkeypatch.delenv("IMAP_PASSWORD", raising=False) |
|
|
|
# Call with no config_file |
|
load_env_for_config(None) |
|
|
|
# Verify default .env was loaded |
|
assert os.getenv("IMAP_PASSWORD") == "default_password" |
|
|
|
# ================================================================== |
|
# LOGGING |
|
# ================================================================== |
|
|
|
def test_logging__creates_timestamped_file__in_cwd(tmp_env): |
|
"""Ensures logging creates timestamped file in CWD""" |
|
logger = setup_logging(verbose=False) |
|
|
|
log_files = list(tmp_env.glob("imap_organizer_*.log")) |
|
|
|
assert len(log_files) == 1 |
|
assert logger.name == "imap_organizer" |
|
|
|
# ================================================================== |
|
# IMAP: CONNECTION |
|
# ================================================================== |
|
|
|
@patch('imap_organizer.IMAPClient') |
|
def test_connect__success__calls_login_once(MockIMAPClient, tmp_env, monkeypatch): |
|
"""Ensures successful connection constructs IMAP and calls login once""" |
|
monkeypatch.setenv("IMAP_PASSWORD", "testpass") |
|
|
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nport = 993\nuser = "[email protected]"\n') |
|
|
|
mock_client = Mock() |
|
MockIMAPClient.return_value = mock_client |
|
|
|
config = Config(config_file=config_file) |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
|
|
result = organizer.connect() |
|
|
|
assert result == mock_client |
|
MockIMAPClient.assert_called_once_with("test.local", port=993, ssl=True) |
|
mock_client.login.assert_called_once_with("[email protected]", "testpass") |
|
|
|
@pytest.mark.parametrize("missing,env_var,match_text", [ |
|
("host", "IMAP_HOST", "Missing IMAP host"), |
|
("user", "IMAP_USER", "Missing IMAP user"), |
|
("password", "IMAP_PASSWORD", "Missing IMAP password"), |
|
]) |
|
def test_connect__missing_credential__raises(tmp_env, monkeypatch, missing, env_var, match_text): |
|
"""Ensures missing credentials raise ValueError with clear message""" |
|
# Set all except the missing one |
|
if missing != "host": |
|
monkeypatch.setenv("IMAP_HOST", "test.local") |
|
if missing != "user": |
|
monkeypatch.setenv("IMAP_USER", "[email protected]") |
|
if missing != "password": |
|
monkeypatch.setenv("IMAP_PASSWORD", "testpass") |
|
|
|
config = Mock() |
|
if missing == "host": |
|
config.get.side_effect = lambda s, k, d=None: None if k == "host" else (993 if k == "port" else "[email protected]" if k == "user" else d) |
|
elif missing == "user": |
|
config.get.side_effect = lambda s, k, d=None: "test.local" if k == "host" else (993 if k == "port" else None if k == "user" else d) |
|
else: # password |
|
config.get.side_effect = lambda s, k, d=None: "test.local" if k == "host" else (993 if k == "port" else "[email protected]" if k == "user" else d) |
|
monkeypatch.delenv("IMAP_PASSWORD", raising=False) |
|
|
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
|
|
with pytest.raises(ValueError, match=match_text): |
|
organizer.connect() |
|
|
|
# ================================================================== |
|
# IMAP: FOLDER OPERATIONS |
|
# ================================================================== |
|
|
|
def test_list_folders__returns_normalized_tuples(tmp_env, mock_imap_client): |
|
"""Ensures list_folders returns normalized (flags, delimiter, name) tuples""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
folders = organizer.list_folders() |
|
|
|
assert len(folders) == 2 |
|
assert folders[0][2] == 'INBOX' |
|
assert folders[1][2] == 'Einkauf' |
|
|
|
def test_get_folder_delimiter__bytes_to_string__with_fallback(tmp_env, mock_imap_client): |
|
"""Ensures delimiter converted from bytes to string, with '/' fallback""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
delimiter = organizer.get_folder_delimiter() |
|
|
|
assert delimiter == '/' |
|
|
|
# Test fallback when list empty |
|
mock_imap_client.list_folders.return_value = [] |
|
delimiter = organizer.get_folder_delimiter() |
|
assert delimiter == '/' |
|
|
|
def test_folder_exists__status_success_or_exception(tmp_env, mock_imap_client): |
|
"""Ensures folder_exists returns True on status success, False on exception""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# False when exception |
|
assert organizer.folder_exists("NonExistent") is False |
|
|
|
# True when success |
|
mock_imap_client.folder_status.side_effect = None |
|
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]} |
|
assert organizer.folder_exists("Einkauf") is True |
|
|
|
@pytest.mark.parametrize("exists,dry_run,should_create", [ |
|
(False, False, True), # Missing, apply → create |
|
(False, True, False), # Missing, dry-run → no create |
|
(True, False, False), # Exists, apply → no create |
|
(True, True, False), # Exists, dry-run → no create |
|
]) |
|
def test_ensure_folder__creates_when_missing__respects_dry_run( |
|
tmp_env, mock_imap_client, exists, dry_run, should_create |
|
): |
|
"""Ensures ensure_folder creates only when missing and not dry-run""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
if exists: |
|
mock_imap_client.folder_status.side_effect = None |
|
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]} |
|
|
|
organizer.ensure_folder("Test/2025", dry_run=dry_run) |
|
|
|
if should_create: |
|
mock_imap_client.create_folder.assert_called_once() |
|
mock_imap_client.subscribe_folder.assert_called_once() |
|
else: |
|
mock_imap_client.create_folder.assert_not_called() |
|
|
|
# ================================================================== |
|
# IMAP: MESSAGE YEAR EXTRACTION |
|
# ================================================================== |
|
|
|
def test_get_message_year__internaldate__returns_year(tmp_env, mock_imap_client): |
|
"""Ensures INTERNALDATE path returns message year""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
year = organizer.get_message_year(101, use_header_date=False) |
|
|
|
assert year == 2025 |
|
mock_imap_client.fetch.assert_called_once_with([101], ['INTERNALDATE']) |
|
|
|
def test_get_message_year__envelope__returns_year(tmp_env, mock_imap_client): |
|
"""Ensures ENVELOPE path returns header date year""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_envelope = Mock() |
|
mock_envelope.date = datetime(2024, 6, 15) |
|
mock_imap_client.fetch.return_value = {101: {b'ENVELOPE': mock_envelope}} |
|
|
|
year = organizer.get_message_year(101, use_header_date=True) |
|
|
|
assert year == 2024 |
|
mock_imap_client.fetch.assert_called_once_with([101], ['ENVELOPE']) |
|
|
|
def test_get_message_year__missing_or_error__returns_none(tmp_env, mock_imap_client): |
|
"""Ensures missing fields or errors return None""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Missing data |
|
mock_imap_client.fetch.return_value = {} |
|
assert organizer.get_message_year(999) is None |
|
|
|
# Fetch error |
|
mock_imap_client.fetch.side_effect = Exception("Fetch failed") |
|
assert organizer.get_message_year(101) is None |
|
|
|
# ================================================================== |
|
# IMAP: MOVE MESSAGE |
|
# ================================================================== |
|
|
|
@pytest.mark.parametrize("capabilities,uses_move,uses_copy,uses_uidplus_expunge", [ |
|
([b'IMAP4REV1', b'MOVE', b'UIDPLUS'], True, False, False), # Best: MOVE |
|
([b'IMAP4REV1', b'MOVE'], True, False, False), # MOVE only |
|
([b'IMAP4REV1', b'UIDPLUS'], False, True, True), # UIDPLUS only (safe expunge) |
|
([b'IMAP4REV1'], False, True, False), # Neither (unsafe expunge) |
|
]) |
|
def test_move__capability_matrix__correct_path( |
|
tmp_env, mock_imap_client, capabilities, uses_move, uses_copy, uses_uidplus_expunge |
|
): |
|
"""Ensures move uses correct IMAP path based on server capabilities""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Destination exists |
|
mock_imap_client.folder_status.side_effect = None |
|
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]} |
|
|
|
mock_imap_client.capabilities.return_value = capabilities |
|
|
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
if uses_move: |
|
mock_imap_client.move.assert_called_once() |
|
mock_imap_client.copy.assert_not_called() |
|
elif uses_copy: |
|
mock_imap_client.copy.assert_called_once() |
|
mock_imap_client.delete_messages.assert_called_once() |
|
if uses_uidplus_expunge: |
|
mock_imap_client.expunge.assert_called_once_with([101]) |
|
else: |
|
mock_imap_client.expunge.assert_called_once_with() |
|
|
|
def test_move__destination_missing__critical_halt(tmp_env, mock_imap_client): |
|
"""Ensures missing destination triggers critical halt immediately""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Destination doesn't exist |
|
mock_imap_client.folder_status.side_effect = Exception("Not found") |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
|
|
with pytest.raises(RuntimeError, match="HALTED.*does not exist"): |
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
assert halt_file.exists() |
|
assert "move_message" in halt_file.read_text() |
|
mock_imap_client.move.assert_not_called() |
|
|
|
def test_move__move_error__critical_halt(tmp_env, mock_imap_client): |
|
"""Ensures move operation error triggers critical halt""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Destination exists |
|
mock_imap_client.folder_status.side_effect = None |
|
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]} |
|
|
|
# Move fails |
|
mock_imap_client.move.side_effect = Exception("Connection lost") |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
|
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
assert halt_file.exists() |
|
assert "move_message" in halt_file.read_text() |
|
|
|
def test_move__dry_run__no_side_effects(tmp_env, mock_imap_client): |
|
"""Ensures dry-run mode logs but doesn't move""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
organizer.move_message(101, "Test/2025", dry_run=True) |
|
|
|
mock_imap_client.move.assert_not_called() |
|
mock_imap_client.copy.assert_not_called() |
|
|
|
def test_move__without_uidplus__warns(tmp_env, mock_imap_client): |
|
"""Ensures missing UIDPLUS logs warning about unsafe expunge""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = Mock() |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Destination exists |
|
mock_imap_client.folder_status.side_effect = None |
|
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]} |
|
|
|
# No MOVE, no UIDPLUS |
|
mock_imap_client.capabilities.return_value = [b'IMAP4REV1'] |
|
|
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
# Should warn |
|
logger.warning.assert_called() |
|
warning_msg = logger.warning.call_args[0][0] |
|
assert "UIDPLUS" in warning_msg or "all deleted messages" in warning_msg.lower() |
|
|
|
# ================================================================== |
|
# IMAP: file_by_year |
|
# ================================================================== |
|
|
|
@patch('imap_organizer.datetime') |
|
def test_file_by_year__current_year__filters_correctly(mock_datetime_module, tmp_env, mock_imap_client, message_sets): |
|
"""Ensures current year mode files only current year messages |
|
|
|
Uses fixed year 2025 to avoid year-boundary flakes. |
|
""" |
|
# Fix current year to 2025 to avoid year-boundary flakes |
|
# Mock needs to return proper datetime for both .year and .strftime() |
|
mock_now = Mock() |
|
mock_now.year = 2025 |
|
mock_now.strftime.return_value = "20250615_120000" # Fixed timestamp for logs |
|
mock_datetime_module.datetime.now.return_value = mock_now |
|
|
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [101, 102, 103, 104] |
|
mock_imap_client.fetch.return_value = { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
102: {b'INTERNALDATE': datetime(2025, 3, 20)}, |
|
103: {b'INTERNALDATE': datetime(2024, 6, 1)}, |
|
104: {b'INTERNALDATE': datetime(2024, 12, 15)}, |
|
} |
|
|
|
total, filed = organizer.file_by_year("TestFolder", year=None, backfill=False, dry_run=True) |
|
|
|
assert total == 4 |
|
assert filed == 2 # Only current year |
|
|
|
def test_file_by_year__specific_year__filters_correctly(tmp_env, mock_imap_client, message_sets): |
|
"""Ensures specific year mode files only that year's messages""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [101, 102, 103] |
|
mock_imap_client.fetch.return_value = message_sets["mixed_years"] |
|
|
|
total, filed = organizer.file_by_year("TestFolder", year=2024, backfill=False, dry_run=True) |
|
|
|
assert total == 3 |
|
assert filed == 1 # Only 2024 |
|
|
|
def test_file_by_year__backfill__processes_all_years(tmp_env, mock_imap_client, message_sets): |
|
"""Ensures backfill mode processes all years""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [101, 102, 103] |
|
mock_imap_client.fetch.return_value = message_sets["mixed_years"] |
|
|
|
total, filed = organizer.file_by_year("TestFolder", year=None, backfill=True, dry_run=True) |
|
|
|
assert total == 3 |
|
assert filed == 3 # All years |
|
|
|
def test_file_by_year__empty_folder__returns_zero(tmp_env, mock_imap_client): |
|
"""Ensures empty folder returns (0, 0)""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [] |
|
|
|
total, filed = organizer.file_by_year("EmptyFolder", dry_run=True) |
|
|
|
assert total == 0 |
|
assert filed == 0 |
|
|
|
def test_file_by_year__missing_year__skips_message(tmp_env, mock_imap_client, message_sets): |
|
"""Ensures messages with undetermined year are skipped""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [101, 102, 103] |
|
mock_imap_client.fetch.return_value = message_sets["with_missing"] |
|
|
|
total, filed = organizer.file_by_year("TestFolder", backfill=True, dry_run=True) |
|
|
|
assert total == 3 |
|
assert filed == 2 # Skip UID 102 (missing date) |
|
|
|
def test_file_by_year__dry_run__no_move_or_create(tmp_env, mock_imap_client): |
|
"""Ensures dry-run doesn't call create or move""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [101] |
|
mock_imap_client.fetch.return_value = { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
} |
|
|
|
total, filed = organizer.file_by_year("TestFolder", dry_run=True) |
|
|
|
mock_imap_client.create_folder.assert_not_called() |
|
mock_imap_client.move.assert_not_called() |
|
mock_imap_client.copy.assert_not_called() |
|
|
|
def test_file_by_year__delimiter_used_in_destination(tmp_env, mock_imap_client): |
|
"""Ensures delimiter is used when constructing destination folder path""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Setup list_folders to return "." delimiter |
|
mock_imap_client.list_folders.return_value = [ |
|
([b'\\HasNoChildren'], b'.', 'Root'), |
|
] |
|
# Mock folder_status to indicate destination folder exists after creation |
|
mock_imap_client.folder_status.side_effect = [ |
|
Exception("Folder not found"), # First check: doesn't exist |
|
(b'OK', [b'EXISTS 0']) # After creation: exists |
|
] |
|
mock_imap_client.search.return_value = [101] |
|
mock_imap_client.fetch.return_value = { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
} |
|
|
|
total, filed = organizer.file_by_year("Root", year=2025, dry_run=False) |
|
|
|
# Assert move called with delimiter-separated path "Root.2025" |
|
assert mock_imap_client.move.called or mock_imap_client.copy.called |
|
if mock_imap_client.move.called: |
|
call_args = mock_imap_client.move.call_args[0] |
|
assert call_args[1] == "Root.2025", f"Expected 'Root.2025', got '{call_args[1]}'" |
|
else: |
|
call_args = mock_imap_client.copy.call_args[0] |
|
assert call_args[1] == "Root.2025", f"Expected 'Root.2025', got '{call_args[1]}'" |
|
|
|
def test_file_by_year__folder_created_once_cache(tmp_env, mock_imap_client): |
|
"""Ensures folder is created only once per year (cache working)""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Mock folder_status: folder doesn't exist first time, then exists |
|
mock_imap_client.folder_status.side_effect = [ |
|
Exception("Folder not found"), # First check: doesn't exist |
|
(b'OK', [b'EXISTS 0']), # After creation: exists (for first move) |
|
(b'OK', [b'EXISTS 0']), # Second move: still exists |
|
(b'OK', [b'EXISTS 0']), # Third move: still exists |
|
] |
|
# 3 messages from same year (2025) |
|
mock_imap_client.search.return_value = [101, 102, 103] |
|
mock_imap_client.fetch.return_value = { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
102: {b'INTERNALDATE': datetime(2025, 3, 20)}, |
|
103: {b'INTERNALDATE': datetime(2025, 6, 10)}, |
|
} |
|
|
|
total, filed = organizer.file_by_year("TestFolder", year=2025, dry_run=False) |
|
|
|
# Assert create_folder called ONCE (cached for subsequent messages) |
|
assert mock_imap_client.create_folder.call_count == 1 |
|
# Assert move called 3 times (one per message) |
|
move_calls = mock_imap_client.move.call_count + mock_imap_client.copy.call_count |
|
assert move_calls == 3 |
|
assert total == 3 |
|
assert filed == 3 |
|
|
|
# ================================================================== |
|
# CIRCUIT BREAKER |
|
# ================================================================== |
|
|
|
@pytest.mark.parametrize("max_failures", [3, 5]) |
|
def test_circuit__non_critical__counts_to_threshold(tmp_env, mock_imap_client, max_failures): |
|
"""Ensures non-critical failures count to configurable threshold""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: max_failures if k == "max_failures" else d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
assert organizer.max_failures == max_failures |
|
|
|
# Mock create failures |
|
mock_imap_client.folder_status.side_effect = Exception("Not found") |
|
mock_imap_client.create_folder.side_effect = Exception("Permission denied") |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
|
|
# Count up to threshold-1 |
|
for i in range(max_failures - 1): |
|
organizer.ensure_folder(f"Folder{i}/2025", dry_run=False) |
|
assert organizer.failure_count == i + 1 |
|
assert not halt_file.exists() |
|
|
|
# Threshold reached |
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.ensure_folder(f"Folder{max_failures}/2025", dry_run=False) |
|
|
|
assert halt_file.exists() |
|
|
|
def test_circuit__critical__halts_immediately(tmp_env, mock_imap_client): |
|
"""Ensures critical failures halt immediately without counting""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Destination missing (critical failure) |
|
mock_imap_client.folder_status.side_effect = Exception("Not found") |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
|
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
assert halt_file.exists() |
|
assert organizer.failure_count == 1 # Still increments |
|
|
|
def test_circuit__halt_exists__blocks_operations(tmp_env, mock_imap_client): |
|
"""Ensures existing HALT file blocks operations immediately""" |
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
halt_file.write_text("Previous failure") |
|
|
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.ensure_folder("Test/2025", dry_run=False) |
|
|
|
def test_circuit__halt_exists__blocks_file_by_year(tmp_env, mock_imap_client): |
|
"""Ensures existing HALT file blocks file_by_year immediately""" |
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
halt_file.write_text("Previous failure") |
|
|
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.search.return_value = [101] |
|
mock_imap_client.fetch.return_value = { |
|
101: {b'INTERNALDATE': datetime(2025, 1, 15)}, |
|
} |
|
|
|
# Should raise immediately due to HALT file, even though we have messages |
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.file_by_year("TestFolder", dry_run=False) |
|
|
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
def test_circuit__threshold_reached__creates_halt_file(tmp_env, mock_imap_client): |
|
"""Ensures HALT file created when threshold reached""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: 2 if k == "max_failures" else d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.folder_status.side_effect = Exception("Not found") |
|
mock_imap_client.create_folder.side_effect = Exception("Fail") |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
|
|
organizer.ensure_folder("Folder1/2025", dry_run=False) |
|
|
|
with pytest.raises(RuntimeError, match="HALTED"): |
|
organizer.ensure_folder("Folder2/2025", dry_run=False) |
|
|
|
assert halt_file.exists() |
|
|
|
def test_circuit__halt_file_content__includes_operation_and_error(tmp_env, mock_imap_client): |
|
"""Ensures HALT file content includes operation name and error""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Trigger critical failure |
|
mock_imap_client.folder_status.side_effect = Exception("Not found") |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
|
|
with pytest.raises(RuntimeError): |
|
organizer.move_message(101, "Test/2025", dry_run=False) |
|
|
|
content = halt_file.read_text() |
|
assert "move_message" in content |
|
assert "does not exist" in content |
|
|
|
def test_circuit__dry_run__does_not_trigger(tmp_env, mock_imap_client): |
|
"""Ensures dry-run never triggers circuit breaker""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
# Multiple dry-run operations |
|
organizer.ensure_folder("Test1/2025", dry_run=True) |
|
organizer.move_message(101, "Test2/2025", dry_run=True) |
|
organizer.ensure_folder("Test3/2025", dry_run=True) |
|
|
|
halt_file = tmp_env / ".imap-organizer.HALT" |
|
assert not halt_file.exists() |
|
assert organizer.failure_count == 0 |
|
|
|
# ================================================================== |
|
# IMAP: CLOSE |
|
# ================================================================== |
|
|
|
def test_close__success__calls_logout(tmp_env, mock_imap_client): |
|
"""Ensures close calls logout""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
organizer.close() |
|
|
|
mock_imap_client.logout.assert_called_once() |
|
|
|
def test_close__error__swallows_exception(tmp_env, mock_imap_client): |
|
"""Ensures close swallows logout exceptions""" |
|
config = Mock() |
|
config.get.side_effect = lambda s, k, d=None: d |
|
logger = setup_logging(verbose=False) |
|
organizer = EmailOrganizer(config, logger) |
|
organizer.client = mock_imap_client |
|
|
|
mock_imap_client.logout.side_effect = Exception("Logout failed") |
|
|
|
# Should not raise |
|
organizer.close() |
|
|
|
# ================================================================== |
|
# CLI: file |
|
# ================================================================== |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__dry_run_default__shows_dry_run_message(MockOrganizer, tmp_env, runner): |
|
"""Ensures file command defaults to dry-run with message""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (10, 5) |
|
|
|
result = runner.invoke(app, ["file"]) |
|
|
|
assert result.exit_code == 0 |
|
assert "DRY-RUN" in result.stdout |
|
# Verify dry_run=True passed |
|
calls = mock_org.file_by_year.call_args_list |
|
assert all(call.kwargs.get('dry_run', True) for call in calls) |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__apply__moves_emails(MockOrganizer, tmp_env, runner): |
|
"""Ensures --apply flag actually moves emails""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (10, 5) |
|
|
|
result = runner.invoke(app, ["file", "--apply"]) |
|
|
|
assert result.exit_code == 0 |
|
assert "APPLY" in result.stdout |
|
# Verify dry_run=False passed |
|
calls = mock_org.file_by_year.call_args_list |
|
assert all(call.kwargs['dry_run'] == False for call in calls) |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__backfill__processes_all_years(MockOrganizer, tmp_env, runner): |
|
"""Ensures --backfill flag processes all years""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (10, 10) |
|
|
|
result = runner.invoke(app, ["file", "--backfill"]) |
|
|
|
assert result.exit_code == 0 |
|
# Verify backfill=True passed |
|
call = mock_org.file_by_year.call_args |
|
assert call.kwargs['backfill'] == True |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__year_flag__processes_specific_years(MockOrganizer, tmp_env, runner): |
|
"""Ensures --year flag processes specific years""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (5, 5) |
|
|
|
result = runner.invoke(app, ["file", "--year", "2024", "--year", "2023"]) |
|
|
|
assert result.exit_code == 0 |
|
# Verify year parameter passed |
|
calls = mock_org.file_by_year.call_args_list |
|
years_called = [call.kwargs['year'] for call in calls] |
|
assert 2024 in years_called |
|
assert 2023 in years_called |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__use_header_date__passes_flag(MockOrganizer, tmp_env, runner): |
|
"""Ensures --use-header-date flag is passed to file_by_year""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (10, 5) |
|
|
|
result = runner.invoke(app, ["file", "--use-header-date"]) |
|
|
|
assert result.exit_code == 0 |
|
# Verify use_header_date=True passed |
|
call = mock_org.file_by_year.call_args |
|
assert call.kwargs['use_header_date'] == True |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__folder_arg__overrides_config(MockOrganizer, tmp_env, runner): |
|
"""Ensures positional folder arguments override config default""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["ConfigFolder"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (10, 5) |
|
|
|
result = runner.invoke(app, ["file", "CLIFolder1", "CLIFolder2"]) |
|
|
|
assert result.exit_code == 0 |
|
# Verify CLI folders used (not config default) |
|
calls = mock_org.file_by_year.call_args_list |
|
# Extract folder from each call (first positional arg or 'folder' kwarg) |
|
folders_called = [] |
|
for call in calls: |
|
if call.args: |
|
folders_called.append(call.args[0]) |
|
elif 'folder' in call.kwargs: |
|
folders_called.append(call.kwargs['folder']) |
|
assert "CLIFolder1" in folders_called |
|
assert "CLIFolder2" in folders_called |
|
assert "ConfigFolder" not in folders_called |
|
|
|
def test_cli_file__no_folders__exits_1(tmp_env, runner): |
|
"""Ensures missing folders exits with code 1""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n') |
|
# No [folders] section |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
result = runner.invoke(app, ["file"]) |
|
|
|
# Primary assertion: exit code (error handling behavior) |
|
assert result.exit_code == 1 |
|
# Note: Rich console stderr output not well-captured by CliRunner |
|
|
|
def test_cli_file__missing_password__exits_1(tmp_env, runner): |
|
"""Ensures missing IMAP_PASSWORD exits with code 1""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
# No .env file, no IMAP_PASSWORD |
|
|
|
result = runner.invoke(app, ["file"]) |
|
|
|
# Primary assertion: exit code (error handling behavior) |
|
assert result.exit_code == 1 |
|
# Note: Rich console stderr output not well-captured by CliRunner |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__config_option__loads_paired_env(MockOrganizer, tmp_env, monkeypatch, runner): |
|
"""Ensures --config option loads paired env file""" |
|
# Clean env |
|
monkeypatch.delenv("IMAP_PASSWORD", raising=False) |
|
|
|
config_file = tmp_env / ".imap-organizer-dev.toml" |
|
config_file.write_text('[connection]\nhost = "dev.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env-dev" |
|
env_file.write_text("IMAP_PASSWORD=dev-password\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.file_by_year.return_value = (1, 1) |
|
|
|
result = runner.invoke(app, ["file", "--config", str(config_file)]) |
|
|
|
assert result.exit_code == 0 |
|
# Verify env loaded |
|
assert os.getenv("IMAP_PASSWORD") == "dev-password" |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_file__verbose__shows_traceback(MockOrganizer, tmp_env, runner): |
|
"""Ensures --verbose flag shows traceback on errors""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.connect.side_effect = Exception("Connection failed") |
|
|
|
result = runner.invoke(app, ["file", "--verbose"]) |
|
|
|
# Primary assertion: exit code (error handling behavior) |
|
assert result.exit_code == 1 |
|
# Note: Rich console stderr output not well-captured by CliRunner |
|
# Exit code verification is sufficient per INSTRUCTIONS.md section 10 |
|
|
|
# ================================================================== |
|
# CLI: list_folders |
|
# ================================================================== |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_list__success__displays_table_and_count(MockOrganizer, tmp_env, runner): |
|
"""Ensures list-folders displays table with folder names and count""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.list_folders.return_value = [ |
|
([b'\\HasNoChildren'], '/', 'INBOX'), |
|
([b'\\HasNoChildren'], '/', 'Einkauf'), |
|
] |
|
|
|
result = runner.invoke(app, ["list-folders"]) |
|
|
|
assert result.exit_code == 0 |
|
assert "INBOX" in result.stdout |
|
assert "Einkauf" in result.stdout |
|
assert "2" in result.stdout # Count |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_list__connection_error__exits_1(MockOrganizer, tmp_env, runner): |
|
"""Ensures connection errors exit with code 1""" |
|
config_file = tmp_env / ".imap-organizer.toml" |
|
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n') |
|
|
|
env_file = tmp_env / ".env" |
|
env_file.write_text("IMAP_PASSWORD=test\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.connect.side_effect = ValueError("Missing IMAP host") |
|
|
|
result = runner.invoke(app, ["list-folders"]) |
|
|
|
# Primary assertion: exit code (error handling behavior) |
|
assert result.exit_code == 1 |
|
# Note: Rich console stderr output not well-captured by CliRunner |
|
# Exit code verification is sufficient per INSTRUCTIONS.md section 10 |
|
|
|
@patch('imap_organizer.EmailOrganizer') |
|
def test_cli_list__config_option__loads_paired_env(MockOrganizer, tmp_env, monkeypatch, runner): |
|
"""Ensures --config option loads paired env file""" |
|
monkeypatch.delenv("IMAP_PASSWORD", raising=False) |
|
|
|
config_file = tmp_env / ".imap-organizer-sandbox.toml" |
|
config_file.write_text('[connection]\nhost = "sandbox.local"\nuser = "[email protected]"\n') |
|
|
|
env_file = tmp_env / ".env-sandbox" |
|
env_file.write_text("IMAP_PASSWORD=sandbox-password\n") |
|
|
|
mock_org = Mock() |
|
MockOrganizer.return_value = mock_org |
|
mock_org.list_folders.return_value = [([b'\\HasNoChildren'], '/', 'INBOX')] |
|
|
|
result = runner.invoke(app, ["list-folders", "--config", str(config_file)]) |
|
|
|
assert result.exit_code == 0 |
|
assert os.getenv("IMAP_PASSWORD") == "sandbox-password" |