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 (result, usage) = ai::extract_roaster(
&state.http_client,
&state.openrouter_url,
&state.openrouter_api_key,
&state.openrouter_model,
&input,

View file

@ -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,

View file

@ -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,

View file

@ -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<BackupService>,
@ -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,

View file

@ -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<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 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<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 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<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 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)

View file

@ -42,6 +42,7 @@ pub struct TestApp {
pub user_repo: Option<Arc<dyn UserRepository>>,
#[allow(dead_code)]
pub token_repo: Option<Arc<dyn TokenRepository>>,
pub session_repo: Option<Arc<dyn SessionRepository>>,
pub auth_token: Option<String>,
#[allow(dead_code)]
pub mock_server: Option<wiremock::MockServer>,
@ -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<dyn RegistrationTokenRepository>,
foursquare_url: String,
foursquare_api_key: String,
openrouter_url: String,
mock_server: Option<wiremock::MockServer>,
) -> TestApp {
let backup_service = Arc::new(BackupService::new(_database.clone_pool()));
let session_repo_clone: Arc<dyn SessionRepository> = 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<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
}