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
This commit is contained in:
Jon Seager 2026-02-05 19:19:05 +00:00
parent a9d97d40bd
commit 35ea246ba2
No known key found for this signature in database
5 changed files with 936 additions and 0 deletions

142
tests/server/checkin_api.rs Normal file
View file

@ -0,0 +1,142 @@
use brewlog::domain::cups::Cup;
use crate::helpers::{
create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth,
};
#[tokio::test]
async fn checkin_with_existing_cafe_creates_cup() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let roaster = create_default_roaster(&app).await;
let roast = create_default_roast(&app, roaster.id).await;
let cafe = create_default_cafe(&app).await;
let payload = serde_json::json!({
"cafe_id": cafe.id.to_string(),
"roast_id": roast.id.to_string(),
});
let response = client
.post(app.api_url("/check-in"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 201);
let cup: Cup = response.json().await.expect("Failed to parse response");
assert_eq!(cup.roast_id, roast.id);
assert_eq!(cup.cafe_id, cafe.id);
}
#[tokio::test]
async fn checkin_with_new_cafe_creates_cafe_and_cup() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let roaster = create_default_roaster(&app).await;
let roast = create_default_roast(&app, roaster.id).await;
let payload = serde_json::json!({
"roast_id": roast.id.to_string(),
"cafe_name": "New Test Cafe",
"cafe_city": "London",
"cafe_country": "UK",
"cafe_lat": 51.5074,
"cafe_lng": -0.1278,
});
let response = client
.post(app.api_url("/check-in"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 201);
let cup: Cup = response.json().await.expect("Failed to parse response");
assert_eq!(cup.roast_id, roast.id);
// Verify the cafe was also created
let cafes_response = client
.get(app.api_url("/cafes"))
.send()
.await
.expect("Failed to list cafes");
let body = cafes_response.text().await.expect("Failed to read body");
assert!(
body.contains("New Test Cafe"),
"Created cafe should appear in cafe list"
);
}
#[tokio::test]
async fn checkin_requires_authentication() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({
"cafe_id": "1",
"roast_id": "1",
});
let response = client
.post(app.api_url("/check-in"))
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn checkin_rejects_invalid_roast_id() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({
"cafe_id": "1",
"roast_id": "not-a-number",
});
let response = client
.post(app.api_url("/check-in"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}
#[tokio::test]
async fn checkin_requires_cafe_name_when_no_cafe_id() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let roaster = create_default_roaster(&app).await;
let roast = create_default_roast(&app, roaster.id).await;
let payload = serde_json::json!({
"roast_id": roast.id.to_string(),
});
let response = client
.post(app.api_url("/check-in"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}

View file

@ -0,0 +1,282 @@
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);
}

View file

@ -3,11 +3,15 @@ pub mod backup;
pub mod bags_api; pub mod bags_api;
pub mod brews_api; pub mod brews_api;
pub mod cafes_api; pub mod cafes_api;
pub mod checkin_api;
pub mod cups_api; pub mod cups_api;
pub mod datastar; pub mod datastar;
pub mod extraction_api;
pub mod gear_api; pub mod gear_api;
pub mod helpers; pub mod helpers;
pub mod nearby_api; pub mod nearby_api;
pub mod pages;
pub mod roasters_api; pub mod roasters_api;
pub mod roasts_api; pub mod roasts_api;
pub mod scan_api;
pub mod timeline; pub mod timeline;

286
tests/server/pages.rs Normal file
View file

@ -0,0 +1,286 @@
use reqwest::redirect::Policy;
use crate::helpers::{
assert_full_page, create_default_bag, create_default_roast, create_default_roaster,
create_session, spawn_app, spawn_app_with_auth,
};
#[tokio::test]
async fn homepage_returns_200_with_empty_database() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.get(app.page_url("/"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert_full_page(&body);
}
#[tokio::test]
async fn homepage_returns_200_with_data() {
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
let roast = create_default_roast(&app, roaster.id).await;
let _bag = create_default_bag(&app, roast.id).await;
let client = reqwest::Client::new();
let response = client
.get(app.page_url("/"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert_full_page(&body);
assert!(
body.contains("Test Roasters"),
"Homepage should contain roaster name"
);
}
#[tokio::test]
async fn homepage_shows_stats_counts() {
let app = spawn_app_with_auth().await;
let roaster1 = create_default_roaster(&app).await;
let _roaster2 = crate::helpers::create_roaster_with_name(&app, "Second Roasters").await;
let _roast = create_default_roast(&app, roaster1.id).await;
let client = reqwest::Client::new();
let response = client
.get(app.page_url("/"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
// Stats section should show counts
assert!(body.contains(">2<"), "Should show 2 roasters in stats");
assert!(body.contains(">1<"), "Should show 1 roast in stats");
}
#[tokio::test]
async fn login_page_returns_200() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.get(app.page_url("/login"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert_full_page(&body);
}
#[tokio::test]
async fn checkin_page_redirects_unauthenticated_to_login() {
let app = spawn_app().await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/check-in"))
.send()
.await
.expect("Failed to execute request");
assert!(
response.status().is_redirection(),
"Expected redirect, got {}",
response.status()
);
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/login"));
}
#[tokio::test]
async fn checkin_page_returns_200_when_authenticated() {
let app = spawn_app_with_auth().await;
let session_token = create_session(&app).await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/check-in"))
.header("Cookie", format!("brewlog_session={session_token}"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert_full_page(&body);
}
#[tokio::test]
async fn add_page_redirects_unauthenticated_to_login() {
let app = spawn_app().await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/add"))
.send()
.await
.expect("Failed to execute request");
assert!(
response.status().is_redirection(),
"Expected redirect, got {}",
response.status()
);
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/login"));
}
#[tokio::test]
async fn add_page_returns_200_when_authenticated() {
let app = spawn_app_with_auth().await;
let session_token = create_session(&app).await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/add"))
.header("Cookie", format!("brewlog_session={session_token}"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert_full_page(&body);
}
#[tokio::test]
async fn account_page_redirects_unauthenticated_to_login() {
let app = spawn_app().await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/account"))
.send()
.await
.expect("Failed to execute request");
assert!(
response.status().is_redirection(),
"Expected redirect, got {}",
response.status()
);
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/login"));
}
#[tokio::test]
async fn account_page_returns_200_when_authenticated() {
let app = spawn_app_with_auth().await;
let session_token = create_session(&app).await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/account"))
.header("Cookie", format!("brewlog_session={session_token}"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("Failed to read body");
assert_full_page(&body);
}
#[tokio::test]
async fn logout_redirects_to_homepage() {
let app = spawn_app_with_auth().await;
let session_token = create_session(&app).await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.post(app.page_url("/logout"))
.header("Cookie", format!("brewlog_session={session_token}"))
.send()
.await
.expect("Failed to execute request");
assert!(
response.status().is_redirection(),
"Expected redirect, got {}",
response.status()
);
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/"));
}
#[tokio::test]
async fn scan_redirect_returns_permanent_redirect() {
let app = spawn_app().await;
let client = reqwest::Client::builder()
.redirect(Policy::none())
.build()
.expect("Failed to build client");
let response = client
.get(app.page_url("/scan"))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 308);
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok());
assert_eq!(location, Some("/"));
}

222
tests/server/scan_api.rs Normal file
View file

@ -0,0 +1,222 @@
use serde::Deserialize;
use crate::helpers::{create_default_roaster, spawn_app_with_auth};
#[derive(Debug, Deserialize)]
struct ScanResult {
redirect: String,
roast_id: i64,
}
fn scan_payload(roaster_name: &str, roast_name: &str, tasting_notes: &str) -> serde_json::Value {
serde_json::json!({
"roaster_name": roaster_name,
"roaster_country": "UK",
"roast_name": roast_name,
"origin": "Ethiopia",
"region": "Yirgacheffe",
"producer": "Test Farm",
"process": "Washed",
"tasting_notes": tasting_notes,
})
}
#[tokio::test]
async fn scan_creates_roaster_and_roast() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let payload = scan_payload("Scan Roasters", "Scan Roast", "Blueberry, Chocolate");
let response = client
.post(app.api_url("/scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 201);
let result: ScanResult = response.json().await.expect("Failed to parse response");
assert!(result.roast_id > 0);
assert!(
result.redirect.contains("scan-roasters"),
"Redirect should contain roaster slug, got: {}",
result.redirect
);
assert!(
result.redirect.contains("scan-roast"),
"Redirect should contain roast slug, got: {}",
result.redirect
);
}
#[tokio::test]
async fn scan_reuses_existing_roaster_by_slug() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
// Create roaster first via the normal API
let _existing = create_default_roaster(&app).await;
// Submit scan with the same roaster name
let payload = scan_payload("Test Roasters", "Scan Roast", "Caramel, Nutty");
let response = client
.post(app.api_url("/scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 201);
// Verify only one roaster exists (the pre-existing one was reused)
let roasters_response = client
.get(app.api_url("/roasters"))
.send()
.await
.expect("Failed to list roasters");
let roasters: Vec<serde_json::Value> = roasters_response
.json()
.await
.expect("Failed to parse roasters");
assert_eq!(
roasters.len(),
1,
"Should reuse existing roaster, not create a new one"
);
}
#[tokio::test]
async fn scan_creates_bag_when_open_bag_is_true() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let mut payload = scan_payload("Bag Roasters", "Bag Roast", "Cherry");
payload["open_bag"] = serde_json::Value::String("true".to_string());
payload["bag_amount"] = serde_json::json!(250.0);
let response = client
.post(app.api_url("/scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 201);
// Verify bag was created
let bags_response = client
.get(app.api_url("/bags"))
.send()
.await
.expect("Failed to list bags");
let bags: Vec<serde_json::Value> = bags_response.json().await.expect("Failed to parse bags");
assert_eq!(bags.len(), 1, "A bag should have been created");
}
#[tokio::test]
async fn scan_skips_bag_when_open_bag_absent() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let payload = scan_payload("No Bag Roasters", "No Bag Roast", "Floral");
let response = client
.post(app.api_url("/scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 201);
// Verify no bag was created
let bags_response = client
.get(app.api_url("/bags"))
.send()
.await
.expect("Failed to list bags");
let bags: Vec<serde_json::Value> = bags_response.json().await.expect("Failed to parse bags");
assert_eq!(bags.len(), 0, "No bag should have been created");
}
#[tokio::test]
async fn scan_requires_authentication() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
let payload = scan_payload("Auth Roasters", "Auth Roast", "Berry");
let response = client
.post(app.api_url("/scan"))
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn scan_validates_required_fields() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
// Empty roast_name should fail
let payload = serde_json::json!({
"roaster_name": "Valid Roasters",
"roaster_country": "UK",
"roast_name": "",
"origin": "Ethiopia",
"region": "Yirgacheffe",
"producer": "Test Farm",
"process": "Washed",
"tasting_notes": "Blueberry",
});
let response = client
.post(app.api_url("/scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}
#[tokio::test]
async fn scan_requires_tasting_notes_for_manual_submission() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
// No tasting_notes and no image/prompt → should fail validation
let payload = serde_json::json!({
"roaster_name": "Notes Roasters",
"roaster_country": "UK",
"roast_name": "Notes Roast",
"origin": "Ethiopia",
"region": "Yirgacheffe",
"producer": "Test Farm",
"process": "Washed",
});
let response = client
.post(app.api_url("/scan"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&payload)
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}