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:
parent
b4a885e990
commit
a9d97d40bd
6 changed files with 123 additions and 5 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue