//! Integration tests for the OCR analysis backend //! (`analysis::ocr::OcrAnalyzeDispatcher`). A stub OCR engine stands in for //! `ocrs` (whose `.rten` models aren't shipped to CI), so these pin the //! storage→OCR→persist wiring: the dispatcher reads the page image, runs the //! engine, and persists the lines via the shared `persist_analysis` path — //! landing `page_ocr_text` rows and a populated `search_doc` exactly like the //! vision backend. Each `#[sqlx::test]` gets a fresh migrated DB. mod common; use std::sync::Arc; use mangalord::analysis::daemon::AnalyzeDispatcher; use mangalord::analysis::ocr::test_support::StubOcrEngine; use mangalord::analysis::ocr::OcrAnalyzeDispatcher; use mangalord::domain::page_analysis::AnalysisStatus; use mangalord::repo; use mangalord::storage::{LocalStorage, Storage}; use sqlx::PgPool; use tempfile::TempDir; use uuid::Uuid; /// Seed a manga → chapter → page chain whose page points at `storage_key`, /// and return the page id. async fn seed_page(pool: &PgPool, storage_key: &str) -> Uuid { let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id") .fetch_one(pool) .await .unwrap(); let chapter_id: Uuid = sqlx::query_scalar( "INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id", ) .bind(manga_id) .fetch_one(pool) .await .unwrap(); sqlx::query_scalar( "INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \ VALUES ($1, 1, $2, 'image/png') RETURNING id", ) .bind(chapter_id) .bind(storage_key) .fetch_one(pool) .await .unwrap() } fn ocr_dispatcher( pool: &PgPool, storage: Arc, lines: &[&str], ) -> OcrAnalyzeDispatcher { OcrAnalyzeDispatcher { db: pool.clone(), storage, engine: StubOcrEngine::new(lines), max_image_bytes: 8 * 1024 * 1024, } } #[sqlx::test(migrations = "./migrations")] async fn dispatch_persists_ocr_lines_and_search_doc(pool: PgPool) { let dir = TempDir::new().unwrap(); let storage: Arc = Arc::new(LocalStorage::new(dir.path())); let key = "mangas/x/p1.png"; storage.put(key, &common::fake_png_bytes()).await.unwrap(); let page_id = seed_page(&pool, key).await; let dispatcher = ocr_dispatcher(&pool, Arc::clone(&storage), &["Hello there", "general"]); dispatcher.dispatch(page_id).await.unwrap(); // Two OCR rows, in order, with the recognized text. let rows: Vec<(String, i32)> = sqlx::query_as( "SELECT text, ord FROM page_ocr_text WHERE page_id = $1 ORDER BY ord", ) .bind(page_id) .fetch_all(&pool) .await .unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].0, "Hello there"); assert_eq!(rows[1].0, "general"); // The analysis row is `done`, stamped with the ocrs model label, and has a // non-empty tsvector so text search works. let row = repo::page_analysis::load(&pool, page_id).await.unwrap().unwrap(); assert_eq!(row.status, AnalysisStatus::Done); assert_eq!(row.model.as_deref(), Some("ocrs")); let has_doc: bool = sqlx::query_scalar( "SELECT search_doc IS NOT NULL AND search_doc != ''::tsvector \ FROM page_analysis WHERE page_id = $1", ) .bind(page_id) .fetch_one(&pool) .await .unwrap(); assert!(has_doc, "search_doc must be populated from OCR text"); } #[sqlx::test(migrations = "./migrations")] async fn dispatch_missing_page_is_noop(pool: PgPool) { let dir = TempDir::new().unwrap(); let storage: Arc = Arc::new(LocalStorage::new(dir.path())); // A page id that was never inserted — the dispatcher must treat it as a // deleted page and succeed without writing anything. let dispatcher = ocr_dispatcher(&pool, storage, &["whatever"]); dispatcher.dispatch(Uuid::new_v4()).await.unwrap(); }