1+ import dataclasses
12from pathlib import Path
2- from typing import Dict , Generator , List , Tuple
3+ from typing import Dict , Generator , List
34
45import pytest
56
2223from 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\n line 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\n line 4\n ' ,
149+ 'nested/deeper/payload.bin' : bytes (reversed (range (32 ))) + b'\n updated' ,
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
100171def __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 )
169288def 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