Updated readme
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
use super::*;
|
||||
|
||||
static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn test_sync() -> DeviceSync {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
init_schema(&conn).unwrap();
|
||||
let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let library_path = std::env::temp_dir().join(format!(
|
||||
"furumi-devices-test-{}-{}-{}.sqlite3",
|
||||
std::process::id(),
|
||||
now_ms(),
|
||||
unique
|
||||
));
|
||||
let sync = DeviceSync {
|
||||
conn: Arc::new(std::sync::Mutex::new(conn)),
|
||||
library: Arc::new(Library::open(&library_path).unwrap()),
|
||||
event_tx: Arc::new(std::sync::Mutex::new(None)),
|
||||
playback: Arc::new(std::sync::Mutex::new(PlaybackShared::default())),
|
||||
};
|
||||
sync.ensure_identity().unwrap();
|
||||
sync
|
||||
}
|
||||
|
||||
fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool {
|
||||
let conn = lock(&sync.conn);
|
||||
conn.query_row(
|
||||
"SELECT revoked_at_ms IS NOT NULL
|
||||
FROM sync_devices
|
||||
WHERE device_id = ?1",
|
||||
[device_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
}
|
||||
|
||||
fn device_known(sync: &DeviceSync, device_id: &str) -> bool {
|
||||
let conn = lock(&sync.conn);
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM sync_devices WHERE device_id = ?1",
|
||||
[device_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn test_fed_track(content_id: &str) -> crate::federation::FedTrack {
|
||||
crate::federation::FedTrack {
|
||||
item_id: "fed_item_1".to_string(),
|
||||
owner: "fed_owner_1".to_string(),
|
||||
own: false,
|
||||
title: "Remote Song".to_string(),
|
||||
artist_names: vec!["Remote Artist".to_string()],
|
||||
featured_artist_names: Vec::new(),
|
||||
year: Some(2026),
|
||||
duration_seconds: Some(123),
|
||||
content_id: Some(content_id.to_string()),
|
||||
release_title: Some("Remote Release".to_string()),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64url_round_trip_without_padding() {
|
||||
for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] {
|
||||
let encoded = base64url_encode(input);
|
||||
assert!(!encoded.contains('='));
|
||||
assert_eq!(base64url_decode(&encoded).unwrap(), input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_detection() {
|
||||
assert!(
|
||||
SyncOpPayload::TrackLikeSet {
|
||||
content_id: "b3:0".into(),
|
||||
liked: false,
|
||||
fed: None,
|
||||
}
|
||||
.is_tombstone()
|
||||
);
|
||||
assert!(
|
||||
!SyncOpPayload::TrackLikeSet {
|
||||
content_id: "b3:0".into(),
|
||||
liked: true,
|
||||
fed: None,
|
||||
}
|
||||
.is_tombstone()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_tracks_do_not_sync_device_local_paths() {
|
||||
let source = TrackItem {
|
||||
id: 7,
|
||||
title: "Local Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
duration_seconds: 180.0,
|
||||
artists: vec![ArtistRef {
|
||||
id: 1,
|
||||
name: "Local Artist".to_string(),
|
||||
}],
|
||||
featured_artists: Vec::new(),
|
||||
release_id: 2,
|
||||
release_title: "Local Release".to_string(),
|
||||
release_year: Some(2026),
|
||||
file_path: r"C:\Users\me\Music\song.mp3".to_string(),
|
||||
content_id: Some(format!("b3:{}", "a".repeat(64))),
|
||||
cover_path: None,
|
||||
audio_format: Some("mp3".to_string()),
|
||||
audio_bitrate: Some(320),
|
||||
audio_sample_rate: Some(44_100),
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: Some(123_456),
|
||||
play_count: 3,
|
||||
fed: None,
|
||||
};
|
||||
|
||||
let wire = PlaybackTrack::from_track(&source);
|
||||
assert!(wire.file_path.is_empty());
|
||||
|
||||
let mut legacy_wire = wire.clone();
|
||||
legacy_wire.file_path = "/Users/me/Music/song.mp3".to_string();
|
||||
let restored = legacy_wire.to_track_item();
|
||||
assert!(restored.id < 0);
|
||||
assert_ne!(restored.id, source.id);
|
||||
assert!(restored.file_path.is_empty());
|
||||
assert_eq!(restored.content_id, source.content_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacted_device_revoke_removes_device_row() {
|
||||
let sync = test_sync();
|
||||
let device_id = "dev_old";
|
||||
|
||||
sync.apply_device_trusted(device_id, 10).unwrap();
|
||||
assert!(device_known(&sync, device_id));
|
||||
|
||||
sync.revoke_device(device_id).unwrap();
|
||||
assert!(!device_known(&sync, device_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_group_self_revokes_then_resets_to_new_group() {
|
||||
let sync = test_sync();
|
||||
let identity = sync.ensure_identity().unwrap();
|
||||
let old_group = identity.group_id.clone();
|
||||
sync.apply_device_trusted("dev_peer", 10).unwrap();
|
||||
|
||||
let op_id = sync.record_leave_group_revoke().unwrap();
|
||||
assert!(device_revoked(&sync, &identity.device_id));
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let payload_json: String = conn
|
||||
.query_row(
|
||||
"SELECT payload_json FROM sync_ops WHERE op_id = ?1",
|
||||
[&op_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let payload: SyncOpPayload = serde_json::from_str(&payload_json).unwrap();
|
||||
match payload {
|
||||
SyncOpPayload::DeviceRevoked {
|
||||
target_device_id,
|
||||
target_max_seq_seen,
|
||||
} => {
|
||||
assert_eq!(target_device_id, identity.device_id);
|
||||
assert_eq!(target_max_seq_seen, 1);
|
||||
}
|
||||
other => panic!("unexpected payload: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let new_group = sync.finish_leave_group_reset().unwrap();
|
||||
assert_ne!(old_group, new_group);
|
||||
let status = sync.status();
|
||||
assert_eq!(status.group_id, new_group);
|
||||
assert_eq!(status.active_devices, 1);
|
||||
assert_eq!(status.devices.len(), 1);
|
||||
assert!(status.devices[0].is_self);
|
||||
assert!(!status.devices[0].revoked);
|
||||
assert_eq!(status.ops_total, 0);
|
||||
assert_eq!(status.outbox_ops, 0);
|
||||
assert!(!device_known(&sync, "dev_peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_command_is_targeted_and_deduplicated() {
|
||||
let sync = test_sync();
|
||||
let identity = sync.ensure_identity().unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
sync.set_event_tx(tx);
|
||||
let command = PlaybackCommand::SetState {
|
||||
state: PlaybackStateWire {
|
||||
queue: Vec::new(),
|
||||
queue_pos: 0,
|
||||
playing: false,
|
||||
paused: false,
|
||||
idle_since_ms: None,
|
||||
position_secs: 0.0,
|
||||
volume: 42,
|
||||
shuffle: false,
|
||||
repeat: PlaybackRepeat::Off,
|
||||
},
|
||||
seek: false,
|
||||
};
|
||||
|
||||
sync.apply_playback_command("dev_other", &command, "op_other")
|
||||
.unwrap();
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
sync.apply_playback_command(&identity.device_id, &command, "op_1")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
rx.try_recv().unwrap(),
|
||||
crate::app::event::AppEvent::PlaybackCommand(_)
|
||||
));
|
||||
|
||||
sync.apply_playback_command(&identity.device_id, &command, "op_1")
|
||||
.unwrap();
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_device_trust_reactivates_revoked_device() {
|
||||
let sync = test_sync();
|
||||
let device_id = "dev_readd";
|
||||
|
||||
sync.apply_device_trusted(device_id, 10).unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_revoked(device_id, 20, "dev_owner", 0)
|
||||
.unwrap();
|
||||
assert!(device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_trusted(device_id, 30).unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_revoked(device_id, 25, "dev_owner", 0)
|
||||
.unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
|
||||
sync.apply_device_profile(
|
||||
&DeviceProfileWire {
|
||||
device_id: device_id.to_string(),
|
||||
name: "readded".to_string(),
|
||||
client_version: CLIENT_VERSION.to_string(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
endpoint_id: String::new(),
|
||||
endpoint_ticket: String::new(),
|
||||
revoked: true,
|
||||
revoke_cutoff_seq: Some(0),
|
||||
updated_at_ms: 20,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!device_revoked(&sync, device_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_gc_waits_for_every_active_remote_ack() {
|
||||
let sync = test_sync();
|
||||
let origin = sync.ensure_identity().unwrap().device_id;
|
||||
sync.apply_device_trusted("dev_a", 1).unwrap();
|
||||
sync.apply_device_trusted("dev_b", 1).unwrap();
|
||||
|
||||
sync.record_local_op(SyncOpPayload::PlaylistDeleted {
|
||||
playlist_id: "pl_deleted".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 1);
|
||||
}
|
||||
|
||||
let ack = BTreeMap::from([(origin, 1)]);
|
||||
sync.note_peer_vector("dev_a", &ack).unwrap();
|
||||
sync.gc_tombstones().unwrap();
|
||||
{
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 1);
|
||||
}
|
||||
|
||||
sync.note_peer_vector("dev_b", &ack).unwrap();
|
||||
sync.gc_tombstones().unwrap();
|
||||
let conn = lock(&sync.conn);
|
||||
let tombstones: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_ops WHERE tombstone = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tombstones, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_carries_deleted_playlists_to_repair_stale_peers() {
|
||||
let source = test_sync();
|
||||
let source_playlist = source.library.create_playlist("Gone").unwrap();
|
||||
let playlist_sync_id = source
|
||||
.library
|
||||
.ensure_playlist_sync_id(source_playlist.id)
|
||||
.unwrap();
|
||||
source
|
||||
.apply_playlist_state(&playlist_sync_id, "Gone", false, 10, "dev_remote:1")
|
||||
.unwrap();
|
||||
source
|
||||
.apply_playlist_state(&playlist_sync_id, "", true, 20, "dev_remote:2")
|
||||
.unwrap();
|
||||
let snapshot = source.snapshot().unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.deleted_playlists
|
||||
.iter()
|
||||
.any(|playlist| playlist.playlist_id == playlist_sync_id)
|
||||
);
|
||||
|
||||
let peer = test_sync();
|
||||
let peer_playlist = peer
|
||||
.library
|
||||
.upsert_synced_playlist(&playlist_sync_id, "Gone")
|
||||
.unwrap();
|
||||
assert!(peer.library.playlist(peer_playlist).is_ok());
|
||||
|
||||
peer.apply_snapshot(snapshot).unwrap();
|
||||
assert!(
|
||||
!peer
|
||||
.library
|
||||
.playlists()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|playlist| playlist.title == "Gone")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_fed_like_metadata_repairs_existing_like_state() {
|
||||
let sync = test_sync();
|
||||
let content_id = format!("b3:{}", "a".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1")
|
||||
.unwrap()
|
||||
);
|
||||
let keys = sync.library.fed_like_ids().unwrap();
|
||||
assert!(keys.contains(&fed.item_id));
|
||||
assert!(keys.contains(&content_id));
|
||||
|
||||
assert!(
|
||||
sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2")
|
||||
.unwrap()
|
||||
);
|
||||
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_fed_likes_are_ordered_by_hlc_not_receive_time() {
|
||||
let sync = test_sync();
|
||||
let old_content_id = format!("b3:{}", "c".repeat(64));
|
||||
let new_content_id = format!("b3:{}", "d".repeat(64));
|
||||
let mut old_fed = test_fed_track(&old_content_id);
|
||||
old_fed.item_id = "fed_old".to_string();
|
||||
old_fed.title = "Old Fed".to_string();
|
||||
let mut new_fed = test_fed_track(&new_content_id);
|
||||
new_fed.item_id = "fed_new".to_string();
|
||||
new_fed.title = "New Fed".to_string();
|
||||
|
||||
let new_synced = SyncedFedTrack::from_fed(&new_fed).unwrap();
|
||||
let old_synced = SyncedFedTrack::from_fed(&old_fed).unwrap();
|
||||
sync.apply_like_state(&new_content_id, true, Some(&new_synced), 20, "dev_remote:2")
|
||||
.unwrap();
|
||||
sync.apply_like_state(&old_content_id, true, Some(&old_synced), 10, "dev_remote:1")
|
||||
.unwrap();
|
||||
|
||||
let titles: Vec<String> = sync
|
||||
.library
|
||||
.playlist(crate::library::LIKES_PLAYLIST_ID)
|
||||
.unwrap()
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| track.title)
|
||||
.collect();
|
||||
assert_eq!(titles, vec!["New Fed", "Old Fed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_playlist_item_metadata_creates_pending_fed_track() {
|
||||
let sync = test_sync();
|
||||
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
|
||||
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "b".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
3,
|
||||
Some(&synced),
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let detail = sync.library.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
assert!(detail.tracks[0].is_fed_pending());
|
||||
assert_eq!(detail.tracks[0].title, fed.title);
|
||||
|
||||
let conn = lock(&sync.conn);
|
||||
assert_eq!(
|
||||
sync.unresolved_playlist_item_count_with_conn(&conn)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
11,
|
||||
"dev_remote:3",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_synced_playlist_item_metadata_repairs_pending_fed_track() {
|
||||
let sync = test_sync();
|
||||
let playlist = sync.library.create_playlist("Remote Mix").unwrap();
|
||||
let playlist_sync_id = sync.library.ensure_playlist_sync_id(playlist.id).unwrap();
|
||||
let content_id = format!("b3:{}", "e".repeat(64));
|
||||
let fed = test_fed_track(&content_id);
|
||||
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
7,
|
||||
None,
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(sync.library.playlist(playlist.id).unwrap().tracks.len(), 0);
|
||||
|
||||
assert!(
|
||||
sync.apply_playlist_item_state(
|
||||
&playlist_sync_id,
|
||||
&content_id,
|
||||
true,
|
||||
7,
|
||||
Some(&synced),
|
||||
10,
|
||||
"dev_remote:2",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let detail = sync.library.playlist(playlist.id).unwrap();
|
||||
assert_eq!(detail.tracks.len(), 1);
|
||||
assert!(detail.tracks[0].is_fed_pending());
|
||||
assert_eq!(detail.tracks[0].title, fed.title);
|
||||
}
|
||||
Reference in New Issue
Block a user