From a9d97d40bd85a61bcd3d7453c81c8651b824ae61 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 5 Feb 2026 19:18:51 +0000 Subject: [PATCH] refactor(ai): make OpenRouter URL configurable for testability Add url parameter to ai::extract_roaster(), extract_roast(), and extract_bag_scan() instead of using a hardcoded constant. Wire through AppState.openrouter_url (defaulting to the production URL), matching the existing pattern used for foursquare_url. Add test infrastructure: session creation helper, page_url() on TestApp, and spawn_app_with_openrouter_mock() for wiremock-based AI extraction testing. --- src/application/routes/api/roasters.rs | 1 + src/application/routes/api/roasts.rs | 1 + src/application/routes/api/scan.rs | 2 + src/application/server.rs | 2 + src/infrastructure/ai.rs | 16 ++-- tests/server/helpers.rs | 106 +++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/application/routes/api/roasters.rs b/src/application/routes/api/roasters.rs index 7289923..ca65e4b 100644 --- a/src/application/routes/api/roasters.rs +++ b/src/application/routes/api/roasters.rs @@ -132,6 +132,7 @@ pub(crate) async fn extract_roaster( let (input, _) = payload.into_parts(); let (result, usage) = ai::extract_roaster( &state.http_client, + &state.openrouter_url, &state.openrouter_api_key, &state.openrouter_model, &input, diff --git a/src/application/routes/api/roasts.rs b/src/application/routes/api/roasts.rs index e12e286..b2b92ea 100644 --- a/src/application/routes/api/roasts.rs +++ b/src/application/routes/api/roasts.rs @@ -272,6 +272,7 @@ pub(crate) async fn extract_roast_info( let (input, _) = payload.into_parts(); let (result, usage) = ai::extract_roast( &state.http_client, + &state.openrouter_url, &state.openrouter_api_key, &state.openrouter_model, &input, diff --git a/src/application/routes/api/scan.rs b/src/application/routes/api/scan.rs index 698de61..be3cd1d 100644 --- a/src/application/routes/api/scan.rs +++ b/src/application/routes/api/scan.rs @@ -27,6 +27,7 @@ pub(crate) async fn extract_bag_scan( let (input, _) = payload.into_parts(); let (result, usage) = ai::extract_bag_scan( &state.http_client, + &state.openrouter_url, &state.openrouter_api_key, &state.openrouter_model, &input, @@ -150,6 +151,7 @@ async fn extract_into_submission( }; let (result, usage) = ai::extract_bag_scan( &state.http_client, + &state.openrouter_url, &state.openrouter_api_key, &state.openrouter_model, &input, diff --git a/src/application/server.rs b/src/application/server.rs index ba4e0b9..d96a3d9 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -66,6 +66,7 @@ pub struct AppState { pub http_client: reqwest::Client, pub foursquare_url: String, pub foursquare_api_key: String, + pub openrouter_url: String, pub openrouter_api_key: String, pub openrouter_model: String, pub backup_service: Arc, @@ -132,6 +133,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { http_client: reqwest::Client::new(), foursquare_url: crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), foursquare_api_key: config.foursquare_api_key, + openrouter_url: crate::infrastructure::ai::OPENROUTER_URL.to_string(), openrouter_api_key: config.openrouter_api_key, openrouter_model: config.openrouter_model, backup_service, diff --git a/src/infrastructure/ai.rs b/src/infrastructure/ai.rs index b868ce6..781da67 100644 --- a/src/infrastructure/ai.rs +++ b/src/infrastructure/ai.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::application::errors::AppError; -const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; +pub const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; const USER_AGENT: &str = "Brewlog/1.0"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(90); @@ -93,11 +93,13 @@ pub struct ExtractedBagScan { pub async fn extract_roaster( client: &reqwest::Client, + url: &str, api_key: &str, model: &str, input: &ExtractionInput, ) -> Result<(ExtractedRoaster, Option), AppError> { - let (content, usage) = call_openrouter(client, api_key, model, ROASTER_PROMPT, input).await?; + let (content, usage) = + call_openrouter(client, url, api_key, model, ROASTER_PROMPT, input).await?; let json = extract_json(&content); let extracted = serde_json::from_str(json).map_err(|e| { @@ -108,11 +110,13 @@ pub async fn extract_roaster( pub async fn extract_roast( client: &reqwest::Client, + url: &str, api_key: &str, model: &str, input: &ExtractionInput, ) -> Result<(ExtractedRoast, Option), AppError> { - let (content, usage) = call_openrouter(client, api_key, model, ROAST_PROMPT, input).await?; + let (content, usage) = + call_openrouter(client, url, api_key, model, ROAST_PROMPT, input).await?; let json = extract_json(&content); let extracted = serde_json::from_str(json).map_err(|e| { @@ -123,11 +127,12 @@ pub async fn extract_roast( pub async fn extract_bag_scan( client: &reqwest::Client, + url: &str, api_key: &str, model: &str, input: &ExtractionInput, ) -> Result<(ExtractedBagScan, Option), AppError> { - let (content, usage) = call_openrouter(client, api_key, model, SCAN_PROMPT, input).await?; + let (content, usage) = call_openrouter(client, url, api_key, model, SCAN_PROMPT, input).await?; let json = extract_json(&content); let extracted = serde_json::from_str(json).map_err(|e| { @@ -140,6 +145,7 @@ pub async fn extract_bag_scan( async fn call_openrouter( client: &reqwest::Client, + url: &str, api_key: &str, model: &str, system_prompt: &str, @@ -183,7 +189,7 @@ async fn call_openrouter( }; let response = client - .post(OPENROUTER_URL) + .post(url) .header("User-Agent", USER_AGENT) .header("Authorization", format!("Bearer {api_key}")) .timeout(REQUEST_TIMEOUT) diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 692a47f..6f604b7 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -42,6 +42,7 @@ pub struct TestApp { pub user_repo: Option>, #[allow(dead_code)] pub token_repo: Option>, + pub session_repo: Option>, pub auth_token: Option, #[allow(dead_code)] pub mock_server: Option, @@ -52,6 +53,10 @@ impl TestApp { pub fn api_url(&self, path: &str) -> String { format!("{}/api/v1{}", self.address, path) } + + pub fn page_url(&self, path: &str) -> String { + format!("{}{}", self.address, path) + } } impl Drop for TestApp { @@ -122,6 +127,7 @@ pub async fn spawn_app() -> TestApp { registration_token_repo, brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), String::new(), + brewlog::infrastructure::ai::OPENROUTER_URL.to_string(), None, ) .await @@ -145,10 +151,13 @@ async fn spawn_app_inner( registration_token_repo: Arc, foursquare_url: String, foursquare_api_key: String, + openrouter_url: String, mock_server: Option, ) -> TestApp { let backup_service = Arc::new(BackupService::new(_database.clone_pool())); + let session_repo_clone: Arc = session_repo.clone(); + // Create application state let state = AppState { roaster_repo: roaster_repo.clone(), @@ -174,6 +183,7 @@ async fn spawn_app_inner( http_client: reqwest::Client::new(), foursquare_url, foursquare_api_key, + openrouter_url, openrouter_api_key: String::new(), openrouter_model: "openrouter/free".to_string(), backup_service, @@ -206,6 +216,7 @@ async fn spawn_app_inner( timeline_repo, user_repo: Some(user_repo), token_repo: Some(token_repo), + session_repo: Some(session_repo_clone), auth_token: None, mock_server, server_handle, @@ -266,6 +277,7 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp { registration_token_repo, foursquare_url, "test-api-key".to_string(), + brewlog::infrastructure::ai::OPENROUTER_URL.to_string(), Some(mock_server), ) .await; @@ -526,3 +538,97 @@ pub async fn create_cafe_with_payload(app: &TestApp, payload: NewCafe) -> Cafe { .await .expect("failed to deserialize cafe from response") } + +/// Creates a session for the authenticated user and returns the raw session token +/// to use as a `brewlog_session` cookie value. +pub async fn create_session(app: &TestApp) -> String { + use brewlog::domain::sessions::NewSession; + use brewlog::infrastructure::auth::{generate_session_token, hash_token}; + + let session_token = generate_session_token(); + let session_hash = hash_token(&session_token); + + // Get the user ID from the auth token + let token_hash = hash_token(app.auth_token.as_ref().expect("auth token required")); + let token = app + .token_repo + .as_ref() + .expect("token_repo required") + .get_by_token_hash(&token_hash) + .await + .expect("failed to find token"); + + let now = chrono::Utc::now(); + #[allow(clippy::expect_used)] + let expires_at = now + .checked_add_signed(chrono::Duration::hours(24)) + .expect("timestamp overflow"); + + let new_session = NewSession::new(token.user_id, session_hash, now, expires_at); + + app.session_repo + .as_ref() + .expect("session_repo required") + .insert(new_session) + .await + .expect("failed to create session"); + + session_token +} + +pub async fn spawn_app_with_openrouter_mock() -> TestApp { + let mock_server = wiremock::MockServer::start().await; + let openrouter_url = format!("{}/api/v1/chat/completions", mock_server.uri()); + + let database = Database::connect("sqlite::memory:") + .await + .expect("Failed to connect to in-memory database"); + + database + .migrate() + .await + .expect("Failed to migrate database"); + + let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); + let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); + let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); + let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); + let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); + let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); + let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool())); + let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); + let user_repo: Arc = + Arc::new(SqlUserRepository::new(database.clone_pool())); + let token_repo: Arc = + Arc::new(SqlTokenRepository::new(database.clone_pool())); + let session_repo: Arc = + Arc::new(SqlSessionRepository::new(database.clone_pool())); + let passkey_repo: Arc = + Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); + let registration_token_repo: Arc = + Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); + + let app = spawn_app_inner( + database, + roaster_repo, + roast_repo, + bag_repo, + gear_repo, + brew_repo, + cafe_repo, + cup_repo, + timeline_repo, + user_repo, + token_repo, + session_repo, + passkey_repo, + registration_token_repo, + brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), + String::new(), + openrouter_url, + Some(mock_server), + ) + .await; + + add_auth_to_app(app).await +}