diff --git a/LORAWEB_NEU/backend/daemon/src/db.rs b/LORAWEB_NEU/backend/daemon/src/db.rs new file mode 100644 index 0000000..fa42386 --- /dev/null +++ b/LORAWEB_NEU/backend/daemon/src/db.rs @@ -0,0 +1,99 @@ +// backend/daemon/src/db.rs +use sqlx::SqlitePool; +use chrono::Utc; +use uuid::Uuid; + +pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> { + sqlx::query("PRAGMA journal_mode=WAL").execute(pool).await?; + sqlx::query("PRAGMA busy_timeout=5000").execute(pool).await?; + + let migration = include_str!("../../../database/migrations/001_initial.sql"); + for statement in migration.split(';') { + let stmt = statement.trim(); + if !stmt.is_empty() { + sqlx::query(stmt).execute(pool).await?; + } + } + Ok(()) +} + +pub async fn register_topic(pool: &SqlitePool, dev_eui: &str) -> Result<(), sqlx::Error> { + let now = Utc::now().to_rfc3339(); + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT OR IGNORE INTO topics (id, topic, approved, alarm_status, alarm_threshold_hours, created_at) \ + VALUES (?, ?, 0, 'unknown', 0.0, ?)" + ) + .bind(&id) + .bind(dev_eui) + .bind(&now) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn update_last_seen(pool: &SqlitePool, dev_eui: &str, timestamp: &str) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE topics SET last_seen = ? WHERE topic = ?") + .bind(timestamp) + .bind(dev_eui) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + + #[tokio::test] + async fn test_init_db_creates_tables() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + init_db(&pool).await.unwrap(); + + let topics: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topics") + .fetch_one(&pool).await.unwrap(); + assert_eq!(topics.0, 0); + + let sensors: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sensors") + .fetch_one(&pool).await.unwrap(); + assert_eq!(sensors.0, 0); + + let users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(&pool).await.unwrap(); + assert_eq!(users.0, 0); + } + + #[tokio::test] + async fn test_init_db_is_idempotent() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + init_db(&pool).await.unwrap(); + init_db(&pool).await.unwrap(); // should not error + } + + #[tokio::test] + async fn test_register_topic() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + init_db(&pool).await.unwrap(); + + register_topic(&pool, "abcdef1234567890").await.unwrap(); + + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topics WHERE topic = ?") + .bind("abcdef1234567890") + .fetch_one(&pool).await.unwrap(); + assert_eq!(count.0, 1); + } + + #[tokio::test] + async fn test_register_topic_idempotent() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + init_db(&pool).await.unwrap(); + + register_topic(&pool, "abcdef1234567890").await.unwrap(); + register_topic(&pool, "abcdef1234567890").await.unwrap(); + + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM topics") + .fetch_one(&pool).await.unwrap(); + assert_eq!(count.0, 1); + } +} diff --git a/LORAWEB_NEU/database/migrations/001_initial.sql b/LORAWEB_NEU/database/migrations/001_initial.sql new file mode 100644 index 0000000..4df91ef --- /dev/null +++ b/LORAWEB_NEU/database/migrations/001_initial.sql @@ -0,0 +1,33 @@ +-- database/migrations/001_initial.sql + +CREATE TABLE IF NOT EXISTS topics ( + id TEXT PRIMARY KEY, + topic TEXT UNIQUE NOT NULL, + approved INTEGER NOT NULL DEFAULT 0, + last_seen TEXT, + alarm_status TEXT NOT NULL DEFAULT 'unknown', + alarm_threshold_hours REAL NOT NULL DEFAULT 0.0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sensors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + sensor_type TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + topic_id TEXT REFERENCES topics(id), + display_config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +);