Skip to content

Commit f110b2d

Browse files
committed
double backup test
1 parent 63bcdee commit f110b2d

1 file changed

Lines changed: 178 additions & 60 deletions

File tree

tests/test_backup_roundtrip.py

Lines changed: 178 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import dataclasses
12
from pathlib import Path
2-
from typing import Dict, Generator, List, Tuple
3+
from typing import Dict, Generator, List
34

45
import pytest
56

@@ -22,14 +23,33 @@
2223
from prime_backup.types.tar_format import TarFormat
2324

2425

25-
def _param_id(value: object) -> str:
26+
@dataclasses.dataclass(frozen=True)
27+
class ExpectedTree:
28+
files: Dict[str, bytes]
29+
dirs: List[str]
30+
31+
32+
@dataclasses.dataclass(frozen=True)
33+
class BackupSnapshot:
34+
name: str
35+
backup_id: int
36+
expected_tree: ExpectedTree
37+
38+
39+
@dataclasses.dataclass(frozen=True)
40+
class BackupArchive:
41+
snapshot: BackupSnapshot
42+
export_path: Path
43+
44+
45+
def __param_id(value: object) -> str:
2646
if hasattr(value, 'name'):
2747
return str(getattr(value, 'name'))
2848
return 'c{}'.format(value)
2949

3050

3151
@pytest.fixture(autouse=True)
32-
def _restore_config_and_db() -> Generator[None, None, None]:
52+
def __restore_config_and_db() -> Generator[None, None, None]:
3353
old_config = Config.get()
3454
try:
3555
yield
@@ -39,62 +59,113 @@ def _restore_config_and_db() -> Generator[None, None, None]:
3959
set_config_instance(old_config)
4060

4161

42-
def _assert_pack_and_chunk_validate_ok() -> None:
62+
def __assert_pack_and_chunk_validate_ok() -> None:
4363
assert ValidatePacksAction().run().bad == 0
4464
assert ValidateChunksAction().run().bad == 0
4565

4666

47-
def _make_chunked_data() -> bytes:
67+
def __make_chunked_data() -> bytes:
4868
return (
49-
b'a' * (4 * 1024) +
50-
b'b' * (4 * 1024) +
51-
b'c' * (4 * 1024) +
52-
b'd' * (4 * 1024) +
69+
b'a' * (64 * 1024) +
70+
b'b' * (64 * 1024) +
71+
b'c' * (64 * 1024) +
72+
b'd' * (64 * 1024) +
5373
b'tail'
5474
)
5575

5676

57-
def _populate_world(world_path: Path) -> Tuple[Dict[str, bytes], List[str]]:
77+
def __make_updated_chunked_data() -> bytes:
78+
return (
79+
b'a' * (64 * 1024) +
80+
b'b' * (64 * 1024) +
81+
b'x' * (64 * 1024) +
82+
b'd' * (64 * 1024) +
83+
b'tail-v2'
84+
)
85+
86+
87+
def __write_world_files(world_path: Path, files: Dict[str, bytes]) -> None:
88+
for rel_path, content in files.items():
89+
path = world_path / rel_path
90+
path.parent.mkdir(parents=True, exist_ok=True)
91+
path.write_bytes(content)
92+
93+
94+
def __read_tree(root_path: Path) -> ExpectedTree:
95+
return ExpectedTree(
96+
files={
97+
path.relative_to(root_path).as_posix(): path.read_bytes()
98+
for path in root_path.rglob('*')
99+
if path.is_file()
100+
},
101+
dirs=[
102+
path.relative_to(root_path).as_posix()
103+
for path in root_path.rglob('*')
104+
if path.is_dir()
105+
],
106+
)
107+
108+
109+
def __populate_world_for_first_backup(world_path: Path) -> ExpectedTree:
58110
files = {
59-
'chunked.dat': _make_chunked_data(),
111+
'chunked.dat': __make_chunked_data(),
60112
'small.txt': b'hello roundtrip matrix',
61113
'empty.txt': b'',
62114
'config/settings.json': b'{"enabled":true,"level":3}\n',
63115
'logs/2026-06-06.log': b'line 1\nline 2\n',
64116
'nested/deeper/payload.bin': bytes(range(32)),
65117
'region/r.0.0.mca': b'mca-like small payload',
118+
'unchanged/reused.dat': b'reused chunked payload\n' * 256,
119+
'old_area/remove_me.txt': b'this file should disappear in backup 2',
66120
}
67121
dirs = [
68122
'config',
69123
'logs',
70124
'nested',
71125
'nested/deeper',
72126
'region',
127+
'unchanged',
128+
'old_area',
73129
'empty_dir',
74130
'empty_dir/child',
75131
]
76132
for rel_dir in dirs:
77133
(world_path / rel_dir).mkdir(parents=True, exist_ok=True)
78-
for rel_path, content in files.items():
79-
path = world_path / rel_path
80-
path.parent.mkdir(parents=True, exist_ok=True)
81-
path.write_bytes(content)
82-
return files, dirs
134+
__write_world_files(world_path, files)
135+
return __read_tree(world_path)
83136

84137

85-
def __assert_restored_tree(restored_world_path: Path, expected_files: Dict[str, bytes], expected_dirs: List[str]) -> None:
86-
restored_files: Dict[str, bytes] = {
87-
path.relative_to(restored_world_path).as_posix(): path.read_bytes()
88-
for path in restored_world_path.rglob('*')
89-
if path.is_file()
90-
}
91-
restored_dirs: List[str] = [
92-
path.relative_to(restored_world_path).as_posix()
93-
for path in restored_world_path.rglob('*')
94-
if path.is_dir()
95-
]
96-
assert restored_files == expected_files
97-
assert sorted(restored_dirs) == sorted(expected_dirs)
138+
def __mutate_world_for_second_backup(world_path: Path) -> ExpectedTree:
139+
(world_path / 'old_area' / 'remove_me.txt').unlink()
140+
(world_path / 'old_area').rmdir()
141+
(world_path / 'empty_dir' / 'child').rmdir()
142+
(world_path / 'empty_dir').rmdir()
143+
(world_path / 'logs' / '2026-06-06.log').unlink()
144+
145+
__write_world_files(world_path, {
146+
'chunked.dat': __make_updated_chunked_data(),
147+
'config/settings.json': b'{"enabled":true,"level":4,"updated":true}\n',
148+
'logs/2026-06-07.log': b'line 3\nline 4\n',
149+
'nested/deeper/payload.bin': bytes(reversed(range(32))) + b'\nupdated',
150+
'new_branch/added.txt': b'new file in backup 2',
151+
'new_branch/deeper/notes.txt': b'nested file added in backup 2',
152+
'new_branch/chunked_added.dat': b'new chunked file\n' * 512,
153+
'region/r.0.1.mca': b'another mca-like payload',
154+
})
155+
(world_path / 'new_empty' / 'child').mkdir(parents=True)
156+
return __read_tree(world_path)
157+
158+
159+
def __assert_restored_tree(restored_world_path: Path, expected_tree: ExpectedTree) -> None:
160+
assert restored_world_path.is_dir()
161+
restored_tree = __read_tree(restored_world_path)
162+
assert restored_tree.files == expected_tree.files
163+
assert sorted(restored_tree.dirs) == sorted(expected_tree.dirs)
164+
165+
166+
def __assert_restored_backup(restored_path: Path, expected_tree: ExpectedTree) -> None:
167+
assert sorted(path.name for path in restored_path.iterdir()) == ['world']
168+
__assert_restored_tree(restored_path / 'world', expected_tree)
98169

99170

100171
def __skip_if_dependencies_unavailable(
@@ -125,13 +196,13 @@ def __skip_if_dependencies_unavailable(
125196
pytest.skip('chunking dependency unavailable for {}: {}'.format(chunk_method.name, e))
126197

127198

128-
def _get_export_extension(export_format: StandaloneBackupFormat) -> str:
199+
def __get_export_extension(export_format: StandaloneBackupFormat) -> str:
129200
if isinstance(export_format.value, TarFormat):
130201
return export_format.value.value.extension
131202
return export_format.value.extension
132203

133204

134-
def _make_config(
205+
def __make_config(
135206
storage_root: Path,
136207
server_path: Path,
137208
hash_method: HashMethod,
@@ -161,11 +232,59 @@ def __export_backup(backup_id: int, export_path: Path, export_format: Standalone
161232
ExportBackupToZipAction(backup_id, export_path, create_meta=True).run()
162233

163234

164-
@pytest.mark.parametrize('hash_method', tuple(HashMethod), ids=_param_id)
165-
@pytest.mark.parametrize('compress_method', tuple(CompressMethod), ids=_param_id)
166-
@pytest.mark.parametrize('chunk_method', tuple(ChunkMethod), ids=_param_id)
167-
@pytest.mark.parametrize('export_format', tuple(StandaloneBackupFormat), ids=_param_id)
168-
@pytest.mark.parametrize('concurrency', (1, 2), ids=_param_id)
235+
def __assert_chunked_blob_exists() -> None:
236+
with DbAccess.open_session() as session:
237+
assert len(session.list_blobs_by_storage_method(BlobStorageMethod.chunked)) > 0
238+
239+
240+
def __create_backup_snapshot(name: str, expected_tree: ExpectedTree) -> BackupSnapshot:
241+
backup = CreateBackupAction(Operator.literal('test'), name).run()
242+
__assert_pack_and_chunk_validate_ok()
243+
__assert_chunked_blob_exists()
244+
return BackupSnapshot(name, backup.id, expected_tree)
245+
246+
247+
def __export_backup_snapshot(
248+
snapshot: BackupSnapshot,
249+
export_dir: Path,
250+
export_format: StandaloneBackupFormat,
251+
) -> BackupArchive:
252+
export_path = export_dir / ('backup_{}{}'.format(snapshot.name, __get_export_extension(export_format)))
253+
__export_backup(snapshot.backup_id, export_path, export_format)
254+
assert export_path.is_file()
255+
assert export_path.stat().st_size > 0
256+
return BackupArchive(snapshot, export_path)
257+
258+
259+
def __import_and_restore_backup_archive(
260+
archive: BackupArchive,
261+
imported_pb_path: Path,
262+
restored_path: Path,
263+
server_path: Path,
264+
hash_method: HashMethod,
265+
compress_method: CompressMethod,
266+
chunk_method: ChunkMethod,
267+
export_format: StandaloneBackupFormat,
268+
concurrency: int,
269+
) -> None:
270+
set_config_instance(__make_config(imported_pb_path, server_path, hash_method, compress_method, chunk_method, concurrency))
271+
DbAccess.init(create=True, migrate=False)
272+
try:
273+
imported_backup = ImportBackupAction(archive.export_path, export_format, ensure_meta=True).run()
274+
__assert_pack_and_chunk_validate_ok()
275+
276+
failures = ExportBackupToDirectoryAction(imported_backup.id, restored_path, restore_mode=True).run()
277+
assert len(failures) == 0
278+
__assert_restored_backup(restored_path, archive.snapshot.expected_tree)
279+
finally:
280+
DbAccess.shutdown()
281+
282+
283+
@pytest.mark.parametrize('hash_method', tuple(HashMethod), ids=__param_id)
284+
@pytest.mark.parametrize('compress_method', tuple(CompressMethod), ids=__param_id)
285+
@pytest.mark.parametrize('chunk_method', tuple(ChunkMethod), ids=__param_id)
286+
@pytest.mark.parametrize('export_format', tuple(StandaloneBackupFormat), ids=__param_id)
287+
@pytest.mark.parametrize('concurrency', (1, 2), ids=__param_id)
169288
def test_backup_roundtrip(
170289
tmp_path: Path,
171290
hash_method: HashMethod,
@@ -177,37 +296,36 @@ def test_backup_roundtrip(
177296
__skip_if_dependencies_unavailable(hash_method, compress_method, chunk_method, export_format)
178297

179298
source_pb_path = tmp_path / 'source_pb'
180-
imported_pb_path = tmp_path / 'imported_pb'
181299
server_path = tmp_path / 'server'
182300
world_path = server_path / 'world'
183-
restored_path = tmp_path / 'restored'
184-
export_path = tmp_path / ('backup' + _get_export_extension(export_format))
301+
export_dir = tmp_path / 'exports'
185302

186303
world_path.mkdir(parents=True)
187-
expected_files, expected_dirs = _populate_world(world_path)
304+
export_dir.mkdir()
305+
first_expected_tree = __populate_world_for_first_backup(world_path)
188306

189-
set_config_instance(_make_config(source_pb_path, server_path, hash_method, compress_method, chunk_method, concurrency))
307+
set_config_instance(__make_config(source_pb_path, server_path, hash_method, compress_method, chunk_method, concurrency))
190308
DbAccess.init(create=True, migrate=False)
191309
try:
192-
backup = CreateBackupAction(Operator.literal('test'), '').run()
193-
_assert_pack_and_chunk_validate_ok()
194-
with DbAccess.open_session() as session:
195-
assert len(session.list_blobs_by_storage_method(BlobStorageMethod.chunked)) > 0
196-
197-
__export_backup(backup.id, export_path, export_format)
198-
assert export_path.is_file()
199-
assert export_path.stat().st_size > 0
310+
first_snapshot = __create_backup_snapshot('first', first_expected_tree)
311+
second_expected_tree = __mutate_world_for_second_backup(world_path)
312+
second_snapshot = __create_backup_snapshot('second', second_expected_tree)
313+
archives = [
314+
__export_backup_snapshot(first_snapshot, export_dir, export_format),
315+
__export_backup_snapshot(second_snapshot, export_dir, export_format),
316+
]
200317
finally:
201318
DbAccess.shutdown()
202319

203-
set_config_instance(_make_config(imported_pb_path, server_path, hash_method, compress_method, chunk_method, concurrency))
204-
DbAccess.init(create=True, migrate=False)
205-
try:
206-
imported_backup = ImportBackupAction(export_path, export_format, ensure_meta=True).run()
207-
_assert_pack_and_chunk_validate_ok()
208-
209-
failures = ExportBackupToDirectoryAction(imported_backup.id, restored_path, restore_mode=True).run()
210-
assert len(failures) == 0
211-
__assert_restored_tree(restored_path / 'world', expected_files, expected_dirs)
212-
finally:
213-
DbAccess.shutdown()
320+
for archive in archives:
321+
__import_and_restore_backup_archive(
322+
archive,
323+
tmp_path / ('imported_pb_' + archive.snapshot.name),
324+
tmp_path / ('restored_' + archive.snapshot.name),
325+
server_path,
326+
hash_method,
327+
compress_method,
328+
chunk_method,
329+
export_format,
330+
concurrency,
331+
)

0 commit comments

Comments
 (0)