//! p9-fb-42: integration tests for `kebab search --bulk`. //! //! Lexical-only — no fastembed / no Ollama. Each test builds its own //! TempDir KB via `common::write_config` + `common::ingest` and drives //! `kebab search --bulk` through stdin. Verifies: //! //! - Two queries over stdin emit per-query ndjson `bulk_search_item.v1` lines. //! - Empty stdin returns empty results with zero summary. //! - Malformed ndjson exits with code 2 (config_invalid). //! - Input over the 100-item cap fails with "max 100" error message. //! - Invalid item field (e.g. bad `mode`) emits per-item error and continues. mod common; use serde_json::Value; use std::fs; use std::io::Write; use std::process::{Command, Stdio}; fn cargo_bin() -> &'static str { env!("CARGO_BIN_EXE_kebab") } fn run_bulk_with_stdin(cfg: &std::path::Path, stdin_body: &str, json: bool) -> std::process::Output { let mut cmd = Command::new(cargo_bin()); cmd.arg("--config").arg(cfg).arg("search").arg("--bulk"); if json { cmd.arg("--json"); } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn().expect("spawn kebab"); { let mut sin = child.stdin.take().expect("stdin"); sin.write_all(stdin_body.as_bytes()).expect("write stdin"); } child.wait_with_output().expect("wait") } fn seed_workspace(workspace: &std::path::Path) { fs::write(workspace.join("a.md"), "# Alpha\n\nrust async hello").unwrap(); fs::write(workspace.join("b.md"), "# Bravo\n\nbread and kebab").unwrap(); } // --------------------------------------------------------------------------- // Test 1: Two queries over stdin emit per-query ndjson // --------------------------------------------------------------------------- #[test] fn two_query_bulk_emits_per_query_ndjson() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 0); seed_workspace(&workspace); common::ingest(&cfg, &workspace); let out = run_bulk_with_stdin( &cfg, "{\"query\":\"rust\",\"mode\":\"lexical\"}\n{\"query\":\"kebab\",\"mode\":\"lexical\"}\n", true, ); assert!( out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); assert_eq!(lines.len(), 2, "expected 2 ndjson lines, got {lines:?}"); for line in &lines { let v: Value = serde_json::from_str(line).expect("valid JSON line"); assert_eq!(v["schema_version"], "bulk_search_item.v1"); assert!(v["response"].is_object()); assert!(v["error"].is_null()); } let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("bulk_summary: total=2 succeeded=2 failed=0"), "stderr summary missing: {stderr}" ); } // --------------------------------------------------------------------------- // Test 2: Empty stdin returns empty results with zero summary // --------------------------------------------------------------------------- #[test] fn empty_stdin_returns_empty_results_with_zero_summary() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 0); seed_workspace(&workspace); common::ingest(&cfg, &workspace); let out = run_bulk_with_stdin(&cfg, "", true); assert!(out.status.success()); let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.trim().is_empty(), "expected empty stdout, got: {stdout}"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("bulk_summary: total=0 succeeded=0 failed=0")); } // --------------------------------------------------------------------------- // Test 3: Malformed ndjson line emits config_invalid exit 2 // --------------------------------------------------------------------------- #[test] fn malformed_ndjson_line_emits_config_invalid_exit_2() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 0); seed_workspace(&workspace); common::ingest(&cfg, &workspace); let out = run_bulk_with_stdin(&cfg, "not json\n", true); assert_eq!(out.status.code(), Some(2), "expected exit 2"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("config_invalid") || stderr.contains("parse error"), "expected config_invalid or parse error in stderr: {stderr}" ); } // --------------------------------------------------------------------------- // Test 4: Over cap input (>100) emits error // --------------------------------------------------------------------------- #[test] fn over_cap_input_emits_error() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 0); seed_workspace(&workspace); common::ingest(&cfg, &workspace); let body: String = (0..101) .map(|_| "{\"query\":\"x\",\"mode\":\"lexical\"}\n") .collect(); let out = run_bulk_with_stdin(&cfg, &body, true); // bulk_search_with_config returns Err — surfaces as exit 1 (anyhow chain) // or 2 if classified by error_wire. Accept either, but message must mention `max 100`. assert!(out.status.code().is_some()); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("max 100"), "expected 'max 100' in stderr: {stderr}" ); } // --------------------------------------------------------------------------- // Test 5: Invalid item field (bad mode) emits per-item error and continues // --------------------------------------------------------------------------- #[test] fn invalid_item_field_emits_per_item_error_continues() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 0); seed_workspace(&workspace); common::ingest(&cfg, &workspace); let out = run_bulk_with_stdin( &cfg, "{\"query\":\"rust\",\"mode\":\"lexical\"}\n{\"query\":\"x\",\"mode\":\"bogus\"}\n", true, ); assert!(out.status.success()); let stdout = String::from_utf8_lossy(&out.stdout); let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); assert_eq!(lines.len(), 2); let v0: Value = serde_json::from_str(lines[0]).unwrap(); let v1: Value = serde_json::from_str(lines[1]).unwrap(); assert!(v0["error"].is_null()); assert!(v1["error"].is_object()); assert_eq!(v1["error"]["code"], "invalid_input"); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("succeeded=1 failed=1")); }