refactor(nearby): replace Nominatim with Foursquare Places API
- Replace osm.rs with foursquare.rs using Foursquare Places Search API - Add isocountry crate for ISO 3166-1 country code to name conversion - Override verbose country names (e.g. "United Kingdom" instead of "United Kingdom of Great Britain and Northern Ireland") - Add BREWLOG_FOURSQUARE_API_KEY env var for API authentication - Update route handler, tests, and template to use Foursquare - Modernise cafes template JS to ES6+ (const/let, arrow fns, template literals)
This commit is contained in:
parent
63765f17d7
commit
ecb010e812
12 changed files with 380 additions and 396 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -345,6 +345,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"isocountry",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"portpicker",
|
"portpicker",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
|
@ -1277,6 +1278,16 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "isocountry"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ea1dc4bf0fb4904ba83ffdb98af3d9c325274e92e6e295e4151e86c96363e04"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ base64 = "0.22"
|
||||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
isocountry = "0.3"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
|
||||||
rpassword = "7.3"
|
rpassword = "7.3"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use crate::application::server::AppState;
|
||||||
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
||||||
use crate::domain::ids::CafeId;
|
use crate::domain::ids::CafeId;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
use crate::infrastructure::osm::{self, NearbyCafe};
|
use crate::infrastructure::foursquare::{self, NearbyCafe};
|
||||||
use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate};
|
use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate};
|
||||||
use crate::presentation::web::views::{CafeView, ListNavigator, Paginated};
|
use crate::presentation::web::views::{CafeView, ListNavigator, Paginated};
|
||||||
|
|
||||||
|
|
@ -209,9 +209,15 @@ pub(crate) async fn nearby_cafes(
|
||||||
return Err(AppError::validation("q must be at least 2 characters").into());
|
return Err(AppError::validation("q must be at least 2 characters").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let cafes = osm::search_nearby(
|
let api_key = state
|
||||||
|
.foursquare_api_key
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| AppError::unexpected("Foursquare API key not configured"))?;
|
||||||
|
|
||||||
|
let cafes = foursquare::search_nearby(
|
||||||
&state.http_client,
|
&state.http_client,
|
||||||
&state.nominatim_url,
|
&state.foursquare_url,
|
||||||
|
api_key,
|
||||||
query.lat,
|
query.lat,
|
||||||
query.lng,
|
query.lng,
|
||||||
q,
|
q,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ pub struct ServerConfig {
|
||||||
pub admin_username: Option<String>,
|
pub admin_username: Option<String>,
|
||||||
pub openrouter_api_key: Option<String>,
|
pub openrouter_api_key: Option<String>,
|
||||||
pub openrouter_model: String,
|
pub openrouter_model: String,
|
||||||
|
pub foursquare_api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -50,7 +51,8 @@ pub struct AppState {
|
||||||
pub token_repo: Arc<dyn TokenRepository>,
|
pub token_repo: Arc<dyn TokenRepository>,
|
||||||
pub session_repo: Arc<dyn SessionRepository>,
|
pub session_repo: Arc<dyn SessionRepository>,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
pub nominatim_url: String,
|
pub foursquare_url: String,
|
||||||
|
pub foursquare_api_key: Option<String>,
|
||||||
pub openrouter_api_key: Option<String>,
|
pub openrouter_api_key: Option<String>,
|
||||||
pub openrouter_model: String,
|
pub openrouter_model: String,
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +72,8 @@ impl AppState {
|
||||||
token_repo: Arc<dyn TokenRepository>,
|
token_repo: Arc<dyn TokenRepository>,
|
||||||
session_repo: Arc<dyn SessionRepository>,
|
session_repo: Arc<dyn SessionRepository>,
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
nominatim_url: String,
|
foursquare_url: String,
|
||||||
|
foursquare_api_key: Option<String>,
|
||||||
openrouter_api_key: Option<String>,
|
openrouter_api_key: Option<String>,
|
||||||
openrouter_model: String,
|
openrouter_model: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
|
@ -87,7 +90,8 @@ impl AppState {
|
||||||
token_repo,
|
token_repo,
|
||||||
session_repo,
|
session_repo,
|
||||||
http_client,
|
http_client,
|
||||||
nominatim_url,
|
foursquare_url,
|
||||||
|
foursquare_api_key,
|
||||||
openrouter_api_key,
|
openrouter_api_key,
|
||||||
openrouter_model,
|
openrouter_model,
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +139,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
token_repo,
|
token_repo,
|
||||||
session_repo,
|
session_repo,
|
||||||
reqwest::Client::new(),
|
reqwest::Client::new(),
|
||||||
crate::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(),
|
crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
|
||||||
|
config.foursquare_api_key,
|
||||||
config.openrouter_api_key,
|
config.openrouter_api_key,
|
||||||
config.openrouter_model,
|
config.openrouter_model,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
261
src/infrastructure/foursquare.rs
Normal file
261
src/infrastructure/foursquare.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use isocountry::CountryCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::application::errors::AppError;
|
||||||
|
|
||||||
|
pub const FOURSQUARE_SEARCH_URL: &str = "https://places-api.foursquare.com/places/search";
|
||||||
|
const USER_AGENT: &str = "Brewlog/1.0";
|
||||||
|
const MAX_RESULTS: &str = "15";
|
||||||
|
const RADIUS: &str = "5000";
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const FIELDS: &str = "name,latitude,longitude,location,website,distance";
|
||||||
|
const API_VERSION: &str = "2025-06-17";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NearbyCafe {
|
||||||
|
pub name: String,
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
pub city: String,
|
||||||
|
pub country: String,
|
||||||
|
pub website: Option<String>,
|
||||||
|
pub distance_meters: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for places matching `query` near the given coordinates via Foursquare.
|
||||||
|
pub async fn search_nearby(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
lat: f64,
|
||||||
|
lng: f64,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<NearbyCafe>, AppError> {
|
||||||
|
let ll = format!("{lat},{lng}");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(base_url)
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
|
.header("X-Places-Api-Version", API_VERSION)
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.query(&[("query", query), ("limit", MAX_RESULTS), ("fields", FIELDS)])
|
||||||
|
.query(&[("ll", ll.as_str()), ("radius", RADIUS)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::unexpected(format!("Foursquare search failed: {e}")))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "(unreadable body)".to_string());
|
||||||
|
return Err(AppError::unexpected(format!(
|
||||||
|
"Foursquare returned status {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: FoursquareResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::unexpected(format!("Failed to parse Foursquare response: {e}")))?;
|
||||||
|
|
||||||
|
let cafes = result
|
||||||
|
.results
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|place| {
|
||||||
|
if place.name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let place_lat = place.latitude?;
|
||||||
|
let place_lng = place.longitude?;
|
||||||
|
let place_location = place.location.unwrap_or_default();
|
||||||
|
|
||||||
|
let country = place_location
|
||||||
|
.country
|
||||||
|
.as_deref()
|
||||||
|
.map(country_name)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let distance = place
|
||||||
|
.distance
|
||||||
|
.unwrap_or_else(|| haversine_distance(lat, lng, place_lat, place_lng) as u32);
|
||||||
|
|
||||||
|
let website = place.website.filter(|w| !w.trim().is_empty());
|
||||||
|
|
||||||
|
Some(NearbyCafe {
|
||||||
|
name: place.name,
|
||||||
|
latitude: place_lat,
|
||||||
|
longitude: place_lng,
|
||||||
|
city: place_location.locality.unwrap_or_default(),
|
||||||
|
country,
|
||||||
|
website,
|
||||||
|
distance_meters: distance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(cafes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a 2-letter ISO 3166-1 alpha-2 country code to a full country name.
|
||||||
|
/// Falls back to the raw code if the lookup fails.
|
||||||
|
fn country_name(code: &str) -> String {
|
||||||
|
// Override verbose ISO 3166-1 names with common short forms
|
||||||
|
match code.to_ascii_uppercase().as_str() {
|
||||||
|
"GB" => "United Kingdom".to_string(),
|
||||||
|
"US" => "United States".to_string(),
|
||||||
|
"KR" => "South Korea".to_string(),
|
||||||
|
"KP" => "North Korea".to_string(),
|
||||||
|
"TW" => "Taiwan".to_string(),
|
||||||
|
"RU" => "Russia".to_string(),
|
||||||
|
"IR" => "Iran".to_string(),
|
||||||
|
"SY" => "Syria".to_string(),
|
||||||
|
"VE" => "Venezuela".to_string(),
|
||||||
|
"BO" => "Bolivia".to_string(),
|
||||||
|
"TZ" => "Tanzania".to_string(),
|
||||||
|
_ => CountryCode::for_alpha2_caseless(code)
|
||||||
|
.map_or_else(|_| code.to_string(), |cc| cc.name().to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Haversine distance in meters between two lat/lng points.
|
||||||
|
fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
|
||||||
|
const R: f64 = 6_371_000.0; // Earth radius in meters
|
||||||
|
|
||||||
|
let d_lat = (lat2 - lat1).to_radians();
|
||||||
|
let d_lng = (lng2 - lng1).to_radians();
|
||||||
|
|
||||||
|
let a = (d_lat / 2.0).sin().powi(2)
|
||||||
|
+ lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2);
|
||||||
|
|
||||||
|
let c = 2.0 * a.sqrt().asin();
|
||||||
|
R * c
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Foursquare API types ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FoursquareResponse {
|
||||||
|
results: Vec<FoursquarePlace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FoursquarePlace {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
latitude: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
longitude: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
location: Option<FoursquareLocation>,
|
||||||
|
#[serde(default)]
|
||||||
|
website: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
distance: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct FoursquareLocation {
|
||||||
|
locality: Option<String>,
|
||||||
|
country: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn haversine_london_to_paris() {
|
||||||
|
// London (51.5074, -0.1278) to Paris (48.8566, 2.3522) ≈ 344 km
|
||||||
|
let dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522);
|
||||||
|
let km = dist / 1000.0;
|
||||||
|
assert!((km - 344.0).abs() < 5.0, "Expected ~344 km, got {km:.1} km");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn haversine_same_point_is_zero() {
|
||||||
|
let dist = haversine_distance(51.5, -0.1, 51.5, -0.1);
|
||||||
|
assert!(dist.abs() < 0.01, "Expected 0, got {dist}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_foursquare_search_response() {
|
||||||
|
let json = r#"{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "Prufrock Coffee",
|
||||||
|
"latitude": 51.5246,
|
||||||
|
"longitude": -0.1098,
|
||||||
|
"location": {
|
||||||
|
"locality": "London",
|
||||||
|
"country": "GB"
|
||||||
|
},
|
||||||
|
"website": "https://www.prufrockcoffee.com",
|
||||||
|
"distance": 2800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Department of Coffee",
|
||||||
|
"latitude": 51.5200,
|
||||||
|
"longitude": -0.1050,
|
||||||
|
"location": {
|
||||||
|
"locality": "London",
|
||||||
|
"country": "GB"
|
||||||
|
},
|
||||||
|
"distance": 2500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let response: FoursquareResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(response.results.len(), 2);
|
||||||
|
|
||||||
|
let first = &response.results[0];
|
||||||
|
assert_eq!(first.name, "Prufrock Coffee");
|
||||||
|
assert_eq!(first.latitude, Some(51.5246));
|
||||||
|
assert_eq!(
|
||||||
|
first.location.as_ref().unwrap().locality.as_deref(),
|
||||||
|
Some("London")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
first.website.as_deref(),
|
||||||
|
Some("https://www.prufrockcoffee.com")
|
||||||
|
);
|
||||||
|
assert_eq!(first.distance, Some(2800));
|
||||||
|
|
||||||
|
let second = &response.results[1];
|
||||||
|
assert_eq!(second.name, "Department of Coffee");
|
||||||
|
assert!(second.website.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_foursquare_result_without_location() {
|
||||||
|
let json = r#"{
|
||||||
|
"results": [{
|
||||||
|
"name": "Café de Flore",
|
||||||
|
"latitude": 48.8566,
|
||||||
|
"longitude": 2.3522,
|
||||||
|
"distance": 100
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let response: FoursquareResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(response.results.len(), 1);
|
||||||
|
assert!(response.results[0].location.is_none());
|
||||||
|
assert!(response.results[0].website.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn country_code_to_name() {
|
||||||
|
assert_eq!(country_name("GB"), "United Kingdom");
|
||||||
|
assert_eq!(country_name("US"), "United States");
|
||||||
|
assert_eq!(country_name("FR"), "France");
|
||||||
|
// Unknown codes fall back to raw value
|
||||||
|
assert_eq!(country_name("XX"), "XX");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,5 +3,5 @@ pub mod auth;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod osm;
|
pub mod foursquare;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::application::errors::AppError;
|
|
||||||
|
|
||||||
pub const NOMINATIM_SEARCH_URL: &str = "https://nominatim.openstreetmap.org/search";
|
|
||||||
const USER_AGENT: &str = "Brewlog/1.0";
|
|
||||||
const MAX_RESULTS: &str = "8";
|
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
/// Viewbox half-size in degrees (~11 km at equator, tighter at higher latitudes).
|
|
||||||
const VIEWBOX_DELTA: f64 = 0.1;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct NearbyCafe {
|
|
||||||
pub name: String,
|
|
||||||
pub latitude: f64,
|
|
||||||
pub longitude: f64,
|
|
||||||
pub city: String,
|
|
||||||
pub country: String,
|
|
||||||
pub website: Option<String>,
|
|
||||||
pub distance_meters: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Searches for places matching `query` near the given coordinates via Nominatim.
|
|
||||||
/// Results are biased towards (but not restricted to) the user's location.
|
|
||||||
pub async fn search_nearby(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
base_url: &str,
|
|
||||||
lat: f64,
|
|
||||||
lng: f64,
|
|
||||||
query: &str,
|
|
||||||
) -> Result<Vec<NearbyCafe>, AppError> {
|
|
||||||
let viewbox = format!(
|
|
||||||
"{},{},{},{}",
|
|
||||||
lng - VIEWBOX_DELTA,
|
|
||||||
lat + VIEWBOX_DELTA,
|
|
||||||
lng + VIEWBOX_DELTA,
|
|
||||||
lat - VIEWBOX_DELTA,
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(base_url)
|
|
||||||
.header("User-Agent", USER_AGENT)
|
|
||||||
.timeout(REQUEST_TIMEOUT)
|
|
||||||
.query(&[
|
|
||||||
("q", query),
|
|
||||||
("format", "json"),
|
|
||||||
("limit", MAX_RESULTS),
|
|
||||||
("addressdetails", "1"),
|
|
||||||
("namedetails", "1"),
|
|
||||||
("extratags", "1"),
|
|
||||||
("viewbox", &viewbox),
|
|
||||||
("bounded", "0"),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::unexpected(format!("Nominatim search failed: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Err(AppError::unexpected(format!(
|
|
||||||
"Nominatim returned status {}",
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let results: Vec<NominatimSearchResult> = response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::unexpected(format!("Failed to parse Nominatim response: {e}")))?;
|
|
||||||
|
|
||||||
let cafes = results
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|r| {
|
|
||||||
let result_lat: f64 = r.lat.parse().ok()?;
|
|
||||||
let result_lng: f64 = r.lon.parse().ok()?;
|
|
||||||
|
|
||||||
let name = r.namedetails.and_then(|nd| nd.name).unwrap_or_else(|| {
|
|
||||||
// Fall back to text before first comma in display_name
|
|
||||||
r.display_name
|
|
||||||
.split(',')
|
|
||||||
.next()
|
|
||||||
.unwrap_or(&r.display_name)
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
if name.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let address = r.address.unwrap_or_default();
|
|
||||||
|
|
||||||
let city = address
|
|
||||||
.city
|
|
||||||
.or(address.town)
|
|
||||||
.or(address.village)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let website = r
|
|
||||||
.extratags
|
|
||||||
.and_then(|tags| {
|
|
||||||
tags.website
|
|
||||||
.or(tags.contact_website)
|
|
||||||
.or(tags.url)
|
|
||||||
.or(tags.contact_url)
|
|
||||||
.or(tags.brand_website)
|
|
||||||
})
|
|
||||||
.filter(|w| !w.trim().is_empty());
|
|
||||||
|
|
||||||
let distance = haversine_distance(lat, lng, result_lat, result_lng);
|
|
||||||
|
|
||||||
Some(NearbyCafe {
|
|
||||||
name,
|
|
||||||
latitude: result_lat,
|
|
||||||
longitude: result_lng,
|
|
||||||
city,
|
|
||||||
country: address.country.unwrap_or_default(),
|
|
||||||
website,
|
|
||||||
distance_meters: distance as u32,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(cafes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Haversine distance in meters between two lat/lng points.
|
|
||||||
fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
|
|
||||||
const R: f64 = 6_371_000.0; // Earth radius in meters
|
|
||||||
|
|
||||||
let d_lat = (lat2 - lat1).to_radians();
|
|
||||||
let d_lng = (lng2 - lng1).to_radians();
|
|
||||||
|
|
||||||
let a = (d_lat / 2.0).sin().powi(2)
|
|
||||||
+ lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2);
|
|
||||||
|
|
||||||
let c = 2.0 * a.sqrt().asin();
|
|
||||||
R * c
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Nominatim types ---
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct NominatimSearchResult {
|
|
||||||
lat: String,
|
|
||||||
lon: String,
|
|
||||||
display_name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
namedetails: Option<NominatimNameDetails>,
|
|
||||||
#[serde(default)]
|
|
||||||
address: Option<NominatimAddress>,
|
|
||||||
#[serde(default)]
|
|
||||||
extratags: Option<NominatimExtraTags>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct NominatimNameDetails {
|
|
||||||
name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct NominatimAddress {
|
|
||||||
city: Option<String>,
|
|
||||||
town: Option<String>,
|
|
||||||
village: Option<String>,
|
|
||||||
country: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct NominatimExtraTags {
|
|
||||||
website: Option<String>,
|
|
||||||
#[serde(rename = "contact:website")]
|
|
||||||
contact_website: Option<String>,
|
|
||||||
url: Option<String>,
|
|
||||||
#[serde(rename = "contact:url")]
|
|
||||||
contact_url: Option<String>,
|
|
||||||
#[serde(rename = "brand:website")]
|
|
||||||
brand_website: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn haversine_london_to_paris() {
|
|
||||||
// London (51.5074, -0.1278) to Paris (48.8566, 2.3522) ≈ 344 km
|
|
||||||
let dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522);
|
|
||||||
let km = dist / 1000.0;
|
|
||||||
assert!((km - 344.0).abs() < 5.0, "Expected ~344 km, got {km:.1} km");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn haversine_same_point_is_zero() {
|
|
||||||
let dist = haversine_distance(51.5, -0.1, 51.5, -0.1);
|
|
||||||
assert!(dist.abs() < 0.01, "Expected 0, got {dist}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_nominatim_search_response() {
|
|
||||||
let json = r#"[
|
|
||||||
{
|
|
||||||
"place_id": 123,
|
|
||||||
"lat": "51.5246",
|
|
||||||
"lon": "-0.1098",
|
|
||||||
"display_name": "Prufrock Coffee, Leather Lane, London, England, United Kingdom",
|
|
||||||
"namedetails": { "name": "Prufrock Coffee" },
|
|
||||||
"address": {
|
|
||||||
"cafe": "Prufrock Coffee",
|
|
||||||
"road": "Leather Lane",
|
|
||||||
"city": "London",
|
|
||||||
"state": "England",
|
|
||||||
"country": "United Kingdom",
|
|
||||||
"country_code": "gb"
|
|
||||||
},
|
|
||||||
"extratags": {
|
|
||||||
"website": "https://www.prufrockcoffee.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"place_id": 456,
|
|
||||||
"lat": "51.4543",
|
|
||||||
"lon": "-2.5930",
|
|
||||||
"display_name": "Full Court Press, Broad Street, Bristol, England, United Kingdom",
|
|
||||||
"namedetails": { "name": "Full Court Press" },
|
|
||||||
"address": {
|
|
||||||
"town": "Bristol",
|
|
||||||
"country": "United Kingdom"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]"#;
|
|
||||||
|
|
||||||
let results: Vec<NominatimSearchResult> = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(results.len(), 2);
|
|
||||||
|
|
||||||
let first = &results[0];
|
|
||||||
assert_eq!(first.lat, "51.5246");
|
|
||||||
assert_eq!(
|
|
||||||
first.namedetails.as_ref().unwrap().name.as_deref(),
|
|
||||||
Some("Prufrock Coffee")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
first.address.as_ref().unwrap().city.as_deref(),
|
|
||||||
Some("London")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
first.extratags.as_ref().unwrap().website.as_deref(),
|
|
||||||
Some("https://www.prufrockcoffee.com")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Second result uses town fallback, no extratags
|
|
||||||
let second = &results[1];
|
|
||||||
assert!(second.address.as_ref().unwrap().city.is_none());
|
|
||||||
assert_eq!(
|
|
||||||
second.address.as_ref().unwrap().town.as_deref(),
|
|
||||||
Some("Bristol")
|
|
||||||
);
|
|
||||||
assert!(second.extratags.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_nominatim_result_without_namedetails() {
|
|
||||||
let json = r#"[{
|
|
||||||
"place_id": 789,
|
|
||||||
"lat": "48.8566",
|
|
||||||
"lon": "2.3522",
|
|
||||||
"display_name": "Café de Flore, Boulevard Saint-Germain, Paris, France",
|
|
||||||
"address": {
|
|
||||||
"city": "Paris",
|
|
||||||
"country": "France"
|
|
||||||
}
|
|
||||||
}]"#;
|
|
||||||
|
|
||||||
let results: Vec<NominatimSearchResult> = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
|
|
||||||
// Without namedetails, should fall back to display_name prefix
|
|
||||||
assert!(results[0].namedetails.is_none());
|
|
||||||
assert_eq!(
|
|
||||||
results[0].display_name.split(',').next().unwrap().trim(),
|
|
||||||
"Café de Flore"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn website_fallback_chain() {
|
|
||||||
// url tag should be used when website and contact:website are absent
|
|
||||||
let json = r#"[{
|
|
||||||
"place_id": 101,
|
|
||||||
"lat": "51.5",
|
|
||||||
"lon": "-0.1",
|
|
||||||
"display_name": "Test Cafe, London, UK",
|
|
||||||
"namedetails": { "name": "Test Cafe" },
|
|
||||||
"address": { "city": "London", "country": "United Kingdom" },
|
|
||||||
"extratags": { "url": "https://testcafe.example.com" }
|
|
||||||
}]"#;
|
|
||||||
|
|
||||||
let results: Vec<NominatimSearchResult> = serde_json::from_str(json).unwrap();
|
|
||||||
let tags = results[0].extratags.as_ref().unwrap();
|
|
||||||
assert!(tags.website.is_none());
|
|
||||||
assert!(tags.contact_website.is_none());
|
|
||||||
assert_eq!(tags.url.as_deref(), Some("https://testcafe.example.com"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -86,6 +86,7 @@ async fn run_server(command: ServeCommand) -> Result<()> {
|
||||||
admin_username: command.admin_username,
|
admin_username: command.admin_username,
|
||||||
openrouter_api_key: command.openrouter_api_key,
|
openrouter_api_key: command.openrouter_api_key,
|
||||||
openrouter_model: command.openrouter_model,
|
openrouter_model: command.openrouter_model,
|
||||||
|
foursquare_api_key: command.foursquare_api_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
serve(config).await
|
serve(config).await
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,9 @@ pub struct ServeCommand {
|
||||||
default_value = "openrouter/free"
|
default_value = "openrouter/free"
|
||||||
)]
|
)]
|
||||||
pub openrouter_model: String,
|
pub openrouter_model: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "BREWLOG_FOURSQUARE_API_KEY")]
|
||||||
|
pub foursquare_api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
|
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<script>
|
<script>
|
||||||
var _userLat = null;
|
let _userLat = null;
|
||||||
var _userLng = null;
|
let _userLng = null;
|
||||||
var _searchTimeout = null;
|
let _searchTimeout = null;
|
||||||
var _nearbyCafes = [];
|
let _nearbyCafes = [];
|
||||||
|
|
||||||
function locateUser(btn) {
|
function locateUser(btn) {
|
||||||
var errorEl = document.getElementById('nearby-error');
|
const errorEl = document.getElementById('nearby-error');
|
||||||
var searchWrap = document.getElementById('nearby-search-wrap');
|
const searchWrap = document.getElementById('nearby-search-wrap');
|
||||||
errorEl.classList.add('hidden');
|
errorEl.classList.add('hidden');
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
|
|
@ -24,14 +24,14 @@
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
function (pos) {
|
(pos) => {
|
||||||
_userLat = pos.coords.latitude;
|
_userLat = pos.coords.latitude;
|
||||||
_userLng = pos.coords.longitude;
|
_userLng = pos.coords.longitude;
|
||||||
btn.classList.add('hidden');
|
btn.classList.add('hidden');
|
||||||
searchWrap.classList.remove('hidden');
|
searchWrap.classList.remove('hidden');
|
||||||
document.getElementById('nearby-search').focus();
|
document.getElementById('nearby-search').focus();
|
||||||
},
|
},
|
||||||
function (err) {
|
(err) => {
|
||||||
btn.textContent = 'Find nearby';
|
btn.textContent = 'Find nearby';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (err.code === 1) {
|
if (err.code === 1) {
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
function onSearchInput(input) {
|
function onSearchInput(input) {
|
||||||
clearTimeout(_searchTimeout);
|
clearTimeout(_searchTimeout);
|
||||||
var resultsEl = document.getElementById('nearby-results');
|
const resultsEl = document.getElementById('nearby-results');
|
||||||
|
|
||||||
if (input.value.trim().length < 2) {
|
if (input.value.trim().length < 2) {
|
||||||
resultsEl.classList.add('hidden');
|
resultsEl.classList.add('hidden');
|
||||||
|
|
@ -57,25 +57,25 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_searchTimeout = setTimeout(function () {
|
_searchTimeout = setTimeout(() => {
|
||||||
searchNearby(input.value.trim());
|
searchNearby(input.value.trim());
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchNearby(query) {
|
async function searchNearby(query) {
|
||||||
var resultsEl = document.getElementById('nearby-results');
|
const resultsEl = document.getElementById('nearby-results');
|
||||||
var errorEl = document.getElementById('nearby-error');
|
const errorEl = document.getElementById('nearby-error');
|
||||||
var spinnerEl = document.getElementById('nearby-spinner');
|
const spinnerEl = document.getElementById('nearby-spinner');
|
||||||
errorEl.classList.add('hidden');
|
errorEl.classList.add('hidden');
|
||||||
spinnerEl.classList.remove('hidden');
|
spinnerEl.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var url = '/api/v1/nearby-cafes?lat=' + _userLat + '&lng=' + _userLng + '&q=' + encodeURIComponent(query);
|
const url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
|
||||||
var resp = await fetch(url, { credentials: 'same-origin' });
|
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
|
||||||
if (!resp.ok) throw new Error('Server returned ' + resp.status);
|
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||||
|
|
||||||
var cafes = await resp.json();
|
const cafes = await resp.json();
|
||||||
_nearbyCafes = cafes;
|
_nearbyCafes = cafes;
|
||||||
|
|
||||||
if (cafes.length === 0) {
|
if (cafes.length === 0) {
|
||||||
|
|
@ -84,17 +84,17 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
let html = '';
|
||||||
for (var i = 0; i < cafes.length; i++) {
|
for (let i = 0; i < cafes.length; i++) {
|
||||||
var dist = cafes[i].distance_meters;
|
const dist = cafes[i].distance_meters;
|
||||||
var distLabel = dist < 1000
|
const distLabel = dist < 1000
|
||||||
? dist + ' m'
|
? `${dist} m`
|
||||||
: (dist / 1000).toFixed(1) + ' km';
|
: `${(dist / 1000).toFixed(1)} km`;
|
||||||
var location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
|
const location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
|
||||||
html += '<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(' + i + ')">'
|
html += `<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(${i})">`
|
||||||
+ '<span class="font-medium text-amber-900">' + escapeHtml(cafes[i].name) + '</span>'
|
+ `<span class="font-medium text-amber-900">${escapeHtml(cafes[i].name)}</span>`
|
||||||
+ '<span class="ml-2 text-xs text-stone-500">' + escapeHtml(location) + ' · ' + distLabel + '</span>'
|
+ `<span class="ml-2 text-xs text-stone-500">${escapeHtml(location)} · ${distLabel}</span>`
|
||||||
+ '</button>';
|
+ `</button>`;
|
||||||
}
|
}
|
||||||
resultsEl.innerHTML = html;
|
resultsEl.innerHTML = html;
|
||||||
resultsEl.classList.remove('hidden');
|
resultsEl.classList.remove('hidden');
|
||||||
|
|
@ -107,10 +107,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPlace(index) {
|
function selectPlace(index) {
|
||||||
var cafe = _nearbyCafes[index];
|
const cafe = _nearbyCafes[index];
|
||||||
if (!cafe) return;
|
if (!cafe) return;
|
||||||
|
|
||||||
var form = document.getElementById('nearby-search').closest('form');
|
const form = document.getElementById('nearby-search').closest('form');
|
||||||
form.querySelector('[name="name"]').value = cafe.name;
|
form.querySelector('[name="name"]').value = cafe.name;
|
||||||
form.querySelector('[name="city"]').value = cafe.city || '';
|
form.querySelector('[name="city"]').value = cafe.city || '';
|
||||||
form.querySelector('[name="country"]').value = cafe.country || '';
|
form.querySelector('[name="country"]').value = cafe.country || '';
|
||||||
|
|
@ -123,7 +123,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
var el = document.createElement('span');
|
const el = document.createElement('span');
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
return el.innerHTML;
|
return el.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,8 @@ pub async fn spawn_app() -> TestApp {
|
||||||
user_repo,
|
user_repo,
|
||||||
token_repo,
|
token_repo,
|
||||||
session_repo,
|
session_repo,
|
||||||
brewlog::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(),
|
brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
|
||||||
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -109,7 +110,8 @@ async fn spawn_app_inner(
|
||||||
user_repo: Arc<dyn UserRepository>,
|
user_repo: Arc<dyn UserRepository>,
|
||||||
token_repo: Arc<dyn TokenRepository>,
|
token_repo: Arc<dyn TokenRepository>,
|
||||||
session_repo: Arc<dyn SessionRepository>,
|
session_repo: Arc<dyn SessionRepository>,
|
||||||
nominatim_url: String,
|
foursquare_url: String,
|
||||||
|
foursquare_api_key: Option<String>,
|
||||||
mock_server: Option<wiremock::MockServer>,
|
mock_server: Option<wiremock::MockServer>,
|
||||||
) -> TestApp {
|
) -> TestApp {
|
||||||
// Create application state
|
// Create application state
|
||||||
|
|
@ -126,7 +128,8 @@ async fn spawn_app_inner(
|
||||||
token_repo.clone(),
|
token_repo.clone(),
|
||||||
session_repo,
|
session_repo,
|
||||||
reqwest::Client::new(),
|
reqwest::Client::new(),
|
||||||
nominatim_url,
|
foursquare_url,
|
||||||
|
foursquare_api_key,
|
||||||
None,
|
None,
|
||||||
"openrouter/free".to_string(),
|
"openrouter/free".to_string(),
|
||||||
);
|
);
|
||||||
|
|
@ -167,9 +170,9 @@ pub async fn spawn_app_with_auth() -> TestApp {
|
||||||
add_auth_to_app(app).await
|
add_auth_to_app(app).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn spawn_app_with_nominatim_mock() -> TestApp {
|
pub async fn spawn_app_with_foursquare_mock() -> TestApp {
|
||||||
let mock_server = wiremock::MockServer::start().await;
|
let mock_server = wiremock::MockServer::start().await;
|
||||||
let nominatim_url = format!("{}/search", mock_server.uri());
|
let foursquare_url = format!("{}/places/search", mock_server.uri());
|
||||||
|
|
||||||
let database = Database::connect("sqlite::memory:")
|
let database = Database::connect("sqlite::memory:")
|
||||||
.await
|
.await
|
||||||
|
|
@ -208,7 +211,8 @@ pub async fn spawn_app_with_nominatim_mock() -> TestApp {
|
||||||
user_repo,
|
user_repo,
|
||||||
token_repo,
|
token_repo,
|
||||||
session_repo,
|
session_repo,
|
||||||
nominatim_url,
|
foursquare_url,
|
||||||
|
Some("test-api-key".to_string()),
|
||||||
Some(mock_server),
|
Some(mock_server),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,48 @@
|
||||||
use brewlog::infrastructure::osm::NearbyCafe;
|
use brewlog::infrastructure::foursquare::NearbyCafe;
|
||||||
use wiremock::matchers::{method, path, query_param};
|
use wiremock::matchers::{header, method, path, query_param};
|
||||||
use wiremock::{Mock, ResponseTemplate};
|
use wiremock::{Mock, ResponseTemplate};
|
||||||
|
|
||||||
use crate::helpers::spawn_app_with_nominatim_mock;
|
use crate::helpers::spawn_app_with_foursquare_mock;
|
||||||
|
|
||||||
/// Canned Nominatim JSON for two results near London (51.5, -0.1).
|
/// Canned Foursquare JSON for two results near London (51.5, -0.1).
|
||||||
fn nominatim_two_results() -> serde_json::Value {
|
fn foursquare_two_results() -> serde_json::Value {
|
||||||
serde_json::json!([
|
serde_json::json!({
|
||||||
{
|
"results": [
|
||||||
"place_id": 123,
|
{
|
||||||
"lat": "51.5246",
|
"name": "Prufrock Coffee",
|
||||||
"lon": "-0.1098",
|
"latitude": 51.5246,
|
||||||
"display_name": "Prufrock Coffee, Leather Lane, London, England, United Kingdom",
|
"longitude": -0.1098,
|
||||||
"namedetails": { "name": "Prufrock Coffee" },
|
"location": {
|
||||||
"address": {
|
"locality": "London",
|
||||||
"cafe": "Prufrock Coffee",
|
"country": "GB"
|
||||||
"road": "Leather Lane",
|
},
|
||||||
"city": "London",
|
"website": "https://www.prufrockcoffee.com",
|
||||||
"country": "United Kingdom"
|
"distance": 2800
|
||||||
},
|
},
|
||||||
"extratags": {
|
{
|
||||||
"website": "https://www.prufrockcoffee.com"
|
"name": "Department of Coffee",
|
||||||
|
"latitude": 51.5200,
|
||||||
|
"longitude": -0.1050,
|
||||||
|
"location": {
|
||||||
|
"locality": "London",
|
||||||
|
"country": "GB"
|
||||||
|
},
|
||||||
|
"distance": 2500
|
||||||
}
|
}
|
||||||
},
|
]
|
||||||
{
|
})
|
||||||
"place_id": 456,
|
|
||||||
"lat": "51.5200",
|
|
||||||
"lon": "-0.1050",
|
|
||||||
"display_name": "Department of Coffee, Leather Lane, London, England, United Kingdom",
|
|
||||||
"namedetails": { "name": "Department of Coffee" },
|
|
||||||
"address": {
|
|
||||||
"city": "London",
|
|
||||||
"country": "United Kingdom"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nearby_search_returns_results() {
|
async fn nearby_search_returns_results() {
|
||||||
let app = spawn_app_with_nominatim_mock().await;
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
let mock_server = app.mock_server.as_ref().unwrap();
|
let mock_server = app.mock_server.as_ref().unwrap();
|
||||||
|
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/search"))
|
.and(path("/places/search"))
|
||||||
.and(query_param("q", "coffee"))
|
.and(query_param("query", "coffee"))
|
||||||
.and(query_param("format", "json"))
|
.and(header("Authorization", "Bearer test-api-key"))
|
||||||
.respond_with(ResponseTemplate::new(200).set_body_json(nominatim_two_results()))
|
.respond_with(ResponseTemplate::new(200).set_body_json(foursquare_two_results()))
|
||||||
.expect(1)
|
.expect(1)
|
||||||
.mount(mock_server)
|
.mount(mock_server)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -72,20 +68,21 @@ async fn nearby_search_returns_results() {
|
||||||
cafes[0].website.as_deref(),
|
cafes[0].website.as_deref(),
|
||||||
Some("https://www.prufrockcoffee.com")
|
Some("https://www.prufrockcoffee.com")
|
||||||
);
|
);
|
||||||
assert!(cafes[0].distance_meters > 0);
|
assert_eq!(cafes[0].distance_meters, 2800);
|
||||||
|
|
||||||
assert_eq!(cafes[1].name, "Department of Coffee");
|
assert_eq!(cafes[1].name, "Department of Coffee");
|
||||||
assert!(cafes[1].website.is_none());
|
assert!(cafes[1].website.is_none());
|
||||||
|
assert_eq!(cafes[1].distance_meters, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nearby_search_returns_empty_for_no_matches() {
|
async fn nearby_search_returns_empty_for_no_matches() {
|
||||||
let app = spawn_app_with_nominatim_mock().await;
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
let mock_server = app.mock_server.as_ref().unwrap();
|
let mock_server = app.mock_server.as_ref().unwrap();
|
||||||
|
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/search"))
|
.and(path("/places/search"))
|
||||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})))
|
||||||
.expect(1)
|
.expect(1)
|
||||||
.mount(mock_server)
|
.mount(mock_server)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -107,7 +104,7 @@ async fn nearby_search_returns_empty_for_no_matches() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nearby_search_requires_authentication() {
|
async fn nearby_search_requires_authentication() {
|
||||||
let app = spawn_app_with_nominatim_mock().await;
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
|
|
@ -122,7 +119,7 @@ async fn nearby_search_requires_authentication() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nearby_search_rejects_short_query() {
|
async fn nearby_search_rejects_short_query() {
|
||||||
let app = spawn_app_with_nominatim_mock().await;
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
|
|
@ -138,7 +135,7 @@ async fn nearby_search_rejects_short_query() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nearby_search_rejects_invalid_coordinates() {
|
async fn nearby_search_rejects_invalid_coordinates() {
|
||||||
let app = spawn_app_with_nominatim_mock().await;
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
|
|
@ -154,11 +151,11 @@ async fn nearby_search_rejects_invalid_coordinates() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nearby_search_returns_500_on_upstream_failure() {
|
async fn nearby_search_returns_500_on_upstream_failure() {
|
||||||
let app = spawn_app_with_nominatim_mock().await;
|
let app = spawn_app_with_foursquare_mock().await;
|
||||||
let mock_server = app.mock_server.as_ref().unwrap();
|
let mock_server = app.mock_server.as_ref().unwrap();
|
||||||
|
|
||||||
Mock::given(method("GET"))
|
Mock::given(method("GET"))
|
||||||
.and(path("/search"))
|
.and(path("/places/search"))
|
||||||
.respond_with(ResponseTemplate::new(503))
|
.respond_with(ResponseTemplate::new(503))
|
||||||
.expect(1)
|
.expect(1)
|
||||||
.mount(mock_server)
|
.mount(mock_server)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue