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.
This commit is contained in:
Jon Seager 2026-02-05 19:18:51 +00:00
parent b4a885e990
commit a9d97d40bd
No known key found for this signature in database
6 changed files with 123 additions and 5 deletions

View file

@ -132,6 +132,7 @@ pub(crate) async fn extract_roaster(
let (input, _) = payload.into_parts(); let (input, _) = payload.into_parts();
let (result, usage) = ai::extract_roaster( let (result, usage) = ai::extract_roaster(
&state.http_client, &state.http_client,
&state.openrouter_url,
&state.openrouter_api_key, &state.openrouter_api_key,
&state.openrouter_model, &state.openrouter_model,
&input, &input,

View file

@ -272,6 +272,7 @@ pub(crate) async fn extract_roast_info(
let (input, _) = payload.into_parts(); let (input, _) = payload.into_parts();
let (result, usage) = ai::extract_roast( let (result, usage) = ai::extract_roast(
&state.http_client, &state.http_client,
&state.openrouter_url,
&state.openrouter_api_key, &state.openrouter_api_key,
&state.openrouter_model, &state.openrouter_model,
&input, &input,

View file

@ -27,6 +27,7 @@ pub(crate) async fn extract_bag_scan(
let (input, _) = payload.into_parts(); let (input, _) = payload.into_parts();
let (result, usage) = ai::extract_bag_scan( let (result, usage) = ai::extract_bag_scan(
&state.http_client, &state.http_client,
&state.openrouter_url,
&state.openrouter_api_key, &state.openrouter_api_key,
&state.openrouter_model, &state.openrouter_model,
&input, &input,
@ -150,6 +151,7 @@ async fn extract_into_submission(
}; };
let (result, usage) = ai::extract_bag_scan( let (result, usage) = ai::extract_bag_scan(
&state.http_client, &state.http_client,
&state.openrouter_url,
&state.openrouter_api_key, &state.openrouter_api_key,
&state.openrouter_model, &state.openrouter_model,
&input, &input,

View file

@ -66,6 +66,7 @@ pub struct AppState {
pub http_client: reqwest::Client, pub http_client: reqwest::Client,
pub foursquare_url: String, pub foursquare_url: String,
pub foursquare_api_key: String, pub foursquare_api_key: String,
pub openrouter_url: String,
pub openrouter_api_key: String, pub openrouter_api_key: String,
pub openrouter_model: String, pub openrouter_model: String,
pub backup_service: Arc<BackupService>, pub backup_service: Arc<BackupService>,
@ -132,6 +133,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),
foursquare_url: crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), foursquare_url: crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
foursquare_api_key: config.foursquare_api_key, foursquare_api_key: config.foursquare_api_key,
openrouter_url: crate::infrastructure::ai::OPENROUTER_URL.to_string(),
openrouter_api_key: config.openrouter_api_key, openrouter_api_key: config.openrouter_api_key,
openrouter_model: config.openrouter_model, openrouter_model: config.openrouter_model,
backup_service, backup_service,

View file

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::application::errors::AppError; 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 USER_AGENT: &str = "Brewlog/1.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(90); const REQUEST_TIMEOUT: Duration = Duration::from_secs(90);
@ -93,11 +93,13 @@ pub struct ExtractedBagScan {
pub async fn extract_roaster( pub async fn extract_roaster(
client: &reqwest::Client, client: &reqwest::Client,
url: &str,
api_key: &str, api_key: &str,
model: &str, model: &str,
input: &ExtractionInput, input: &ExtractionInput,
) -> Result<(ExtractedRoaster, Option<Usage>), AppError> { ) -> Result<(ExtractedRoaster, Option<Usage>), 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 json = extract_json(&content);
let extracted = serde_json::from_str(json).map_err(|e| { let extracted = serde_json::from_str(json).map_err(|e| {
@ -108,11 +110,13 @@ pub async fn extract_roaster(
pub async fn extract_roast( pub async fn extract_roast(
client: &reqwest::Client, client: &reqwest::Client,
url: &str,
api_key: &str, api_key: &str,
model: &str, model: &str,
input: &ExtractionInput, input: &ExtractionInput,
) -> Result<(ExtractedRoast, Option<Usage>), AppError> { ) -> Result<(ExtractedRoast, Option<Usage>), 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 json = extract_json(&content);
let extracted = serde_json::from_str(json).map_err(|e| { 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( pub async fn extract_bag_scan(
client: &reqwest::Client, client: &reqwest::Client,
url: &str,
api_key: &str, api_key: &str,
model: &str, model: &str,
input: &ExtractionInput, input: &ExtractionInput,
) -> Result<(ExtractedBagScan, Option<Usage>), AppError> { ) -> Result<(ExtractedBagScan, Option<Usage>), 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 json = extract_json(&content);
let extracted = serde_json::from_str(json).map_err(|e| { let extracted = serde_json::from_str(json).map_err(|e| {
@ -140,6 +145,7 @@ pub async fn extract_bag_scan(
async fn call_openrouter( async fn call_openrouter(
client: &reqwest::Client, client: &reqwest::Client,
url: &str,
api_key: &str, api_key: &str,
model: &str, model: &str,
system_prompt: &str, system_prompt: &str,
@ -183,7 +189,7 @@ async fn call_openrouter(
}; };
let response = client let response = client
.post(OPENROUTER_URL) .post(url)
.header("User-Agent", USER_AGENT) .header("User-Agent", USER_AGENT)
.header("Authorization", format!("Bearer {api_key}")) .header("Authorization", format!("Bearer {api_key}"))
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)

View file

@ -42,6 +42,7 @@ pub struct TestApp {
pub user_repo: Option<Arc<dyn UserRepository>>, pub user_repo: Option<Arc<dyn UserRepository>>,
#[allow(dead_code)] #[allow(dead_code)]
pub token_repo: Option<Arc<dyn TokenRepository>>, pub token_repo: Option<Arc<dyn TokenRepository>>,
pub session_repo: Option<Arc<dyn SessionRepository>>,
pub auth_token: Option<String>, pub auth_token: Option<String>,
#[allow(dead_code)] #[allow(dead_code)]
pub mock_server: Option<wiremock::MockServer>, pub mock_server: Option<wiremock::MockServer>,
@ -52,6 +53,10 @@ impl TestApp {
pub fn api_url(&self, path: &str) -> String { pub fn api_url(&self, path: &str) -> String {
format!("{}/api/v1{}", self.address, path) format!("{}/api/v1{}", self.address, path)
} }
pub fn page_url(&self, path: &str) -> String {
format!("{}{}", self.address, path)
}
} }
impl Drop for TestApp { impl Drop for TestApp {
@ -122,6 +127,7 @@ pub async fn spawn_app() -> TestApp {
registration_token_repo, registration_token_repo,
brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
String::new(), String::new(),
brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
None, None,
) )
.await .await
@ -145,10 +151,13 @@ async fn spawn_app_inner(
registration_token_repo: Arc<dyn RegistrationTokenRepository>, registration_token_repo: Arc<dyn RegistrationTokenRepository>,
foursquare_url: String, foursquare_url: String,
foursquare_api_key: String, foursquare_api_key: String,
openrouter_url: String,
mock_server: Option<wiremock::MockServer>, mock_server: Option<wiremock::MockServer>,
) -> TestApp { ) -> TestApp {
let backup_service = Arc::new(BackupService::new(_database.clone_pool())); let backup_service = Arc::new(BackupService::new(_database.clone_pool()));
let session_repo_clone: Arc<dyn SessionRepository> = session_repo.clone();
// Create application state // Create application state
let state = AppState { let state = AppState {
roaster_repo: roaster_repo.clone(), roaster_repo: roaster_repo.clone(),
@ -174,6 +183,7 @@ async fn spawn_app_inner(
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),
foursquare_url, foursquare_url,
foursquare_api_key, foursquare_api_key,
openrouter_url,
openrouter_api_key: String::new(), openrouter_api_key: String::new(),
openrouter_model: "openrouter/free".to_string(), openrouter_model: "openrouter/free".to_string(),
backup_service, backup_service,
@ -206,6 +216,7 @@ async fn spawn_app_inner(
timeline_repo, timeline_repo,
user_repo: Some(user_repo), user_repo: Some(user_repo),
token_repo: Some(token_repo), token_repo: Some(token_repo),
session_repo: Some(session_repo_clone),
auth_token: None, auth_token: None,
mock_server, mock_server,
server_handle, server_handle,
@ -266,6 +277,7 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp {
registration_token_repo, registration_token_repo,
foursquare_url, foursquare_url,
"test-api-key".to_string(), "test-api-key".to_string(),
brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
Some(mock_server), Some(mock_server),
) )
.await; .await;
@ -526,3 +538,97 @@ pub async fn create_cafe_with_payload(app: &TestApp, payload: NewCafe) -> Cafe {
.await .await
.expect("failed to deserialize cafe from response") .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<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> =
Arc::new(SqlTokenRepository::new(database.clone_pool()));
let session_repo: Arc<dyn SessionRepository> =
Arc::new(SqlSessionRepository::new(database.clone_pool()));
let passkey_repo: Arc<dyn PasskeyCredentialRepository> =
Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool()));
let registration_token_repo: Arc<dyn RegistrationTokenRepository> =
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
}