With incremental recursion rsync only knows the total near the end, so the bar showed no ETA for most of the backup. Save the file count as a third line of the state file and use it as an estimate until rsync reports the real total.
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
"""Tests for the last backup state file and --last-backup"""
|
|
|
|
import os
|
|
import pwd
|
|
from collections.abc import Callable
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from simple_backup import simple_backup as sb
|
|
|
|
DESTINATION = '/backups/simple_backup/2026-10-25_10-00-00'
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def as_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Run as a non-root user (the tests may run as root in a chroot)"""
|
|
|
|
monkeypatch.setattr(os, 'geteuid', lambda: 1000)
|
|
monkeypatch.setenv('USER', 'user')
|
|
|
|
|
|
@pytest.fixture
|
|
def state(tmp_path: Path) -> Path:
|
|
return tmp_path / 'state' / 'simple_backup' / 'last_backup'
|
|
|
|
|
|
def write_state(state: Path, text: str) -> None:
|
|
state.parent.mkdir(parents=True)
|
|
state.write_text(text)
|
|
|
|
|
|
def test_write(state: Path) -> None:
|
|
sb._write_state(None, DESTINATION)
|
|
|
|
time, destination = state.read_text().splitlines()
|
|
assert abs(datetime.now().astimezone() - datetime.fromisoformat(time)) < timedelta(minutes=1)
|
|
assert destination == DESTINATION
|
|
# No temporary file left behind
|
|
assert os.listdir(state.parent) == ['last_backup']
|
|
|
|
|
|
def test_overwrite(state: Path) -> None:
|
|
write_state(state, '2026-10-01T10:00:00+02:00\n/old\n')
|
|
|
|
sb._write_state(None, DESTINATION)
|
|
|
|
assert state.read_text().splitlines()[1] == DESTINATION
|
|
|
|
|
|
def test_file_count(state: Path) -> None:
|
|
sb._write_state(None, DESTINATION, 1234)
|
|
|
|
assert state.read_text().splitlines()[2] == '1234'
|
|
assert sb._last_file_count(None) == 1234
|
|
|
|
|
|
def test_file_count_kept_when_unknown(state: Path) -> None:
|
|
write_state(state, '2026-10-01T10:00:00+02:00\n/old\n1234\n')
|
|
|
|
sb._write_state(None, DESTINATION)
|
|
|
|
assert state.read_text().splitlines()[1:] == [DESTINATION, '1234']
|
|
|
|
|
|
@pytest.mark.parametrize('text', [None, '2026-10-01T10:00:00+02:00\n/old\n', '2026-10-01T10:00:00+02:00\n/old\nx\n'])
|
|
def test_no_file_count(state: Path, text: str | None) -> None:
|
|
if text is not None:
|
|
write_state(state, text)
|
|
|
|
assert sb._last_file_count(None) == 0
|
|
|
|
|
|
def test_relative_xdg_state_home_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv('XDG_STATE_HOME', 'relative')
|
|
monkeypatch.setenv('HOME', str(tmp_path / 'home'))
|
|
|
|
sb._write_state(None, DESTINATION)
|
|
|
|
assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists()
|
|
|
|
|
|
def test_write_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
# A file where the state directory should be
|
|
(tmp_path / 'not a dir').write_text('')
|
|
monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'not a dir'))
|
|
|
|
sb._write_state(None, DESTINATION)
|
|
|
|
assert 'Cannot save the time of the last backup' in caplog.text
|
|
|
|
|
|
def test_root_writes_as_user(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
calls: list[tuple[str, int]] = []
|
|
egid = os.getegid()
|
|
owner = pwd.struct_passwd(('user', 'x', 1000, 1001, '', str(tmp_path / 'home'), '/bin/sh'))
|
|
|
|
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
|
monkeypatch.setattr(pwd, 'getpwnam', lambda name: owner)
|
|
monkeypatch.setattr(os, 'seteuid', lambda uid: calls.append(('euid', uid)))
|
|
monkeypatch.setattr(os, 'setegid', lambda gid: calls.append(('egid', gid)))
|
|
|
|
sb._write_state('user', DESTINATION)
|
|
|
|
# Root's XDG_STATE_HOME is not used
|
|
assert (tmp_path / 'home' / '.local' / 'state' / 'simple_backup' / 'last_backup').exists()
|
|
assert calls == [('egid', 1001), ('euid', 1000), ('euid', 0), ('egid', egid)]
|
|
|
|
|
|
class TestStateOwner:
|
|
def test_non_root(self) -> None:
|
|
assert sb._state_owner('user') is None
|
|
|
|
@pytest.mark.parametrize('name, expected', [('user', 'user'), ('root', None), ('unknown', None), (None, None)])
|
|
def test_root(self, monkeypatch: pytest.MonkeyPatch, name: str | None, expected: str | None) -> None:
|
|
users = {'user': pwd.struct_passwd(('user', 'x', 1000, 1000, '', '/home/user', '/bin/sh')),
|
|
'root': pwd.struct_passwd(('root', 'x', 0, 0, '', '/root', '/bin/sh'))}
|
|
|
|
def getpwnam(name: str) -> pwd.struct_passwd:
|
|
if name not in users:
|
|
raise KeyError(name)
|
|
|
|
return users[name]
|
|
|
|
monkeypatch.setattr(os, 'geteuid', lambda: 0)
|
|
monkeypatch.setattr(pwd, 'getpwnam', getpwnam)
|
|
|
|
owner = sb._state_owner(name)
|
|
assert (owner.pw_name if owner is not None else None) == expected
|
|
|
|
|
|
class TestShow:
|
|
@pytest.mark.parametrize('days, age', [(0, 'today'), (1, 'yesterday'), (3, '3 days ago')])
|
|
def test_age(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str],
|
|
days: int, age: str) -> None:
|
|
completed = (datetime.now() - timedelta(days=days)).replace(hour=12, minute=30).astimezone()
|
|
write_state(state, f'{completed.isoformat(timespec="seconds")}\n{DESTINATION}\n')
|
|
|
|
assert run_cli('--last-backup') == 0
|
|
assert capsys.readouterr().out == \
|
|
f'Last backup: {completed:%Y-%m-%d} 12:30 ({age})\nDirectory: {DESTINATION}\n'
|
|
|
|
def test_after_write(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None:
|
|
sb._write_state(None, DESTINATION)
|
|
|
|
assert run_cli('--last-backup') == 0
|
|
assert '(today)' in capsys.readouterr().out
|
|
|
|
def test_no_backup(self, run_cli: Callable[..., int], capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert run_cli('--last-backup') == 1
|
|
assert capsys.readouterr().out == 'No backup recorded yet\n'
|
|
|
|
@pytest.mark.parametrize('text', ['', '2026-10-25T10:00:00+02:00\n', 'yesterday\n/backups\n'])
|
|
def test_bad_file(self, run_cli: Callable[..., int], state: Path, capsys: pytest.CaptureFixture[str],
|
|
text: str) -> None:
|
|
write_state(state, text)
|
|
|
|
assert run_cli('--last-backup') == 1
|
|
assert 'Cannot read' in capsys.readouterr().err
|