507 lines
15 KiB
Rust
507 lines
15 KiB
Rust
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);
|
|
}
|