From 934fb31059da10fa843d96a10c37f181eaa89456 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Mon, 21 Jul 2025 21:10:22 +0300 Subject: feat: add weather pooler and config --- src/weather_api_data.rs | 227 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 src/weather_api_data.rs (limited to 'src/weather_api_data.rs') diff --git a/src/weather_api_data.rs b/src/weather_api_data.rs new file mode 100644 index 0000000..14a1cac --- /dev/null +++ b/src/weather_api_data.rs @@ -0,0 +1,227 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone, PartialEq, ToSchema)] +pub struct WeatherApiData { + pub id: i64, + pub user_id: i64, + pub location_id: i64, + pub api_type: String, + pub data: String, // JSON data from the API + #[sqlx(rename = "fetched_at")] + pub fetched_at: String, // Store as string, convert when needed +} + +impl WeatherApiData { + #[allow(dead_code)] + pub fn fetched_at_datetime(&self) -> Result, chrono::ParseError> { + DateTime::parse_from_rfc3339(&self.fetched_at).map(|dt| dt.with_timezone(&Utc)) + } +} + +pub struct WeatherApiDataRepository<'a> { + pub db: &'a sqlx::SqlitePool, +} + +impl<'a> WeatherApiDataRepository<'a> { + pub async fn create_weather_data( + &self, + user_id: i64, + location_id: i64, + api_type: String, + data: String, + ) -> Result { + sqlx::query_as::<_, WeatherApiData>( + "INSERT INTO weather_api_data (user_id, location_id, api_type, data) VALUES (?, ?, ?, ?) RETURNING id, user_id, location_id, api_type, data, fetched_at" + ) + .bind(user_id) + .bind(location_id) + .bind(api_type) + .bind(data) + .fetch_one(self.db) + .await + } + + #[allow(dead_code)] + pub async fn get_latest_weather_data( + &self, + user_id: i64, + location_id: i64, + api_type: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, WeatherApiData>( + "SELECT id, user_id, location_id, api_type, data, fetched_at FROM weather_api_data WHERE user_id = ? AND location_id = ? AND api_type = ? ORDER BY fetched_at DESC LIMIT 1" + ) + .bind(user_id) + .bind(location_id) + .bind(api_type) + .fetch_optional(self.db) + .await + } + + #[allow(dead_code)] + pub async fn list_weather_data_by_user( + &self, + user_id: i64, + limit: Option, + ) -> Result, sqlx::Error> { + let limit = limit.unwrap_or(100); + sqlx::query_as::<_, WeatherApiData>( + "SELECT id, user_id, location_id, api_type, data, fetched_at FROM weather_api_data WHERE user_id = ? ORDER BY fetched_at DESC LIMIT ?" + ) + .bind(user_id) + .bind(limit) + .fetch_all(self.db) + .await + } + + #[allow(dead_code)] + pub async fn delete_old_weather_data(&self, days_old: i64) -> Result { + let result = sqlx::query( + "DELETE FROM weather_api_data WHERE fetched_at < datetime('now', '-{} days')", + ) + .bind(days_old) + .execute(self.db) + .await?; + Ok(result.rows_affected()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::locations::LocationRepository; + use crate::users::{UserRepository, UserRole}; + use sqlx::{Executor, SqlitePool}; + + async fn setup_db() -> SqlitePool { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + pool.execute( + "CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'user' + );", + ) + .await + .unwrap(); + pool.execute( + "CREATE TABLE locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE NO ACTION + );", + ) + .await + .unwrap(); + pool.execute( + "CREATE TABLE weather_api_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + location_id INTEGER NOT NULL, + api_type TEXT NOT NULL DEFAULT 'openweathermap', + data TEXT NOT NULL, + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(location_id) REFERENCES locations(id) ON DELETE CASCADE + );", + ) + .await + .unwrap(); + pool + } + + async fn create_user(pool: &SqlitePool) -> i64 { + let repo = UserRepository { db: pool }; + let user = repo + .create_user(Some("test_user".to_string()), Some(UserRole::User)) + .await + .unwrap(); + user.id + } + + async fn create_location(pool: &SqlitePool, user_id: i64) -> i64 { + let repo = LocationRepository { db: pool }; + let location = repo + .create_location(60.1699, 24.9384, user_id) // Helsinki coordinates + .await + .unwrap(); + location.id + } + + #[tokio::test] + async fn test_create_and_get_weather_data() { + let pool = setup_db().await; + let user_id = create_user(&pool).await; + let location_id = create_location(&pool, user_id).await; + + let repo = WeatherApiDataRepository { db: &pool }; + let test_data = r#"{"temp": 20.5, "humidity": 65}"#.to_string(); + + let weather_data = repo + .create_weather_data( + user_id, + location_id, + "openweathermap".to_string(), + test_data.clone(), + ) + .await + .unwrap(); + + assert_eq!(weather_data.user_id, user_id); + assert_eq!(weather_data.location_id, location_id); + assert_eq!(weather_data.api_type, "openweathermap"); + assert_eq!(weather_data.data, test_data); + + let latest = repo + .get_latest_weather_data(user_id, location_id, "openweathermap") + .await + .unwrap() + .unwrap(); + + assert_eq!(latest.id, weather_data.id); + assert_eq!(latest.data, test_data); + } + + #[tokio::test] + async fn test_list_weather_data_by_user() { + let pool = setup_db().await; + let user_id = create_user(&pool).await; + let location_id = create_location(&pool, user_id).await; + + let repo = WeatherApiDataRepository { db: &pool }; + let test_data1 = r#"{"temp": 20.5}"#.to_string(); + let test_data2 = r#"{"temp": 22.0}"#.to_string(); + + repo.create_weather_data( + user_id, + location_id, + "openweathermap".to_string(), + test_data1.clone(), + ) + .await + .unwrap(); + repo.create_weather_data( + user_id, + location_id, + "openweathermap".to_string(), + test_data2.clone(), + ) + .await + .unwrap(); + + let data_list = repo + .list_weather_data_by_user(user_id, Some(10)) + .await + .unwrap(); + assert_eq!(data_list.len(), 2); + // Check that both data entries are present (order may vary) + let data_values: Vec<&str> = data_list.iter().map(|d| d.data.as_str()).collect(); + assert!(data_values.contains(&test_data1.as_str())); + assert!(data_values.contains(&test_data2.as_str())); + } +} -- cgit v1.2.3