Step 5 implemented

This commit is contained in:
Jim Lancaster
2026-06-25 10:00:00 -05:00
parent abf5829c6b
commit 3e057c0eff
15 changed files with 1796 additions and 33 deletions
+2
View File
@@ -12,6 +12,8 @@ description = "Historical document transcription system"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.138.0",
"nicegui>=3.13.0",
"openrouter>=0.7.0", "openrouter>=0.7.0",
"pydantic>=2.13.4", "pydantic>=2.13.4",
"pydantic-settings>=2.9.1", "pydantic-settings>=2.9.1",
+1
View File
@@ -0,0 +1 @@
"""API route modules for the transcription app."""
+16
View File
@@ -0,0 +1,16 @@
"""Health endpoint routes."""
from fastapi import APIRouter
router = APIRouter()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
@router.get("/healthz")
def healthz_route() -> dict[str, str]:
"""Route wrapper for health status payload."""
return healthz()
+61
View File
@@ -0,0 +1,61 @@
"""Application factory and lifespan wiring for the transcription app."""
from __future__ import annotations
from contextlib import asynccontextmanager
from threading import Event, Thread
from fastapi import FastAPI
from transcription.api.health import router as health_router
from transcription.config import get_settings, setup_logging
from transcription.db import create_all
from transcription.ui import register_pages
from transcription.worker import run_worker_loop
def _start_worker(app: FastAPI) -> None:
stop_event = Event()
worker_thread = Thread(
target=run_worker_loop,
kwargs={"stop_event": stop_event, "poll_interval_seconds": 1.0},
daemon=True,
)
worker_thread.start()
app.state.worker_stop_event = stop_event
app.state.worker_thread = worker_thread
def _stop_worker(app: FastAPI) -> None:
stop_event = getattr(app.state, "worker_stop_event", None)
worker_thread = getattr(app.state, "worker_thread", None)
if stop_event is not None:
stop_event.set()
if worker_thread is not None:
worker_thread.join(timeout=2.0)
@asynccontextmanager
async def _lifespan(app: FastAPI):
setup_logging()
create_all()
settings = get_settings()
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
_start_worker(app)
try:
yield
finally:
_stop_worker(app)
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan)
register_pages(app)
app.include_router(health_router)
return app
+16
View File
@@ -0,0 +1,16 @@
"""UI page registration exports."""
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.jobs_page import register_page as register_jobs_page
from transcription.ui.upload_page import register_page as register_upload_page
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
register_upload_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
+135
View File
@@ -0,0 +1,135 @@
"""Jobs list and detail page registration."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document, Job, Transcript
@dataclass(frozen=True)
class JobView:
"""Read model for rendering job rows in the UI."""
id: UUID
status: str
created_at: str
updated_at: str
def fetch_jobs() -> list[JobView]:
"""Return jobs for display in most-recent-first order."""
with get_session() as session:
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
return [
JobView(
id=job.id,
status=job.status.value,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in jobs
]
def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
"""Return job, document, and transcript for detail view."""
with get_session() as session:
job = session.get(Job, job_id)
if job is None:
return None, None, None
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
return job, document, transcript
def register_page() -> None:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
def jobs_page() -> None:
ui.label("Transcription Jobs")
status = ui.label("Ready")
table_container = ui.column()
def render_table() -> None:
table_container.clear()
jobs = fetch_jobs()
with table_container:
if not jobs:
ui.label("No jobs yet.")
return
rows = [
{
"id": str(job.id),
"status": job.status,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
for job in jobs
]
ui.table(
columns=[
{"name": "id", "label": "Job ID", "field": "id"},
{"name": "status", "label": "Status", "field": "status"},
{"name": "created_at", "label": "Created", "field": "created_at"},
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
],
rows=rows,
row_key="id",
)
for row in rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
def refresh() -> None:
status.text = "Refreshing..."
try:
render_table()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {exc}"
ui.button("Refresh", on_click=refresh)
render_table()
ui.link("Back to upload", "/")
@ui.page("/jobs/{job_id}")
def job_detail_page(job_id: str) -> None:
ui.label("Job Detail")
try:
parsed_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id")
ui.link("Back to jobs", "/jobs")
return
job, document, transcript = fetch_job_detail(parsed_id)
if job is None:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
ui.label(f"Job ID: {job.id}")
ui.label(f"Status: {job.status.value}")
ui.label(f"Created: {job.created_at.isoformat()}")
ui.label(f"Updated: {job.updated_at.isoformat()}")
if document is not None:
ui.label(f"Filename: {document.filename}")
ui.label(f"File path: {document.file_path}")
if transcript is None:
ui.label("Transcript not available yet.")
elif transcript.text:
ui.label("Transcript:")
ui.markdown(transcript.text)
elif transcript.error_detail:
ui.label("Failure detail:")
ui.label(transcript.error_detail)
ui.link("Back to jobs", "/jobs")
+62
View File
@@ -0,0 +1,62 @@
"""Upload page registration and handlers."""
from __future__ import annotations
from dataclasses import dataclass
from nicegui import ui
from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
@dataclass
class UploadPageState:
"""Simple state container for upload page feedback."""
loading: bool = False
message: str = ""
def accepted_upload_types() -> str:
"""Return accepted file type string for upload input."""
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
"""Create an upload job from incoming file data."""
return create_upload_job(filename=filename, file_bytes=file_bytes)
def register_page() -> None:
"""Register the upload page route."""
@ui.page("/")
def upload_page() -> None:
state = UploadPageState()
status_label = ui.label("Upload a document to start transcription.")
def on_upload(event: UploadEventArguments) -> None:
state.loading = True
status_label.text = "Uploading..."
try:
payload = event.content.read()
result = submit_upload(filename=event.name, file_bytes=payload)
state.message = f"Created job {result.job_id}"
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = str(exc)
status_label.text = f"Upload failed: {state.message}"
ui.notify(f"Upload failed: {state.message}", type="negative")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props(f"accept={accepted_upload_types()}")
with ui.row():
ui.link("View jobs", "/ui/jobs")
+21
View File
@@ -0,0 +1,21 @@
"""Tests for transcription.api.health."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.health import router
class TestHealthEndpoint:
"""Verify /healthz endpoint behavior."""
def test_healthz_returns_ok_status(self):
"""GET /healthz returns a healthy status payload."""
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@@ -13,11 +13,8 @@ original envelope with its 2 cent stamp. The letter has a number of references t
children. Peter was Louis; Polly was Edith; and the 'little black rascal' referred to Maurice. children. Peter was Louis; Polly was Edith; and the 'little black rascal' referred to Maurice.
Miss Saville was the nurse at the Nome Hospital that was mentioned in the article by Inez in Miss Saville was the nurse at the Nome Hospital that was mentioned in the article by Inez in
the family newsletter two years ago. the family newsletter two years ago.
Nome Alaska August 26, 1923 Nome Alaska August 26, 1923
My Dear Ethel et al. My Dear Ethel et al.
I don't know when I did write or when you did I don't know when I did write or when you did
but I am going to write now however and never but I am going to write now however and never
the less. But I wish I could talk (I can yet but I the less. But I wish I could talk (I can yet but I
@@ -26,7 +23,6 @@ and Polly sit up and listen and that little black
rascal of yours would fairly sparkle with rascal of yours would fairly sparkle with
listening. Can't I see him listening now to all the listening. Can't I see him listening now to all the
yarns we told last summer? yarns we told last summer?
[photo of people on a frozen body of water with icebergs and a boat]
You see, we-Miss Saville and I, took a trip north You see, we-Miss Saville and I, took a trip north
on the Buford and it was very interesting. We on the Buford and it was very interesting. We
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
@@ -37,13 +33,13 @@ along the icebergs and the walrus were 'heisted' on board by the use of the cran
tons of freight and the beasts were so huge that they made the pulleys just creak. They were tons of freight and the beasts were so huge that they made the pulleys just creak. They were
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
the most repulsive looking animals imaginable and tho I have always read about them I never the most repulsive looking animals imaginable and tho I have always read about them I never
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
ship with any degree of comfort unless you had per chance lost your sense of smell. ship with any degree of comfort unless you had per chance lost your sense of smell.
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
@@ -58,7 +54,6 @@ comfortable. The babies were in fur skins with the fur inside and they looked li
bears with faces. I guess they had never seen white women, not so many at one time anyway. bears with faces. I guess they had never seen white women, not so many at one time anyway.
We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band
playing and the rear of the last life boat was the movie man. 'Twas very thrilling. playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists' The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists'
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
something to buy. Prices raised right before your eyes. One would but something for $1.00 something to buy. Prices raised right before your eyes. One would but something for $1.00
@@ -72,21 +67,19 @@ come down and the natives capture them. There was more junk brot aboard than bag
do believe. And they say that at the first stop it was worse than here. The red flag was flying do believe. And they say that at the first stop it was worse than here. The red flag was flying
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
number. number.
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
didn't and Polly would in my lap and I don't know much about the youngest one of yours so didn't and Polly would in my lap and I don't know much about the youngest one of yours so
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf. likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
I expect there were 150 passengers on board and almost or more of the crew and helpers. We I expect there were 150 passengers on board and almost or more of the crew and helpers. We
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times. had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
I have had jobs nearly all summer but not very much in them. Next week, September 4, I have had jobs nearly all summer but not very much in them. Next week, September 4,
school opens. I wish they would wait for a week but you know these school men. Wouldn't school opens. I wish they would wait for a week but you know these school men. Wouldn't
make any special difference I suppose for I would just fritter away the time but still one likes make any special difference I suppose for I would just fritter away the time but still one likes
to postpone the inevitable. to postpone the inevitable.
I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray
for reading over your shoulder. It would seem, then that you have bright children. Maybe for reading over your shoulder. It would seem, then that you have bright children. Maybe
they do know something about Geography. But it is ridiculous to speak of Louis finishing the they do know something about Geography. But it is ridiculous to speak of Louis finishing the
@@ -95,19 +88,13 @@ am rather afraid he doesn't know much. I quite remember your little timid Mauric
he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a
week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose
he does? he does?
Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done
well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens, well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens,
I'll write again. Don't suppose it ever will, tho. I'll write again. Don't suppose it ever will, tho.
Lots of love to all, Lots of love to all,
Ome Ome
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986 Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
© JECFA 1986 © JECFA 1986
Up Up
jecochranclan.org ~ Contact webmaster jecochranclan.org ~ Contact webmaster
@@ -2,29 +2,29 @@ source: Rod Moser Letter - p1.jpg
provider: openrouter provider: openrouter
model: google/gemini-2.5-flash model: google/gemini-2.5-flash
--- ---
JOHN ISBILL R. T. MOSER JOHN ISBILL R.T. MOSER
ISBILL & MOSER ISBILL & MOSER
DEALERS IN DEALERS IN
GENERAL MERCHANDISE GENERAL MERCHANDISE
Vonore, Tenn. [Janu]ary 27 1913 Vonore, Tenn. January 27- 1913-
Dear [Much Au][illegible]
Has at hour a Dear Much Aunt Aduian
few nigh [ago I] said a How are hour [sic] a
letter f[rom] your folks, so few nights ago r [and] said a
I [decide]d to [write] you letter from your folks, So
a few lines myself & I decided to write you
I am continuously a a few lines myself ok
trip out just next summer I am contemplate [sic] a
& I [want] [lo]t [of figures?] to go trip out west next summer
r [and] want one of Aldons to go
where I am. where I am.
We are getting We are getting
up in years & unmarried up in years r [and] remarrie[d]
so you see the object o[f] So you are the object of
my trip is to get a wife my trip, is to get a wife
If th[ere] is any old maids to put in any old maid
or widows out there I or widows out there, I
want you to kiss them want you to kiss them
at my [hand] and my at them at my [illegible] or at them
as soon as I get them as soon as I get them
+75
View File
@@ -0,0 +1,75 @@
"""Tests for transcription.app."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.app import create_app
@pytest.mark.unit
class TestAppFactory:
"""Verify FastAPI app factory wiring."""
def test_create_app_returns_fastapi_instance(self):
"""create_app returns a FastAPI application instance."""
app = create_app()
assert isinstance(app, FastAPI)
@pytest.mark.integration
class TestAppLifespan:
"""Verify startup and shutdown lifecycle behavior."""
def test_startup_initializes_runtime_dependencies(self, monkeypatch):
"""Startup initializes logging, schema, directories, and worker resources."""
calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
monkeypatch.setattr("transcription.app.create_all", lambda: calls.append("schema"))
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
class _Dir:
def mkdir(self, parents: bool, exist_ok: bool):
calls.append("mkdir")
class _Settings:
upload_dir = _Dir()
prompt_dir = _Dir()
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
app = create_app()
with TestClient(app):
pass
assert "logging" in calls
assert "schema" in calls
assert "mkdir" in calls
assert "start_worker" in calls
def test_shutdown_stops_worker_resources(self, monkeypatch):
"""Shutdown signals and stops worker resources cleanly."""
calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
monkeypatch.setattr("transcription.app.create_all", lambda: None)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
class _Dir:
def mkdir(self, parents: bool, exist_ok: bool):
return None
class _Settings:
upload_dir = _Dir()
prompt_dir = _Dir()
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
app = create_app()
with TestClient(app):
pass
assert calls == ["start_worker", "stop_worker"]
+95
View File
@@ -0,0 +1,95 @@
"""Tests for transcription.ui.jobs_page."""
from uuid import uuid4
import pytest
from transcription.models import Document, Job, Transcript
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs
@pytest.mark.integration
class TestJobsListBehavior:
"""Verify job list data and rendering helpers."""
def test_fetch_jobs_returns_job_view_rows(self, session, monkeypatch):
"""fetch_jobs returns normalized JobView rows for UI consumption."""
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id)
session.add(job)
session.commit()
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
rows = fetch_jobs()
assert len(rows) == 1
assert rows[0].id == job.id
assert rows[0].status == "queued"
@pytest.mark.integration
class TestJobDetailBehavior:
"""Verify job detail retrieval behavior."""
def test_fetch_job_detail_returns_related_records_when_present(self, session, monkeypatch):
"""fetch_job_detail returns job, document, and transcript when available."""
document = Document(filename="typed.jpg", file_path="uploads/typed.jpg")
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id)
session.add(job)
session.commit()
session.refresh(job)
transcript = Transcript(job_id=job.id, text="Transcript text")
session.add(transcript)
session.commit()
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(job.id)
assert fetched_job is not None
assert fetched_document is not None
assert fetched_transcript is not None
assert fetched_job.id == job.id
assert fetched_document.id == document.id
assert fetched_transcript.job_id == job.id
def test_fetch_job_detail_returns_nones_for_missing_job(self, session, monkeypatch):
"""fetch_job_detail returns triple None when job does not exist."""
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(uuid4())
assert fetched_job is None
assert fetched_document is None
assert fetched_transcript is None
+26
View File
@@ -0,0 +1,26 @@
"""Tests for UI page registration wiring."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from transcription.ui import register_pages
@pytest.mark.integration
class TestPageRegistration:
"""Verify page registration and route wiring."""
def test_register_pages_adds_expected_routes(self):
"""register_pages wires upload and jobs routes into the app."""
app = FastAPI()
register_pages(app)
app.add_api_route("/healthz", lambda: {"status": "ok"}, methods=["GET"])
client = TestClient(app)
ui_response = client.get("/ui")
health_response = client.get("/healthz")
assert ui_response.status_code == 200
assert health_response.status_code == 200
assert health_response.json() == {"status": "ok"}
+53
View File
@@ -0,0 +1,53 @@
"""Tests for transcription.ui.upload_page."""
from pathlib import Path
from uuid import uuid4
import pytest
from transcription.services.upload import UploadError, UploadJobResult
from transcription.ui import upload_page
@pytest.mark.unit
class TestUploadPageBehavior:
"""Verify upload page helper and submission behavior."""
def test_accepted_upload_types_contains_supported_extensions(self):
"""accepted_upload_types includes all MVP-supported upload extensions."""
accepted = upload_page.accepted_upload_types()
assert ".jpg" in accepted
assert ".jpeg" in accepted
assert ".png" in accepted
assert ".tif" in accepted
assert ".tiff" in accepted
assert ".pdf" in accepted
def test_submit_upload_calls_upload_service(self, monkeypatch):
"""submit_upload delegates file persistence and job creation to upload service."""
expected = UploadJobResult(
document_id=uuid4(),
job_id=uuid4(),
stored_path=Path("uploads/mock.jpg"),
original_filename="mock.jpg",
)
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
assert filename == "mock.jpg"
assert file_bytes == b"bytes"
return expected
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
result = upload_page.submit_upload(filename="mock.jpg", file_bytes=b"bytes")
assert result == expected
def test_submit_upload_surfaces_upload_error(self, monkeypatch):
"""submit_upload raises UploadError for invalid upload payloads."""
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
raise UploadError("invalid payload")
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
with pytest.raises(UploadError):
upload_page.submit_upload(filename="bad.jpg", file_bytes=b"")
Generated
+1213
View File
File diff suppressed because it is too large Load Diff