Files
simple_backup/tests/test_state.py
T
Fuxino ec27b8d77f Estimate progress while rsync scans from the last backup's file count
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.
2026-11-02 20:56:12 +01:00

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