From c2e0c4d091cb9d801f702396991781b48d39b400 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Wed, 6 May 2026 21:43:48 +0200 Subject: [PATCH] test(gpodder): implement full test suite * these tests were partially generated using AI, and reviewed by me * there are 4 failing tests related to subscription counts; this is known functionality that still needs to be implemented --- CHANGELOG.md | 4 + gpodder/src/models.rs | 2 +- gpodder_sqlite/src/repository/auth.rs | 60 +++ gpodder_sqlite/src/repository/device.rs | 42 ++ .../src/repository/episode_action.rs | 53 ++ gpodder_sqlite/src/repository/subscription.rs | 65 +++ gpodder_test/src/auth.rs | 322 +++++++++++- gpodder_test/src/device.rs | 316 ++++++++++++ gpodder_test/src/episode_action.rs | 442 ++++++++++++++++ gpodder_test/src/lib.rs | 36 +- gpodder_test/src/subscription.rs | 475 ++++++++++++++++++ 11 files changed, 1813 insertions(+), 4 deletions(-) create mode 100644 gpodder_test/src/episode_action.rs create mode 100644 gpodder_test/src/subscription.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1529316..adf9ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * CLI command to toggle admin status of users * Admin user management page +### Changed + +* Gpodder repository implementation is now properly tested + ## [0.3.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.3.0) ### Added diff --git a/gpodder/src/models.rs b/gpodder/src/models.rs index 9240af8..45ca3d1 100644 --- a/gpodder/src/models.rs +++ b/gpodder/src/models.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct User { pub id: i64, pub username: String, diff --git a/gpodder_sqlite/src/repository/auth.rs b/gpodder_sqlite/src/repository/auth.rs index cf55d94..7d26319 100644 --- a/gpodder_sqlite/src/repository/auth.rs +++ b/gpodder_sqlite/src/repository/auth.rs @@ -277,4 +277,64 @@ mod tests { let store = SqliteRepository::in_memory().unwrap(); gpodder_test::auth::test_remove_old_sessions(store); } + + #[test] + fn test_get_unknown_session() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_get_unknown_session(store); + } + + #[test] + fn test_session_user_agent_stored() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_session_user_agent_stored(store); + } + + #[test] + fn test_update_user() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_update_user(store); + } + + #[test] + fn test_remove_user() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_remove_user(store); + } + + #[test] + fn test_paginated_users() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_paginated_users(store); + } + + #[test] + fn test_paginated_users_filter() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_paginated_users_filter(store); + } + + #[test] + fn test_paginated_sessions() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_paginated_sessions(store); + } + + #[test] + fn test_insert_and_get_signup_link() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_insert_and_get_signup_link(store); + } + + #[test] + fn test_get_unknown_signup_link() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_get_unknown_signup_link(store); + } + + #[test] + fn test_remove_signup_link() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::auth::test_remove_signup_link(store); + } } diff --git a/gpodder_sqlite/src/repository/device.rs b/gpodder_sqlite/src/repository/device.rs index f41f3d6..e3fd667 100644 --- a/gpodder_sqlite/src/repository/device.rs +++ b/gpodder_sqlite/src/repository/device.rs @@ -307,4 +307,46 @@ mod tests { let store = SqliteRepository::in_memory().unwrap(); gpodder_test::device::test_insert_devices(store); } + + #[test] + fn test_device_isolation() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_device_isolation(store); + } + + #[test] + fn test_sync_groups() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_sync_groups(store); + } + + #[test] + fn test_update_existing_device() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_update_existing_device(store); + } + + #[test] + fn test_merge_overlapping_sync_groups() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_merge_overlapping_sync_groups(store); + } + + #[test] + fn test_remove_from_sync_group() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_remove_from_sync_group(store); + } + + #[test] + fn test_synchronize_sync_group_subscriptions() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_synchronize_sync_group_subscriptions(store); + } + + #[test] + fn test_subscription_count_in_device() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::device::test_subscription_count_in_device(store); + } } diff --git a/gpodder_sqlite/src/repository/episode_action.rs b/gpodder_sqlite/src/repository/episode_action.rs index 4664e05..7055d3d 100644 --- a/gpodder_sqlite/src/repository/episode_action.rs +++ b/gpodder_sqlite/src/repository/episode_action.rs @@ -174,3 +174,56 @@ impl gpodder::GpodderEpisodeActionStore for SqliteRepository { .map_err(AuthErr::from) } } + +#[cfg(test)] +mod tests { + use crate::SqliteRepository; + + #[test] + fn test_add_and_retrieve_episode_actions() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_add_and_retrieve_episode_actions(store); + } + + #[test] + fn test_episode_actions_since_filter() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_episode_actions_since_filter(store); + } + + #[test] + fn test_episode_actions_podcast_filter() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_episode_actions_podcast_filter(store); + } + + #[test] + fn test_episode_actions_device_filter() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_episode_actions_device_filter(store); + } + + #[test] + fn test_episode_actions_play_positions() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_episode_actions_play_positions(store); + } + + #[test] + fn test_episode_actions_aggregated() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_episode_actions_aggregated(store); + } + + #[test] + fn test_episode_actions_isolation_between_users() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_episode_actions_isolation_between_users(store); + } + + #[test] + fn test_add_episode_actions_time_changed() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::episode_action::test_add_episode_actions_time_changed(store); + } +} diff --git a/gpodder_sqlite/src/repository/subscription.rs b/gpodder_sqlite/src/repository/subscription.rs index 975d372..f848cf5 100644 --- a/gpodder_sqlite/src/repository/subscription.rs +++ b/gpodder_sqlite/src/repository/subscription.rs @@ -372,3 +372,68 @@ impl gpodder::GpodderSubscriptionStore for SqliteRepository { .map_err(AuthErr::from) } } + +#[cfg(test)] +mod tests { + use crate::SqliteRepository; + + #[test] + fn test_set_subscriptions_for_device() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_set_subscriptions_for_device(store); + } + + #[test] + fn test_set_subscriptions_replaces() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_set_subscriptions_replaces(store); + } + + #[test] + fn test_update_subscriptions_add() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_update_subscriptions_add(store); + } + + #[test] + fn test_update_subscriptions_remove() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_update_subscriptions_remove(store); + } + + #[test] + fn test_update_subscriptions_add_and_remove() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_update_subscriptions_add_and_remove(store); + } + + #[test] + fn test_subscriptions_for_user() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_subscriptions_for_user(store); + } + + #[test] + fn test_subscription_updates_since() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_subscription_updates_since(store); + } + + #[test] + fn test_subscription_updates_add_and_remove() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_subscription_updates_add_and_remove(store); + } + + #[test] + fn test_subscription_propagation_in_sync_group() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_subscription_propagation_in_sync_group(store); + } + + #[test] + fn test_subscription_isolation_between_users() { + let store = SqliteRepository::in_memory().unwrap(); + gpodder_test::subscription::test_subscription_isolation_between_users(store); + } +} diff --git a/gpodder_test/src/auth.rs b/gpodder_test/src/auth.rs index 685bdca..ca657d4 100644 --- a/gpodder_test/src/auth.rs +++ b/gpodder_test/src/auth.rs @@ -1,5 +1,5 @@ use chrono::{SubsecRound, TimeDelta}; -use gpodder::{GpodderAuthStore, Session}; +use gpodder::{GpodderAuthStore, Page, Session, SignupLink, UserFilter}; pub fn test_create_user(store: impl GpodderAuthStore) { let user = store.get_user("test1"); @@ -117,3 +117,323 @@ pub fn test_remove_old_sessions(store: impl GpodderAuthStore) { }) ); } + +pub fn test_get_unknown_session(store: impl GpodderAuthStore) { + assert_eq!( + store.get_session(999).expect("get session shouldn't fail"), + None + ); +} + +pub fn test_session_user_agent_stored(store: impl GpodderAuthStore) { + let users = super::create_test_users(&store); + + let session = Session { + id: 1, + user_agent: Some("Mozilla/5.0 (Test)".to_string()), + last_seen: chrono::Utc::now().trunc_subsecs(0), + user: users[0].clone(), + }; + + store + .insert_session(&session) + .expect("insert session shouldn't fail"); + + let retrieved = store + .get_session(1) + .expect("get session shouldn't fail") + .expect("session should exist"); + + assert_eq!(retrieved.user_agent, Some("Mozilla/5.0 (Test)".to_string())); +} + +pub fn test_update_user(store: impl GpodderAuthStore) { + let mut user = store + .insert_user("original_name", "original_hash") + .expect("insert user shouldn't fail"); + + assert_eq!(user.username, "original_name"); + assert_eq!(user.password_hash, "original_hash"); + assert!(!user.admin); + + user.username = "updated_name".to_string(); + user.password_hash = "updated_hash".to_string(); + user.admin = true; + + let updated = store + .update_user(user.clone()) + .expect("update user shouldn't fail"); + + assert_eq!(updated.username, "updated_name"); + assert_eq!(updated.password_hash, "updated_hash"); + assert!(updated.admin); + + // Verify the changes are persisted + let fetched = store + .get_user("updated_name") + .expect("get user shouldn't fail") + .expect("user should exist"); + + assert_eq!(fetched, updated); + + // Old username should no longer exist + assert_eq!( + store + .get_user("original_name") + .expect("get user shouldn't fail"), + None + ); +} + +pub fn test_remove_user(store: impl GpodderAuthStore) { + let user = store + .insert_user("to_remove", "hash") + .expect("insert user shouldn't fail"); + + assert!( + store + .get_user("to_remove") + .expect("get user shouldn't fail") + .is_some() + ); + + // First removal should return true + assert!( + store + .remove_user(user.id) + .expect("remove user shouldn't fail") + ); + + // User should no longer exist + assert_eq!( + store + .get_user("to_remove") + .expect("get user shouldn't fail"), + None + ); + + // Second removal should return false + assert!( + !store + .remove_user(user.id) + .expect("remove user shouldn't fail") + ); +} + +pub fn test_paginated_users(store: impl GpodderAuthStore) { + // Insert users with names that sort predictably + let usernames = ["alpha", "beta", "gamma", "delta", "epsilon"]; + for name in &usernames { + store + .insert_user(name, "hash") + .expect("insert user shouldn't fail"); + } + + let page1 = store + .paginated_users( + Page { + page: 0, + per_page: 3, + }, + &UserFilter::default(), + ) + .expect("paginated_users shouldn't fail"); + + let page2 = store + .paginated_users( + Page { + page: 1, + per_page: 3, + }, + &UserFilter::default(), + ) + .expect("paginated_users shouldn't fail"); + + assert_eq!(page1.len(), 3); + assert_eq!(page2.len(), 2); + + // Pages should not overlap + let page1_names: Vec<&str> = page1.iter().map(|u| u.username.as_str()).collect(); + let page2_names: Vec<&str> = page2.iter().map(|u| u.username.as_str()).collect(); + for name in &page2_names { + assert!(!page1_names.contains(name), "pages should not overlap"); + } + + // Combined pages should contain all inserted usernames + let mut all_names: Vec<&str> = page1_names + .iter() + .chain(page2_names.iter()) + .copied() + .collect(); + all_names.sort(); + let mut sorted_usernames = usernames.to_vec(); + sorted_usernames.sort(); + assert_eq!(all_names, sorted_usernames); + + assert!(page1.iter().map(|u| &u.username).is_sorted()); +} + +pub fn test_paginated_users_filter(store: impl GpodderAuthStore) { + let usernames = ["alice", "alicia", "bob", "aleph"]; + for name in &usernames { + store + .insert_user(name, "hash") + .expect("insert user shouldn't fail"); + } + + let filter = UserFilter { + username: Some("ali".to_string()), + }; + + let results = store + .paginated_users( + Page { + page: 0, + per_page: 10, + }, + &filter, + ) + .expect("paginated_users with filter shouldn't fail"); + + // Should match "alice" and "alicia" but not "bob" or "aleph" + let names: Vec<&str> = results.iter().map(|u| u.username.as_str()).collect(); + assert!(names.contains(&"alice"), "alice should match filter 'ali'"); + assert!( + names.contains(&"alicia"), + "alicia should match filter 'ali'" + ); + assert!(!names.contains(&"bob"), "bob should not match filter 'ali'"); + assert!( + !names.contains(&"aleph"), + "aleph should not match filter 'ali'" + ); +} + +pub fn test_paginated_sessions(store: impl GpodderAuthStore) { + let users = super::create_test_users(&store); + + let now = chrono::Utc::now().trunc_subsecs(0); + + // Insert 4 sessions with different last_seen timestamps + for i in 0..4i64 { + store + .insert_session(&Session { + id: i, + user_agent: None, + last_seen: now - TimeDelta::seconds(i), + user: users[0].clone(), + }) + .expect("insert session shouldn't fail"); + } + + // Insert a session for a different user to check isolation + store + .insert_session(&Session { + id: 99, + user_agent: None, + last_seen: now, + user: users[1].clone(), + }) + .expect("insert session shouldn't fail"); + + let page1 = store + .paginated_sessions( + &users[0], + Page { + page: 0, + per_page: 2, + }, + ) + .expect("paginated_sessions shouldn't fail"); + + let page2 = store + .paginated_sessions( + &users[0], + Page { + page: 1, + per_page: 2, + }, + ) + .expect("paginated_sessions shouldn't fail"); + + assert_eq!(page1.len(), 2); + assert_eq!(page2.len(), 2); + + // Should be ordered descending by last_seen + assert!(page1[0].last_seen >= page1[1].last_seen); + assert!(page2[0].last_seen >= page2[1].last_seen); + assert!(page1[1].last_seen >= page2[0].last_seen); + + // All sessions should belong to users[0] + for session in page1.iter().chain(page2.iter()) { + assert_eq!(session.user.id, users[0].id); + } + + // Session 99 (users[1]) must not appear in users[0]'s pages + let all_ids: Vec = page1.iter().chain(page2.iter()).map(|s| s.id).collect(); + assert!(!all_ids.contains(&99)); +} + +pub fn test_insert_and_get_signup_link(store: impl GpodderAuthStore) { + let now = chrono::Utc::now().trunc_subsecs(0); + + let link = SignupLink { + id: 42, + time_created: now, + }; + + store + .insert_signup_link(&link) + .expect("insert signup link shouldn't fail"); + + let retrieved = store + .get_signup_link(42) + .expect("get signup link shouldn't fail") + .expect("signup link should exist"); + + assert_eq!(retrieved, link); +} + +pub fn test_get_unknown_signup_link(store: impl GpodderAuthStore) { + assert_eq!( + store + .get_signup_link(999) + .expect("get signup link shouldn't fail"), + None + ); +} + +pub fn test_remove_signup_link(store: impl GpodderAuthStore) { + let now = chrono::Utc::now().trunc_subsecs(0); + + let link = SignupLink { + id: 7, + time_created: now, + }; + + store + .insert_signup_link(&link) + .expect("insert signup link shouldn't fail"); + + // First removal should return true + assert!( + store + .remove_signup_link(7) + .expect("remove signup link shouldn't fail") + ); + + // Link should no longer exist + assert_eq!( + store + .get_signup_link(7) + .expect("get signup link shouldn't fail"), + None + ); + + // Second removal should return false + assert!( + !store + .remove_signup_link(7) + .expect("remove signup link shouldn't fail") + ); +} diff --git a/gpodder_test/src/device.rs b/gpodder_test/src/device.rs index 7fe11b3..27ea2d2 100644 --- a/gpodder_test/src/device.rs +++ b/gpodder_test/src/device.rs @@ -1,3 +1,4 @@ +use chrono::SubsecRound; use gpodder::{DevicePatch, DeviceType, GpodderStore}; pub fn test_insert_devices(store: impl GpodderStore) { @@ -33,3 +34,318 @@ pub fn test_insert_devices(store: impl GpodderStore) { assert_eq!(devices[0].caption, "caption1"); assert_eq!(devices[0].r#type, DeviceType::Other); } + +pub fn test_device_isolation(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + store + .update_device_info( + &users[0], + "device1", + DevicePatch { + caption: Some("caption1".to_string()), + r#type: Some(DeviceType::Other), + }, + ) + .unwrap(); + store + .update_device_info( + &users[1], + "device2", + DevicePatch { + caption: Some("caption2".to_string()), + r#type: Some(DeviceType::Laptop), + }, + ) + .unwrap(); + + let devices = store.devices_for_user(&users[0]).unwrap(); + assert_eq!(1, devices.len()); + assert_eq!("device1", devices[0].id); + assert_eq!("caption1", devices[0].caption); + assert_eq!(0, devices[0].subscriptions); + assert_eq!(DeviceType::Other, devices[0].r#type); + + let devices = store.devices_for_user(&users[1]).unwrap(); + assert_eq!(1, devices.len()); + assert_eq!("device2", devices[0].id); + assert_eq!("caption2", devices[0].caption); + assert_eq!(0, devices[0].subscriptions); + assert_eq!(DeviceType::Laptop, devices[0].r#type); +} + +pub fn test_sync_groups(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + store + .merge_sync_groups(&users[0], vec![&devices[0].id, &devices[1].id]) + .unwrap(); + + let (mut ungrouped, mut groups) = store.devices_by_sync_group(&users[0]).unwrap(); + ungrouped.sort(); + + assert_eq!( + ungrouped, + vec![devices[2].id.clone(), devices[3].id.clone()] + ); + + assert_eq!(groups.len(), 1); + groups[0].sort(); + + let mut expected_group = vec![devices[0].id.clone(), devices[1].id.clone()]; + expected_group.sort(); + + assert_eq!(expected_group, groups[0]); + + // Merging a third device into the existing group should expand it + store + .merge_sync_groups(&users[0], vec![&devices[0].id, &devices[2].id]) + .unwrap(); + + let (mut ungrouped, mut groups) = store.devices_by_sync_group(&users[0]).unwrap(); + ungrouped.sort(); + + assert_eq!(ungrouped, vec![devices[3].id.clone()]); + + assert_eq!(groups.len(), 1); + groups[0].sort(); + + let mut expected_group = vec![ + devices[0].id.clone(), + devices[1].id.clone(), + devices[2].id.clone(), + ]; + expected_group.sort(); + + assert_eq!(groups[0], expected_group); + + // check another user can't see the sync groups + let devices_user1 = super::create_test_devices(&store, &users[1]); + let (ungrouped_u1, groups_u1) = store.devices_by_sync_group(&users[1]).unwrap(); + + assert_eq!(groups_u1.len(), 0); + assert_eq!(ungrouped_u1.len(), devices_user1.len()); + + // group_id_0 should still be the same group (not a new one) + let (_, groups_after) = store.devices_by_sync_group(&users[0]).unwrap(); + assert_eq!(groups_after.len(), 1); +} + +pub fn test_update_existing_device(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + store + .update_device_info( + &users[0], + "device1", + DevicePatch { + caption: Some("initial caption".to_string()), + r#type: Some(DeviceType::Mobile), + }, + ) + .expect("first update_device_info shouldn't fail"); + + store + .update_device_info( + &users[0], + "device1", + DevicePatch { + caption: Some("updated caption".to_string()), + r#type: Some(DeviceType::Desktop), + }, + ) + .expect("second update_device_info shouldn't fail"); + + let devices = store + .devices_for_user(&users[0]) + .expect("devices_for_user shouldn't fail"); + + assert_eq!( + devices.len(), + 1, + "updating should not create a duplicate device" + ); + assert_eq!(devices[0].caption, "updated caption"); + assert_eq!(devices[0].r#type, DeviceType::Desktop); +} + +pub fn test_merge_overlapping_sync_groups(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + // Put devices[0] + devices[1] in one group, devices[2] + devices[3] in another + store + .merge_sync_groups(&users[0], vec![&devices[0].id, &devices[1].id]) + .expect("first merge_sync_groups shouldn't fail"); + store + .merge_sync_groups(&users[0], vec![&devices[2].id, &devices[3].id]) + .expect("second merge_sync_groups shouldn't fail"); + + let (ungrouped, groups) = store + .devices_by_sync_group(&users[0]) + .expect("devices_by_sync_group shouldn't fail"); + + assert_eq!(ungrouped.len(), 0, "all devices should be in a sync group"); + assert_eq!(groups.len(), 2, "should have two separate sync groups"); + + // Merge two devices from different groups + store + .merge_sync_groups(&users[0], vec![&devices[1].id, &devices[2].id]) + .expect("merging overlapping groups shouldn't fail"); + + let (ungrouped, mut groups) = store + .devices_by_sync_group(&users[0]) + .expect("devices_by_sync_group after merge shouldn't fail"); + + assert_eq!(ungrouped.len(), 0, "no devices should be ungrouped"); + assert_eq!( + groups.len(), + 1, + "the two groups should have been merged into one" + ); + + groups[0].sort(); + let mut expected: Vec = devices.iter().map(|d| d.id.clone()).collect(); + expected.sort(); + + assert_eq!( + groups[0], expected, + "all four devices should be in the merged group" + ); +} + +pub fn test_remove_from_sync_group(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + store + .merge_sync_groups( + &users[0], + vec![&devices[0].id, &devices[1].id, &devices[2].id], + ) + .expect("merge_sync_groups shouldn't fail"); + + store + .remove_from_sync_group(&users[0], vec![&devices[0].id]) + .expect("remove_from_sync_group shouldn't fail"); + + let (mut ungrouped, mut groups) = store + .devices_by_sync_group(&users[0]) + .expect("devices_by_sync_group shouldn't fail"); + + ungrouped.sort(); + + let mut expected_ungrouped = vec![devices[0].id.clone(), devices[3].id.clone()]; + expected_ungrouped.sort(); + + assert_eq!( + ungrouped, expected_ungrouped, + "removed device should be ungrouped" + ); + assert_eq!( + groups.len(), + 1, + "remaining grouped devices should still form one group" + ); + + groups[0].sort(); + let mut expected_group = vec![devices[1].id.clone(), devices[2].id.clone()]; + expected_group.sort(); + + assert_eq!( + groups[0], expected_group, + "devices[1] and devices[2] should remain grouped" + ); +} + +pub fn test_synchronize_sync_group_subscriptions(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let group_id = store + .merge_sync_groups(&users[0], vec![&devices[0].id, &devices[1].id]) + .expect("merge_sync_groups shouldn't fail"); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + // Add subscriptions only to devices[0] + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/podcast1".to_string(), + "https://feed.example.com/podcast2".to_string(), + ], + vec![], + time_changed, + ) + .expect("update_subscriptions_for_device shouldn't fail"); + + store + .synchronize_sync_group(group_id, time_changed) + .expect("synchronize_sync_group shouldn't fail"); + + let subs_device1 = store + .subscriptions_for_device(&users[0], &devices[1].id) + .expect("subscriptions_for_device shouldn't fail"); + + let mut urls_device1: Vec<&str> = subs_device1.iter().map(|s| s.url.as_str()).collect(); + urls_device1.sort(); + + assert_eq!( + urls_device1, + vec![ + "https://feed.example.com/podcast1", + "https://feed.example.com/podcast2", + ], + "devices[1] should have the same subscriptions as devices[0] after sync" + ); +} + +pub fn test_subscription_count_in_device(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/podcast1".to_string(), + "https://feed.example.com/podcast2".to_string(), + "https://feed.example.com/podcast3".to_string(), + ], + vec![], + time_changed, + ) + .expect("update_subscriptions_for_device shouldn't fail"); + + let all_devices = store + .devices_for_user(&users[0]) + .expect("devices_for_user shouldn't fail"); + + let device = all_devices + .iter() + .find(|d| d.id == devices[0].id) + .expect("device should exist"); + + assert_eq!( + device.subscriptions, 3, + "subscription count on device should reflect the number of active subscriptions" + ); + + // Other devices in the same user's list should still report 0 + let other_device = all_devices + .iter() + .find(|d| d.id == devices[1].id) + .expect("other device should exist"); + + assert_eq!( + other_device.subscriptions, 0, + "other device should still report 0 subscriptions" + ); +} diff --git a/gpodder_test/src/episode_action.rs b/gpodder_test/src/episode_action.rs new file mode 100644 index 0000000..d0d3154 --- /dev/null +++ b/gpodder_test/src/episode_action.rs @@ -0,0 +1,442 @@ +use chrono::{SubsecRound, TimeDelta}; +use gpodder::{EpisodeAction, EpisodeActionType, GpodderStore}; + +fn make_download_action( + podcast: &str, + episode: &str, + timestamp: chrono::DateTime, +) -> EpisodeAction { + EpisodeAction { + podcast: podcast.to_string(), + episode: episode.to_string(), + timestamp: Some(timestamp), + time_changed: timestamp, + device: None, + action: EpisodeActionType::Download, + } +} + +pub fn test_add_and_retrieve_episode_actions(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let now = chrono::Utc::now().trunc_subsecs(0); + + let actions = vec![ + make_download_action( + "https://podcast.example.com/feed1", + "https://podcast.example.com/ep1", + now, + ), + make_download_action( + "https://podcast.example.com/feed2", + "https://podcast.example.com/ep2", + now, + ), + make_download_action( + "https://podcast.example.com/feed3", + "https://podcast.example.com/ep3", + now, + ), + ]; + + store + .add_episode_actions(&users[0], actions, now) + .expect("add_episode_actions shouldn't fail"); + + let retrieved = store + .episode_actions_for_user(&users[0], None, None, None, false) + .expect("episode_actions_for_user shouldn't fail"); + + assert_eq!( + retrieved.len(), + 3, + "all three added actions should be retrieved" + ); + + let mut urls: Vec<&str> = retrieved.iter().map(|a| a.episode.as_str()).collect(); + urls.sort(); + + assert_eq!( + urls, + vec![ + "https://podcast.example.com/ep1", + "https://podcast.example.com/ep2", + "https://podcast.example.com/ep3", + ] + ); +} + +pub fn test_episode_actions_since_filter(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let t_old = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(60); + let t_new = chrono::Utc::now().trunc_subsecs(0); + + store + .add_episode_actions( + &users[0], + vec![make_download_action( + "https://podcast.example.com/feed1", + "https://podcast.example.com/old_ep", + t_old, + )], + t_old, + ) + .expect("add old episode action shouldn't fail"); + + store + .add_episode_actions( + &users[0], + vec![make_download_action( + "https://podcast.example.com/feed1", + "https://podcast.example.com/new_ep", + t_new, + )], + t_new, + ) + .expect("add new episode action shouldn't fail"); + + // Querying since t_new should include only the new action + let results = store + .episode_actions_for_user(&users[0], Some(t_new), None, None, false) + .expect("episode_actions_for_user with since filter shouldn't fail"); + + let episode_urls: Vec<&str> = results.iter().map(|a| a.episode.as_str()).collect(); + assert!( + episode_urls.contains(&"https://podcast.example.com/new_ep"), + "new episode should be included" + ); + assert!( + !episode_urls.contains(&"https://podcast.example.com/old_ep"), + "old episode should be excluded by since filter" + ); + + // Querying since t_old should include both + let all_results = store + .episode_actions_for_user(&users[0], Some(t_old), None, None, false) + .expect("episode_actions_for_user with since=t_old shouldn't fail"); + + assert_eq!( + all_results.len(), + 2, + "both actions should be returned when since == t_old" + ); +} + +pub fn test_episode_actions_podcast_filter(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let now = chrono::Utc::now().trunc_subsecs(0); + + store + .add_episode_actions( + &users[0], + vec![ + make_download_action( + "https://podcast.example.com/feed1", + "https://podcast.example.com/feed1/ep1", + now, + ), + make_download_action( + "https://podcast.example.com/feed1", + "https://podcast.example.com/feed1/ep2", + now, + ), + make_download_action( + "https://podcast.example.com/feed2", + "https://podcast.example.com/feed2/ep1", + now, + ), + ], + now, + ) + .expect("add_episode_actions shouldn't fail"); + + let results = store + .episode_actions_for_user( + &users[0], + None, + Some("https://podcast.example.com/feed1".to_string()), + None, + false, + ) + .expect("episode_actions_for_user with podcast filter shouldn't fail"); + + assert_eq!(results.len(), 2, "only feed1 episodes should be returned"); + + for action in &results { + assert_eq!( + action.podcast, "https://podcast.example.com/feed1", + "all results should belong to the filtered podcast" + ); + } +} + +pub fn test_episode_actions_device_filter(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let now = chrono::Utc::now().trunc_subsecs(0); + + let action_on_device0 = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep_device0".to_string(), + timestamp: Some(now), + time_changed: now, + device: Some(devices[0].id.clone()), + action: EpisodeActionType::Download, + }; + + let action_on_device1 = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep_device1".to_string(), + timestamp: Some(now), + time_changed: now, + device: Some(devices[1].id.clone()), + action: EpisodeActionType::Download, + }; + + store + .add_episode_actions(&users[0], vec![action_on_device0, action_on_device1], now) + .expect("add_episode_actions shouldn't fail"); + + let results = store + .episode_actions_for_user(&users[0], None, None, Some(devices[0].id.clone()), false) + .expect("episode_actions_for_user with device filter shouldn't fail"); + + assert_eq!( + results.len(), + 1, + "only actions for devices[0] should be returned" + ); + assert_eq!(results[0].episode, "https://podcast.example.com/ep_device0"); + assert_eq!( + results[0].device.as_deref(), + Some(devices[0].id.as_str()), + "action should belong to devices[0]" + ); +} + +pub fn test_episode_actions_play_positions(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let now = chrono::Utc::now().trunc_subsecs(0); + + let play_action = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep1".to_string(), + timestamp: Some(now), + time_changed: now, + device: None, + action: EpisodeActionType::Play { + started: Some(10), + position: 120, + total: Some(3600), + }, + }; + + store + .add_episode_actions(&users[0], vec![play_action], now) + .expect("add_episode_actions shouldn't fail"); + + let results = store + .episode_actions_for_user(&users[0], None, None, None, false) + .expect("episode_actions_for_user shouldn't fail"); + + assert_eq!(results.len(), 1); + + assert!(matches!( + &results[0].action, + EpisodeActionType::Play { + started: Some(10), + position: 120, + total: Some(3600), + } + )); + + // Also verify that None play position fields round-trip correctly + let play_action_partial = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep2".to_string(), + timestamp: Some(now), + time_changed: now, + device: None, + action: EpisodeActionType::Play { + started: None, + position: 60, + total: None, + }, + }; + + store + .add_episode_actions(&users[0], vec![play_action_partial], now) + .expect("add partial play action shouldn't fail"); + + let results = store + .episode_actions_for_user( + &users[0], + None, + Some("https://podcast.example.com/feed1".to_string()), + None, + false, + ) + .expect("episode_actions_for_user shouldn't fail"); + + let partial = results + .iter() + .find(|a| a.episode == "https://podcast.example.com/ep2") + .expect("partial play action should be present"); + + assert!(matches!( + partial.action, + EpisodeActionType::Play { + started: None, + position: 60, + total: None + } + )) +} + +pub fn test_episode_actions_aggregated(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let t1 = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(10); + let t2 = chrono::Utc::now().trunc_subsecs(0); + + let early_action = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep1".to_string(), + timestamp: Some(t1), + time_changed: t1, + device: None, + action: EpisodeActionType::Play { + started: Some(0), + position: 30, + total: Some(3600), + }, + }; + + let late_action = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep1".to_string(), + timestamp: Some(t2), + time_changed: t2, + device: None, + action: EpisodeActionType::Play { + started: Some(0), + position: 120, + total: Some(3600), + }, + }; + + store + .add_episode_actions(&users[0], vec![early_action], t1) + .expect("add early action shouldn't fail"); + store + .add_episode_actions(&users[0], vec![late_action], t2) + .expect("add late action shouldn't fail"); + + // Without aggregation: both actions should be returned + let all_results = store + .episode_actions_for_user(&users[0], None, None, None, false) + .expect("episode_actions_for_user (not aggregated) shouldn't fail"); + + assert_eq!( + all_results.len(), + 2, + "both actions should be returned when aggregated=false" + ); + + // With aggregation: only the latest action for the episode should be returned + let aggregated = store + .episode_actions_for_user(&users[0], None, None, None, true) + .expect("episode_actions_for_user (aggregated) shouldn't fail"); + + assert_eq!( + aggregated.len(), + 1, + "only one action should be returned when aggregated=true" + ); + assert_eq!(aggregated[0].timestamp, Some(t2)); + + assert!(matches!( + aggregated[0].action, + EpisodeActionType::Play { + started: Some(0), + position: 120, + total: Some(3600) + } + )); +} + +pub fn test_episode_actions_isolation_between_users(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let now = chrono::Utc::now().trunc_subsecs(0); + + store + .add_episode_actions( + &users[0], + vec![make_download_action( + "https://podcast.example.com/feed1", + "https://podcast.example.com/ep1", + now, + )], + now, + ) + .expect("add_episode_actions for user[0] shouldn't fail"); + + let results = store + .episode_actions_for_user(&users[1], None, None, None, false) + .expect("episode_actions_for_user for user[1] shouldn't fail"); + + assert_eq!( + results.len(), + 0, + "user[1] should not see user[0]'s episode actions" + ); +} + +pub fn test_add_episode_actions_time_changed(store: impl GpodderStore) { + let users = super::create_test_users(&store); + + let action_timestamp = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(3600); + let time_changed_old = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(60); + let time_changed_new = chrono::Utc::now().trunc_subsecs(0); + + // Action has an old action timestamp but is recorded at time_changed_old + let action = EpisodeAction { + podcast: "https://podcast.example.com/feed1".to_string(), + episode: "https://podcast.example.com/ep1".to_string(), + timestamp: Some(action_timestamp), + time_changed: time_changed_old, + device: None, + action: EpisodeActionType::Download, + }; + + store + .add_episode_actions(&users[0], vec![action], time_changed_old) + .expect("add_episode_actions shouldn't fail"); + + // Timestamp for the episode action should be overwritten by time_changed_old + let results_after = store + .episode_actions_for_user(&users[0], Some(time_changed_new), None, None, false) + .expect("episode_actions_for_user with recent since shouldn't fail"); + + assert_eq!( + results_after.len(), + 0, + "action should be excluded: its time_changed is before the since cutoff" + ); + + // Filtering since time_changed_old should include it + let results_from = store + .episode_actions_for_user(&users[0], Some(time_changed_old), None, None, false) + .expect("episode_actions_for_user with since=time_changed_old shouldn't fail"); + + assert_eq!( + results_from.len(), + 1, + "action should be included when since == time_changed" + ); +} diff --git a/gpodder_test/src/lib.rs b/gpodder_test/src/lib.rs index 43dc4d6..8efdb25 100644 --- a/gpodder_test/src/lib.rs +++ b/gpodder_test/src/lib.rs @@ -1,12 +1,17 @@ -use gpodder::{GpodderAuthStore, User}; +use gpodder::{Device, DeviceType, GpodderAuthStore, GpodderStore, User}; pub mod auth; pub mod device; +pub mod episode_action; +pub mod subscription; + +const NUM_USERS: usize = 4; +const NUM_DEVICES: usize = 4; fn create_test_users(store: &impl GpodderAuthStore) -> Vec { let mut users = Vec::new(); - for i in 0..4 { + for i in 0..NUM_USERS { let username = format!("test{}", i + 1); let password_hash = format!("dummyhash{}", i + 1); @@ -15,3 +20,30 @@ fn create_test_users(store: &impl GpodderAuthStore) -> Vec { users } + +pub fn create_test_devices(store: &impl GpodderStore, user: &User) -> Vec { + let mut device_ids = Vec::new(); + + for i in 0..NUM_DEVICES { + let id = format!("device_{}_{i}", user.id); + store + .update_device_info( + user, + &id, + gpodder::DevicePatch { + caption: None, + r#type: Some(DeviceType::Other), + }, + ) + .unwrap(); + + device_ids.push(id); + } + + store + .devices_for_user(user) + .unwrap() + .into_iter() + .filter(|d| device_ids.contains(&d.id)) + .collect() +} diff --git a/gpodder_test/src/subscription.rs b/gpodder_test/src/subscription.rs new file mode 100644 index 0000000..546d5a3 --- /dev/null +++ b/gpodder_test/src/subscription.rs @@ -0,0 +1,475 @@ +use chrono::{SubsecRound, TimeDelta}; +use gpodder::GpodderStore; + +pub fn test_set_subscriptions_for_device(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .set_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/podcast1".to_string(), + "https://feed.example.com/podcast2".to_string(), + ], + time_changed, + ) + .expect("set_subscriptions_for_device shouldn't fail"); + + let mut subs = store + .subscriptions_for_device(&users[0], &devices[0].id) + .expect("subscriptions_for_device shouldn't fail"); + + subs.sort_by(|a, b| a.url.cmp(&b.url)); + + assert_eq!(subs.len(), 2); + assert_eq!(subs[0].url, "https://feed.example.com/podcast1"); + assert_eq!(subs[1].url, "https://feed.example.com/podcast2"); +} + +pub fn test_set_subscriptions_replaces(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let t1 = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(10); + let t2 = chrono::Utc::now().trunc_subsecs(0); + + store + .set_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/old1".to_string(), + "https://feed.example.com/old2".to_string(), + ], + t1, + ) + .expect("first set_subscriptions_for_device shouldn't fail"); + + store + .set_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/new1".to_string(), + "https://feed.example.com/new2".to_string(), + "https://feed.example.com/new3".to_string(), + ], + t2, + ) + .expect("second set_subscriptions_for_device shouldn't fail"); + + let mut subs = store + .subscriptions_for_device(&users[0], &devices[0].id) + .expect("subscriptions_for_device shouldn't fail"); + + subs.sort_by(|a, b| a.url.cmp(&b.url)); + + assert_eq!( + subs.len(), + 3, + "second set should have fully replaced the first" + ); + + let urls: Vec<&str> = subs.iter().map(|s| s.url.as_str()).collect(); + assert!(urls.contains(&"https://feed.example.com/new1")); + assert!(urls.contains(&"https://feed.example.com/new2")); + assert!(urls.contains(&"https://feed.example.com/new3")); + assert!( + !urls.contains(&"https://feed.example.com/old1"), + "old subscription should have been replaced" + ); + assert!( + !urls.contains(&"https://feed.example.com/old2"), + "old subscription should have been replaced" + ); +} + +pub fn test_update_subscriptions_add(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec!["https://feed.example.com/podcast1".to_string()], + vec![], + time_changed, + ) + .expect("update_subscriptions_for_device shouldn't fail"); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec!["https://feed.example.com/podcast2".to_string()], + vec![], + time_changed, + ) + .expect("second update_subscriptions_for_device shouldn't fail"); + + let mut subs = store + .subscriptions_for_device(&users[0], &devices[0].id) + .expect("subscriptions_for_device shouldn't fail"); + + subs.sort_by(|a, b| a.url.cmp(&b.url)); + + assert_eq!(subs.len(), 2, "both added subscriptions should be present"); + assert_eq!(subs[0].url, "https://feed.example.com/podcast1"); + assert_eq!(subs[1].url, "https://feed.example.com/podcast2"); +} + +pub fn test_update_subscriptions_remove(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .set_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/podcast1".to_string(), + "https://feed.example.com/podcast2".to_string(), + "https://feed.example.com/podcast3".to_string(), + ], + time_changed, + ) + .expect("set_subscriptions_for_device shouldn't fail"); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![], + vec!["https://feed.example.com/podcast2".to_string()], + time_changed, + ) + .expect("update_subscriptions_for_device shouldn't fail"); + + let mut subs = store + .subscriptions_for_device(&users[0], &devices[0].id) + .expect("subscriptions_for_device shouldn't fail"); + + subs.sort_by(|a, b| a.url.cmp(&b.url)); + + assert_eq!( + subs.len(), + 2, + "removed subscription should no longer be present" + ); + + let urls: Vec<&str> = subs.iter().map(|s| s.url.as_str()).collect(); + assert!(urls.contains(&"https://feed.example.com/podcast1")); + assert!(urls.contains(&"https://feed.example.com/podcast3")); + assert!( + !urls.contains(&"https://feed.example.com/podcast2"), + "podcast2 should have been removed" + ); +} + +pub fn test_update_subscriptions_add_and_remove(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .set_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/keep".to_string(), + "https://feed.example.com/remove".to_string(), + ], + time_changed, + ) + .expect("set_subscriptions_for_device shouldn't fail"); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec!["https://feed.example.com/add".to_string()], + vec!["https://feed.example.com/remove".to_string()], + time_changed, + ) + .expect("update_subscriptions_for_device shouldn't fail"); + + let mut subs = store + .subscriptions_for_device(&users[0], &devices[0].id) + .expect("subscriptions_for_device shouldn't fail"); + + subs.sort_by(|a, b| a.url.cmp(&b.url)); + + assert_eq!(subs.len(), 2); + + let urls: Vec<&str> = subs.iter().map(|s| s.url.as_str()).collect(); + assert!(urls.contains(&"https://feed.example.com/keep")); + assert!(urls.contains(&"https://feed.example.com/add")); + assert!( + !urls.contains(&"https://feed.example.com/remove"), + "removed subscription should be gone" + ); +} + +pub fn test_subscriptions_for_user(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .set_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/podcast1".to_string(), + "https://feed.example.com/shared".to_string(), + ], + time_changed, + ) + .expect("set_subscriptions_for_device for device[0] shouldn't fail"); + + store + .set_subscriptions_for_device( + &users[0], + &devices[1].id, + vec![ + "https://feed.example.com/podcast2".to_string(), + "https://feed.example.com/shared".to_string(), + ], + time_changed, + ) + .expect("set_subscriptions_for_device for device[1] shouldn't fail"); + + let mut subs = store + .subscriptions_for_user(&users[0]) + .expect("subscriptions_for_user shouldn't fail"); + + subs.sort_by(|a, b| a.url.cmp(&b.url)); + + let urls: Vec<&str> = subs.iter().map(|s| s.url.as_str()).collect(); + + assert!( + urls.contains(&"https://feed.example.com/podcast1"), + "podcast1 from device[0] should appear" + ); + assert!( + urls.contains(&"https://feed.example.com/podcast2"), + "podcast2 from device[1] should appear" + ); + assert!( + urls.contains(&"https://feed.example.com/shared"), + "shared podcast should appear" + ); + + let shared_count = urls + .iter() + .filter(|&&u| u == "https://feed.example.com/shared") + .count(); + assert_eq!( + shared_count, 1, + "shared subscription should be deduplicated" + ); +} + +pub fn test_subscription_updates_since(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let t_old = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(60); + let t_new = chrono::Utc::now().trunc_subsecs(0); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec!["https://feed.example.com/old".to_string()], + vec![], + t_old, + ) + .expect("update_subscriptions_for_device (old) shouldn't fail"); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec!["https://feed.example.com/new".to_string()], + vec![], + t_new, + ) + .expect("update_subscriptions_for_device (new) shouldn't fail"); + + // Querying since t_old should return only changes at or after t_old + let (added, removed) = store + .subscription_updates_for_device(&users[0], &devices[0].id, t_old) + .expect("subscription_updates_for_device shouldn't fail"); + + let added_urls: Vec<&str> = added.iter().map(|s| s.url.as_str()).collect(); + assert!( + added_urls.contains(&"https://feed.example.com/old"), + "old subscription should be included when since == t_old" + ); + assert!( + added_urls.contains(&"https://feed.example.com/new"), + "new subscription should be included" + ); + assert!(removed.is_empty(), "no removals should be present"); + + // Querying since after t_old should exclude the older subscription + let since = t_old + TimeDelta::seconds(1); + let (added_recent, removed_recent) = store + .subscription_updates_for_device(&users[0], &devices[0].id, since) + .expect("subscription_updates_for_device (recent) shouldn't fail"); + + let added_recent_urls: Vec<&str> = added_recent.iter().map(|s| s.url.as_str()).collect(); + assert!( + !added_recent_urls.contains(&"https://feed.example.com/old"), + "old subscription should be excluded when since is after t_old" + ); + assert!( + added_recent_urls.contains(&"https://feed.example.com/new"), + "new subscription should still be included" + ); + assert!(removed_recent.is_empty()); +} + +pub fn test_subscription_updates_add_and_remove(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + let t1 = chrono::Utc::now().trunc_subsecs(0) - TimeDelta::seconds(10); + let t2 = chrono::Utc::now().trunc_subsecs(0); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec!["https://feed.example.com/podcast1".to_string()], + vec![], + t1, + ) + .expect("adding subscription shouldn't fail"); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![], + vec!["https://feed.example.com/podcast1".to_string()], + t2, + ) + .expect("removing subscription shouldn't fail"); + + let (added, removed) = store + .subscription_updates_for_device(&users[0], &devices[0].id, t1) + .expect("subscription_updates_for_device shouldn't fail"); + + let removed_urls: Vec<&str> = removed.iter().map(|s| s.url.as_str()).collect(); + assert!( + removed_urls.contains(&"https://feed.example.com/podcast1"), + "podcast1 should appear in removals after being removed" + ); + + let added_urls: Vec<&str> = added.iter().map(|s| s.url.as_str()).collect(); + assert!( + !added_urls.contains(&"https://feed.example.com/podcast1"), + "podcast1 should not appear in additions since it was ultimately removed" + ); +} + +pub fn test_subscription_propagation_in_sync_group(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices = super::create_test_devices(&store, &users[0]); + + store + .merge_sync_groups(&users[0], vec![&devices[0].id, &devices[1].id]) + .expect("merge_sync_groups shouldn't fail"); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .update_subscriptions_for_device( + &users[0], + &devices[0].id, + vec![ + "https://feed.example.com/podcast1".to_string(), + "https://feed.example.com/podcast2".to_string(), + ], + vec![], + time_changed, + ) + .expect("update_subscriptions_for_device shouldn't fail"); + + let mut subs_device1 = store + .subscriptions_for_device(&users[0], &devices[1].id) + .expect("subscriptions_for_device for devices[1] shouldn't fail"); + + subs_device1.sort_by(|a, b| a.url.cmp(&b.url)); + + assert_eq!( + subs_device1.len(), + 2, + "devices[1] should receive subscriptions added to devices[0] via the sync group" + ); + + let urls: Vec<&str> = subs_device1.iter().map(|s| s.url.as_str()).collect(); + assert!(urls.contains(&"https://feed.example.com/podcast1")); + assert!(urls.contains(&"https://feed.example.com/podcast2")); + + // Devices outside the sync group should not be affected + let subs_device2 = store + .subscriptions_for_device(&users[0], &devices[2].id) + .expect("subscriptions_for_device for devices[2] shouldn't fail"); + + assert_eq!( + subs_device2.len(), + 0, + "devices[2] is not in the sync group and should have no subscriptions" + ); +} + +pub fn test_subscription_isolation_between_users(store: impl GpodderStore) { + let users = super::create_test_users(&store); + let devices_u0 = super::create_test_devices(&store, &users[0]); + let devices_u1 = super::create_test_devices(&store, &users[1]); + + let time_changed = chrono::Utc::now().trunc_subsecs(0); + + store + .set_subscriptions_for_device( + &users[0], + &devices_u0[0].id, + vec!["https://feed.example.com/user0only".to_string()], + time_changed, + ) + .expect("set_subscriptions_for_device for user[0] shouldn't fail"); + + let subs_u1 = store + .subscriptions_for_device(&users[1], &devices_u1[0].id) + .expect("subscriptions_for_device for user[1] shouldn't fail"); + + assert_eq!( + subs_u1.len(), + 0, + "user[1]'s device should not see user[0]'s subscriptions" + ); + + let all_subs_u1 = store + .subscriptions_for_user(&users[1]) + .expect("subscriptions_for_user for user[1] shouldn't fail"); + + assert_eq!( + all_subs_u1.len(), + 0, + "user[1] should have no subscriptions at all" + ); +}