|
1 | 1 | import asyncio |
| 2 | +import errno |
2 | 3 | import shutil |
3 | 4 | import tempfile |
4 | 5 | from io import BytesIO |
@@ -515,3 +516,119 @@ async def create_and_remove_dir(dir_name): |
515 | 516 | dirs = await handler.list_directories(".") |
516 | 517 | for i in range(5): |
517 | 518 | assert f"test_dir_{i}" not in dirs |
| 519 | + |
| 520 | + async def test_copy_file_does_not_hardlink_by_default( |
| 521 | + self, handler: FSHandler, sample_file_content |
| 522 | + ): |
| 523 | + """copy_file defaults to allow_link=False: the destination is a real |
| 524 | + copy, so mutating it won't affect the source. Callers must opt in to |
| 525 | + hardlinking explicitly.""" |
| 526 | + source_full = handler.base_path / "source.bin" |
| 527 | + source_full.write_bytes(sample_file_content) |
| 528 | + |
| 529 | + await handler.copy_file(source_full, "dest.bin") |
| 530 | + |
| 531 | + dest_full = handler.base_path / "dest.bin" |
| 532 | + assert dest_full.read_bytes() == sample_file_content |
| 533 | + assert source_full.stat().st_ino != dest_full.stat().st_ino |
| 534 | + |
| 535 | + async def test_copy_file_allow_link_true_hardlinks( |
| 536 | + self, handler: FSHandler, sample_file_content |
| 537 | + ): |
| 538 | + """allow_link=True: when source and dest are on the same filesystem, |
| 539 | + the destination is a hardlink to the source (same inode).""" |
| 540 | + source_full = handler.base_path / "source.bin" |
| 541 | + source_full.write_bytes(sample_file_content) |
| 542 | + |
| 543 | + await handler.copy_file(source_full, "dest.bin", allow_link=True) |
| 544 | + |
| 545 | + dest_full = handler.base_path / "dest.bin" |
| 546 | + assert dest_full.read_bytes() == sample_file_content |
| 547 | + assert source_full.stat().st_ino == dest_full.stat().st_ino |
| 548 | + |
| 549 | + async def test_copy_file_allow_link_false_does_real_copy( |
| 550 | + self, handler: FSHandler, sample_file_content |
| 551 | + ): |
| 552 | + """allow_link=False (explicit) produces a real copy — content matches |
| 553 | + but inodes differ. This matches the default but is exercised explicitly |
| 554 | + to lock in the contract.""" |
| 555 | + source_full = handler.base_path / "source.bin" |
| 556 | + source_full.write_bytes(sample_file_content) |
| 557 | + |
| 558 | + await handler.copy_file(source_full, "dest.bin", allow_link=False) |
| 559 | + |
| 560 | + dest_full = handler.base_path / "dest.bin" |
| 561 | + assert dest_full.read_bytes() == sample_file_content |
| 562 | + assert source_full.stat().st_ino != dest_full.stat().st_ino |
| 563 | + |
| 564 | + async def test_copy_file_allow_link_falls_back_to_copy_on_exdev( |
| 565 | + self, handler: FSHandler, sample_file_content |
| 566 | + ): |
| 567 | + """When allow_link=True and os.link raises EXDEV (cross-filesystem), |
| 568 | + copy_file transparently falls back to a real copy.""" |
| 569 | + source_full = handler.base_path / "source.bin" |
| 570 | + source_full.write_bytes(sample_file_content) |
| 571 | + |
| 572 | + with patch( |
| 573 | + "utils.filesystem.os.link", |
| 574 | + side_effect=OSError(errno.EXDEV, "Cross-device link"), |
| 575 | + ): |
| 576 | + await handler.copy_file(source_full, "dest.bin", allow_link=True) |
| 577 | + |
| 578 | + dest_full = handler.base_path / "dest.bin" |
| 579 | + assert dest_full.read_bytes() == sample_file_content |
| 580 | + assert source_full.stat().st_ino != dest_full.stat().st_ino |
| 581 | + |
| 582 | + async def test_copy_file_nonexistent_source(self, handler: FSHandler): |
| 583 | + """Missing source must raise FileNotFoundError regardless of link mode.""" |
| 584 | + with pytest.raises(FileNotFoundError, match="Source file not found"): |
| 585 | + await handler.copy_file(handler.base_path / "missing.bin", "dest.bin") |
| 586 | + |
| 587 | + |
| 588 | +class TestFSHandlerTolerateMissingBase: |
| 589 | + """Tests for the tolerate_missing_base flag, which lets optional features |
| 590 | + (like the sync folder) come up degraded instead of crashing the whole app |
| 591 | + when the base path can't be created.""" |
| 592 | + |
| 593 | + def test_default_raises_on_mkdir_failure(self): |
| 594 | + """Default behavior: an OSError from mkdir propagates, so misconfigured |
| 595 | + critical paths (resources, library) still fail loudly at startup.""" |
| 596 | + with patch.object( |
| 597 | + Path, "mkdir", side_effect=PermissionError(errno.EACCES, "denied") |
| 598 | + ): |
| 599 | + with pytest.raises(PermissionError): |
| 600 | + FSHandler("/some/unwritable/path") |
| 601 | + |
| 602 | + def test_tolerate_missing_base_swallows_oserror(self, tmp_path, caplog): |
| 603 | + """With tolerate_missing_base=True, mkdir failure is logged but does |
| 604 | + not raise — the handler instance is still constructed so module-level |
| 605 | + imports don't crash.""" |
| 606 | + target = tmp_path / "missing" |
| 607 | + |
| 608 | + with patch.object( |
| 609 | + Path, "mkdir", side_effect=PermissionError(errno.EACCES, "denied") |
| 610 | + ): |
| 611 | + handler = FSHandler(str(target), tolerate_missing_base=True) |
| 612 | + |
| 613 | + # Handler exists and base_path is set, even though the directory |
| 614 | + # could not be created. |
| 615 | + assert handler.base_path == target.resolve() |
| 616 | + assert not handler.base_path.exists() |
| 617 | + |
| 618 | + def test_tolerate_missing_base_still_creates_when_possible(self, tmp_path): |
| 619 | + """tolerate_missing_base=True should not change behavior when mkdir |
| 620 | + succeeds — the directory must still be created normally.""" |
| 621 | + target = tmp_path / "new_dir" |
| 622 | + assert not target.exists() |
| 623 | + |
| 624 | + handler = FSHandler(str(target), tolerate_missing_base=True) |
| 625 | + |
| 626 | + assert handler.base_path.exists() |
| 627 | + assert handler.base_path.is_dir() |
| 628 | + |
| 629 | + def test_tolerate_missing_base_reraises_non_oserror(self, tmp_path): |
| 630 | + """Only OSError gets swallowed; programming errors (e.g. a RuntimeError |
| 631 | + from corrupted state) must still surface.""" |
| 632 | + with patch.object(Path, "mkdir", side_effect=RuntimeError("unexpected")): |
| 633 | + with pytest.raises(RuntimeError, match="unexpected"): |
| 634 | + FSHandler(str(tmp_path / "x"), tolerate_missing_base=True) |
0 commit comments