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
This commit is contained in:
Jef Roosens 2026-05-06 21:43:48 +02:00
parent 8498fe9661
commit c2e0c4d091
Signed by: Jef Roosens
GPG key ID: 21FD3D77D56BAF49
11 changed files with 1813 additions and 4 deletions

View file

@ -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 * CLI command to toggle admin status of users
* Admin user management page * 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) ## [0.3.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.3.0)
### Added ### Added

View file

@ -1,6 +1,6 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct User { pub struct User {
pub id: i64, pub id: i64,
pub username: String, pub username: String,

View file

@ -277,4 +277,64 @@ mod tests {
let store = SqliteRepository::in_memory().unwrap(); let store = SqliteRepository::in_memory().unwrap();
gpodder_test::auth::test_remove_old_sessions(store); 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);
}
} }

View file

@ -307,4 +307,46 @@ mod tests {
let store = SqliteRepository::in_memory().unwrap(); let store = SqliteRepository::in_memory().unwrap();
gpodder_test::device::test_insert_devices(store); 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);
}
} }

View file

@ -174,3 +174,56 @@ impl gpodder::GpodderEpisodeActionStore for SqliteRepository {
.map_err(AuthErr::from) .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);
}
}

View file

@ -372,3 +372,68 @@ impl gpodder::GpodderSubscriptionStore for SqliteRepository {
.map_err(AuthErr::from) .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);
}
}

View file

@ -1,5 +1,5 @@
use chrono::{SubsecRound, TimeDelta}; use chrono::{SubsecRound, TimeDelta};
use gpodder::{GpodderAuthStore, Session}; use gpodder::{GpodderAuthStore, Page, Session, SignupLink, UserFilter};
pub fn test_create_user(store: impl GpodderAuthStore) { pub fn test_create_user(store: impl GpodderAuthStore) {
let user = store.get_user("test1"); 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<i64> = 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")
);
}

View file

@ -1,3 +1,4 @@
use chrono::SubsecRound;
use gpodder::{DevicePatch, DeviceType, GpodderStore}; use gpodder::{DevicePatch, DeviceType, GpodderStore};
pub fn test_insert_devices(store: impl 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].caption, "caption1");
assert_eq!(devices[0].r#type, DeviceType::Other); 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<String> = 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"
);
}

View file

@ -0,0 +1,442 @@
use chrono::{SubsecRound, TimeDelta};
use gpodder::{EpisodeAction, EpisodeActionType, GpodderStore};
fn make_download_action(
podcast: &str,
episode: &str,
timestamp: chrono::DateTime<chrono::Utc>,
) -> 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"
);
}

View file

@ -1,12 +1,17 @@
use gpodder::{GpodderAuthStore, User}; use gpodder::{Device, DeviceType, GpodderAuthStore, GpodderStore, User};
pub mod auth; pub mod auth;
pub mod device; 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<User> { fn create_test_users(store: &impl GpodderAuthStore) -> Vec<User> {
let mut users = Vec::new(); let mut users = Vec::new();
for i in 0..4 { for i in 0..NUM_USERS {
let username = format!("test{}", i + 1); let username = format!("test{}", i + 1);
let password_hash = format!("dummyhash{}", i + 1); let password_hash = format!("dummyhash{}", i + 1);
@ -15,3 +20,30 @@ fn create_test_users(store: &impl GpodderAuthStore) -> Vec<User> {
users users
} }
pub fn create_test_devices(store: &impl GpodderStore, user: &User) -> Vec<Device> {
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()
}

View file

@ -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"
);
}