brewlog/tests/server/extraction_api.rs
Jon Seager 35ea246ba2
test: add coverage for homepage, check-in, scan, and AI extraction
- 12 page smoke tests: homepage (empty/with data/stats), login,
  auth-gated redirects (check-in, add, account), authenticated page
  loads, logout, and scan redirect
- 5 check-in API tests: existing cafe, new cafe creation, auth,
  and validation
- 7 scan API tests: roaster+roast creation, slug-based reuse, bag
  creation with timeline event, auth, and field validation
- 9 AI extraction tests: all 3 extract endpoints with wiremock-mocked
  OpenRouter, both JSON and Datastar signal responses, auth and
  empty input validation
2026-02-05 19:19:05 +00:00

282 lines
8.8 KiB
Rust

use brewlog::infrastructure::ai::{ExtractedBagScan, ExtractedRoast, ExtractedRoaster};
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
use crate::helpers::spawn_app_with_openrouter_mock;
fn mock_openrouter_response(json_content: &str) -> ResponseTemplate {
let body = serde_json::json!({
"id": "gen-test",
"model": "test-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": json_content
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"cost": 0.001
}
});
ResponseTemplate::new(200).set_body_json(body)
}
// --- extract-roaster ---
#[tokio::test]
async fn extract_roaster_returns_json() {
let app = spawn_app_with_openrouter_mock().await;
let mock_server = app.mock_server.as_ref().unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/chat/completions"))
.respond_with(mock_openrouter_response(
r#"{"name": "Square Mile", "country": "United Kingdom", "city": "London"}"#,
))
.mount(mock_server)
.await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "Square Mile Coffee" });
let response = client
.post(app.api_url("/extract-roaster"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let result: ExtractedRoaster = response.json().await.expect("Failed to parse response");
assert_eq!(result.name.as_deref(), Some("Square Mile"));
assert_eq!(result.country.as_deref(), Some("United Kingdom"));
assert_eq!(result.city.as_deref(), Some("London"));
}
#[tokio::test]
async fn extract_roaster_returns_datastar_signals() {
let app = spawn_app_with_openrouter_mock().await;
let mock_server = app.mock_server.as_ref().unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/chat/completions"))
.respond_with(mock_openrouter_response(
r#"{"name": "Has Bean", "country": "UK"}"#,
))
.mount(mock_server)
.await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "Has Bean" });
let response = client
.post(app.api_url("/extract-roaster"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
assert_eq!(body["_roasterName"], "Has Bean");
assert_eq!(body["_roasterCountry"], "UK");
}
#[tokio::test]
async fn extract_roaster_requires_auth() {
let app = spawn_app_with_openrouter_mock().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "test" });
let response = client
.post(app.api_url("/extract-roaster"))
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn extract_roaster_rejects_empty_input() {
let app = spawn_app_with_openrouter_mock().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({});
let response = client
.post(app.api_url("/extract-roaster"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}
// --- extract-roast ---
#[tokio::test]
async fn extract_roast_returns_json() {
let app = spawn_app_with_openrouter_mock().await;
let mock_server = app.mock_server.as_ref().unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/chat/completions"))
.respond_with(mock_openrouter_response(
r#"{"name": "Red Brick", "origin": "Brazil", "tasting_notes": ["Chocolate", "Hazelnut"]}"#,
))
.mount(mock_server)
.await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "Red Brick coffee" });
let response = client
.post(app.api_url("/extract-roast"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let result: ExtractedRoast = response.json().await.expect("Failed to parse response");
assert_eq!(result.name.as_deref(), Some("Red Brick"));
assert_eq!(result.origin.as_deref(), Some("Brazil"));
assert_eq!(
result.tasting_notes.as_deref(),
Some(&["Chocolate".to_string(), "Hazelnut".to_string()][..])
);
}
#[tokio::test]
async fn extract_roast_returns_datastar_signals() {
let app = spawn_app_with_openrouter_mock().await;
let mock_server = app.mock_server.as_ref().unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/chat/completions"))
.respond_with(mock_openrouter_response(
r#"{"name": "Ethiopia Natural", "origin": "Ethiopia", "process": "Natural"}"#,
))
.mount(mock_server)
.await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "Ethiopia Natural" });
let response = client
.post(app.api_url("/extract-roast"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
assert_eq!(body["_roastName"], "Ethiopia Natural");
assert_eq!(body["_origin"], "Ethiopia");
assert_eq!(body["_process"], "Natural");
}
// --- extract-bag-scan ---
#[tokio::test]
async fn extract_bag_scan_returns_json() {
let app = spawn_app_with_openrouter_mock().await;
let mock_server = app.mock_server.as_ref().unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/chat/completions"))
.respond_with(mock_openrouter_response(
r#"{"roaster": {"name": "Origin", "country": "UK"}, "roast": {"name": "Blend One", "origin": "Colombia"}}"#,
))
.mount(mock_server)
.await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "Origin Blend One" });
let response = client
.post(app.api_url("/extract-bag-scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let result: ExtractedBagScan = response.json().await.expect("Failed to parse response");
assert_eq!(result.roaster.name.as_deref(), Some("Origin"));
assert_eq!(result.roast.name.as_deref(), Some("Blend One"));
}
#[tokio::test]
async fn extract_bag_scan_returns_datastar_signals() {
let app = spawn_app_with_openrouter_mock().await;
let mock_server = app.mock_server.as_ref().unwrap();
Mock::given(method("POST"))
.and(path("/api/v1/chat/completions"))
.respond_with(mock_openrouter_response(
r#"{"roaster": {"name": "Allpress"}, "roast": {"name": "Redchurch", "tasting_notes": ["Chocolate", "Caramel"]}}"#,
))
.mount(mock_server)
.await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "Allpress Redchurch" });
let response = client
.post(app.api_url("/extract-bag-scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
assert_eq!(body["_roasterName"], "Allpress");
assert_eq!(body["_roastName"], "Redchurch");
assert_eq!(body["_tastingNotes"], "Chocolate, Caramel");
assert_eq!(body["_scanExtracted"], true);
}
#[tokio::test]
async fn extract_bag_scan_requires_auth() {
let app = spawn_app_with_openrouter_mock().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({ "prompt": "test" });
let response = client
.post(app.api_url("/extract-bag-scan"))
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 401);
}