//! P6-4 image ingest wiring — end-to-end integration. //! //! Each test spins up a `TempDir` workspace + writes one PNG fixture + //! routes OCR / caption HTTP calls through a `wiremock` server that //! impersonates Ollama's `/api/generate` endpoint. The kb-app code //! under test is sync; the wiremock server is async, so test bodies //! drive blocking work via `tokio::task::spawn_blocking`. mod common; use std::path::Path; use common::TestEnv; use kebab_config::Config; use serde_json::json; use tokio::task::spawn_blocking; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; // ── Fixture helpers ────────────────────────────────────────────────────── /// Tiny solid-red PNG written into the test workspace at `/`. /// 100×50 — small enough to skip downscale by default but non-trivially /// inspectable in stored DB rows. fn write_red_png(root: &Path, name: &str) -> std::path::PathBuf { use image::{ImageBuffer, Rgb}; let img: ImageBuffer, _> = ImageBuffer::from_fn(100, 50, |_, _| Rgb([255, 0, 0])); let path = root.join(name); img.save(&path).expect("write PNG fixture"); path } fn cfg_with_image_pipeline(env: &TestEnv, mock_endpoint: &str) -> Config { let mut cfg = env.config.clone(); // p9-fb-25: workspace.include removed; extension routing is now // handled by extractor matching alone (no config knob). cfg.ingest.image.ocr.enabled = true; cfg.ingest.image.ocr.endpoint = Some(mock_endpoint.to_string()); cfg.ingest.image.ocr.model = "vision-mock:1b".to_string(); cfg.ingest.image.ocr.max_pixels = 512; cfg.ingest.image.caption.enabled = false; // tested separately below cfg.models.llm.endpoint = mock_endpoint.to_string(); cfg.models.llm.model = "vision-mock:1b".to_string(); cfg } // ── 1. Happy path: OCR-only ingest ─────────────────────────────────────── /// One PNG asset + OCR enabled (caption off) → ingest produces 1 doc + 1 /// chunk; chunk text contains alt + OCR transcription joined by `\n\n`. #[tokio::test] async fn ingest_image_with_ocr_produces_chunk_containing_ocr_text() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/generate")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "model": "vision-mock:1b", "response": "Hello World 2026", "done": true, "done_reason": "stop" }))) .mount(&server) .await; let env = TestEnv::lexical_only(); let png = write_red_png(&env.workspace_root, "diagram.png"); eprintln!("PNG written to {}", png.display()); let cfg = cfg_with_image_pipeline(&env, &server.uri()); let cfg_clone = cfg.clone(); let env_workspace = env.workspace_root.clone(); let env_scope = env.scope(); let report = spawn_blocking(move || { kebab_app::ingest_with_config(cfg_clone, env_scope, false) .expect("image ingest must succeed") }) .await .expect("blocking task panicked"); // Counters: scanned should include the PNG; new ≥ 1 (markdown // fixtures from the workspace tree may also count). assert!( report.scanned >= 1, "scanned={}, items={:?}", report.scanned, report.items ); assert_eq!(report.errors, 0, "no errors on lenient OCR path"); // Locate the image doc in the report items. let items = report.items.expect("items present (summary_only=false)"); let img_item = items .iter() .find(|i| i.doc_path.0.ends_with("diagram.png")) .expect("image doc item must be present"); assert_eq!( img_item.kind, kebab_core::IngestItemKind::New, "image asset must be classified New on first ingest" ); assert_eq!( img_item.chunk_count, Some(1), "image emits exactly one chunk" ); // Inspect the stored chunk text via kb-app's inspect_chunk facade. let doc_id = img_item.doc_id.clone().expect("image doc id"); let doc = kebab_app::inspect_doc_with_config(cfg.clone(), &doc_id) .expect("inspect_doc returns the image document"); let block = match doc.blocks.first() { Some(kebab_core::Block::ImageRef(b)) => b, other => panic!("expected ImageRef, got {other:?}"), }; assert!(block.ocr.is_some(), "block.ocr populated by apply_ocr"); assert_eq!( block.ocr.as_ref().unwrap().joined, "Hello World 2026", "OCR text from mock" ); assert!( block.caption.is_none(), "caption disabled in cfg → block.caption stays None" ); // Sanity: the doc was actually persisted into SQLite (kb-app's // list_docs facade reads the same store the chunker writes to). let summaries = kebab_app::list_docs_with_config(cfg, kebab_core::DocFilter::default()).expect("list_docs"); assert!( summaries .iter() .any(|s| s.doc_path.0.ends_with("diagram.png")), "image doc must appear in list_docs" ); drop(env_workspace); // keep TempDir alive until here drop(env); } // ── 2. OCR + caption together ──────────────────────────────────────────── /// Both OCR and caption enabled. The mock returns the same JSON body /// for every `/api/generate` POST — wiremock has no per-prompt routing /// on the default `Mock` so we treat both calls as equivalent. We then /// verify both `block.ocr` and `block.caption` are populated, and the /// chunk text contains both fragments separated by `\n\n`. #[tokio::test] async fn ingest_image_with_ocr_and_caption_populates_both_fields() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/generate")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "response": "shared mock body", "done": true, "done_reason": "stop" }))) .mount(&server) .await; let env = TestEnv::lexical_only(); write_red_png(&env.workspace_root, "diagram.png"); let mut cfg = cfg_with_image_pipeline(&env, &server.uri()); cfg.ingest.image.caption.enabled = true; cfg.ingest.image.caption.max_pixels = 384; let cfg_clone = cfg.clone(); let scope = env.scope(); let report = spawn_blocking(move || { kebab_app::ingest_with_config(cfg_clone, scope, false) .expect("ingest must succeed with both OCR+caption") }) .await .expect("task"); assert_eq!(report.errors, 0); let img_item = report .items .as_ref() .unwrap() .iter() .find(|i| i.doc_path.0.ends_with("diagram.png")) .unwrap(); let doc = kebab_app::inspect_doc_with_config(cfg, img_item.doc_id.as_ref().unwrap()).unwrap(); let block = match &doc.blocks[0] { kebab_core::Block::ImageRef(b) => b, _ => unreachable!(), }; assert!(block.ocr.is_some(), "OCR populated"); assert!(block.caption.is_some(), "caption populated"); drop(env); } // ── 3. Lenient failure: OCR Ollama 503 → asset still indexed ───────────── /// OCR endpoint returns 503. Spec contract: image is still indexed, /// `block.ocr = None`, Provenance has a Warning event, `errors` /// counter NOT incremented. #[tokio::test] async fn ocr_failure_indexes_asset_with_warning_no_error_counter() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/generate")) .respond_with(ResponseTemplate::new(503)) .mount(&server) .await; let env = TestEnv::lexical_only(); write_red_png(&env.workspace_root, "broken.png"); let cfg = cfg_with_image_pipeline(&env, &server.uri()); let cfg_clone = cfg.clone(); let scope = env.scope(); let report = spawn_blocking(move || { kebab_app::ingest_with_config(cfg_clone, scope, false) .expect("ingest does not abort on lenient OCR failure") }) .await .expect("task"); assert_eq!( report.errors, 0, "lenient OCR failure must NOT increment errors counter (spec)" ); let img_item = report .items .as_ref() .unwrap() .iter() .find(|i| i.doc_path.0.ends_with("broken.png")) .expect("asset still indexed despite OCR failure"); assert_eq!(img_item.kind, kebab_core::IngestItemKind::New); assert_eq!(img_item.chunk_count, Some(1)); assert!( !img_item.warnings.is_empty(), "lenient OCR failure must surface a warning on the IngestItem" ); let doc_id = img_item.doc_id.clone().unwrap(); let doc = kebab_app::inspect_doc_with_config(cfg, &doc_id).unwrap(); let block = match &doc.blocks[0] { kebab_core::Block::ImageRef(b) => b, _ => unreachable!(), }; assert!(block.ocr.is_none(), "block.ocr stays None on OCR failure"); let warning = doc .provenance .events .iter() .find(|e| e.kind == kebab_core::ProvenanceKind::Warning && e.agent == "kb-app") .expect("Provenance Warning attributed to kb-app"); let note = warning.note.as_deref().unwrap_or(""); assert!( note.contains("OcrFailed"), "warning note must describe OCR failure with OcrFailed prefix \ (markdown-style WarningKind format): {note}" ); } // ── 4. Both image.ocr.enabled and image.caption.enabled = false ────────── /// When both adapters are disabled, the image is still extracted + /// chunked. Chunk text falls back to the filename. EXIF + dimensions /// are populated by the extractor regardless. #[tokio::test] async fn image_indexed_with_filename_when_ocr_and_caption_disabled() { // No mock server needed — neither HTTP path is touched. let env = TestEnv::lexical_only(); write_red_png(&env.workspace_root, "raw.png"); let mut cfg = env.config.clone(); // p9-fb-25: workspace.include removed; extension routing is now // handled by extractor matching alone (no config knob). cfg.ingest.image.ocr.enabled = false; cfg.ingest.image.caption.enabled = false; let cfg_clone = cfg.clone(); let scope = env.scope(); let report = spawn_blocking(move || { kebab_app::ingest_with_config(cfg_clone, scope, false).expect("ingest with no OCR/caption") }) .await .expect("task"); assert_eq!(report.errors, 0); let img_item = report .items .as_ref() .unwrap() .iter() .find(|i| i.doc_path.0.ends_with("raw.png")) .unwrap(); assert_eq!(img_item.chunk_count, Some(1), "image emits one chunk"); let doc = kebab_app::inspect_doc_with_config(cfg, img_item.doc_id.as_ref().unwrap()).unwrap(); let block = match &doc.blocks[0] { kebab_core::Block::ImageRef(b) => b, _ => unreachable!(), }; assert!(block.ocr.is_none() && block.caption.is_none()); // EXIF + dimensions still populated by the extractor. let dims = doc .metadata .user .get("dimensions") .and_then(|v: &serde_json::Value| v.as_object()) .expect("dimensions object present"); assert_eq!( dims.get("w").and_then(|v: &serde_json::Value| v.as_u64()), Some(100) ); assert_eq!( dims.get("h").and_then(|v: &serde_json::Value| v.as_u64()), Some(50) ); } // ── 5. Garbage bytes (not an image) → errors counter exactly 1 ────────── /// `kebab-source-fs` classifies a `.png` extension as /// `MediaType::Image(Png)` regardless of content. When the bytes don't /// decode as any image format, `ImageExtractor::extract` returns Err /// and the asset must be classified as `IngestItemKind::Error` with /// the `errors` counter incremented **exactly once** (regression for /// the double-count bug surfaced during P6-4 manual smoke). #[tokio::test] async fn garbage_png_increments_errors_counter_exactly_once() { // No mock server needed — extract fails before any HTTP call. let env = TestEnv::lexical_only(); // Single non-image asset with .png extension. std::fs::write( env.workspace_root.join("garbage.png"), b"this is not an image at all", ) .expect("write garbage fixture"); let mut cfg = env.config.clone(); // p9-fb-25: workspace.include removed; extension routing is now // handled by extractor matching alone (no config knob). cfg.ingest.image.ocr.enabled = false; cfg.ingest.image.caption.enabled = false; let cfg_clone = cfg.clone(); let scope = env.scope(); let report = spawn_blocking(move || { kebab_app::ingest_with_config(cfg_clone, scope, false) .expect("ingest does not abort on per-asset failure") }) .await .expect("task"); // Exactly-once: scanned counts the asset, errors counts it once, // and (scanned == new + updated + skipped + errors) holds. assert_eq!( report.errors, 1, "garbage PNG must increment errors exactly once, not twice (double-count regression)" ); assert_eq!( report.scanned, report.new + report.updated + report.skipped + report.errors, "counter sum must equal scanned — invariant of the IngestReport contract" ); // The single Error item carries the propagated extract error. let items = report.items.expect("items present"); let err_item = items .iter() .find(|i| i.doc_path.0.ends_with("garbage.png")) .expect("garbage item present"); assert_eq!(err_item.kind, kebab_core::IngestItemKind::Error); assert!(err_item.error.is_some(), "Error item carries error string"); } // ── 6. Determinism: re-ingest produces identical doc_id / chunk_id ─────── /// Idempotency contract — running the same ingest twice keeps the /// doc_id stable. p9-fb-23 task 7 introduced the early-skip path for /// incremental ingest: when checksum + parser/chunker/embedding versions /// all match, the second run reports `Unchanged` rather than `Updated`. /// The pre-p9-fb-23 contract was `Updated` — that path is still exercised /// by `force_reingest = true` tests in `incremental_ingest.rs`. #[tokio::test] async fn re_ingest_image_produces_unchanged_with_same_doc_id() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/generate")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "response": "stable", "done": true, "done_reason": "stop" }))) .mount(&server) .await; let env = TestEnv::lexical_only(); write_red_png(&env.workspace_root, "diagram.png"); let cfg = cfg_with_image_pipeline(&env, &server.uri()); let scope = env.scope(); let cfg1 = cfg.clone(); let cfg2 = cfg.clone(); let scope1 = scope.clone(); let scope2 = scope.clone(); let r1 = spawn_blocking(move || kebab_app::ingest_with_config(cfg1, scope1, false).unwrap()) .await .unwrap(); let r2 = spawn_blocking(move || kebab_app::ingest_with_config(cfg2, scope2, false).unwrap()) .await .unwrap(); let id1 = r1 .items .as_ref() .unwrap() .iter() .find(|i| i.doc_path.0.ends_with("diagram.png")) .unwrap() .doc_id .clone() .unwrap(); let img2 = r2 .items .as_ref() .unwrap() .iter() .find(|i| i.doc_path.0.ends_with("diagram.png")) .unwrap(); assert_eq!(img2.kind, kebab_core::IngestItemKind::Unchanged); assert_eq!(img2.doc_id.as_ref().unwrap(), &id1); }