Skip to main content

jellytau_lib/storage/
mod.rs

1//! Offline storage module using SQLite
2//!
3//! Provides local caching of Jellyfin metadata, download management,
4//! and offline mutation queue for sync-back operations.
5
6pub mod db_service;
7pub mod models;
8pub mod schema;
9
10use crate::utils::lock::MutexSafe;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13
14use log::{debug, error, info};
15use rusqlite::{Connection, Result as SqliteResult};
16
17pub use db_service::{DatabaseService, RusqliteService};
18use schema::MIGRATIONS;
19
20/// Database connection wrapper with thread-safe access
21pub struct Database {
22    conn: Arc<Mutex<Connection>>,
23    path: PathBuf,
24}
25
26impl Database {
27    /// Open or create the database at a specific path
28    pub fn open(path: &PathBuf) -> SqliteResult<Self> {
29        // Ensure parent directory exists
30        if let Some(parent) = path.parent() {
31            std::fs::create_dir_all(parent).ok();
32        }
33
34        let conn = Connection::open(path)?;
35
36        // Enable foreign keys
37        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
38
39        // Enable WAL mode for better concurrent access
40        conn.execute_batch("PRAGMA journal_mode = WAL;")?;
41
42        let db = Self {
43            conn: Arc::new(Mutex::new(conn)),
44            path: path.clone(),
45        };
46
47        // Run migrations
48        db.migrate()?;
49
50        Ok(db)
51    }
52
53    /// Open an in-memory database (for testing)
54    #[cfg(test)]
55    pub fn open_in_memory() -> SqliteResult<Self> {
56        let conn = Connection::open_in_memory()?;
57
58        // Enable foreign keys
59        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
60
61        let db = Self {
62            conn: Arc::new(Mutex::new(conn)),
63            path: PathBuf::from(":memory:"),
64        };
65
66        // Run migrations
67        db.migrate()?;
68
69        Ok(db)
70    }
71
72    /// Get connection (for testing)
73    #[cfg(test)]
74    pub fn connection(&self) -> Arc<Mutex<Connection>> {
75        Arc::clone(&self.conn)
76    }
77
78    /// Run all pending migrations.
79    pub fn migrate(&self) -> SqliteResult<()> {
80        self.migrate_with(MIGRATIONS)
81    }
82
83    /// Apply `migrations` in order, skipping ones `_migrations` already records.
84    ///
85    /// **Each migration is one transaction, and the `_migrations` row is written
86    /// inside it.** SQLite autocommits every statement otherwise, so a migration
87    /// that failed partway — low disk, an OOM kill, the process dying mid-boot —
88    /// used to leave its earlier statements applied while recording nothing.
89    /// `execute_batch` aborts on the first error, so the retry on the next launch
90    /// then failed at statement 1 ("duplicate column name") and kept failing
91    /// forever; `Database::open` turns that into a panic, so the app never
92    /// started again and the only fix was clearing app data. Committing the
93    /// schema change and the bookkeeping together makes a migration all-or-nothing
94    /// and a retry always safe.
95    ///
96    /// Every migration is pure DDL/DML, which SQLite runs transactionally — a
97    /// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
98    ///
99    /// Split out from [`Self::migrate`] so tests can inject a failing migration.
100    ///
101    /// TRACES: UR-002 | DR-012 | UT-014
102    fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
103        info!("Starting database migrations...");
104        let conn = self.conn.lock_safe();
105
106        // Create migrations table if it doesn't exist
107        debug!("Creating _migrations table if it doesn't exist...");
108        match conn.execute(
109            "CREATE TABLE IF NOT EXISTS _migrations (
110                id INTEGER PRIMARY KEY,
111                name TEXT NOT NULL UNIQUE,
112                applied_at TEXT DEFAULT CURRENT_TIMESTAMP
113            )",
114            [],
115        ) {
116            Ok(_) => debug!("_migrations table ready"),
117            Err(e) => {
118                error!("Failed to create _migrations table: {}", e);
119                return Err(e);
120            }
121        }
122
123        // Get applied migrations
124        debug!("Querying applied migrations...");
125        let mut stmt = conn.prepare("SELECT name FROM _migrations")?;
126        let applied: Vec<String> = stmt
127            .query_map([], |row: &rusqlite::Row| row.get(0))?
128            .filter_map(|r| r.ok())
129            .collect();
130        debug!("Found {} applied migrations", applied.len());
131
132        // Apply pending migrations
133        for (name, sql) in migrations {
134            if applied.contains(&name.to_string()) {
135                debug!("Skipping already applied migration: {}", name);
136                continue;
137            }
138
139            info!("Applying migration: {}", name);
140
141            // `unchecked_transaction` because the connection is reached through a
142            // shared guard rather than `&mut`. Dropping the transaction without
143            // committing rolls it back, which is exactly what the `?`s below do.
144            let tx = conn.unchecked_transaction()?;
145
146            if let Err(e) = tx.execute_batch(sql) {
147                error!("Failed to apply migration {} (rolled back): {}", name, e);
148                return Err(e);
149            }
150            if let Err(e) = tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
151                error!("Failed to record migration {} (rolled back): {}", name, e);
152                return Err(e);
153            }
154            tx.commit()?;
155
156            info!("Successfully applied migration: {}", name);
157        }
158
159        info!("All migrations completed successfully");
160        Ok(())
161    }
162
163    /// Get a database service for async-safe operations
164    ///
165    /// This wraps all blocking database operations in spawn_blocking to prevent
166    /// freezing the async runtime.
167    pub fn service(&self) -> RusqliteService {
168        RusqliteService::new(Arc::clone(&self.conn))
169    }
170
171    /// Get the database file path
172    pub fn path(&self) -> &PathBuf {
173        &self.path
174    }
175
176    /// Get database file size in bytes
177    pub fn file_size(&self) -> Option<u64> {
178        std::fs::metadata(&self.path).ok().map(|m| m.len())
179    }
180}
181
182// TRACES: UR-002, UR-012, UR-019, UR-025 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use rusqlite::params;
187
188    #[test]
189    fn test_open_in_memory() {
190        let db = Database::open_in_memory().unwrap();
191        assert_eq!(db.path().to_str(), Some(":memory:"));
192    }
193
194    /// A migration that dies partway must leave *nothing* behind.
195    ///
196    /// SQLite autocommits each statement, so before migrations were wrapped in a
197    /// transaction the first `ADD COLUMN` of a failing batch stuck while the
198    /// `_migrations` row was never written. `execute_batch` aborts on the first
199    /// error, so the retry on the next launch failed at statement 1 with
200    /// "duplicate column name" and kept failing forever — and `Database::open`
201    /// panics on that, so the app never started again.
202    ///
203    /// TRACES: UR-002 | DR-012 | UT-014
204    #[test]
205    fn test_failed_migration_rolls_back_and_stays_retryable() {
206        let db = Database::open_in_memory().unwrap();
207
208        // Statement 1 succeeds, statement 2 fails, statement 3 never runs.
209        let poisoned = &[(
210            "900_partially_failing",
211            "ALTER TABLE downloads ADD COLUMN audit_a TEXT;
212             ALTER TABLE downloads ADD COLUMN audit_b TEXT FROM NOWHERE;
213             ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
214        )][..];
215
216        let first = db.migrate_with(poisoned).unwrap_err();
217
218        // Nothing from the batch may survive, or the retry cannot re-run it.
219        assert!(
220            !has_column(&db, "downloads", "audit_a"),
221            "statement 1 of a failed migration was left applied: the batch did not roll back"
222        );
223        assert!(!has_column(&db, "downloads", "audit_c"));
224
225        // And it must not be recorded as applied.
226        let recorded: i64 = db
227            .connection()
228            .lock_safe()
229            .query_row(
230                "SELECT COUNT(*) FROM _migrations WHERE name = ?1",
231                ["900_partially_failing"],
232                |r| r.get(0),
233            )
234            .unwrap();
235        assert_eq!(recorded, 0, "a failed migration must not be recorded");
236
237        // The retry must fail the same way it did the first time — reaching the
238        // real error — rather than tripping over its own leftovers.
239        let second = db.migrate_with(poisoned).unwrap_err();
240        assert!(
241            !second.to_string().contains("duplicate column"),
242            "the retry hit leftovers from the failed run instead of the real error: {second}"
243        );
244        assert_eq!(first.to_string(), second.to_string());
245
246        // A corrected migration under the same name then applies cleanly.
247        let fixed = &[(
248            "900_partially_failing",
249            "ALTER TABLE downloads ADD COLUMN audit_a TEXT;
250             ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
251        )][..];
252        db.migrate_with(fixed).unwrap();
253        assert!(has_column(&db, "downloads", "audit_a"));
254        assert!(has_column(&db, "downloads", "audit_c"));
255    }
256
257    /// A committed migration is recorded, so it is never applied twice.
258    ///
259    /// TRACES: UR-002 | DR-012 | UT-014
260    #[test]
261    fn test_successful_migration_is_recorded_in_the_same_transaction() {
262        let db = Database::open_in_memory().unwrap();
263        let m = &[(
264            "901_adds_a_column",
265            "ALTER TABLE downloads ADD COLUMN audit_d TEXT;",
266        )][..];
267
268        db.migrate_with(m).unwrap();
269        // Re-running must be a no-op, not a "duplicate column" failure.
270        db.migrate_with(m).unwrap();
271        assert!(has_column(&db, "downloads", "audit_d"));
272    }
273
274    fn has_column(db: &Database, table: &str, column: &str) -> bool {
275        let conn = db.connection();
276        let conn = conn.lock_safe();
277        let mut stmt = conn
278            .prepare(&format!("PRAGMA table_info({table})"))
279            .unwrap();
280        let mut names = stmt
281            .query_map([], |row| row.get::<_, String>(1))
282            .unwrap()
283            .filter_map(|r| r.ok());
284        names.any(|n| n == column)
285    }
286
287    #[test]
288    fn test_migrations_run() {
289        let db = Database::open_in_memory().unwrap();
290        let conn = db.connection();
291        let conn = conn.lock_safe();
292
293        // Check that tables exist
294        let mut stmt = conn
295            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")
296            .unwrap();
297        let exists: Option<String> = stmt.query_row([], |row: &rusqlite::Row| row.get(0)).ok();
298        assert!(exists.is_some());
299    }
300
301    #[test]
302    fn test_all_tables_created() {
303        let db = Database::open_in_memory().unwrap();
304        let conn = db.connection();
305        let conn = conn.lock_safe();
306
307        let expected_tables = [
308            "servers",
309            "users",
310            "libraries",
311            "items",
312            "media_streams",
313            "user_data",
314            "downloads",
315            "sync_queue",
316            "thumbnails",
317            "playlists",
318            "playlist_items",
319            "genres",
320        ];
321
322        for table in expected_tables {
323            let exists: Option<String> = conn
324                .query_row(
325                    "SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
326                    [table],
327                    |row: &rusqlite::Row| row.get(0),
328                )
329                .ok();
330            assert!(exists.is_some(), "Table '{}' should exist", table);
331        }
332    }
333
334    #[test]
335    fn test_fts_table_created() {
336        let db = Database::open_in_memory().unwrap();
337        let conn = db.connection();
338        let conn = conn.lock_safe();
339
340        let exists: Option<String> = conn
341            .query_row(
342                "SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
343                [],
344                |row: &rusqlite::Row| row.get(0),
345            )
346            .ok();
347        assert!(exists.is_some(), "FTS table 'items_fts' should exist");
348    }
349
350    #[test]
351    fn test_server_crud() {
352        let db = Database::open_in_memory().unwrap();
353        let conn = db.connection();
354        let conn = conn.lock_safe();
355
356        // Insert a server
357        conn.execute(
358            "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
359            params!["server1", "My Server", "http://localhost:8096", "10.8.0"],
360        )
361        .unwrap();
362
363        // Read it back
364        let (name, url): (String, String) = conn
365            .query_row(
366                "SELECT name, url FROM servers WHERE id = ?1",
367                ["server1"],
368                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
369            )
370            .unwrap();
371        assert_eq!(name, "My Server");
372        assert_eq!(url, "http://localhost:8096");
373
374        // Update it
375        conn.execute(
376            "UPDATE servers SET name = ?1 WHERE id = ?2",
377            params!["Updated Server", "server1"],
378        )
379        .unwrap();
380
381        let name: String = conn
382            .query_row(
383                "SELECT name FROM servers WHERE id = ?1",
384                ["server1"],
385                |row: &rusqlite::Row| row.get(0),
386            )
387            .unwrap();
388        assert_eq!(name, "Updated Server");
389
390        // Delete it
391        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
392            .unwrap();
393
394        let count: i32 = conn
395            .query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
396                row.get(0)
397            })
398            .unwrap();
399        assert_eq!(count, 0);
400    }
401
402    #[test]
403    fn test_user_crud() {
404        let db = Database::open_in_memory().unwrap();
405        let conn = db.connection();
406        let conn = conn.lock_safe();
407
408        // Create a server first (foreign key)
409        conn.execute(
410            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
411            params!["server1", "Test Server", "http://localhost:8096"],
412        )
413        .unwrap();
414
415        // Insert a user
416        conn.execute(
417            "INSERT INTO users (id, server_id, username, is_active)
418             VALUES (?1, ?2, ?3, ?4)",
419            params!["user1", "server1", "admin", 1],
420        )
421        .unwrap();
422
423        // Read it back
424        let (username, is_active): (String, i32) = conn
425            .query_row(
426                "SELECT username, is_active FROM users WHERE id = ?1",
427                ["user1"],
428                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
429            )
430            .unwrap();
431        assert_eq!(username, "admin");
432        assert_eq!(is_active, 1);
433
434        // Update is_active
435        conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
436            .unwrap();
437
438        let is_active: i32 = conn
439            .query_row(
440                "SELECT is_active FROM users WHERE id = ?1",
441                ["user1"],
442                |row: &rusqlite::Row| row.get(0),
443            )
444            .unwrap();
445        assert_eq!(is_active, 0);
446    }
447
448    #[test]
449    fn test_cascade_delete_server_removes_users() {
450        let db = Database::open_in_memory().unwrap();
451        let conn = db.connection();
452        let conn = conn.lock_safe();
453
454        // Create server and user
455        conn.execute(
456            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
457            params!["server1", "Test Server", "http://localhost:8096"],
458        )
459        .unwrap();
460
461        conn.execute(
462            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
463            params!["user1", "server1", "admin"],
464        )
465        .unwrap();
466
467        // Verify user exists
468        let count: i32 = conn
469            .query_row(
470                "SELECT COUNT(*) FROM users WHERE server_id = ?1",
471                ["server1"],
472                |row: &rusqlite::Row| row.get(0),
473            )
474            .unwrap();
475        assert_eq!(count, 1);
476
477        // Delete server
478        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
479            .unwrap();
480
481        // User should be deleted via CASCADE
482        let count: i32 = conn
483            .query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
484                row.get(0)
485            })
486            .unwrap();
487        assert_eq!(count, 0);
488    }
489
490    #[test]
491    fn test_item_insert_and_fts_search() {
492        let db = Database::open_in_memory().unwrap();
493        let conn = db.connection();
494        let conn = conn.lock_safe();
495
496        // Create server first
497        conn.execute(
498            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
499            params!["server1", "Test Server", "http://localhost:8096"],
500        )
501        .unwrap();
502
503        // Insert an item
504        conn.execute(
505            "INSERT INTO items (id, server_id, name, item_type, overview, album_name, artists)
506             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
507            params![
508                "item1",
509                "server1",
510                "Bohemian Rhapsody",
511                "Audio",
512                "A legendary rock song",
513                "A Night at the Opera",
514                "[\"Queen\"]"
515            ],
516        )
517        .unwrap();
518
519        // Search via FTS
520        let mut stmt = conn
521            .prepare(
522                "SELECT i.name FROM items i
523                 JOIN items_fts ON i.rowid = items_fts.rowid
524                 WHERE items_fts MATCH ?1",
525            )
526            .unwrap();
527
528        // Search by song name
529        let result: Option<String> = stmt
530            .query_row(["Bohemian"], |row: &rusqlite::Row| row.get(0))
531            .ok();
532        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
533
534        // Search by album name
535        let result: Option<String> = stmt
536            .query_row(["Opera"], |row: &rusqlite::Row| row.get(0))
537            .ok();
538        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
539
540        // Search by artist
541        let result: Option<String> = stmt
542            .query_row(["Queen"], |row: &rusqlite::Row| row.get(0))
543            .ok();
544        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
545    }
546
547    #[test]
548    fn test_user_data_playback_position() {
549        let db = Database::open_in_memory().unwrap();
550        let conn = db.connection();
551        let conn = conn.lock_safe();
552
553        // Setup: server, user, item
554        conn.execute(
555            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
556            params!["server1", "Test", "http://localhost"],
557        )
558        .unwrap();
559
560        conn.execute(
561            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
562            params!["user1", "server1", "admin"],
563        )
564        .unwrap();
565
566        conn.execute(
567            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
568            params!["item1", "server1", "Test Movie", "Movie"],
569        )
570        .unwrap();
571
572        // Insert user data with playback position
573        conn.execute(
574            "INSERT INTO user_data (user_id, item_id, playback_position_ticks, is_played)
575             VALUES (?1, ?2, ?3, ?4)",
576            params!["user1", "item1", 12345678900_i64, 0],
577        )
578        .unwrap();
579
580        // Read back
581        let (position, is_played): (i64, i32) = conn
582            .query_row(
583                "SELECT playback_position_ticks, is_played FROM user_data
584                 WHERE user_id = ?1 AND item_id = ?2",
585                ["user1", "item1"],
586                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
587            )
588            .unwrap();
589        assert_eq!(position, 12345678900);
590        assert_eq!(is_played, 0);
591
592        // Update to mark as played
593        conn.execute(
594            "UPDATE user_data SET is_played = 1, playback_position_ticks = 0
595             WHERE user_id = ?1 AND item_id = ?2",
596            ["user1", "item1"],
597        )
598        .unwrap();
599
600        let is_played: i32 = conn
601            .query_row(
602                "SELECT is_played FROM user_data WHERE user_id = ?1 AND item_id = ?2",
603                ["user1", "item1"],
604                |row: &rusqlite::Row| row.get(0),
605            )
606            .unwrap();
607        assert_eq!(is_played, 1);
608    }
609
610    #[test]
611    fn test_sync_queue_operations() {
612        let db = Database::open_in_memory().unwrap();
613        let conn = db.connection();
614        let conn = conn.lock_safe();
615
616        // Setup
617        conn.execute(
618            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
619            params!["server1", "Test", "http://localhost"],
620        )
621        .unwrap();
622
623        conn.execute(
624            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
625            params!["user1", "server1", "admin"],
626        )
627        .unwrap();
628
629        // Queue a sync operation
630        conn.execute(
631            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status)
632             VALUES (?1, ?2, ?3, ?4, ?5)",
633            params![
634                "user1",
635                "mark_favorite",
636                "item123",
637                r#"{"favorite": true}"#,
638                "pending"
639            ],
640        )
641        .unwrap();
642
643        // Get pending operations
644        let mut stmt = conn
645            .prepare("SELECT operation, item_id FROM sync_queue WHERE status = 'pending'")
646            .unwrap();
647
648        let ops: Vec<(String, String)> = stmt
649            .query_map([], |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)))
650            .unwrap()
651            .filter_map(|r| r.ok())
652            .collect();
653
654        assert_eq!(ops.len(), 1);
655        assert_eq!(ops[0].0, "mark_favorite");
656        assert_eq!(ops[0].1, "item123");
657
658        // Mark as completed
659        conn.execute(
660            "UPDATE sync_queue SET status = 'completed' WHERE item_id = ?1",
661            ["item123"],
662        )
663        .unwrap();
664
665        let pending_count: i32 = conn
666            .query_row(
667                "SELECT COUNT(*) FROM sync_queue WHERE status = 'pending'",
668                [],
669                |row: &rusqlite::Row| row.get(0),
670            )
671            .unwrap();
672        assert_eq!(pending_count, 0);
673    }
674
675    #[test]
676    fn test_downloads_table() {
677        let db = Database::open_in_memory().unwrap();
678        let conn = db.connection();
679        let conn = conn.lock_safe();
680
681        // Setup
682        conn.execute(
683            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
684            params!["server1", "Test", "http://localhost"],
685        )
686        .unwrap();
687
688        conn.execute(
689            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
690            params!["user1", "server1", "admin"],
691        )
692        .unwrap();
693
694        conn.execute(
695            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
696            params!["item1", "server1", "Test Song", "Audio"],
697        )
698        .unwrap();
699
700        // Queue a download
701        conn.execute(
702            "INSERT INTO downloads (item_id, user_id, file_path, status, progress)
703             VALUES (?1, ?2, ?3, ?4, ?5)",
704            params!["item1", "user1", "/data/downloads/test.mp3", "pending", 0.0],
705        )
706        .unwrap();
707
708        // Update progress
709        conn.execute(
710            "UPDATE downloads SET status = 'downloading', progress = 0.5
711             WHERE item_id = ?1",
712            ["item1"],
713        )
714        .unwrap();
715
716        let (status, progress): (String, f64) = conn
717            .query_row(
718                "SELECT status, progress FROM downloads WHERE item_id = ?1",
719                ["item1"],
720                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
721            )
722            .unwrap();
723        assert_eq!(status, "downloading");
724        assert!((progress - 0.5).abs() < 0.001);
725
726        // Complete download
727        conn.execute(
728            "UPDATE downloads SET status = 'completed', progress = 1.0
729             WHERE item_id = ?1",
730            ["item1"],
731        )
732        .unwrap();
733
734        let status: String = conn
735            .query_row(
736                "SELECT status FROM downloads WHERE item_id = ?1",
737                ["item1"],
738                |row: &rusqlite::Row| row.get(0),
739            )
740            .unwrap();
741        assert_eq!(status, "completed");
742    }
743
744    #[test]
745    fn test_migrations_idempotent() {
746        let db = Database::open_in_memory().unwrap();
747
748        // Run migrations again - should not fail
749        let result = db.migrate();
750        assert!(result.is_ok());
751
752        // Tables should still exist
753        let conn = db.connection();
754        let conn = conn.lock_safe();
755
756        let count: i32 = conn
757            .query_row(
758                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'",
759                [],
760                |row: &rusqlite::Row| row.get(0),
761            )
762            .unwrap();
763        assert_eq!(count, 1);
764    }
765
766    #[test]
767    fn test_global_active_user_deactivation() {
768        let db = Database::open_in_memory().unwrap();
769        let conn = db.connection();
770        let conn = conn.lock_safe();
771
772        // Create two servers
773        conn.execute(
774            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
775            params!["server1", "Server 1", "http://server1.com"],
776        )
777        .unwrap();
778
779        conn.execute(
780            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
781            params!["server2", "Server 2", "http://server2.com"],
782        )
783        .unwrap();
784
785        // Create users on different servers
786        conn.execute(
787            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
788             VALUES (?1, ?2, ?3, 1, '2024-01-01 10:00:00')",
789            params!["user1", "server1", "admin"],
790        )
791        .unwrap();
792
793        conn.execute(
794            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
795             VALUES (?1, ?2, ?3, 1, '2024-01-01 11:00:00')",
796            params!["user2", "server2", "admin"],
797        )
798        .unwrap();
799
800        // Initially both users are active (simulating the old bug)
801        let active_count: i32 = conn
802            .query_row(
803                "SELECT COUNT(*) FROM users WHERE is_active = 1",
804                [],
805                |row: &rusqlite::Row| row.get(0),
806            )
807            .unwrap();
808        assert_eq!(active_count, 2);
809
810        // Now simulate setting user1 as active (global deactivation)
811        conn.execute("UPDATE users SET is_active = 0", []).unwrap();
812        conn.execute(
813            "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
814            ["user1"],
815        )
816        .unwrap();
817
818        // Only one user should be active now
819        let active_count: i32 = conn
820            .query_row(
821                "SELECT COUNT(*) FROM users WHERE is_active = 1",
822                [],
823                |row: &rusqlite::Row| row.get(0),
824            )
825            .unwrap();
826        assert_eq!(active_count, 1);
827
828        // And it should be user1
829        let active_user: String = conn
830            .query_row(
831                "SELECT id FROM users WHERE is_active = 1",
832                [],
833                |row: &rusqlite::Row| row.get(0),
834            )
835            .unwrap();
836        assert_eq!(active_user, "user1");
837    }
838
839    #[test]
840    fn test_active_session_query_ordering() {
841        let db = Database::open_in_memory().unwrap();
842        let conn = db.connection();
843        let conn = conn.lock_safe();
844
845        // Create server
846        conn.execute(
847            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
848            params!["server1", "Test Server", "http://localhost:8096"],
849        )
850        .unwrap();
851
852        // Create users with different login times
853        conn.execute(
854            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
855             VALUES (?1, ?2, ?3, 0, '2024-01-01 10:00:00')",
856            params!["user1", "server1", "old_user"],
857        )
858        .unwrap();
859
860        conn.execute(
861            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
862             VALUES (?1, ?2, ?3, 1, '2024-01-01 12:00:00')",
863            params!["user2", "server1", "recent_user"],
864        )
865        .unwrap();
866
867        // Query for active user ordered by last_login_at DESC (simulating storage_get_active_session)
868        let (user_id, username): (String, String) = conn
869            .query_row(
870                "SELECT u.id, u.username FROM users u
871                 JOIN servers s ON u.server_id = s.id
872                 WHERE u.is_active = 1
873                 ORDER BY u.last_login_at DESC
874                 LIMIT 1",
875                [],
876                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
877            )
878            .unwrap();
879
880        assert_eq!(user_id, "user2");
881        assert_eq!(username, "recent_user");
882    }
883}