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:
parent
a9d97d40bd
commit
35ea246ba2
5 changed files with 936 additions and 0 deletions
142
tests/server/checkin_api.rs
Normal file
142
tests/server/checkin_api.rs
Normal 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);
|
||||||
|
}
|
||||||
282
tests/server/extraction_api.rs
Normal file
282
tests/server/extraction_api.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
286
tests/server/pages.rs
Normal 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
222
tests/server/scan_api.rs
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue