experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

store: add SqliteStore tests, fix placeholder on rehydration

karitham (Jun 1, 2026, 2:03 PM +0200) 1c02355b 2f22c554

+363 -9
+363 -9
src/store.rs
··· 92 92 /// Retrieve the most recent chat messages for a room, newest first. 93 93 #[allow(dead_code)] 94 94 async fn recent_chat(&self, room_id: &str, limit: i64) 95 - -> Result<Vec<ChatMessage>, sqlx::Error>; 95 + -> Result<Vec<ChatMessage>, sqlx::Error>; 96 96 97 97 /// Delete a room and all its associated data (CASCADE). 98 98 #[allow(dead_code)] ··· 756 756 }; 757 757 758 758 // 2. Queue: not played, not started, ordered by position. 759 - // JOIN media_cache for resolved metadata. 759 + // JOIN media_cache for resolved metadata. NULLIF coerces the 760 + // default empty strings (from media_cache's schema defaults) 761 + // to NULL so the COALESCE fallback kicks in for pending 762 + // entries. 760 763 let queue_rows: Vec<TrackRow> = sqlx::query_as( 761 764 "SELECT t.id, t.url, t.played_at, 762 - COALESCE(m.title, 'Loading...') AS title, 763 - COALESCE(m.duration, '--:--') AS duration, 765 + COALESCE(NULLIF(m.title, ''), 'Loading...') AS title, 766 + COALESCE(NULLIF(m.duration, ''), '--:--') AS duration, 764 767 m.thumbnail, m.state 765 768 FROM tracks t 766 769 LEFT JOIN media_cache m ON m.url = t.url ··· 774 777 // 3. Active: not played, started (at most one). 775 778 let active_row: Option<TrackRow> = sqlx::query_as( 776 779 "SELECT t.id, t.url, t.played_at, 777 - COALESCE(m.title, 'Loading...') AS title, 778 - COALESCE(m.duration, '--:--') AS duration, 780 + COALESCE(NULLIF(m.title, ''), 'Loading...') AS title, 781 + COALESCE(NULLIF(m.duration, ''), '--:--') AS duration, 779 782 m.thumbnail, m.state 780 783 FROM tracks t 781 784 LEFT JOIN media_cache m ON m.url = t.url ··· 814 817 .fetch_all(&self.pool) 815 818 .await?; 816 819 817 - // 6. Playlist entries with media_cache metadata. 820 + // 6. Playlist entries with media_cache metadata. NULLIF coerces 821 + // the default empty strings (from media_cache's schema defaults) 822 + // to NULL so the COALESCE fallback kicks in for pending entries. 818 823 let playlist_rows: Vec<PlaylistRow> = sqlx::query_as( 819 824 "SELECT p.id, p.url, p.added_by, p.added_at, 820 - COALESCE(m.title, 'Loading...') AS title, 821 - COALESCE(m.duration, '--:--') AS duration, 825 + COALESCE(NULLIF(m.title, ''), 'Loading...') AS title, 826 + COALESCE(NULLIF(m.duration, ''), '--:--') AS duration, 822 827 m.thumbnail, m.source, m.state 823 828 FROM playlist_entries p 824 829 LEFT JOIN media_cache m ON m.url = p.url ··· 1320 1325 assert_eq!(recent.len(), 3); 1321 1326 assert_eq!(recent[0].content, "msg 4"); 1322 1327 assert_eq!(recent[2].content, "msg 2"); 1328 + } 1329 + 1330 + // ============================================================ 1331 + // SqliteStore tests. These exercise the actual SQL paths for the 1332 + // newer effects (ReorderQueue, RemoveQueuedTrack, AddPlaylistEntry, 1333 + // RemovePlaylistEntry) and verify the on-disk state survives a 1334 + // re-load. InMemoryStore tests above already cover the queue/active 1335 + // /history round-trip; SqliteStore catches SQL bugs that the 1336 + // in-memory impl can't (scoping to played=0, started_at_ms IS NULL, 1337 + // referential integrity via foreign keys, etc.). 1338 + // ============================================================ 1339 + 1340 + async fn sqlite_test_store() -> SqliteStore { 1341 + use sqlx::sqlite::SqlitePoolOptions; 1342 + let pool = SqlitePoolOptions::new() 1343 + .max_connections(1) 1344 + .connect("sqlite::memory:") 1345 + .await 1346 + .unwrap(); 1347 + SqliteStore::new(pool).await.unwrap() 1348 + } 1349 + 1350 + fn queued_track(id: u128, url: &str, title: &str) -> (QueuedTrack, Effect) { 1351 + let t = QueuedTrack { 1352 + id: tid(id), 1353 + title: title.into(), 1354 + url: url.into(), 1355 + duration: "5:00".into(), 1356 + thumbnail: None, 1357 + pending: false, 1358 + }; 1359 + (t.clone(), Effect::PersistQueuedTrack(t)) 1360 + } 1361 + 1362 + #[tokio::test] 1363 + async fn sqlite_reorder_queue_renumbers_positions() { 1364 + let store = sqlite_test_store().await; 1365 + store.create_room("r", "R").await.unwrap(); 1366 + 1367 + for (i, url) in ["a", "b", "c", "d"].iter().enumerate() { 1368 + let (_, eff) = queued_track(i as u128 + 1, &format!("u/{url}"), url); 1369 + store.persist("r", &[eff]).await.unwrap(); 1370 + } 1371 + // Sanity: initial load returns them in insertion order. 1372 + let snap = store.load("r").await.unwrap().unwrap(); 1373 + let ids: Vec<_> = snap.queue.iter().map(|t| t.id).collect(); 1374 + assert_eq!(ids, vec![tid(1), tid(2), tid(3), tid(4)]); 1375 + 1376 + // Move first to last (c, d, a, b). 1377 + store 1378 + .persist( 1379 + "r", 1380 + &[Effect::ReorderQueue { 1381 + new_order: vec![tid(3), tid(4), tid(1), tid(2)], 1382 + }], 1383 + ) 1384 + .await 1385 + .unwrap(); 1386 + 1387 + let snap = store.load("r").await.unwrap().unwrap(); 1388 + let ids: Vec<_> = snap.queue.iter().map(|t| t.id).collect(); 1389 + assert_eq!( 1390 + ids, 1391 + vec![tid(3), tid(4), tid(1), tid(2)], 1392 + "ReorderQueue must renumber so load returns new_order" 1393 + ); 1394 + } 1395 + 1396 + #[tokio::test] 1397 + async fn sqlite_reorder_queue_does_not_touch_active_or_history() { 1398 + let store = sqlite_test_store().await; 1399 + store.create_room("r", "R").await.unwrap(); 1400 + 1401 + // Queue then start one track to make it active, then queue three more. 1402 + let (_, a) = queued_track(100, "u/active", "active"); 1403 + store.persist("r", &[a]).await.unwrap(); 1404 + store 1405 + .persist("r", &[Effect::PersistStarted(tid(100))]) 1406 + .await 1407 + .unwrap(); 1408 + for (i, url) in ["x", "y", "z"].iter().enumerate() { 1409 + let (_, q) = queued_track(i as u128 + 1, &format!("u/{url}"), url); 1410 + store.persist("r", &[q]).await.unwrap(); 1411 + } 1412 + 1413 + // Reorder the queue. The active track (id 100) should be left alone. 1414 + store 1415 + .persist( 1416 + "r", 1417 + &[Effect::ReorderQueue { 1418 + new_order: vec![tid(3), tid(1), tid(2)], 1419 + }], 1420 + ) 1421 + .await 1422 + .unwrap(); 1423 + 1424 + let snap = store.load("r").await.unwrap().unwrap(); 1425 + let active_id = snap.active.as_ref().map(|t| t.id); 1426 + assert_eq!(active_id, Some(tid(100)), "active track must be unchanged"); 1427 + let queue_ids: Vec<_> = snap.queue.iter().map(|t| t.id).collect(); 1428 + assert_eq!(queue_ids, vec![tid(3), tid(1), tid(2)]); 1429 + } 1430 + 1431 + #[tokio::test] 1432 + async fn sqlite_reorder_queue_with_id_not_in_room_is_a_noop() { 1433 + // The store's UPDATE is scoped to the current room; a stale 1434 + // track id from a different room should be silently skipped. 1435 + let store = sqlite_test_store().await; 1436 + store.create_room("r1", "R1").await.unwrap(); 1437 + store.create_room("r2", "R2").await.unwrap(); 1438 + 1439 + let (_, a) = queued_track(1, "u/a", "a"); 1440 + store.persist("r1", &[a]).await.unwrap(); 1441 + let (_, b) = queued_track(2, "u/b", "b"); 1442 + store.persist("r2", &[b]).await.unwrap(); 1443 + 1444 + // Apply a reorder on r1 that includes r2's track id. r1's 1445 + // queue should stay intact. 1446 + store 1447 + .persist( 1448 + "r1", 1449 + &[Effect::ReorderQueue { 1450 + new_order: vec![tid(2), tid(1)], 1451 + }], 1452 + ) 1453 + .await 1454 + .unwrap(); 1455 + 1456 + let snap1 = store.load("r1").await.unwrap().unwrap(); 1457 + assert_eq!(snap1.queue.len(), 1); 1458 + assert_eq!(snap1.queue[0].id, tid(1)); 1459 + // r2 untouched. 1460 + let snap2 = store.load("r2").await.unwrap().unwrap(); 1461 + assert_eq!(snap2.queue[0].id, tid(2)); 1462 + } 1463 + 1464 + #[tokio::test] 1465 + async fn sqlite_remove_queued_track_deletes_row() { 1466 + let store = sqlite_test_store().await; 1467 + store.create_room("r", "R").await.unwrap(); 1468 + 1469 + for (i, url) in ["a", "b", "c"].iter().enumerate() { 1470 + let (_, q) = queued_track(i as u128 + 1, &format!("u/{url}"), url); 1471 + store.persist("r", &[q]).await.unwrap(); 1472 + } 1473 + 1474 + store 1475 + .persist( 1476 + "r", 1477 + &[ 1478 + Effect::RemoveQueuedTrack { track_id: tid(2) }, 1479 + Effect::ReorderQueue { 1480 + new_order: vec![tid(1), tid(3)], 1481 + }, 1482 + ], 1483 + ) 1484 + .await 1485 + .unwrap(); 1486 + 1487 + let snap = store.load("r").await.unwrap().unwrap(); 1488 + let ids: Vec<_> = snap.queue.iter().map(|t| t.id).collect(); 1489 + assert_eq!(ids, vec![tid(1), tid(3)]); 1490 + } 1491 + 1492 + #[tokio::test] 1493 + async fn sqlite_remove_queued_track_does_not_touch_active() { 1494 + let store = sqlite_test_store().await; 1495 + store.create_room("r", "R").await.unwrap(); 1496 + 1497 + let (_, a) = queued_track(100, "u/active", "active"); 1498 + store.persist("r", &[a]).await.unwrap(); 1499 + store 1500 + .persist("r", &[Effect::PersistStarted(tid(100))]) 1501 + .await 1502 + .unwrap(); 1503 + let (_, q) = queued_track(1, "u/q", "q"); 1504 + store.persist("r", &[q]).await.unwrap(); 1505 + 1506 + // Try to remove the active track id via RemoveQueuedTrack — 1507 + // should be a no-op because the SQL scopes to queued rows. 1508 + store 1509 + .persist("r", &[Effect::RemoveQueuedTrack { track_id: tid(100) }]) 1510 + .await 1511 + .unwrap(); 1512 + 1513 + let snap = store.load("r").await.unwrap().unwrap(); 1514 + assert_eq!(snap.active.as_ref().unwrap().id, tid(100)); 1515 + assert_eq!(snap.queue.len(), 1); 1516 + } 1517 + 1518 + #[tokio::test] 1519 + async fn sqlite_add_playlist_entry_persists_and_appears_in_load() { 1520 + let store = sqlite_test_store().await; 1521 + store.create_room("r", "R").await.unwrap(); 1522 + 1523 + let entry_id = PlaylistEntryId(uuid::Uuid::from_u128(1)); 1524 + store 1525 + .persist( 1526 + "r", 1527 + &[Effect::AddPlaylistEntry { 1528 + id: entry_id, 1529 + url: "https://example.com/x".into(), 1530 + title: "Loading...".into(), 1531 + duration: "--:--".into(), 1532 + thumbnail: None, 1533 + source: "ytdlp".into(), 1534 + }], 1535 + ) 1536 + .await 1537 + .unwrap(); 1538 + 1539 + let snap = store.load("r").await.unwrap().unwrap(); 1540 + assert_eq!(snap.playlist.len(), 1); 1541 + assert_eq!(snap.playlist[0].id, entry_id); 1542 + assert_eq!(snap.playlist[0].url, "https://example.com/x"); 1543 + // Without a matching media_cache row, the load shows 1544 + // 'Loading...' / pending=true via the LEFT JOIN. 1545 + assert_eq!(snap.playlist[0].title, "Loading..."); 1546 + assert!(snap.playlist[0].pending); 1547 + } 1548 + 1549 + #[tokio::test] 1550 + async fn sqlite_add_playlist_entry_picks_up_resolved_metadata() { 1551 + // Add the playlist entry first (this seeds media_cache with 1552 + // defaults), then PersistMetadata updates the same row to the 1553 + // resolved state. The load should pick up the resolved title. 1554 + let store = sqlite_test_store().await; 1555 + store.create_room("r", "R").await.unwrap(); 1556 + 1557 + let entry_id = PlaylistEntryId(uuid::Uuid::from_u128(1)); 1558 + store 1559 + .persist( 1560 + "r", 1561 + &[Effect::AddPlaylistEntry { 1562 + id: entry_id, 1563 + url: "https://example.com/x".into(), 1564 + title: "Loading...".into(), 1565 + duration: "--:--".into(), 1566 + thumbnail: None, 1567 + source: "ytdlp".into(), 1568 + }], 1569 + ) 1570 + .await 1571 + .unwrap(); 1572 + 1573 + store 1574 + .persist( 1575 + "r", 1576 + &[Effect::PersistMetadata { 1577 + item_id: TrackId::nil(), 1578 + url: "https://example.com/x".into(), 1579 + title: "Real Title".into(), 1580 + duration: "4:20".into(), 1581 + thumbnail: Some("https://img/x.jpg".into()), 1582 + source: "ytdlp".into(), 1583 + }], 1584 + ) 1585 + .await 1586 + .unwrap(); 1587 + 1588 + let snap = store.load("r").await.unwrap().unwrap(); 1589 + assert_eq!(snap.playlist.len(), 1); 1590 + let entry = &snap.playlist[0]; 1591 + assert_eq!(entry.title, "Real Title"); 1592 + assert_eq!(entry.duration, "4:20"); 1593 + assert_eq!(entry.thumbnail, Some("https://img/x.jpg".into())); 1594 + assert!(!entry.pending); 1595 + } 1596 + 1597 + #[tokio::test] 1598 + async fn sqlite_remove_playlist_entry_deletes_row() { 1599 + let store = sqlite_test_store().await; 1600 + store.create_room("r", "R").await.unwrap(); 1601 + 1602 + let id_a = PlaylistEntryId(uuid::Uuid::from_u128(1)); 1603 + let id_b = PlaylistEntryId(uuid::Uuid::from_u128(2)); 1604 + for id in [id_a, id_b] { 1605 + store 1606 + .persist( 1607 + "r", 1608 + &[Effect::AddPlaylistEntry { 1609 + id, 1610 + url: format!("u/{id}", id = id.as_str_buf()), 1611 + title: "t".into(), 1612 + duration: "--:--".into(), 1613 + thumbnail: None, 1614 + source: "ytdlp".into(), 1615 + }], 1616 + ) 1617 + .await 1618 + .unwrap(); 1619 + } 1620 + assert_eq!(store.load("r").await.unwrap().unwrap().playlist.len(), 2); 1621 + 1622 + store 1623 + .persist("r", &[Effect::RemovePlaylistEntry(id_a)]) 1624 + .await 1625 + .unwrap(); 1626 + 1627 + let snap = store.load("r").await.unwrap().unwrap(); 1628 + assert_eq!(snap.playlist.len(), 1); 1629 + assert_eq!(snap.playlist[0].id, id_b); 1630 + } 1631 + 1632 + #[tokio::test] 1633 + async fn sqlite_cascade_delete_room_removes_tracks_and_playlist() { 1634 + // foreign_keys=ON, ON DELETE CASCADE — deleting a room must 1635 + // remove its tracks, playlist, and chat messages. 1636 + let store = sqlite_test_store().await; 1637 + store.create_room("r", "R").await.unwrap(); 1638 + let (_, q) = queued_track(1, "u/a", "a"); 1639 + store.persist("r", &[q]).await.unwrap(); 1640 + let entry_id = PlaylistEntryId(uuid::Uuid::from_u128(1)); 1641 + store 1642 + .persist( 1643 + "r", 1644 + &[Effect::AddPlaylistEntry { 1645 + id: entry_id, 1646 + url: "u/a".into(), 1647 + title: "t".into(), 1648 + duration: "--:--".into(), 1649 + thumbnail: None, 1650 + source: "ytdlp".into(), 1651 + }], 1652 + ) 1653 + .await 1654 + .unwrap(); 1655 + 1656 + store.delete_room("r").await.unwrap(); 1657 + assert!(store.load("r").await.unwrap().is_none()); 1658 + } 1659 + 1660 + #[tokio::test] 1661 + async fn sqlite_queued_track_loads_placeholder_when_unresolved() { 1662 + // Verifies the NULLIF/COALESCE fix: a queued track whose URL 1663 + // has a default media_cache row (title='', state='pending') 1664 + // should load as 'Loading...' / '--:--' / pending=true. Before 1665 + // the fix, this returned '' / '' / pending=true. 1666 + let store = sqlite_test_store().await; 1667 + store.create_room("r", "R").await.unwrap(); 1668 + let (_, q) = queued_track(1, "https://example.com/new", "Loading..."); 1669 + store.persist("r", &[q]).await.unwrap(); 1670 + 1671 + let snap = store.load("r").await.unwrap().unwrap(); 1672 + assert_eq!(snap.queue.len(), 1); 1673 + let t = &snap.queue[0]; 1674 + assert_eq!(t.title, "Loading..."); 1675 + assert_eq!(t.duration, "--:--"); 1676 + assert!(t.pending); 1323 1677 } 1324 1678 }