Files
transcription/tests/test_config.py
T
zoltan57andCopilot App 7b9715b3f1 V4.6 Phase 3: worker and provider reliability
Claim jobs atomically [CRIT-01]
- Replace JobService.read_next_queued_job with claim_next_queued_job, which
  selects and transitions QUEUED -> PROCESSING inside one transaction. The old
  read-then-write sequence left a window in which two workers could observe the
  same QUEUED row.
- Add the missing .limit(1). The poll previously ordered the entire queued set
  and discarded all but the first row.
- Drop the eager loads from the hot poll entirely. They were pure waste:
  process_queued_job immediately re-reads the job through read_job with the
  relationships it actually needs.
- Guard the row with with_for_update(skip_locked=True) on PostgreSQL so the
  claim stays correct once more than one worker exists. On SQLite the claim is a
  bounded single-writer transaction.
- Correct the comment at the remaining direct-call claim site, which described
  the hazard rather than the guarantee.

Reuse the provider connection [HIGH-02]
- Build the ServiceBundle once per worker loop instead of once per job, and
  close it at loop shutdown. Every job previously constructed a new
  SourceService, and with it a new provider adapter and a new httpx.AsyncClient,
  paying a full TLS handshake per page and discarding the connection pool.
- process_next_queued_job now accepts an optional caller-owned bundle and only
  closes bundles it created itself.

Uncap the provider timeout [HIGH-03]
- Remove le=20.0 from worker_provider_timeout_seconds. The cap equalled the
  default, so the ceiling could never be raised, and dense-page vision
  transcription routinely needs longer. Default raised to 180s.
- Pass an explicit httpx.Timeout to the OpenRouter AsyncClient. httpx defaults
  every phase to 5 seconds, so the real read budget was 5s regardless of the
  configured value; the outer asyncio.wait_for could never be the binding
  constraint. Connect stays at 10s.

Tighten the provider boundary [MED-03]
- Declare model, current_request_manifest, current_transport_evidence, and
  aclose on the TranscriptionProvider Protocol.
- Delete the per-call inspect.signature(adapter.transcribe).parameters
  reflection and the untyped kwargs dict it fed. The Protocol had declared
  requested_model all along, so the reflection was dead defensive weight on the
  hot path.
- Replace the three getattr probes for aclose and the evidence attributes with
  direct typed access.

Deduplicate bundle construction [MED-06]
- Add ServiceBundle.from_session_factory and ServiceBundle.aclose, replacing the
  duplicated four-service instantiation blocks in app.py and worker.py.
- _recover_stale_processing_jobs now uses the bundle built moments earlier
  instead of constructing a second JobService.

Tests
- Claiming returns the oldest job, marks it PROCESSING, never hands the same job
  out twice, and emits exactly one unadorned SELECT carrying LIMIT and no JOIN.
- The worker loop threads one bundle through consecutive jobs and closes it once
  at shutdown; a caller-owned bundle is left open.
- Settings accepts a timeout above 20 seconds and still rejects zero.
- The OpenRouter client's read, write, and pool timeouts track the configured
  budget rather than the httpx default.

Note: .env in this checkout still pins WORKER_PROVIDER_TIMEOUT_SECONDS=20 and
should be raised to pick up this fix.

Verified: 268 passed, 4 skipped; ruff check clean.

Co-authored-by: Copilot App <[email protected]>
2026-08-17 16:26:31 -05:00

177 lines
6.7 KiB
Python

"""Tests for transcription.config — settings loading, provider defaults, and paths."""
from pathlib import Path
import pytest
from pydantic import ValidationError
from transcription.config import Provider
from transcription.config import Settings
from transcription.config import parse_cli_settings
def _make_settings(**overrides) -> Settings:
"""Build a Settings instance with a dummy API key unless overridden."""
defaults = {"openrouter_api_key": "test-key-abc123", "provider_models": None}
defaults.update(overrides)
return Settings(**defaults)
class TestSettingsLoading:
"""Verify Settings construction and required-field validation."""
def test_loads_from_env(self, monkeypatch):
"""Settings constructs when OPENROUTER_API_KEY is provided."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key-xyz")
settings = Settings()
assert settings.openrouter_api_key.get_secret_value() == "test-key-xyz"
def test_requires_api_key(self, monkeypatch):
"""Settings raises ValidationError when OPENROUTER_API_KEY is missing."""
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
with pytest.raises(ValidationError):
Settings(_env_file=None)
def test_ignores_process_cli_arguments(self, monkeypatch):
"""Ordinary settings construction does not consume tooling arguments."""
monkeypatch.setattr("sys.argv", ["pytest", "--rootdir=/tmp/project"])
settings = _make_settings()
assert settings.port == 8000
def test_explicit_cli_parser_reads_arguments(self):
"""The executable settings boundary accepts application CLI flags."""
settings = parse_cli_settings(
[
"--openrouter-api-key",
"test-key",
"--port",
"8123",
"--reload",
]
)
assert settings.openrouter_api_key.get_secret_value() == "test-key"
assert settings.port == 8123
assert settings.reload is True
class TestProviderSettings:
"""Verify provider enum defaults and validation."""
def test_defaults_to_openrouter(self):
"""Default provider is openrouter when not explicitly set."""
settings = _make_settings()
assert settings.provider == Provider.OPENROUTER
assert settings.provider == "openrouter"
def test_rejects_invalid_value(self):
"""Setting PROVIDER to an invalid value raises ValidationError."""
with pytest.raises(ValidationError):
_make_settings(provider="not-a-provider")
def test_optional_provider_header_fields_default_to_none(self):
"""openrouter_http_referer and openrouter_app_title are None when unset."""
settings = _make_settings()
assert settings.openrouter_http_referer is None
assert settings.openrouter_app_title is None
@pytest.mark.parametrize(
("field", "value"),
[
("transcription_temperature", -0.1),
("transcription_temperature", 2.1),
("transcription_top_p", -0.1),
("transcription_top_p", 1.1),
],
)
def test_rejects_sampling_values_outside_provider_ranges(self, field, value):
with pytest.raises(ValidationError):
_make_settings(**{field: value})
def test_rejects_prompt_paths_outside_prompt_directory(self):
with pytest.raises(ValidationError):
_make_settings(default_prompt_name="../secret.md")
def test_settings_are_immutable_runtime_snapshots(self):
settings = _make_settings()
with pytest.raises(ValidationError):
settings.port = 9000
def test_provider_model_accepts_env_default(self, monkeypatch):
"""provider_model is sourced when provided through environment configuration."""
monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash")
settings = Settings(openrouter_api_key="test-key-abc123")
assert settings.provider_model == "google/gemini-2.5-flash"
def test_provider_models_defaults_to_default_model(self):
settings = _make_settings(provider_model="vendor/default")
assert settings.provider_models == ("vendor/default",)
def test_provider_models_are_default_first_trimmed_and_deduplicated(self):
settings = _make_settings(
provider_model=" vendor/default ",
provider_models=["vendor/alternate", "vendor/default", " vendor/other "],
)
assert settings.provider_models == ("vendor/default", "vendor/alternate", "vendor/other")
def test_provider_models_rejects_empty_list(self):
with pytest.raises(ValidationError):
_make_settings(provider_models=[])
def test_provider_models_loads_json_from_environment(self, monkeypatch):
monkeypatch.setenv("PROVIDER_MODEL", "vendor/default")
monkeypatch.setenv("PROVIDER_MODELS", '["vendor/alternate","vendor/default"]')
settings = Settings(openrouter_api_key="test-key-abc123")
assert settings.provider_models == ("vendor/default", "vendor/alternate")
class TestPathSettings:
"""Verify filesystem path field types."""
def test_path_fields_are_path_objects(self):
"""upload_dir and prompt_dir are Path instances."""
settings = _make_settings()
assert isinstance(settings.upload_dir, Path)
assert isinstance(settings.prompt_dir, Path)
class TestWorkerReliabilitySettings:
"""Verify worker retry settings defaults."""
def test_worker_retry_defaults(self):
"""worker retry settings default to no retries."""
settings = _make_settings()
assert settings.worker_max_retries == 0
def test_provider_timeout_is_not_capped_at_twenty_seconds():
"""HIGH-03: vision transcription regularly runs past the old le=20.0 ceiling."""
settings = Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=300.0)
assert settings.worker_provider_timeout_seconds == 300.0
def test_provider_timeout_must_still_be_positive():
with pytest.raises(ValidationError):
Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=0.0)
def test_openrouter_client_timeout_tracks_the_configured_budget():
"""HIGH-03: httpx defaults every phase to 5s, silently capping the provider call."""
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
settings = Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=123.0)
provider = OpenRouterTranscriptionProvider(settings=settings)
timeout = provider._capturing_client._client.timeout
assert timeout.read == 123.0
assert timeout.write == 123.0
assert timeout.pool == 123.0
assert timeout.connect == 10.0