From a5083776dac7418c3d785b19422503f488ab494d Mon Sep 17 00:00:00 2001 From: TapTap Date: Wed, 16 Sep 2026 23:29:25 +0200 Subject: [PATCH] test(delete): cover per-dir timings for files-from scope, max-delete, excluded protection, refuse-delete, type conflicts --- .../integration/test_delete_timing_parity.py | 36 ++++++++++++++++ tests/integration/test_features.py | 43 +++++++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_delete_timing_parity.py b/tests/integration/test_delete_timing_parity.py index 1c027a9..1991f87 100644 --- a/tests/integration/test_delete_timing_parity.py +++ b/tests/integration/test_delete_timing_parity.py @@ -233,6 +233,42 @@ class TestDeleteTimingFinalStateParity: ) +class TestDeleteTimingTypeConflictParity: + """A destination entry whose type differs from the source is replaced, in + both per-directory timings and in both directions, exactly like rsync.""" + + @pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"]) + @requires_rsync + def test_type_conflicts_match_rsync(self, timing): + source = os.path.join(TEST_DATA_DIR, "dtc_src") + clean_dir(source) + _write(os.path.join(source, "foo"), b"now a file\n") + _write(os.path.join(source, "bar", "inner.txt"), b"now a dir\n") + + def seed_dest(root): + clean_dir(root) + _write(os.path.join(root, "foo", "inner.txt"), b"was a dir\n") + _write(os.path.join(root, "bar"), b"was a file\n") + + rsync_dst = os.path.join(TEST_DATA_DIR, "dtc_rsync_dst") + seed_dest(rsync_dst) + rsync_result = _rsync(["-a", timing, source + "/", rsync_dst + "/"]) + assert rsync_result.returncode == 0, rsync_result.stderr + rsync_tree = _tree(rsync_dst) + + dest = os.path.join(TEST_DATA_DIR, "dtc_dst") + clean_dir(dest) + received = get_dest_received_dir(dest, source) + seed_dest(received) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, flags=[timing], port=server.port) + assert result.returncode == 0, (result.stderr or result.stdout)[:300] + assert _tree(received) == rsync_tree, ( + f"{timing}: fastsync tree {_tree(received)} != rsync tree {rsync_tree}" + ) + + class TestDeleteTimingFailure: """A mid-transfer failure distinguishes during from delay.""" diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 2587351..dbd4e71 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -3820,10 +3820,11 @@ class TestDeleteTiming: assert _read_file(os.path.join(received, "sub", "deep.txt")) == b"deeply nested file\n" assert not os.path.exists(extra), "--delete-delay did not remove the extra" - def test_early_flag_respected_when_server_refuses_delete(self, shared_server): - """With an --allow-delete-less server the client's early timing still - completes (no deadlock on the pre-delete ack) and simply never deletes, - exactly like the plain server policy.""" + @pytest.mark.parametrize("flag", ["--delete-before", "--delete-during", "--delete-delay"]) + def test_early_flag_respected_when_server_refuses_delete(self, flag, shared_server): + """With an --allow-delete-less server the client's timing still completes + (no deadlock on the pre-delete ack, no per-directory deletion) and simply + never deletes, exactly like the plain server policy.""" source = self._seed("refused") dest = os.path.join(TEST_DATA_DIR, "deltiming_refused_dst") clean_dir(dest) @@ -3833,9 +3834,9 @@ class TestDeleteTiming: extra = os.path.join(received, "extra.txt") with open(extra, "wb") as fh: fh.write(b"extra file") - result, _ = run_client(source, dest, flags=["--delete-before"], port=shared_server.port) + result, _ = run_client(source, dest, flags=[flag], port=shared_server.port) assert result.returncode == 0, \ - f"--delete-before against a refuse-delete server failed: {result.stderr[:300]}" + f"{flag} against a refuse-delete server failed: {result.stderr[:300]}" assert os.path.exists(extra), "unauthorized delete removed an extra file" @@ -3930,6 +3931,31 @@ class TestDeleteScope: finally: server.stop() + @pytest.mark.parametrize("mt", [False, True]) + @pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"]) + @pytest.mark.ci + def test_files_from_per_dir_timing_confined_to_listed_dirs(self, mt, timing): + """The per-directory timings honor the same --files-from scope: an extra + inside a listed directory is removed, while unlisted siblings and the + receive-root extra survive.""" + source, dest, received, server = self._seed(f"pd_{timing.strip('-')}_{mt}") + try: + listed = _write_rel_list(b"listed.txt\nsub/\n") + flags = ["--files-from", listed, timing] + (["--threads"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 0, f"{timing} delete failed: {result.stderr[:300]}" + assert not os.path.exists(os.path.join(received, "sub", "extra.txt")), \ + f"{timing} did not delete the in-scope extra" + assert os.path.isfile(os.path.join(received, "sub", "x.txt")) + assert os.path.exists(os.path.join(received, "unlisted.txt")), \ + f"{timing} deleted an unlisted path (data loss)" + assert os.path.exists(os.path.join(received, "other", "c.txt")), \ + f"{timing} deleted an unlisted sibling directory (data loss)" + assert os.path.exists(os.path.join(received, "rootextra.txt")), \ + f"{timing} deleted the receive-root extra (data loss)" + finally: + server.stop() + class TestDeleteExtraneousSymlinks: """#290 (3): --delete unlinks extraneous destination symlinks (never follows @@ -4017,7 +4043,8 @@ class TestDeletePolicy: @pytest.mark.parametrize("mt", [False, True]) @pytest.mark.parametrize("timing", - ["--delete", "--delete-before", "--delete-after", "--delete-delay"]) + ["--delete", "--delete-before", "--delete-after", "--delete-delay", + "--delete-during"]) def test_delete_protects_excluded_by_default_and_delete_excluded_removes(self, mt, timing): """rsync parity: with a --delete timing the destination mirror path whose source was excluded survives (protected by default); --delete-excluded @@ -4101,7 +4128,7 @@ class TestDeletePolicy: "--delete-excluded did not remove the excluded dir subtree" @pytest.mark.parametrize("mt", [False, True]) - @pytest.mark.parametrize("timing", ["--delete", "--delete-before"]) + @pytest.mark.parametrize("timing", ["--delete", "--delete-before", "--delete-during"]) def test_max_delete_exceeded_deletes_up_to_cap_and_exits_25(self, mt, timing): """rsync parity: --max-delete=N deletes up to N extras, skips the rest and still succeeds as a transfer, exiting 25 with a diagnostic."""