add config-sqlite and http-api adapters
SQLite config store: full ConfigRepository impl with JSON serialization for mappings, layouts, data source configs. 12 integration tests. HTTP API: Axum REST endpoints for widgets, data sources, layout, presets. 6 integration tests using tower::oneshot. Port traits updated to return Send futures for Axum compatibility.
This commit is contained in:
13
crates/adapters/config-sqlite/Cargo.toml
Normal file
13
crates/adapters/config-sqlite/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "config-sqlite"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
262
crates/adapters/config-sqlite/src/lib.rs
Normal file
262
crates/adapters/config-sqlite/src/lib.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
mod serialization;
|
||||
|
||||
use std::time::Duration;
|
||||
use sqlx::{SqlitePool, Row};
|
||||
use domain::{
|
||||
ConfigRepository,
|
||||
DataSource, DataSourceId, DataSourceConfig, DataSourceType,
|
||||
Layout, LayoutPreset, LayoutPresetId,
|
||||
WidgetConfig, WidgetId,
|
||||
};
|
||||
use serialization as ser;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SqliteConfigError {
|
||||
Sql(sqlx::Error),
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SqliteConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SqliteConfigError::Sql(e) => write!(f, "sql: {e}"),
|
||||
SqliteConfigError::Serialization(e) => write!(f, "serialization: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SqliteConfigStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteConfigStore {
|
||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
let pool = SqlitePool::connect(database_url).await?;
|
||||
let store = Self { pool };
|
||||
store.migrate().await?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
async fn migrate(&self) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS widgets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
display_hint TEXT NOT NULL,
|
||||
data_source_id INTEGER NOT NULL,
|
||||
mappings TEXT NOT NULL,
|
||||
max_data_size INTEGER NOT NULL
|
||||
)"
|
||||
).execute(&self.pool).await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS data_sources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
poll_interval_secs INTEGER NOT NULL,
|
||||
config TEXT NOT NULL
|
||||
)"
|
||||
).execute(&self.pool).await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS layout (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL
|
||||
)"
|
||||
).execute(&self.pool).await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS presets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
layout_data TEXT NOT NULL
|
||||
)"
|
||||
).execute(&self.pool).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigRepository for SqliteConfigStore {
|
||||
type Error = SqliteConfigError;
|
||||
|
||||
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
|
||||
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::widget_from_row(&row)?)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM widgets")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(|r| ser::widget_from_row(r)).collect()
|
||||
}
|
||||
|
||||
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
|
||||
let mappings_json = ser::mappings_to_json(&config.mappings)?;
|
||||
let hint_str = ser::display_hint_to_str(&config.display_hint);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO widgets (id, name, display_hint, data_source_id, mappings, max_data_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(config.id as i64)
|
||||
.bind(&config.name)
|
||||
.bind(hint_str)
|
||||
.bind(config.data_source_id as i64)
|
||||
.bind(&mappings_json)
|
||||
.bind(config.max_data_size as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
|
||||
sqlx::query("DELETE FROM widgets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
|
||||
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM data_sources")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(|r| ser::data_source_from_row(r)).collect()
|
||||
}
|
||||
|
||||
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
|
||||
let config_json = ser::data_source_config_to_json(&source.config)?;
|
||||
let type_str = ser::data_source_type_to_str(&source.source_type);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(source.id as i64)
|
||||
.bind(&source.name)
|
||||
.bind(type_str)
|
||||
.bind(source.poll_interval.as_secs() as i64)
|
||||
.bind(&config_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
|
||||
sqlx::query("DELETE FROM data_sources WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
|
||||
let row = sqlx::query("SELECT data FROM layout WHERE id = 1")
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => {
|
||||
let json: String = row.get("data");
|
||||
Ok(Some(ser::layout_from_json(&json)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
|
||||
let json = ser::layout_to_json(layout)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)"
|
||||
)
|
||||
.bind(&json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::preset_from_row(&row)?)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM presets")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(|r| ser::preset_from_row(r)).collect()
|
||||
}
|
||||
|
||||
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
|
||||
let layout_json = ser::layout_to_json(&preset.layout)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(preset.id as i64)
|
||||
.bind(&preset.name)
|
||||
.bind(&layout_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||
sqlx::query("DELETE FROM presets WHERE id = ?")
|
||||
.bind(id as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
220
crates/adapters/config-sqlite/src/serialization.rs
Normal file
220
crates/adapters/config-sqlite/src/serialization.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use std::time::Duration;
|
||||
use sqlx::Row;
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use domain::{
|
||||
ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
|
||||
DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset,
|
||||
Sizing, WidgetConfig,
|
||||
};
|
||||
use crate::SqliteConfigError;
|
||||
|
||||
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
|
||||
match hint {
|
||||
DisplayHint::IconValue => "icon_value",
|
||||
DisplayHint::TextBlock => "text_block",
|
||||
DisplayHint::KeyValue => "key_value",
|
||||
}
|
||||
}
|
||||
|
||||
fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
|
||||
match s {
|
||||
"icon_value" => Ok(DisplayHint::IconValue),
|
||||
"text_block" => Ok(DisplayHint::TextBlock),
|
||||
"key_value" => Ok(DisplayHint::KeyValue),
|
||||
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||
match t {
|
||||
DataSourceType::Weather => "weather",
|
||||
DataSourceType::Media => "media",
|
||||
DataSourceType::Xtb => "xtb",
|
||||
DataSourceType::Rss => "rss",
|
||||
DataSourceType::HttpJson => "http_json",
|
||||
DataSourceType::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
|
||||
fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigError> {
|
||||
match s {
|
||||
"weather" => Ok(DataSourceType::Weather),
|
||||
"media" => Ok(DataSourceType::Media),
|
||||
"xtb" => Ok(DataSourceType::Xtb),
|
||||
"rss" => Ok(DataSourceType::Rss),
|
||||
"http_json" => Ok(DataSourceType::HttpJson),
|
||||
"webhook" => Ok(DataSourceType::Webhook),
|
||||
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
|
||||
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| {
|
||||
serde_json::json!({
|
||||
"source_path": m.source_path,
|
||||
"target_key": m.target_key,
|
||||
})
|
||||
}).collect();
|
||||
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
|
||||
let entries: Vec<serde_json::Value> = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
entries.iter().map(|v| {
|
||||
Ok(KeyMapping {
|
||||
source_path: v["source_path"].as_str()
|
||||
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(),
|
||||
target_key: v["target_key"].as_str()
|
||||
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(),
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> {
|
||||
let v = serde_json::json!({
|
||||
"url": config.url,
|
||||
"headers": config.headers,
|
||||
"api_key": config.api_key,
|
||||
});
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
let url = v["url"].as_str().map(String::from);
|
||||
let api_key = v["api_key"].as_str().map(String::from);
|
||||
let headers = match v["headers"].as_array() {
|
||||
Some(arr) => arr.iter().filter_map(|h| {
|
||||
let pair = h.as_array()?;
|
||||
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
||||
}).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
Ok(DataSourceConfig { url, headers, api_key })
|
||||
}
|
||||
|
||||
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||
let v = node_to_json(&layout.root);
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
|
||||
let v: serde_json::Value = serde_json::from_str(json)
|
||||
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
let root = node_from_json(&v)?;
|
||||
Ok(Layout { root })
|
||||
}
|
||||
|
||||
fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
|
||||
LayoutNode::Container(c) => {
|
||||
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| {
|
||||
let sizing = match &ch.sizing {
|
||||
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
|
||||
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
|
||||
};
|
||||
serde_json::json!({
|
||||
"sizing": sizing,
|
||||
"node": node_to_json(&ch.node),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
serde_json::json!({
|
||||
"type": "container",
|
||||
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
|
||||
"gap": c.gap,
|
||||
"padding": c.padding,
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError> {
|
||||
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
|
||||
|
||||
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
|
||||
"leaf" => {
|
||||
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16;
|
||||
Ok(LayoutNode::Leaf(id))
|
||||
}
|
||||
"container" => {
|
||||
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? {
|
||||
"row" => Direction::Row,
|
||||
"column" => Direction::Column,
|
||||
d => return Err(err(&format!("unknown direction: {d}"))),
|
||||
};
|
||||
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
|
||||
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
|
||||
let children = v["children"].as_array()
|
||||
.ok_or_else(|| err("missing children"))?
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let sizing_v = &ch["sizing"];
|
||||
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? {
|
||||
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16),
|
||||
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8),
|
||||
s => return Err(err(&format!("unknown sizing: {s}"))),
|
||||
};
|
||||
let node = node_from_json(&ch["node"])?;
|
||||
Ok(LayoutChild { sizing, node })
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
|
||||
}
|
||||
t => Err(err(&format!("unknown node type: {t}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let hint_str: String = row.get("display_hint");
|
||||
let ds_id: i64 = row.get("data_source_id");
|
||||
let mappings_json: String = row.get("mappings");
|
||||
let max_size: i64 = row.get("max_data_size");
|
||||
|
||||
Ok(WidgetConfig {
|
||||
id: id as u16,
|
||||
name,
|
||||
display_hint: display_hint_from_str(&hint_str)?,
|
||||
data_source_id: ds_id as u16,
|
||||
mappings: mappings_from_json(&mappings_json)?,
|
||||
max_data_size: max_size as u16,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let type_str: String = row.get("source_type");
|
||||
let interval_secs: i64 = row.get("poll_interval_secs");
|
||||
let config_json: String = row.get("config");
|
||||
|
||||
Ok(DataSource {
|
||||
id: id as u16,
|
||||
name,
|
||||
source_type: data_source_type_from_str(&type_str)?,
|
||||
poll_interval: Duration::from_secs(interval_secs as u64),
|
||||
config: data_source_config_from_json(&config_json)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let layout_json: String = row.get("layout_data");
|
||||
|
||||
Ok(LayoutPreset {
|
||||
id: id as u16,
|
||||
name,
|
||||
layout: layout_from_json(&layout_json)?,
|
||||
})
|
||||
}
|
||||
203
crates/adapters/config-sqlite/tests/config_store_tests.rs
Normal file
203
crates/adapters/config-sqlite/tests/config_store_tests.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use std::time::Duration;
|
||||
use domain::{
|
||||
ConfigRepository, DisplayHint, KeyMapping, WidgetConfig,
|
||||
DataSource, DataSourceConfig, DataSourceType,
|
||||
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
|
||||
LayoutPreset,
|
||||
};
|
||||
use config_sqlite::SqliteConfigStore;
|
||||
|
||||
async fn test_store() -> SqliteConfigStore {
|
||||
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
|
||||
}
|
||||
|
||||
fn weather_widget() -> WidgetConfig {
|
||||
WidgetConfig {
|
||||
id: 1,
|
||||
name: "weather".into(),
|
||||
display_hint: DisplayHint::IconValue,
|
||||
data_source_id: 10,
|
||||
mappings: vec![
|
||||
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
|
||||
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
|
||||
],
|
||||
max_data_size: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
fn weather_source() -> DataSource {
|
||||
DataSource {
|
||||
id: 10,
|
||||
name: "openweather".into(),
|
||||
source_type: DataSourceType::Weather,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
config: DataSourceConfig {
|
||||
url: Some("https://api.openweather.org".into()),
|
||||
headers: vec![],
|
||||
api_key: Some("test-key".into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn test_layout() -> Layout {
|
||||
Layout {
|
||||
root: LayoutNode::Container(ContainerNode {
|
||||
direction: Direction::Row,
|
||||
gap: 4,
|
||||
padding: 2,
|
||||
children: vec![
|
||||
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
|
||||
LayoutChild { sizing: Sizing::Fixed(80), node: LayoutNode::Leaf(2) },
|
||||
],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_retrieve_widget() {
|
||||
let store = test_store().await;
|
||||
store.save_widget(&weather_widget()).await.unwrap();
|
||||
|
||||
let w = store.get_widget(1).await.unwrap().unwrap();
|
||||
assert_eq!(w.id, 1);
|
||||
assert_eq!(w.name, "weather");
|
||||
assert_eq!(w.display_hint, DisplayHint::IconValue);
|
||||
assert_eq!(w.data_source_id, 10);
|
||||
assert_eq!(w.mappings.len(), 2);
|
||||
assert_eq!(w.mappings[0].source_path, "$.temp");
|
||||
assert_eq!(w.max_data_size, 2048);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_nonexistent_widget_returns_none() {
|
||||
let store = test_store().await;
|
||||
assert!(store.get_widget(99).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_widgets_returns_all() {
|
||||
let store = test_store().await;
|
||||
store.save_widget(&weather_widget()).await.unwrap();
|
||||
store.save_widget(&WidgetConfig {
|
||||
id: 2,
|
||||
name: "portfolio".into(),
|
||||
display_hint: DisplayHint::KeyValue,
|
||||
data_source_id: 20,
|
||||
mappings: vec![],
|
||||
max_data_size: 1024,
|
||||
}).await.unwrap();
|
||||
|
||||
let widgets = store.list_widgets().await.unwrap();
|
||||
assert_eq!(widgets.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_widget_removes_it() {
|
||||
let store = test_store().await;
|
||||
store.save_widget(&weather_widget()).await.unwrap();
|
||||
store.delete_widget(1).await.unwrap();
|
||||
assert!(store.get_widget(1).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_retrieve_data_source() {
|
||||
let store = test_store().await;
|
||||
store.save_data_source(&weather_source()).await.unwrap();
|
||||
|
||||
let ds = store.get_data_source(10).await.unwrap().unwrap();
|
||||
assert_eq!(ds.id, 10);
|
||||
assert_eq!(ds.name, "openweather");
|
||||
assert_eq!(ds.source_type, DataSourceType::Weather);
|
||||
assert_eq!(ds.poll_interval, Duration::from_secs(300));
|
||||
assert_eq!(ds.config.url, Some("https://api.openweather.org".into()));
|
||||
assert_eq!(ds.config.api_key, Some("test-key".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_and_delete_data_sources() {
|
||||
let store = test_store().await;
|
||||
store.save_data_source(&weather_source()).await.unwrap();
|
||||
assert_eq!(store.list_data_sources().await.unwrap().len(), 1);
|
||||
|
||||
store.delete_data_source(10).await.unwrap();
|
||||
assert!(store.list_data_sources().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_retrieve_layout() {
|
||||
let store = test_store().await;
|
||||
let layout = test_layout();
|
||||
store.save_layout(&layout).await.unwrap();
|
||||
|
||||
let loaded = store.get_layout().await.unwrap().unwrap();
|
||||
assert_eq!(loaded, layout);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn layout_starts_as_none() {
|
||||
let store = test_store().await;
|
||||
assert!(store.get_layout().await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_layout_replaces_previous() {
|
||||
let store = test_store().await;
|
||||
store.save_layout(&test_layout()).await.unwrap();
|
||||
|
||||
let new_layout = Layout {
|
||||
root: LayoutNode::Leaf(42),
|
||||
};
|
||||
store.save_layout(&new_layout).await.unwrap();
|
||||
|
||||
let loaded = store.get_layout().await.unwrap().unwrap();
|
||||
assert_eq!(loaded, new_layout);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_retrieve_preset() {
|
||||
let store = test_store().await;
|
||||
let preset = LayoutPreset {
|
||||
id: 1,
|
||||
name: "dashboard".into(),
|
||||
layout: test_layout(),
|
||||
};
|
||||
store.save_preset(&preset).await.unwrap();
|
||||
|
||||
let loaded = store.get_preset(1).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.id, 1);
|
||||
assert_eq!(loaded.name, "dashboard");
|
||||
assert_eq!(loaded.layout, test_layout());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_and_delete_presets() {
|
||||
let store = test_store().await;
|
||||
store.save_preset(&LayoutPreset {
|
||||
id: 1, name: "a".into(), layout: test_layout(),
|
||||
}).await.unwrap();
|
||||
store.save_preset(&LayoutPreset {
|
||||
id: 2, name: "b".into(), layout: test_layout(),
|
||||
}).await.unwrap();
|
||||
|
||||
assert_eq!(store.list_presets().await.unwrap().len(), 2);
|
||||
|
||||
store.delete_preset(1).await.unwrap();
|
||||
assert_eq!(store.list_presets().await.unwrap().len(), 1);
|
||||
assert!(store.get_preset(1).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_widget_updates_existing() {
|
||||
let store = test_store().await;
|
||||
store.save_widget(&weather_widget()).await.unwrap();
|
||||
|
||||
let mut updated = weather_widget();
|
||||
updated.name = "updated_weather".into();
|
||||
updated.max_data_size = 4096;
|
||||
store.save_widget(&updated).await.unwrap();
|
||||
|
||||
let loaded = store.get_widget(1).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.name, "updated_weather");
|
||||
assert_eq!(loaded.max_data_size, 4096);
|
||||
assert_eq!(store.list_widgets().await.unwrap().len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user