generated from john/python-template
V4.2 complete
This commit is contained in:
@@ -46,7 +46,7 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
|
|||||||
- Polling stops when the Job becomes terminal or a refresh fails.
|
- Polling stops when the Job becomes terminal or a refresh fails.
|
||||||
- Queued and processing Jobs expose **Cancel**.
|
- Queued and processing Jobs expose **Cancel**.
|
||||||
- Jobs other than `transcribed` expose **Resubmit** under the current UI rule. The service blocks resubmission while processing is active or when no failed Sources exist.
|
- Jobs other than `transcribed` expose **Resubmit** under the current UI rule. The service blocks resubmission while processing is active or when no failed Sources exist.
|
||||||
- All Jobs expose **Delete Job**, subject to delete guardrails.
|
- All Jobs expose **Delete Job**, subject to explicit evidence-deletion guardrails.
|
||||||
- Invalid and missing IDs produce explicit states.
|
- Invalid and missing IDs produce explicit states.
|
||||||
|
|
||||||
## Cancel Behavior
|
## Cancel Behavior
|
||||||
@@ -67,7 +67,10 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
|
|||||||
## Delete Behavior
|
## Delete Behavior
|
||||||
|
|
||||||
- Deletion is blocked while status is `processing`.
|
- Deletion is blocked while status is `processing`.
|
||||||
- Allowed deletion warns that related JobSource links are removed.
|
- Allowed deletion explicitly warns that related `JobSource` projections,
|
||||||
|
immutable execution attempts, captured transport responses, and attempt-owned
|
||||||
|
artifacts are permanently removed.
|
||||||
|
- Source records and source files remain available for separate deletion.
|
||||||
- Success returns to the Jobs list.
|
- Success returns to the Jobs list.
|
||||||
|
|
||||||
## Acceptance Checklist
|
## Acceptance Checklist
|
||||||
|
|||||||
@@ -43,12 +43,18 @@ async def dispose_database_runtime() -> None:
|
|||||||
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
||||||
"""Initialize lifespan-owned async DB resources once per process."""
|
"""Initialize lifespan-owned async DB resources once per process."""
|
||||||
global _runtime
|
global _runtime
|
||||||
runtime = _runtime
|
|
||||||
if runtime is not None:
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
active_settings = settings or get_settings()
|
active_settings = settings or get_settings()
|
||||||
database_url = get_database_url(active_settings)
|
database_url = get_database_url(active_settings)
|
||||||
|
runtime = _runtime
|
||||||
|
if runtime is not None:
|
||||||
|
runtime_url = runtime.engine.url.render_as_string(hide_password=False)
|
||||||
|
if runtime_url != database_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Database runtime is already initialized for a different database: "
|
||||||
|
f"{runtime_url!r} != {database_url!r}"
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
engine = get_engine(database_url)
|
engine = get_engine(database_url)
|
||||||
session_factory = get_session_factory(database_url)
|
session_factory = get_session_factory(database_url)
|
||||||
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -36,7 +39,25 @@ from transcription.providers.evidence import filter_safe_response_headers
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
|
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
|
||||||
OPENROUTER_ADAPTER_VERSION = "1"
|
OPENROUTER_ADAPTER_VERSION = "2"
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingAsyncByteStream(httpx.AsyncByteStream):
|
||||||
|
"""Copy streamed response bytes without changing what the SDK consumes."""
|
||||||
|
|
||||||
|
def __init__(self, stream: httpx.AsyncByteStream, on_complete: Callable[[bytes], None]):
|
||||||
|
self._stream = stream
|
||||||
|
self._on_complete = on_complete
|
||||||
|
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
content = bytearray()
|
||||||
|
async for chunk in self._stream:
|
||||||
|
content.extend(chunk)
|
||||||
|
yield chunk
|
||||||
|
self._on_complete(bytes(content))
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._stream.aclose()
|
||||||
|
|
||||||
|
|
||||||
class _CapturingAsyncClient:
|
class _CapturingAsyncClient:
|
||||||
@@ -45,10 +66,15 @@ class _CapturingAsyncClient:
|
|||||||
def __init__(self, client: httpx.AsyncClient):
|
def __init__(self, client: httpx.AsyncClient):
|
||||||
self._client = client
|
self._client = client
|
||||||
self.last_response: httpx.Response | None = None
|
self.last_response: httpx.Response | None = None
|
||||||
|
self.last_body: bytes | None = None
|
||||||
|
|
||||||
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
||||||
response = await self._client.send(request, **kwargs)
|
response = await self._client.send(request, **kwargs)
|
||||||
self.last_response = response
|
self.last_response = response
|
||||||
|
try:
|
||||||
|
self.last_body = response.content
|
||||||
|
except httpx.ResponseNotRead:
|
||||||
|
response.stream = _CapturingAsyncByteStream(response.stream, self._capture_body)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
|
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
|
||||||
@@ -59,6 +85,10 @@ class _CapturingAsyncClient:
|
|||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
self.last_response = None
|
self.last_response = None
|
||||||
|
self.last_body = None
|
||||||
|
|
||||||
|
def _capture_body(self, body: bytes) -> None:
|
||||||
|
self.last_body = body
|
||||||
|
|
||||||
|
|
||||||
class _ProviderModel(BaseModel):
|
class _ProviderModel(BaseModel):
|
||||||
@@ -245,7 +275,7 @@ class OpenRouterTranscriptionProvider:
|
|||||||
else "connection"
|
else "connection"
|
||||||
)
|
)
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
"OpenRouter request failed",
|
self._transport_error_message(transport),
|
||||||
request_manifest=manifest,
|
request_manifest=manifest,
|
||||||
transport_evidence=transport,
|
transport_evidence=transport,
|
||||||
failure_phase=failure_phase,
|
failure_phase=failure_phase,
|
||||||
@@ -354,10 +384,7 @@ class OpenRouterTranscriptionProvider:
|
|||||||
if response is None:
|
if response is None:
|
||||||
return TransportEvidence(response_received=False)
|
return TransportEvidence(response_received=False)
|
||||||
headers = filter_safe_response_headers(response.headers)
|
headers = filter_safe_response_headers(response.headers)
|
||||||
try:
|
body = self._capturing_client.last_body if self._capturing_client is not None else None
|
||||||
body = response.content
|
|
||||||
except httpx.ResponseNotRead:
|
|
||||||
body = None
|
|
||||||
return TransportEvidence(
|
return TransportEvidence(
|
||||||
response_received=True,
|
response_received=True,
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
@@ -369,6 +396,25 @@ class OpenRouterTranscriptionProvider:
|
|||||||
generation_id=headers.get("x-openrouter-generation-id"),
|
generation_id=headers.get("x-openrouter-generation-id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_error_message(transport: TransportEvidence) -> str:
|
||||||
|
message = "OpenRouter request failed"
|
||||||
|
if transport.status_code is not None:
|
||||||
|
message += f" with HTTP {transport.status_code}"
|
||||||
|
if transport.body is None:
|
||||||
|
return message
|
||||||
|
try:
|
||||||
|
payload = json.loads(transport.body)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return message
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return message
|
||||||
|
error = payload.get("error")
|
||||||
|
detail = error.get("message") if isinstance(error, dict) else None
|
||||||
|
if isinstance(detail, str) and detail.strip():
|
||||||
|
return f"{message}: {detail.strip()[:500]}"
|
||||||
|
return message
|
||||||
|
|
||||||
def _build_metadata(self, response: OpenRouterResponse) -> TranscriptionMetadata:
|
def _build_metadata(self, response: OpenRouterResponse) -> TranscriptionMetadata:
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
finish_reason = choice.finish_reason.strip() if choice.finish_reason and choice.finish_reason.strip() else None
|
finish_reason = choice.finish_reason.strip() if choice.finish_reason and choice.finish_reason.strip() else None
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -13,11 +15,14 @@ from ..db.models import Job
|
|||||||
from ..db.models import JobSource
|
from ..db.models import JobSource
|
||||||
from ..db.models import JobSourceStatus
|
from ..db.models import JobSourceStatus
|
||||||
from ..db.models import JobStatus
|
from ..db.models import JobStatus
|
||||||
|
from ..db.models import ProcessingArtifact
|
||||||
from ..db.models import Source
|
from ..db.models import Source
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class JobDeleteBlockedError(AppError):
|
class JobDeleteBlockedError(AppError):
|
||||||
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||||
@@ -253,6 +258,81 @@ class JobService(ServiceBase):
|
|||||||
await _session.delete(job)
|
await _session.delete(job)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
async def delete_job_and_evidence(self, *, job_id: UUID) -> None:
|
||||||
|
"""Explicitly delete a terminal job and all evidence owned by its attempts."""
|
||||||
|
external_references: list[str] = []
|
||||||
|
async with self._session_scope() as session:
|
||||||
|
job = (
|
||||||
|
await session.exec(
|
||||||
|
select(Job)
|
||||||
|
.options(selectinload(Job.job_sources))
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if job is None:
|
||||||
|
raise self._not_found(job_id)
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
raise JobDeleteBlockedError(
|
||||||
|
"Job delete blocked while status is processing",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Wait for processing to complete, or cancel it before deleting evidence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts = list(
|
||||||
|
(
|
||||||
|
await session.exec(
|
||||||
|
select(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if attempts:
|
||||||
|
attempt_ids = [attempt.id for attempt in attempts]
|
||||||
|
artifacts = list(
|
||||||
|
(
|
||||||
|
await session.exec(
|
||||||
|
select(ProcessingArtifact).where(
|
||||||
|
ProcessingArtifact.execution_attempt_id.in_(attempt_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
external_references = [
|
||||||
|
artifact.external_reference
|
||||||
|
for artifact in artifacts
|
||||||
|
if artifact.external_reference is not None
|
||||||
|
]
|
||||||
|
for artifact in artifacts:
|
||||||
|
await session.delete(artifact)
|
||||||
|
await session.flush()
|
||||||
|
for attempt in attempts:
|
||||||
|
await session.delete(attempt)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for job_source in list(job.job_sources):
|
||||||
|
await session.delete(job_source)
|
||||||
|
await session.flush()
|
||||||
|
await session.delete(job)
|
||||||
|
await self._finalize(session=session, caller_session=None)
|
||||||
|
|
||||||
|
for external_reference in external_references:
|
||||||
|
self._delete_external_artifact(external_reference)
|
||||||
|
|
||||||
|
def _delete_external_artifact(self, external_reference: str) -> None:
|
||||||
|
relative_path = Path(external_reference)
|
||||||
|
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||||
|
logger.warning("Skipped unsafe external artifact reference during job deletion: %s", external_reference)
|
||||||
|
return
|
||||||
|
artifact_root = self.settings.artifact_dir.resolve()
|
||||||
|
artifact_path = (artifact_root / relative_path).resolve()
|
||||||
|
if artifact_root not in artifact_path.parents:
|
||||||
|
logger.warning("Skipped external artifact outside configured root: %s", external_reference)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to delete external artifact: %s", artifact_path)
|
||||||
|
|
||||||
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
||||||
"""Cancel a queued/processing job and stop remaining source work."""
|
"""Cancel a queued/processing job and stop remaining source work."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
|||||||
@@ -1155,7 +1155,7 @@ def handle_transcription_errors():
|
|||||||
) from exc
|
) from exc
|
||||||
except ProviderError as exc:
|
except ProviderError as exc:
|
||||||
raise TranscriptionError(
|
raise TranscriptionError(
|
||||||
"Provider transcription failed",
|
f"Provider transcription failed: {exc}",
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
||||||
retriable=True,
|
retriable=True,
|
||||||
|
|||||||
@@ -330,15 +330,20 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.label("This action permanently deletes the job.").classes("text-xs ui-text-danger font-medium")
|
ui.label("This action permanently deletes the job and its immutable execution evidence.").classes(
|
||||||
|
"text-xs ui-text-danger font-medium"
|
||||||
|
)
|
||||||
if job.job_sources:
|
if job.job_sources:
|
||||||
ui.label("Related JobSource links will be removed as part of delete.").classes(
|
ui.label(
|
||||||
|
"Related JobSource links, execution attempts, transport responses, and attempt artifacts "
|
||||||
|
"will be removed. Source records and files remain until deleted separately."
|
||||||
|
).classes(
|
||||||
"text-xs ui-text-muted"
|
"text-xs ui-text-muted"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
async def submit_delete() -> None:
|
||||||
try:
|
try:
|
||||||
await jobs_service.delete_job_with_guardrails(job_id=job.id)
|
await jobs_service.delete_job_and_evidence(job_id=job.id)
|
||||||
except JobDeleteBlockedError as exc:
|
except JobDeleteBlockedError as exc:
|
||||||
ui.notify(exc.message, type="warning")
|
ui.notify(exc.message, type="warning")
|
||||||
return
|
return
|
||||||
@@ -355,7 +360,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
destructive_button(
|
destructive_button(
|
||||||
"Delete job permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
"Delete job and evidence", on_click=submit_delete, icon="delete_forever", variant="solid"
|
||||||
)
|
)
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -4,6 +4,8 @@ Every test gets a fresh in-memory SQLite database so tests are
|
|||||||
isolated, fast, and leave no artifacts on disk.
|
isolated, fast, and leave no artifacts on disk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
@@ -12,7 +14,7 @@ from sqlmodel import create_engine
|
|||||||
from sqlmodel.pool import StaticPool
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import SqliteSettings
|
||||||
from transcription.db.engine import get_database_url
|
from transcription.db.engine import get_database_url
|
||||||
from transcription.db.engine import get_engine
|
from transcription.db.engine import get_engine
|
||||||
from transcription.db.session import dispose_session_factory
|
from transcription.db.session import dispose_session_factory
|
||||||
@@ -37,10 +39,17 @@ def session():
|
|||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def default_settings():
|
async def default_settings(tmp_path):
|
||||||
"""Provide default settings for tests."""
|
"""Provide default settings for tests."""
|
||||||
settings = get_settings(database_url="sqlite:///:memory:")
|
database_path = tmp_path / "tests.db"
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=str(database_path)),
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
db_url = get_database_url(settings)
|
db_url = get_database_url(settings)
|
||||||
|
if Path(str(get_engine(database_url=db_url).url.database)).resolve() != database_path.resolve():
|
||||||
|
raise RuntimeError(f"Refusing to initialize destructive test fixtures against {db_url}")
|
||||||
await dispose_session_factory(db_url)
|
await dispose_session_factory(db_url)
|
||||||
engine = get_engine(database_url=db_url)
|
engine = get_engine(database_url=db_url)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from transcription.services.jobs import JobDeleteBlockedError
|
|||||||
from transcription.services.jobs import JobNotFoundError
|
from transcription.services.jobs import JobNotFoundError
|
||||||
from transcription.services.jobs import JobResubmitBlockedError
|
from transcription.services.jobs import JobResubmitBlockedError
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.sources import SourceService
|
||||||
|
|
||||||
|
|
||||||
class TestJobService:
|
class TestJobService:
|
||||||
@@ -232,6 +233,41 @@ class TestJobService:
|
|||||||
with pytest.raises(JobNotFoundError):
|
with pytest.raises(JobNotFoundError):
|
||||||
await job_service.read_job(job_id=job.id)
|
await job_service.read_job(job_id=job.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_job_and_evidence_removes_attempts_but_preserves_source(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
source_service = SourceService(session_factory=job_service.session_factory)
|
||||||
|
document = await document_service.create_document(Document(name="evidence-delete-doc"))
|
||||||
|
job = await job_service.create_job(Job(document_id=document.id, status=JobStatus.FAILED))
|
||||||
|
source = await source_service.create_source(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="evidence.jpg",
|
||||||
|
filename="evidence.jpg",
|
||||||
|
file_path="/uploads/evidence.jpg",
|
||||||
|
file_hash="d" * 64,
|
||||||
|
file_size_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await source_service.create_job_source(JobSource(job_id=job.id, source_id=source.id))
|
||||||
|
await source_service.update_job_source_transcription(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
text=None,
|
||||||
|
error_detail="fixture failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
await job_service.delete_job_and_evidence(job_id=job.id)
|
||||||
|
|
||||||
|
with pytest.raises(JobNotFoundError):
|
||||||
|
await job_service.read_job(job_id=job.id)
|
||||||
|
assert await source_service.list_execution_attempts(job_id=job.id) == []
|
||||||
|
assert (await source_service.read_source(source.id)).id == source.id
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_job_marks_non_transcribed_sources_failed(
|
async def test_cancel_job_marks_non_transcribed_sources_failed(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -62,6 +62,28 @@ async def test_get_session_yields_async_session(tmp_path):
|
|||||||
await dispose_database_runtime()
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runtime_rejects_reinitialization_for_different_database(tmp_path):
|
||||||
|
"""An existing process runtime cannot silently switch database targets."""
|
||||||
|
first_settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=str(tmp_path / "first.db")),
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
|
second_settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=str(tmp_path / "second.db")),
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
|
initialize_database_runtime(settings=first_settings)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError, match="already initialized for a different database"):
|
||||||
|
initialize_database_runtime(settings=second_settings)
|
||||||
|
finally:
|
||||||
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_all_seeds_default_registry_rows(tmp_path):
|
async def test_create_all_seeds_default_registry_rows(tmp_path):
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ from transcription.services.sources import TranscriptionError
|
|||||||
from transcription.services.sources import transcribe_document_image
|
from transcription.services.sources import transcribe_document_image
|
||||||
|
|
||||||
|
|
||||||
|
class _ChunkedAsyncStream(httpx.AsyncByteStream):
|
||||||
|
def __init__(self, chunks: list[bytes]):
|
||||||
|
self._chunks = chunks
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
for chunk in self._chunks:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_openrouter_captures_exact_transport_and_secret_safe_manifest():
|
async def test_openrouter_captures_exact_transport_and_secret_safe_manifest():
|
||||||
response_body = (
|
response_body = (
|
||||||
@@ -87,6 +99,39 @@ async def test_openrouter_captures_exact_transport_and_secret_safe_manifest():
|
|||||||
assert result.request_manifest.omitted_optional_parameters == ("temperature", "top_p")
|
assert result.request_manifest.omitted_optional_parameters == ("temperature", "top_p")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openrouter_captures_body_consumed_as_sdk_stream():
|
||||||
|
response_body = (
|
||||||
|
b'{"id":"gen-2","created":1,"model":"vendor/model","object":"chat.completion",'
|
||||||
|
b'"system_fingerprint":null,"choices":[{"index":0,"finish_reason":"stop",'
|
||||||
|
b'"message":{"role":"assistant","content":"Transcript"}}],'
|
||||||
|
b'"unknown_streamed_field":true}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
stream=_ChunkedAsyncStream([response_body[:23], response_body[23:61], response_body[61:]]),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = OpenRouterTranscriptionProvider(
|
||||||
|
settings=Settings(openrouter_api_key="test-key"),
|
||||||
|
async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||||
|
)
|
||||||
|
result = await provider.transcribe(
|
||||||
|
prompt_text="Literal prompt",
|
||||||
|
image_bytes=b"source-bytes",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.transport_evidence is not None
|
||||||
|
assert result.transport_evidence.body == response_body
|
||||||
|
assert b'"unknown_streamed_field":true' in result.transport_evidence.body
|
||||||
|
await provider.aclose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_openrouter_failure_retains_safe_response_evidence():
|
async def test_openrouter_failure_retains_safe_response_evidence():
|
||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
@@ -113,6 +158,7 @@ async def test_openrouter_failure_retains_safe_response_evidence():
|
|||||||
assert evidence.status_code == 500
|
assert evidence.status_code == 500
|
||||||
assert evidence.body == b'{"error":{"message":"provider unavailable"}}'
|
assert evidence.body == b'{"error":{"message":"provider unavailable"}}'
|
||||||
assert evidence.safe_headers == {"content-type": "application/json", "retry-after": "2"}
|
assert evidence.safe_headers == {"content-type": "application/json", "retry-after": "2"}
|
||||||
|
assert str(failure.value) == "OpenRouter request failed with HTTP 500: provider unavailable"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ def app_client(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[tuple
|
|||||||
app = create_app(settings=settings)
|
app = create_app(settings=settings)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
|
runtime_path = Path(str(app.state.runtime.engine.url.database)).resolve()
|
||||||
|
expected_path = Path(settings.database.path).resolve()
|
||||||
|
if runtime_path != expected_path:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Refusing to initialize destructive UI fixtures against "
|
||||||
|
f"{runtime_path}; expected {expected_path}"
|
||||||
|
)
|
||||||
yield app, client
|
yield app, client
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -164,5 +164,6 @@ class TestJobsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Delete Processing Job" in response.text
|
assert "Delete Processing Job" in response.text
|
||||||
assert "Delete job permanently" in response.text
|
assert "Delete job and evidence" in response.text
|
||||||
|
assert "immutable execution evidence" in response.text
|
||||||
assert "Delete is blocked" not in response.text
|
assert "Delete is blocked" not in response.text
|
||||||
|
|||||||
Reference in New Issue
Block a user