528 lines
14 KiB
Python
528 lines
14 KiB
Python
import os
|
|
import time
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import mirro.main as mirro
|
|
|
|
|
|
# ============================================================
|
|
# get_version
|
|
# ============================================================
|
|
|
|
|
|
def test_get_version_found(monkeypatch):
|
|
monkeypatch.setattr(mirro.importlib.metadata, "version", lambda _: "1.2.3")
|
|
assert mirro.get_version() == "1.2.3"
|
|
|
|
|
|
def test_get_version_not_found(monkeypatch):
|
|
def raiser(_):
|
|
raise mirro.importlib.metadata.PackageNotFoundError
|
|
|
|
monkeypatch.setattr(mirro.importlib.metadata, "version", raiser)
|
|
assert mirro.get_version() == "unknown"
|
|
|
|
|
|
# ============================================================
|
|
# read_file / write_file
|
|
# ============================================================
|
|
|
|
|
|
def test_read_file_exists(tmp_path):
|
|
p = tmp_path / "x.txt"
|
|
p.write_text("hello\n", encoding="utf-8")
|
|
assert mirro.read_file(p) == "hello\n"
|
|
|
|
|
|
def test_read_file_missing(tmp_path):
|
|
assert mirro.read_file(tmp_path / "nope.txt") == ""
|
|
|
|
|
|
def test_write_file(tmp_path):
|
|
p = tmp_path / "y.txt"
|
|
mirro.write_file(p, "data")
|
|
assert p.read_text(encoding="utf-8") == "data"
|
|
|
|
|
|
# ============================================================
|
|
# strip_mirro_header
|
|
# ============================================================
|
|
|
|
|
|
def test_strip_header_removes_header():
|
|
header_text = (
|
|
"# ---------------------------------------------------\n"
|
|
"# mirro backup\n"
|
|
"# something\n"
|
|
"# ---------------------------------------------------\n"
|
|
"\n"
|
|
"#!/bin/bash\n"
|
|
"echo hi\n"
|
|
)
|
|
|
|
out = mirro.strip_mirro_header(header_text)
|
|
assert out.startswith("#!/bin/bash")
|
|
assert "mirro backup" not in out
|
|
|
|
|
|
def test_strip_header_preserves_shebang():
|
|
text = "#!/usr/bin/env python3\nprint('hi')\n"
|
|
out = mirro.strip_mirro_header(text)
|
|
assert out == text
|
|
|
|
|
|
def test_strip_header_non_header_file():
|
|
text = "# just a comment\nvalue\n"
|
|
out = mirro.strip_mirro_header(text)
|
|
assert out == text
|
|
|
|
|
|
# ============================================================
|
|
# backup_original
|
|
# ============================================================
|
|
|
|
|
|
def test_backup_original(tmp_path, monkeypatch):
|
|
original_path = tmp_path / "a.txt"
|
|
original_content = "ABC"
|
|
backup_dir = tmp_path / "backups"
|
|
|
|
monkeypatch.setattr(
|
|
time,
|
|
"gmtime",
|
|
lambda: time.struct_time((2023, 1, 2, 3, 4, 5, 0, 0, 0)),
|
|
)
|
|
monkeypatch.setattr(
|
|
time,
|
|
"strftime",
|
|
lambda fmt, _: {
|
|
"%Y-%m-%d %H:%M:%S UTC": "2023-01-02 03:04:05 UTC",
|
|
"%Y%m%dT%H%M%S": "20230102T030405",
|
|
}[fmt],
|
|
)
|
|
|
|
backup_path = mirro.backup_original(
|
|
original_path, original_content, backup_dir
|
|
)
|
|
|
|
assert backup_path.exists()
|
|
text = backup_path.read_text()
|
|
assert "mirro backup" in text
|
|
assert "Original file" in text
|
|
assert "ABC" in text
|
|
|
|
|
|
# ============================================================
|
|
# Helper to simulate main()
|
|
# ============================================================
|
|
|
|
|
|
def simulate_main(
|
|
monkeypatch,
|
|
capsys,
|
|
args,
|
|
*,
|
|
editor="nano",
|
|
start_content=None,
|
|
edited_content=None,
|
|
file_exists=True,
|
|
override_access=None,
|
|
):
|
|
monkeypatch.setenv("EDITOR", editor)
|
|
|
|
def fake_call(cmd):
|
|
temp = Path(cmd[-1])
|
|
if edited_content is None:
|
|
temp.write_text(start_content or "", encoding="utf-8")
|
|
else:
|
|
temp.write_text(edited_content, encoding="utf-8")
|
|
return 0
|
|
|
|
monkeypatch.setattr(subprocess, "call", fake_call)
|
|
|
|
if override_access:
|
|
monkeypatch.setattr(os, "access", override_access)
|
|
else:
|
|
monkeypatch.setattr(os, "access", lambda p, m: True)
|
|
|
|
target = Path(args[-1]).expanduser().resolve()
|
|
if file_exists:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(start_content or "", encoding="utf-8")
|
|
|
|
with patch("sys.argv", ["mirro"] + args):
|
|
result = mirro.main()
|
|
|
|
out = capsys.readouterr().out
|
|
return result, out
|
|
|
|
|
|
# ============================================================
|
|
# main: missing positional file
|
|
# ============================================================
|
|
|
|
|
|
def test_main_missing_argument(capsys):
|
|
with patch("sys.argv", ["mirro"]):
|
|
with pytest.raises(SystemExit):
|
|
mirro.main()
|
|
assert (
|
|
"the following arguments are required: file" in capsys.readouterr().err
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# main: unchanged file
|
|
# ============================================================
|
|
|
|
|
|
def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
|
target = tmp_path / "file.txt"
|
|
target.write_text("hello\n")
|
|
|
|
def fake_call(cmd):
|
|
temp = Path(cmd[-1])
|
|
temp.write_text("hello\n")
|
|
|
|
monkeypatch.setenv("EDITOR", "nano")
|
|
monkeypatch.setattr(subprocess, "call", fake_call)
|
|
monkeypatch.setattr(os, "access", lambda p, m: True)
|
|
|
|
with patch("sys.argv", ["mirro", str(target)]):
|
|
mirro.main()
|
|
|
|
assert "file hasn't changed" in capsys.readouterr().out
|
|
|
|
|
|
# ============================================================
|
|
# main: changed file
|
|
# ============================================================
|
|
|
|
|
|
def test_main_existing_changed(tmp_path, monkeypatch, capsys):
|
|
target = tmp_path / "f2.txt"
|
|
|
|
result, out = simulate_main(
|
|
monkeypatch,
|
|
capsys,
|
|
args=[str(target)],
|
|
start_content="old\n",
|
|
edited_content="new\n",
|
|
file_exists=True,
|
|
)
|
|
|
|
assert "file changed; original backed up at" in out
|
|
assert target.read_text() == "new\n"
|
|
|
|
|
|
# ============================================================
|
|
# main: new file unchanged
|
|
# ============================================================
|
|
|
|
|
|
def test_main_new_file_unchanged(tmp_path, monkeypatch, capsys):
|
|
new = tmp_path / "new.txt"
|
|
|
|
result, out = simulate_main(
|
|
monkeypatch,
|
|
capsys,
|
|
args=[str(new)],
|
|
start_content=None,
|
|
edited_content="This is a new file created with 'mirro'!\n",
|
|
file_exists=False,
|
|
)
|
|
|
|
assert "file hasn't changed" in out
|
|
assert not new.exists()
|
|
|
|
|
|
# ============================================================
|
|
# main: new file changed
|
|
# ============================================================
|
|
|
|
|
|
def test_main_new_file_changed(tmp_path, monkeypatch, capsys):
|
|
new = tmp_path / "new2.txt"
|
|
|
|
result, out = simulate_main(
|
|
monkeypatch,
|
|
capsys,
|
|
args=[str(new)],
|
|
start_content=None,
|
|
edited_content="XYZ\n",
|
|
file_exists=False,
|
|
)
|
|
|
|
assert "file changed; original backed up at" in out
|
|
assert new.read_text() == "XYZ\n"
|
|
|
|
|
|
# ============================================================
|
|
# Permission denied branches
|
|
# ============================================================
|
|
|
|
|
|
def test_main_permission_denied_existing(tmp_path, monkeypatch, capsys):
|
|
tgt = tmp_path / "blocked.txt"
|
|
tgt.write_text("hi")
|
|
|
|
monkeypatch.setenv("EDITOR", "nano")
|
|
monkeypatch.setattr(os, "access", lambda p, m: False)
|
|
|
|
with patch("sys.argv", ["mirro", str(tgt)]):
|
|
result = mirro.main()
|
|
|
|
assert result == 1
|
|
assert "Need elevated privileges to open" in capsys.readouterr().out
|
|
|
|
|
|
def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
|
|
new = tmp_path / "sub/xx.txt"
|
|
new.parent.mkdir(parents=True)
|
|
|
|
def fake_access(path, mode):
|
|
return False if path == new.parent else True
|
|
|
|
monkeypatch.setattr(os, "access", fake_access)
|
|
monkeypatch.setenv("EDITOR", "nano")
|
|
|
|
with patch("sys.argv", ["mirro", str(new)]):
|
|
result = mirro.main()
|
|
|
|
assert result == 1
|
|
assert "Need elevated privileges to create" in capsys.readouterr().out
|
|
|
|
|
|
# ============================================================
|
|
# --list
|
|
# ============================================================
|
|
|
|
|
|
def test_main_list_no_dir(tmp_path, capsys):
|
|
with patch(
|
|
"sys.argv", ["mirro", "--list", "--backup-dir", str(tmp_path / "none")]
|
|
):
|
|
mirro.main()
|
|
assert "No backups found." in capsys.readouterr().out
|
|
|
|
|
|
def test_main_list_entries(tmp_path, capsys):
|
|
d = tmp_path / "bk"
|
|
d.mkdir()
|
|
(d / "a.txt.orig.1").write_text("x")
|
|
(d / "b.txt.orig.2").write_text("y")
|
|
|
|
with patch("sys.argv", ["mirro", "--list", "--backup-dir", str(d)]):
|
|
mirro.main()
|
|
|
|
out = capsys.readouterr().out
|
|
assert "a.txt.orig.1" in out
|
|
assert "b.txt.orig.2" in out
|
|
|
|
|
|
# ============================================================
|
|
# --restore-last
|
|
# ============================================================
|
|
|
|
|
|
def test_restore_last_no_dir(tmp_path, capsys):
|
|
d = tmp_path / "none"
|
|
target = tmp_path / "x.txt"
|
|
with patch(
|
|
"sys.argv",
|
|
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
|
):
|
|
result = mirro.main()
|
|
|
|
assert result == 1
|
|
assert "No backup directory found." in capsys.readouterr().out
|
|
|
|
|
|
def test_restore_last_no_backups(tmp_path, capsys):
|
|
d = tmp_path / "bk"
|
|
d.mkdir()
|
|
target = tmp_path / "t.txt"
|
|
|
|
with patch(
|
|
"sys.argv",
|
|
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
|
):
|
|
result = mirro.main()
|
|
|
|
out = capsys.readouterr().out
|
|
assert result == 1
|
|
assert "No history found for" in out
|
|
assert str(target) in out
|
|
|
|
|
|
def test_restore_last_success(tmp_path, capsys):
|
|
d = tmp_path / "bk"
|
|
d.mkdir()
|
|
target = tmp_path / "t.txt"
|
|
|
|
mirro_header = (
|
|
"# ---------------------------------------------------\n"
|
|
"# mirro backup\n"
|
|
"# Original file: x\n"
|
|
"# Timestamp: test\n"
|
|
"# Delete this header if you want to restore the file\n"
|
|
"# ---------------------------------------------------\n"
|
|
"\n"
|
|
)
|
|
|
|
b1 = d / "t.txt.orig.2020"
|
|
b2 = d / "t.txt.orig.2021"
|
|
|
|
b1.write_text(mirro_header + "old1")
|
|
b2.write_text(mirro_header + "old2")
|
|
|
|
os.utime(b2, (time.time(), time.time()))
|
|
|
|
with patch(
|
|
"sys.argv",
|
|
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
|
):
|
|
mirro.main()
|
|
|
|
assert target.read_text() == "old2"
|
|
assert "Restored" in capsys.readouterr().out
|
|
|
|
|
|
# ============================================================
|
|
# --restore (new)
|
|
# ============================================================
|
|
|
|
|
|
def test_restore_backup_to_original_location(tmp_path, capsys):
|
|
backup_dir = tmp_path / "bk"
|
|
backup_dir.mkdir()
|
|
|
|
original_dir = tmp_path / "orig"
|
|
original_dir.mkdir()
|
|
|
|
target = original_dir / "file.txt"
|
|
|
|
mirro_header = (
|
|
"# ---------------------------------------------------\n"
|
|
"# mirro backup\n"
|
|
f"# Original file: {target}\n"
|
|
"# Timestamp: test\n"
|
|
"# Delete this header if you want to restore the file\n"
|
|
"# ---------------------------------------------------\n"
|
|
"\n"
|
|
)
|
|
|
|
backup = backup_dir / "file.txt.orig.20250101T010203"
|
|
backup.write_text(mirro_header + "restored content\n")
|
|
|
|
with patch(
|
|
"sys.argv",
|
|
["mirro", "--restore", backup.name, "--backup-dir", str(backup_dir)],
|
|
):
|
|
result = mirro.main()
|
|
|
|
assert result == 0
|
|
assert target.exists()
|
|
assert target.read_text() == "restored content\n"
|
|
assert "Restored" in capsys.readouterr().out
|
|
|
|
|
|
def test_restore_missing_backup(tmp_path, capsys):
|
|
backup_dir = tmp_path / "bk"
|
|
backup_dir.mkdir()
|
|
|
|
with patch(
|
|
"sys.argv",
|
|
[
|
|
"mirro",
|
|
"--restore",
|
|
"nope.orig.123",
|
|
"--backup-dir",
|
|
str(backup_dir),
|
|
],
|
|
):
|
|
result = mirro.main()
|
|
|
|
assert result == 1
|
|
assert "Backup not found" in capsys.readouterr().out
|
|
|
|
|
|
def test_restore_missing_original_path_in_header(tmp_path, capsys):
|
|
backup_dir = tmp_path / "bk"
|
|
backup_dir.mkdir()
|
|
|
|
bad_backup = backup_dir / "x.txt.orig.123"
|
|
bad_backup.write_text(
|
|
"# ---------------------------------------------------\n"
|
|
"# mirro backup\n"
|
|
"# Timestamp: test\n"
|
|
"\n"
|
|
"data\n"
|
|
)
|
|
|
|
with patch(
|
|
"sys.argv",
|
|
[
|
|
"mirro",
|
|
"--restore",
|
|
bad_backup.name,
|
|
"--backup-dir",
|
|
str(backup_dir),
|
|
],
|
|
):
|
|
result = mirro.main()
|
|
|
|
assert result == 1
|
|
assert (
|
|
"Could not determine original file location" in capsys.readouterr().out
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# --status
|
|
# ============================================================
|
|
|
|
|
|
def test_status_no_backups(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
backup_dir = tmp_path / "bk"
|
|
with patch(
|
|
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
|
):
|
|
result = mirro.main()
|
|
|
|
out = capsys.readouterr().out
|
|
assert result == 0
|
|
assert f"No mirro backups found in {tmp_path}" in out
|
|
|
|
|
|
def test_status_backups_found(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
cwd = tmp_path
|
|
backup_dir = tmp_path / "bk"
|
|
backup_dir.mkdir()
|
|
|
|
(tmp_path / "a.txt").write_text("data1")
|
|
(tmp_path / "b.txt").write_text("data2")
|
|
|
|
(backup_dir / "a.txt.orig.1").write_text("x")
|
|
(backup_dir / "a.txt.orig.2").write_text("y")
|
|
(backup_dir / "b.txt.orig.3").write_text("z")
|
|
|
|
with patch(
|
|
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
|
):
|
|
result = mirro.main()
|
|
|
|
out = capsys.readouterr().out
|
|
assert result == 0
|
|
assert f"Files with history in {cwd}:" in out
|
|
assert "a.txt" in out
|
|
assert "b.txt" in out
|