|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import importlib.util |
| 6 | +import os |
| 7 | +import stat |
6 | 8 | import subprocess |
7 | 9 | import sys |
8 | | -from collections.abc import Iterator |
9 | 10 | from pathlib import Path |
10 | 11 | from types import SimpleNamespace |
11 | 12 |
|
@@ -37,15 +38,31 @@ def test_overfull_migration_directory_stops_at_its_sentinel_entry( |
37 | 38 | ) -> None: |
38 | 39 | """Directory discovery must reject at its bounded sentinel entry.""" |
39 | 40 |
|
40 | | - def entries() -> Iterator[Path]: |
41 | | - for index in range(exporter.MAX_MIGRATION_DIRECTORY_ENTRIES + 1): |
42 | | - yield Path(f"{index:04d}_migration.sql") |
43 | | - raise AssertionError("migration discovery consumed beyond its sentinel entry") |
| 41 | + class ScandirContext: |
| 42 | + """Bounded fake directory stream.""" |
44 | 43 |
|
45 | | - def iterdir(_: Path) -> Iterator[Path]: |
46 | | - return entries() |
| 44 | + def __init__(self) -> None: |
| 45 | + self.index = 0 |
47 | 46 |
|
48 | | - monkeypatch.setattr(Path, "iterdir", iterdir) |
| 47 | + def __enter__(self) -> ScandirContext: |
| 48 | + return self |
| 49 | + |
| 50 | + def __exit__(self, *_: object) -> None: |
| 51 | + return None |
| 52 | + |
| 53 | + def __iter__(self) -> ScandirContext: |
| 54 | + return self |
| 55 | + |
| 56 | + def __next__(self) -> object: |
| 57 | + if self.index == exporter.MAX_MIGRATION_DIRECTORY_ENTRIES + 1: |
| 58 | + raise AssertionError("directory scan consumed beyond its sentinel entry") |
| 59 | + self.index += 1 |
| 60 | + return object() |
| 61 | + |
| 62 | + def scandir(_: Path) -> ScandirContext: |
| 63 | + return ScandirContext() |
| 64 | + |
| 65 | + monkeypatch.setattr(os, "scandir", scandir) |
49 | 66 | with pytest.raises(RuntimeError, match=r"migration directory: entries exceed"): |
50 | 67 | exporter._numbered_migrations() |
51 | 68 |
|
@@ -92,6 +109,87 @@ def read_bytes(*_: object, **__: object) -> bytes: |
92 | 109 | assert not read_bytes_called |
93 | 110 |
|
94 | 111 |
|
| 112 | +def test_frame_rejects_over_budget_text_before_encoding() -> None: |
| 113 | + """A character-length overflow must not invoke a costly custom encoder.""" |
| 114 | + |
| 115 | + class EncodingTrap(str): |
| 116 | + """String that fails if the exporter encodes it.""" |
| 117 | + |
| 118 | + def encode(self, *_: object, **__: object) -> bytes: |
| 119 | + raise AssertionError("over-budget text must not be encoded") |
| 120 | + |
| 121 | + chunk = EncodingTrap("x" * exporter.MAX_BYTES) |
| 122 | + with pytest.raises(ValueError, match=r"frame: framed bytes exceed"): |
| 123 | + exporter._frame([chunk], label="frame") |
| 124 | + |
| 125 | + |
| 126 | +def test_frame_checks_encoded_multibyte_length_before_nul_scan() -> None: |
| 127 | + """UTF-8 expansion must still respect the remaining byte budget.""" |
| 128 | + chunk = "é" * (exporter.MAX_BYTES // 2) |
| 129 | + with pytest.raises(ValueError, match=r"frame: framed bytes exceed"): |
| 130 | + exporter._frame([chunk], label="frame") |
| 131 | + |
| 132 | + |
| 133 | +def test_frame_preserves_empty_chunk_precedence_after_a_full_chunk() -> None: |
| 134 | + """An empty chunk remains invalid before any aggregate-size error.""" |
| 135 | + full_chunk = "x" * (exporter.MAX_BYTES - 1) |
| 136 | + with pytest.raises(ValueError, match=r"frame: empty chunk 1"): |
| 137 | + exporter._frame([full_chunk, ""], label="frame") |
| 138 | + |
| 139 | + |
| 140 | +@pytest.mark.parametrize( |
| 141 | + ("mode", "size"), |
| 142 | + [ |
| 143 | + (stat.S_IFREG, exporter.MAX_BYTES + 1), |
| 144 | + (stat.S_IFDIR, len(b"expected")), |
| 145 | + ], |
| 146 | +) |
| 147 | +def test_fixture_check_uses_one_descriptor_and_stops_before_read( |
| 148 | + monkeypatch: pytest.MonkeyPatch, |
| 149 | + mode: int, |
| 150 | + size: int, |
| 151 | +) -> None: |
| 152 | + """Oversize and nonregular fixtures must stop after descriptor metadata.""" |
| 153 | + open_calls = 0 |
| 154 | + |
| 155 | + class Fixture: |
| 156 | + """Descriptor-backed fixture trap.""" |
| 157 | + |
| 158 | + def __enter__(self) -> Fixture: |
| 159 | + return self |
| 160 | + |
| 161 | + def __exit__(self, *_: object) -> None: |
| 162 | + return None |
| 163 | + |
| 164 | + def fileno(self) -> int: |
| 165 | + return 41 |
| 166 | + |
| 167 | + def read(self, _: int = -1) -> bytes: |
| 168 | + raise AssertionError("metadata-rejected fixture must not be read") |
| 169 | + |
| 170 | + class FixturePath: |
| 171 | + """Path-shaped trap for the exporter only.""" |
| 172 | + |
| 173 | + def open(self, *_: object, **__: object) -> Fixture: |
| 174 | + nonlocal open_calls |
| 175 | + open_calls += 1 |
| 176 | + return Fixture() |
| 177 | + |
| 178 | + def stat(self, *_: object, **__: object) -> SimpleNamespace: |
| 179 | + raise AssertionError("fixture checks must not use Path.stat()") |
| 180 | + |
| 181 | + def __str__(self) -> str: |
| 182 | + return "fixture.bin" |
| 183 | + |
| 184 | + def fstat(descriptor: int) -> SimpleNamespace: |
| 185 | + assert descriptor == 41 |
| 186 | + return SimpleNamespace(st_mode=mode, st_size=size) |
| 187 | + |
| 188 | + monkeypatch.setattr(os, "fstat", fstat) |
| 189 | + assert not exporter._check(FixturePath(), b"expected") |
| 190 | + assert open_calls == 1 |
| 191 | + |
| 192 | + |
95 | 193 | def test_numbered_migrations_preserve_unique_missing_and_duplicate_results( |
96 | 194 | monkeypatch: pytest.MonkeyPatch, |
97 | 195 | tmp_path: Path, |
|
0 commit comments