brewlog/tests/server/scan_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

222 lines
6.2 KiB
Rust

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);
}