Why: - The failure modes worth testing are races, and they are cheap to force against the fake and expensive to observe anywhere else. Changes: - Unit tests for both boundaries, the repeat delete, a missing neighbour, the traversal property after several deletes, and — via a repository that bumps a rival's version before each apply — both the partial-apply repair and the unconvergent 409. - One new shared contract scenario (a multi-point batch applies every patch, including nulling a pointer) so it runs against the fake and real Qdrant. - HTTP tests against real Postgres and Qdrant for relinking, soft-not-hard delete, cross-tenant 404, and scope enforcement on both routes. - create_source_file factory for tests addressing a file without uploading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
3.0 KiB
Python
106 lines
3.0 KiB
Python
"""Object builders for test fixtures (ADR-0016).
|
|
|
|
Insert rows via a caller-supplied `AsyncSession` without committing — callers
|
|
decide their own transaction boundary (the `db_session` fixture rolls back
|
|
after each test).
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.application.auth.keys import generate_api_key, hash_secret
|
|
from src.infrastructure.postgres.models.api_key import ApiKey
|
|
from src.infrastructure.postgres.models.source_file import SourceFile
|
|
from src.infrastructure.postgres.models.tenant import Tenant
|
|
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
|
|
|
|
|
|
async def create_tenant(
|
|
session: AsyncSession, *, slug: str | None = None, status: str = "active"
|
|
) -> Tenant:
|
|
slug = slug or f"tenant-{uuid.uuid4().hex[:8]}"
|
|
tenant = Tenant(id=uuid.uuid4(), slug=slug, name=slug, status=status)
|
|
session.add(tenant)
|
|
await session.flush()
|
|
return tenant
|
|
|
|
|
|
async def create_api_key(
|
|
session: AsyncSession,
|
|
*,
|
|
tenant: Tenant,
|
|
scopes: list[str] | None = None,
|
|
status: str = "active",
|
|
) -> tuple[ApiKey, str]:
|
|
"""Returns `(api_key, full_key)`. `full_key` is the bearer token to send;
|
|
only its hash is persisted.
|
|
"""
|
|
key_prefix, secret, full_key = generate_api_key()
|
|
api_key = ApiKey(
|
|
id=uuid.uuid4(),
|
|
tenant_id=tenant.id,
|
|
name="test-key",
|
|
key_prefix=key_prefix,
|
|
key_hash=hash_secret(secret),
|
|
scopes=scopes or ["files:write"],
|
|
status=status,
|
|
)
|
|
session.add(api_key)
|
|
await session.flush()
|
|
return api_key, full_key
|
|
|
|
|
|
async def create_source_file(
|
|
session: AsyncSession,
|
|
*,
|
|
tenant: Tenant,
|
|
source_file_id: uuid.UUID | None = None,
|
|
domain: str = "general",
|
|
status: str = "active",
|
|
) -> SourceFile:
|
|
"""A `source_files` row for tests that address a file without uploading one.
|
|
|
|
`DELETE /v1/files/{file_id}` authorizes against this row before touching a
|
|
single point, so a delete test needs it even though the interesting state
|
|
lives in Qdrant.
|
|
"""
|
|
source_file = SourceFile(
|
|
id=source_file_id or uuid.uuid4(),
|
|
tenant_id=tenant.id,
|
|
domain=domain,
|
|
source_filename="handbook.docx",
|
|
source_type="docx",
|
|
content_sha256="0" * 64,
|
|
byte_size=1024,
|
|
storage_uri="s3://bucket/key",
|
|
status=status,
|
|
)
|
|
session.add(source_file)
|
|
await session.flush()
|
|
return source_file
|
|
|
|
|
|
async def create_tenant_domain(
|
|
session: AsyncSession,
|
|
*,
|
|
tenant: Tenant,
|
|
domain: str = "general",
|
|
status: str = "active",
|
|
) -> TenantDomain:
|
|
"""Register a domain so an upload to it passes the allowlist check.
|
|
|
|
Uploads reject an unregistered domain (ADR-0009), so any test that uploads
|
|
needs one of these.
|
|
"""
|
|
tenant_domain = TenantDomain(
|
|
id=uuid.uuid4(),
|
|
tenant_id=tenant.id,
|
|
domain=domain,
|
|
display_name=domain,
|
|
status=status,
|
|
)
|
|
session.add(tenant_domain)
|
|
await session.flush()
|
|
return tenant_domain
|