195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
"""ADR-0017's two-transaction upload shape, exercised against real Postgres.
|
|
|
|
Object storage is faked (`FakeObjectStorage`) -- it's a port, not the thing
|
|
under test here. `MinioObjectStorage` itself is covered in
|
|
`tests/integration/minio/`.
|
|
"""
|
|
|
|
import pytest
|
|
from anyio import CapacityLimiter
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from src.application.auth.context import AuthContext
|
|
from src.application.files.upload import upload_source_file
|
|
from src.infrastructure.postgres.models.ingestion_job import IngestionJob
|
|
from tests.fakes import FakeObjectStorage
|
|
from tests.support.factories import create_api_key, create_tenant
|
|
|
|
pytestmark = [
|
|
pytest.mark.integration,
|
|
pytest.mark.postgres,
|
|
pytest.mark.asyncio(loop_scope="session"),
|
|
]
|
|
|
|
_CSV_BYTES = b"name,value\nfirst,1\n"
|
|
|
|
|
|
async def _auth_for(db_session: AsyncSession) -> AuthContext:
|
|
tenant = await create_tenant(db_session)
|
|
api_key, _ = await create_api_key(db_session, tenant=tenant)
|
|
await db_session.commit()
|
|
return AuthContext(
|
|
tenant_id=tenant.id,
|
|
tenant_slug=tenant.slug,
|
|
api_key_id=api_key.id,
|
|
scopes=frozenset({"files:write"}),
|
|
actor_type="backend",
|
|
)
|
|
|
|
|
|
async def test_upload_source_file_commits_running_job_before_storage_write(
|
|
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""After phase 1 commits, a *separate* session must already see the
|
|
running job -- proving txn A committed and released before phase 2 work,
|
|
per ADR-0017.
|
|
"""
|
|
auth = await _auth_for(db_session)
|
|
storage = FakeObjectStorage()
|
|
|
|
result = await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=storage,
|
|
auth=auth,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
|
|
assert result.status == "succeeded"
|
|
assert result.chunks_indexed == 0
|
|
assert result.is_new_attempt
|
|
|
|
async with db_sessionmaker() as verify_session:
|
|
job = await verify_session.get(IngestionJob, result.ingestion_job_id)
|
|
assert job is not None
|
|
assert job.status == "succeeded"
|
|
assert storage.objects # bytes were actually written
|
|
|
|
|
|
async def test_upload_source_file_duplicate_hash_returns_existing_without_reingesting(
|
|
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
auth = await _auth_for(db_session)
|
|
storage = FakeObjectStorage()
|
|
|
|
first = await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=storage,
|
|
auth=auth,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
second = await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=storage,
|
|
auth=auth,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
|
|
assert second.file_id == first.file_id
|
|
assert second.ingestion_job_id == first.ingestion_job_id
|
|
assert not second.is_new_attempt
|
|
assert len(storage.objects) == 1 # no second write
|
|
|
|
|
|
async def test_upload_source_file_retries_after_failed_job(
|
|
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""A duplicate upload whose last attempt failed must get a fresh job,
|
|
not be treated as already-succeeded (ADR-0017: safe to run more than
|
|
once).
|
|
"""
|
|
auth = await _auth_for(db_session)
|
|
failing_storage = FakeObjectStorage(fail_next=True)
|
|
|
|
with pytest.raises(OSError):
|
|
await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=failing_storage,
|
|
auth=auth,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
|
|
async with db_sessionmaker() as verify_session:
|
|
jobs = (
|
|
(
|
|
await verify_session.execute(
|
|
select(IngestionJob).where(IngestionJob.tenant_id == auth.tenant_id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert len(jobs) == 1
|
|
assert jobs[0].status == "failed"
|
|
|
|
retry = await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=FakeObjectStorage(),
|
|
auth=auth,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
|
|
assert retry.status == "succeeded"
|
|
assert retry.is_new_attempt
|
|
assert retry.file_id == jobs[0].source_file_id
|
|
|
|
|
|
async def test_upload_source_file_is_tenant_isolated_for_identical_content(
|
|
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
|
) -> None:
|
|
"""Two different tenants uploading byte-identical files must not collide
|
|
on the (tenant_id, domain, content_sha256) idempotency key.
|
|
"""
|
|
auth_a = await _auth_for(db_session)
|
|
auth_b = await _auth_for(db_session)
|
|
|
|
result_a = await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=FakeObjectStorage(),
|
|
auth=auth_a,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
result_b = await upload_source_file(
|
|
sessionmaker=db_sessionmaker,
|
|
storage=FakeObjectStorage(),
|
|
auth=auth_b,
|
|
domain="general",
|
|
filename="report.csv",
|
|
data=_CSV_BYTES,
|
|
max_upload_size_bytes=1_000_000,
|
|
chunking_strategy="fixed_size",
|
|
validation_limiter=CapacityLimiter(2),
|
|
)
|
|
|
|
assert result_a.file_id != result_b.file_id
|