- 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
282 lines
8.8 KiB
Rust
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);
|
|
}
|