157 lines
5.2 KiB
Python
157 lines
5.2 KiB
Python
|
|
"""Tests for app.storage.object_store — local-filesystem object store.
|
||
|
|
|
||
|
|
Exercises the async hash-dedup store against a real tmp_path on disk. All disk
|
||
|
|
I/O is dispatched via ``asyncio.to_thread`` so the methods are genuinely async.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from app.storage.object_store import ObjectStore
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# put_bytes / get_path / exists
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_bytes_writes_file_and_exists_reports_true(tmp_path: Path):
|
||
|
|
"""put_bytes writes the blob; exists() then reports True; path matches key."""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
key = "foo/bar.txt"
|
||
|
|
data = b"hello"
|
||
|
|
|
||
|
|
assert await store.exists(key) is False
|
||
|
|
|
||
|
|
path = await store.put_bytes(key, data)
|
||
|
|
assert path == tmp_path / key
|
||
|
|
assert path.exists()
|
||
|
|
assert path.read_bytes() == data
|
||
|
|
|
||
|
|
assert await store.exists(key) is True
|
||
|
|
|
||
|
|
|
||
|
|
async def test_get_path_returns_base_plus_key(tmp_path: Path):
|
||
|
|
"""get_path is a pure path computation (no I/O).
|
||
|
|
|
||
|
|
``/`` in the key becomes real path segments (subdirectories).
|
||
|
|
"""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
assert store.get_path("a/b/c.bin") == tmp_path / "a" / "b" / "c.bin"
|
||
|
|
assert store.get_path("flat.txt") == tmp_path / "flat.txt"
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_bytes_overwrites_on_change(tmp_path: Path):
|
||
|
|
"""A second put_bytes with new content overwrites the file."""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
await store.put_bytes("k", b"v1")
|
||
|
|
await store.put_bytes("k", b"v2")
|
||
|
|
assert (tmp_path / "k").read_bytes() == b"v2"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# put_by_hash (dedup)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_by_hash_returns_sha256_and_path_with_layout(tmp_path: Path):
|
||
|
|
"""Returns (sha256_hex, base/<2 chars>/<hash><ext>); file content correct."""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
data = b"abc"
|
||
|
|
expected_hash = hashlib.sha256(data).hexdigest()
|
||
|
|
|
||
|
|
h, path = await store.put_by_hash(data, ext=".txt")
|
||
|
|
|
||
|
|
assert h == expected_hash
|
||
|
|
assert path == tmp_path / expected_hash[:2] / (expected_hash + ".txt")
|
||
|
|
assert path.exists()
|
||
|
|
assert path.read_bytes() == data
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_by_hash_dedups_same_bytes_returns_same_path(tmp_path: Path):
|
||
|
|
"""put_by_hash with identical bytes twice -> same (hash, path)."""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
data = b"identical"
|
||
|
|
|
||
|
|
h1, p1 = await store.put_by_hash(data)
|
||
|
|
h2, p2 = await store.put_by_hash(data)
|
||
|
|
|
||
|
|
assert h1 == h2
|
||
|
|
assert p1 == p2
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_by_hash_does_not_rewrite_existing_file(tmp_path: Path):
|
||
|
|
"""Dedup: a second call for an existing hash must NOT touch the file.
|
||
|
|
|
||
|
|
Proves the implementation checks ``exists`` before writing. We corrupt the
|
||
|
|
file between calls; a dedup-aware store must leave the corruption in place
|
||
|
|
(no rewrite), since the blob is already present.
|
||
|
|
"""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
data = b"original"
|
||
|
|
|
||
|
|
_h1, p1 = await store.put_by_hash(data, ext=".bin")
|
||
|
|
assert p1.read_bytes() == data
|
||
|
|
|
||
|
|
# Corrupt the on-disk file.
|
||
|
|
p1.write_bytes(b"CORRUPTED")
|
||
|
|
|
||
|
|
_h2, p2 = await store.put_by_hash(data, ext=".bin")
|
||
|
|
assert p2 == p1
|
||
|
|
# Dedup short-circuit: file content was NOT overwritten.
|
||
|
|
assert p2.read_bytes() == b"CORRUPTED"
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_by_hash_distinct_bytes_get_distinct_paths(tmp_path: Path):
|
||
|
|
"""Different bytes -> different hash -> different path."""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
h1, p1 = await store.put_by_hash(b"aaa")
|
||
|
|
h2, p2 = await store.put_by_hash(b"bbb")
|
||
|
|
assert h1 != h2
|
||
|
|
assert p1 != p2
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_by_hash_default_ext_empty(tmp_path: Path):
|
||
|
|
"""Default ext='' -> no suffix appended to the file."""
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
data = b"x"
|
||
|
|
expected_hash = hashlib.sha256(data).hexdigest()
|
||
|
|
h, p = await store.put_by_hash(data)
|
||
|
|
assert h == expected_hash
|
||
|
|
assert p == tmp_path / expected_hash[:2] / expected_hash
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# put_stream
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_put_stream_writes_concatenated_chunks(tmp_path: Path):
|
||
|
|
"""put_stream consumes an async generator and writes concatenated bytes."""
|
||
|
|
|
||
|
|
async def gen():
|
||
|
|
yield b"chunk1-"
|
||
|
|
yield b"chunk2-"
|
||
|
|
yield b"chunk3"
|
||
|
|
|
||
|
|
store = ObjectStore(tmp_path)
|
||
|
|
path = await store.put_stream("streamed/key", gen())
|
||
|
|
assert path == tmp_path / "streamed" / "key"
|
||
|
|
assert path.read_bytes() == b"chunk1-chunk2-chunk3"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Default base dir (Settings.media_dir)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_object_store_default_base_uses_settings_media_dir(tmp_path: Path, monkeypatch):
|
||
|
|
"""Omitting base_dir falls back to Settings.media_dir."""
|
||
|
|
monkeypatch.setenv("MEDIA_DIR", str(tmp_path))
|
||
|
|
from app.config import get_settings
|
||
|
|
|
||
|
|
get_settings.cache_clear()
|
||
|
|
store = ObjectStore()
|
||
|
|
assert store.base_dir == get_settings().media_dir
|