test_archive_copy.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. """
  2. Tests for the 3MF archive copy path.
  3. Regression guards for #1032 where large 3MF files were silently truncated
  4. during archiving on Raspberry Pi OS / armv7l, leaving the archive row in
  5. place but the on-disk file no longer a valid ZIP.
  6. """
  7. import io
  8. import logging
  9. import os
  10. import zipfile
  11. from pathlib import Path
  12. import pytest
  13. from backend.app.services.archive import ThreeMFParser, _copy_and_fsync
  14. def _make_3mf(path: Path, payload_size: int = 0) -> None:
  15. """Write a minimal valid 3MF (ZIP) file with an optional large payload."""
  16. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  17. zf.writestr("Metadata/slice_info.config", "<config/>")
  18. if payload_size:
  19. # Uncompressible payload forces real bytes on disk.
  20. zf.writestr("blob.bin", os.urandom(payload_size))
  21. class TestCopyAndFsync:
  22. def test_copies_small_file_byte_for_byte(self, tmp_path: Path) -> None:
  23. src = tmp_path / "src.bin"
  24. dst = tmp_path / "dst.bin"
  25. src.write_bytes(b"hello world")
  26. _copy_and_fsync(src, dst)
  27. assert dst.read_bytes() == b"hello world"
  28. def test_copies_large_file_byte_for_byte(self, tmp_path: Path) -> None:
  29. """Spans multiple 1 MiB chunks to exercise the copy loop."""
  30. src = tmp_path / "src.bin"
  31. dst = tmp_path / "dst.bin"
  32. payload = os.urandom(5 * 1024 * 1024 + 123) # 5 MiB + change
  33. src.write_bytes(payload)
  34. _copy_and_fsync(src, dst)
  35. assert dst.stat().st_size == len(payload)
  36. assert dst.read_bytes() == payload
  37. def test_preserves_mtime_via_copystat(self, tmp_path: Path) -> None:
  38. src = tmp_path / "src.bin"
  39. dst = tmp_path / "dst.bin"
  40. src.write_bytes(b"x")
  41. os.utime(src, (1_700_000_000, 1_700_000_000))
  42. _copy_and_fsync(src, dst)
  43. assert int(dst.stat().st_mtime) == 1_700_000_000
  44. def test_overwrites_existing_destination(self, tmp_path: Path) -> None:
  45. src = tmp_path / "src.bin"
  46. dst = tmp_path / "dst.bin"
  47. src.write_bytes(b"new")
  48. dst.write_bytes(b"old old old")
  49. _copy_and_fsync(src, dst)
  50. assert dst.read_bytes() == b"new"
  51. def test_produces_valid_zip_on_3mf(self, tmp_path: Path) -> None:
  52. """The whole point of #1032: copy of a valid 3MF stays a valid ZIP."""
  53. src = tmp_path / "src.3mf"
  54. dst = tmp_path / "dst.3mf"
  55. _make_3mf(src, payload_size=2 * 1024 * 1024) # 2 MiB, multi-chunk
  56. assert zipfile.is_zipfile(src)
  57. _copy_and_fsync(src, dst)
  58. assert zipfile.is_zipfile(dst)
  59. class TestThreeMFParserErrorVisibility:
  60. def test_parse_logs_warning_on_corrupted_zip(
  61. self,
  62. tmp_path: Path,
  63. caplog: pytest.LogCaptureFixture,
  64. ) -> None:
  65. """Silent `except Exception: pass` was how #1032 escaped detection;
  66. parse() must now surface the failure at WARNING."""
  67. corrupted = tmp_path / "bad.3mf"
  68. corrupted.write_bytes(b"not a zip")
  69. with caplog.at_level(logging.WARNING, logger="backend.app.services.archive"):
  70. result = ThreeMFParser(corrupted).parse()
  71. assert result == {}
  72. assert any("failed to parse" in rec.message and str(corrupted) in rec.message for rec in caplog.records), (
  73. "Expected a WARNING mentioning the failed parse and file path"
  74. )
  75. def test_parse_returns_partial_metadata_without_raising(
  76. self,
  77. tmp_path: Path,
  78. ) -> None:
  79. """A valid-but-minimal 3MF must still parse without raising."""
  80. p = tmp_path / "ok.3mf"
  81. with zipfile.ZipFile(p, "w") as zf:
  82. zf.writestr("Metadata/slice_info.config", "<config/>")
  83. result = ThreeMFParser(p).parse()
  84. # No assertions about which keys are present — just that it didn't blow up.
  85. assert isinstance(result, dict)
  86. def test_filament_metadata_only_includes_filaments_with_used_g(
  87. self,
  88. tmp_path: Path,
  89. ) -> None:
  90. """slice_and_persist_as_archive uses parsed_metadata.filament_type/color
  91. to populate the new archive's filament list. The parser must filter
  92. out filaments whose used_g==0 — otherwise the resulting archive card
  93. shows every project-wide AMS slot (16+ swatches) for what was
  94. actually a 2-color print on a single plate.
  95. """
  96. p = tmp_path / "two-of-eighteen.3mf"
  97. with zipfile.ZipFile(p, "w") as zf:
  98. zf.writestr("3D/3dmodel.model", "<model/>")
  99. # 4 declared slots, only 2 actually consumed on this plate.
  100. zf.writestr(
  101. "Metadata/slice_info.config",
  102. """<?xml version="1.0"?>
  103. <config>
  104. <plate>
  105. <metadata key="index" value="1"/>
  106. <filament id="1" type="PLA" color="#FFFFFF" used_g="25.0" used_m="8.5"/>
  107. <filament id="2" type="PETG" color="#FF0000" used_g="0" used_m="0"/>
  108. <filament id="3" type="PLA" color="#000000" used_g="12.5" used_m="4.2"/>
  109. <filament id="4" type="ABS" color="#00FF00" used_g="0" used_m="0"/>
  110. </plate>
  111. </config>""",
  112. )
  113. result = ThreeMFParser(p).parse()
  114. # Both fields should be comma-joined strings of only the consumed
  115. # filaments — slot 2 (PETG #FF0000) and slot 4 (ABS #00FF00) must
  116. # not appear on the new archive card. The parser dedupes types,
  117. # so both PLA slots collapse into a single "PLA" entry; colors
  118. # are unique per swatch and stay distinct.
  119. types = result.get("filament_type", "")
  120. assert "PLA" in types
  121. assert "PETG" not in types # used_g=0 → excluded
  122. assert "ABS" not in types
  123. colors = result.get("filament_color", "")
  124. assert "#FFFFFF" in colors
  125. assert "#000000" in colors
  126. assert "#FF0000" not in colors
  127. assert "#00FF00" not in colors
  128. class TestZipFileSentinel:
  129. """Sanity check the sentinel the archive pipeline relies on."""
  130. def test_is_zipfile_on_truncated_zip_returns_false(self, tmp_path: Path) -> None:
  131. """Truncating a valid ZIP mid-stream must flip is_zipfile() to False.
  132. This is the exact post-condition archive_print now trusts."""
  133. src = tmp_path / "src.3mf"
  134. _make_3mf(src, payload_size=1024 * 1024)
  135. full = src.read_bytes()
  136. assert zipfile.is_zipfile(io.BytesIO(full))
  137. truncated = tmp_path / "truncated.3mf"
  138. # Strip the trailing end-of-central-directory record — exactly what a
  139. # short sendfile return would leave behind.
  140. truncated.write_bytes(full[: len(full) // 2])
  141. assert not zipfile.is_zipfile(truncated)