diff --git a/tests/server/checkin_api.rs b/tests/server/checkin_api.rs new file mode 100644 index 0000000..22c0e0c --- /dev/null +++ b/tests/server/checkin_api.rs @@ -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); +} diff --git a/tests/server/extraction_api.rs b/tests/server/extraction_api.rs new file mode 100644 index 0000000..8d6429e --- /dev/null +++ b/tests/server/extraction_api.rs @@ -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); +} diff --git a/tests/server/main.rs b/tests/server/main.rs index 36accd0..daffe05 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -3,11 +3,15 @@ pub mod backup; pub mod bags_api; pub mod brews_api; pub mod cafes_api; +pub mod checkin_api; pub mod cups_api; pub mod datastar; +pub mod extraction_api; pub mod gear_api; pub mod helpers; pub mod nearby_api; +pub mod pages; pub mod roasters_api; pub mod roasts_api; +pub mod scan_api; pub mod timeline; diff --git a/tests/server/pages.rs b/tests/server/pages.rs new file mode 100644 index 0000000..dc6592a --- /dev/null +++ b/tests/server/pages.rs @@ -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("/")); +} diff --git a/tests/server/scan_api.rs b/tests/server/scan_api.rs new file mode 100644 index 0000000..9aa3201 --- /dev/null +++ b/tests/server/scan_api.rs @@ -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 = 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 = 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 = 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); +}