feat(files): add source-file upload with MinIO storage and Postgres repositories
Why: - Implements plan 001 Phase 3's upload orchestration. Changes: - ObjectStorage port and MinIO adapter, thread-offloaded per ADR-0017. - Tenant-scoped repositories for api_keys, source_files, ingestion_jobs. - upload_source_file implementing the two-transaction shape with (tenant_id, domain, content_sha256) idempotency. Impact: - This phase stores bytes only -- chunks_indexed is always 0 until Phase 4/5 add parsing/embedding.
This commit is contained in:
0
tests/unit/application/files/__init__.py
Normal file
0
tests/unit/application/files/__init__.py
Normal file
25
tests/unit/application/files/test_storage_keys.py
Normal file
25
tests/unit/application/files/test_storage_keys.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from src.application.files.storage_keys import source_file_object_key
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_source_file_object_key_is_server_derived_not_filename() -> None:
|
||||
tenant_id = uuid.uuid4()
|
||||
source_file_id = uuid.uuid4()
|
||||
|
||||
key = source_file_object_key(tenant_id, source_file_id)
|
||||
|
||||
assert key == f"tenants/{tenant_id}/source-files/{source_file_id}/original"
|
||||
|
||||
|
||||
def test_source_file_object_key_differs_per_tenant() -> None:
|
||||
source_file_id = uuid.uuid4()
|
||||
|
||||
key_a = source_file_object_key(uuid.uuid4(), source_file_id)
|
||||
key_b = source_file_object_key(uuid.uuid4(), source_file_id)
|
||||
|
||||
assert key_a != key_b
|
||||
58
tests/unit/application/files/test_validation.py
Normal file
58
tests/unit/application/files/test_validation.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from src.application.files.errors import FileTooLargeError, InvalidUploadError
|
||||
from src.application.files.validation import validate_and_hash_upload
|
||||
from src.application.ingestion.errors import UnsupportedSourceTypeError
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_DOCX_HEADER = b"PK\x03\x04" + b"\x00" * 20
|
||||
_CSV_BYTES = b"name,value\nfirst,1\n"
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_accepts_matching_csv() -> None:
|
||||
result = validate_and_hash_upload(filename="report.csv", data=_CSV_BYTES, max_size_bytes=1_000)
|
||||
|
||||
assert result.source_type == "csv"
|
||||
assert result.content_type == "text/csv"
|
||||
assert len(result.content_sha256) == 64
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_accepts_matching_docx() -> None:
|
||||
result = validate_and_hash_upload(
|
||||
filename="report.docx", data=_DOCX_HEADER, max_size_bytes=1_000
|
||||
)
|
||||
|
||||
assert result.source_type == "docx"
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_rejects_doc_as_unsupported() -> None:
|
||||
with pytest.raises(UnsupportedSourceTypeError):
|
||||
validate_and_hash_upload(filename="legacy.doc", data=_CSV_BYTES, max_size_bytes=1_000)
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_rejects_unknown_extension() -> None:
|
||||
with pytest.raises(UnsupportedSourceTypeError):
|
||||
validate_and_hash_upload(filename="report.pdf", data=_CSV_BYTES, max_size_bytes=1_000)
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_rejects_empty_file() -> None:
|
||||
with pytest.raises(InvalidUploadError):
|
||||
validate_and_hash_upload(filename="report.csv", data=b"", max_size_bytes=1_000)
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_rejects_oversized_file() -> None:
|
||||
with pytest.raises(FileTooLargeError):
|
||||
validate_and_hash_upload(filename="report.csv", data=_CSV_BYTES, max_size_bytes=4)
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_rejects_spoofed_docx_extension() -> None:
|
||||
"""Content is really CSV text, but the filename claims `.docx`."""
|
||||
with pytest.raises(InvalidUploadError):
|
||||
validate_and_hash_upload(filename="report.docx", data=_CSV_BYTES, max_size_bytes=1_000)
|
||||
|
||||
|
||||
def test_validate_and_hash_upload_rejects_spoofed_csv_extension() -> None:
|
||||
"""Content is really an OOXML zip, but the filename claims `.csv`."""
|
||||
with pytest.raises(InvalidUploadError):
|
||||
validate_and_hash_upload(filename="report.csv", data=_DOCX_HEADER, max_size_bytes=1_000)
|
||||
Reference in New Issue
Block a user