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:
parent
8498fe9661
commit
c2e0c4d091
11 changed files with 1813 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
442
gpodder_test/src/episode_action.rs
Normal file
442
gpodder_test/src/episode_action.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
475
gpodder_test/src/subscription.rs
Normal file
475
gpodder_test/src/subscription.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue