#43 — Имя файла и пути по умолчанию: - resolve_output_path() в yaml_config.py: резолвит --output с учётом output.dir, filename_template, default_format из YAML-конфига - Bare format (xlsx/odt/...) → путь по шаблону - Без расширения → автодописывается default_format (.xlsx) - Config.get_output_dir/filename/default_format() #47 — datetime precision и дедупликация: - period.last_used.from/to в YAML-конфиге и AppConfig - period.precision: date|datetime - _compute_dedup_cutoff() в cli.py: при precision=datetime вычисляет cutoff из period.last_used.to - _parse_datetime() + AND-логика дедупликации в client.py: запись исключается если created_on И updated_on < cutoff - Пропуск issues без часов после дедупликации Closes #43 Closes #47
209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
import logging
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
from redmine_reporter.yaml_config import (
|
||
check_file_permissions,
|
||
ensure_config_dir,
|
||
expand_filename_template,
|
||
resolve_env_vars,
|
||
)
|
||
|
||
|
||
class TestResolveEnvVars:
|
||
"""Tests for ${VAR} resolution."""
|
||
|
||
@mock.patch.dict(os.environ, {"MY_VAR": "hello"}, clear=True)
|
||
def test_replaces_var_with_env_value(self):
|
||
assert resolve_env_vars("prefix_${MY_VAR}_suffix") == "prefix_hello_suffix"
|
||
|
||
@mock.patch.dict(os.environ, {}, clear=True)
|
||
def test_missing_var_returns_empty_and_warns(self, caplog):
|
||
with caplog.at_level(logging.WARNING):
|
||
result = resolve_env_vars("${MISSING_VAR}")
|
||
assert result == ""
|
||
assert "MISSING_VAR" in caplog.text
|
||
|
||
def test_no_braces_returns_unchanged(self):
|
||
assert resolve_env_vars("plain text no vars") == "plain text no vars"
|
||
|
||
@mock.patch.dict(os.environ, {"A": "${B}", "B": "final"}, clear=True)
|
||
def test_non_recursive_no_double_resolution(self):
|
||
# A → literal "${B}", B → "final". Non-recursive means we get "${B}", not "final".
|
||
result = resolve_env_vars("${A}")
|
||
assert result == "${B}"
|
||
|
||
|
||
class TestEnsureConfigDir:
|
||
"""Tests for config directory creation."""
|
||
|
||
def test_creates_dir_with_0700(self):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
config_dir = Path(tmp) / "test_config"
|
||
result = ensure_config_dir(config_dir)
|
||
assert result == config_dir
|
||
assert result.exists()
|
||
assert result.is_dir()
|
||
mode = result.stat().st_mode & 0o777
|
||
assert mode == 0o700
|
||
|
||
def test_noop_if_dir_exists(self):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
config_dir = Path(tmp) / "existing"
|
||
config_dir.mkdir(mode=0o700)
|
||
mtime_before = config_dir.stat().st_mtime
|
||
result = ensure_config_dir(config_dir)
|
||
assert result == config_dir
|
||
assert config_dir.stat().st_mtime == mtime_before
|
||
|
||
|
||
class TestCheckFilePermissions:
|
||
"""Tests for config file permission checks."""
|
||
|
||
def test_warns_on_permissive_file(self):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
f = Path(tmp) / "open.yml"
|
||
f.write_text("key: value")
|
||
f.chmod(0o644)
|
||
warnings = check_file_permissions(f)
|
||
assert len(warnings) >= 1
|
||
assert "0644" in warnings[0] or "permissions" in warnings[0].lower()
|
||
|
||
def test_silent_on_0600(self):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
f = Path(tmp) / "secure.yml"
|
||
f.write_text("key: value")
|
||
f.chmod(0o600)
|
||
warnings = check_file_permissions(f)
|
||
assert warnings == []
|
||
|
||
|
||
class TestExpandFilenameTemplate:
|
||
"""Tests for filename template expansion."""
|
||
|
||
def test_expands_all_placeholders(self):
|
||
result = expand_filename_template(
|
||
"report_{author}_{from}_{to}.{ext}",
|
||
author="Кокос А.А.",
|
||
from_date="2026-06-01",
|
||
to_date="2026-06-30",
|
||
ext="xlsx",
|
||
)
|
||
assert result == "report_Кокос_А.А._2026-06-01_2026-06-30.xlsx"
|
||
|
||
def test_date_placeholder_dd_mm_yyyy(self):
|
||
result = expand_filename_template(
|
||
"отчёт_{date}.{ext}",
|
||
author="Кокос А.А.",
|
||
from_date="2026-03-01",
|
||
to_date="2026-03-31",
|
||
ext="odt",
|
||
)
|
||
assert result == "отчёт_31_03_2026.odt"
|
||
|
||
def test_no_placeholders_returns_unchanged(self):
|
||
result = expand_filename_template(
|
||
"report.odt",
|
||
author="Кокос А.А.",
|
||
from_date="2026-01-01",
|
||
to_date="2026-01-31",
|
||
ext="odt",
|
||
)
|
||
assert result == "report.odt"
|
||
|
||
def test_unknown_placeholder_left_as_is(self):
|
||
result = expand_filename_template(
|
||
"{author}_{unknown}.{ext}",
|
||
author="Кокос А.А.",
|
||
from_date="2026-01-01",
|
||
to_date="2026-01-31",
|
||
ext="xlsx",
|
||
)
|
||
assert result == "Кокос_А.А._{unknown}.xlsx"
|
||
|
||
|
||
class TestResolveOutputPath:
|
||
"""Tests for resolve_output_path()."""
|
||
|
||
def test_none_returns_none(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
assert resolve_output_path(None) is None
|
||
|
||
def test_explicit_path_with_known_extension_is_unchanged(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path("/tmp/report.xlsx")
|
||
assert result == "/tmp/report.xlsx"
|
||
|
||
def test_explicit_path_with_unknown_extension_is_unchanged(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path("report.odt")
|
||
assert result == "report.odt"
|
||
|
||
def test_no_extension_appends_default_format(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path("report", default_format="xlsx")
|
||
assert result == "report.xlsx"
|
||
|
||
def test_no_extension_appends_custom_default_format(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path("file", default_format="csv")
|
||
assert result == "file.csv"
|
||
|
||
def test_bare_format_name_resolves_to_template_path(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path(
|
||
"xlsx",
|
||
output_dir="/tmp/reports",
|
||
filename_template="{date}.{ext}",
|
||
author="Кокос А.А.",
|
||
from_date="2026-06-01",
|
||
to_date="2026-06-30",
|
||
default_format="xlsx",
|
||
)
|
||
assert result == "/tmp/reports/30_06_2026.xlsx"
|
||
|
||
def test_bare_format_name_odt_resolves_to_template_path(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path(
|
||
"odt",
|
||
output_dir="~/reports",
|
||
filename_template="{author}_{from}_{to}.{ext}",
|
||
author="Кокос А.А.",
|
||
from_date="2026-06-01",
|
||
to_date="2026-06-30",
|
||
default_format="xlsx",
|
||
)
|
||
assert result.startswith("/") and result.endswith(
|
||
"/reports/Кокос_А.А._2026-06-01_2026-06-30.odt"
|
||
)
|
||
assert "~" not in result
|
||
|
||
def test_bare_format_name_with_empty_output_dir_uses_cwd(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path(
|
||
"csv",
|
||
output_dir="",
|
||
filename_template="report.{ext}",
|
||
author="A",
|
||
from_date="2026-01-01",
|
||
to_date="2026-01-31",
|
||
)
|
||
assert not result.startswith("/")
|
||
assert "report.csv" in result
|
||
|
||
def test_unknown_bare_format_is_treated_as_path(self):
|
||
from redmine_reporter.yaml_config import resolve_output_path
|
||
|
||
result = resolve_output_path("unknown_format")
|
||
assert result == "unknown_format.xlsx"
|