use eternaltwin_core::api::SyncRef; use eternaltwin_core::auth::{AuthContext, AuthScope, GuestAuthContext, UserAuthContext}; use eternaltwin_core::clock::VirtualClock; use eternaltwin_core::core::{Duration, Instant, Listing, ListingCount, LocaleId, SecretString}; use eternaltwin_core::forum::{ AddModeratorOptions, CreatePostOptions, CreateThreadOptions, DeleteModeratorOptions, DeletePostOptions, ForumActor, ForumPost, ForumPostListing, ForumPostRevision, ForumPostRevisionContent, ForumPostRevisionListing, ForumRole, ForumRoleGrant, ForumSection, ForumSectionKey, ForumSectionListing, ForumSectionMeta, ForumSectionSelf, ForumStore, ForumStoreRef, ForumThread, ForumThreadListing, ForumThreadMeta, ForumThreadMetaWithSection, GetForumSectionOptions, GetThreadOptions, LatestForumPostRevisionListing, ShortForumPost, UpdatePostError, UpdatePostOptions, UpsertSystemSectionOptions, UserForumActor, }; use eternaltwin_core::user::{CreateUserOptions, ShortUser, UserStore, UserStoreRef}; use eternaltwin_core::uuid::Uuid4Generator; use eternaltwin_db_schema::force_create_latest; use eternaltwin_forum_store::pg::PgForumStore; use eternaltwin_services::forum::{AddModeratorError, DeleteModeratorError, ForumService}; use eternaltwin_user_store::pg::PgUserStore; use opentelemetry::trace::noop::NoopTracerProvider; use opentelemetry::trace::TracerProvider; use serial_test::serial; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use sqlx::PgPool; use std::str::FromStr; use std::sync::Arc; async fn make_test_api() -> TestApi< Arc, Arc, Arc>>, Arc, Arc, > { let config = eternaltwin_config::Config::for_test(); let tracer_provider = NoopTracerProvider::new(); let tracer = tracer_provider.tracer("eternaltwin_services_test"); let admin_database: PgPool = PgPoolOptions::new() .max_connections(5) .connect_with( PgConnectOptions::new() .host(&config.postgres.host.value) .port(config.postgres.port.value) .database(&config.postgres.name.value) .username(&config.postgres.admin_user.value) .password(&config.postgres.admin_password.value), ) .await .unwrap(); force_create_latest(&admin_database, true).await.unwrap(); admin_database.close().await; let database: PgPool = PgPoolOptions::new() .max_connections(5) .connect_with( PgConnectOptions::new() .host(&config.postgres.host.value) .port(config.postgres.port.value) .database(&config.postgres.name.value) .username(&config.postgres.user.value) .password(&config.postgres.password.value), ) .await .unwrap(); let database = Arc::new(database); let uuid = Arc::new(Uuid4Generator); let clock = Arc::new(VirtualClock::new(Instant::ymd_hms(2020, 1, 1, 0, 0, 0))); let uuid_generator = Arc::new(Uuid4Generator); let forum_store: Arc = Arc::new(PgForumStore::new( Arc::clone(&clock), Arc::clone(&database), Arc::clone(&uuid_generator), )); let user_store: Arc = Arc::new(PgUserStore::new( Arc::clone(&clock), Arc::clone(&database), SecretString::new("dev_secret".to_string()), tracer, Arc::clone(&uuid), )); let forum = Arc::new(ForumService::new( Arc::clone(&clock), Arc::clone(&forum_store), Arc::clone(&user_store), )); TestApi { clock, forum, _forum_store: Arc::clone(&forum_store), user_store: Arc::clone(&user_store), } } struct TestApi where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { pub(crate) clock: Arc, pub(crate) forum: TyForum, pub(crate) _forum_store: TyForumStore, pub(crate) user_store: TyUserStore, } #[tokio::test] #[serial] async fn test_create_main_forum_section() { inner_test_create_main_forum_section(make_test_api().await).await; } async fn inner_test_create_main_forum_section( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let actual = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let expected = ForumSection { id: actual.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 20, count: 0, items: vec![], }, role_grants: vec![], this: ForumSectionSelf { roles: vec![] }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_upsert_forum_section_idempotent() { inner_test_upsert_forum_section_idempotent(make_test_api().await).await; } async fn inner_test_upsert_forum_section_idempotent( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let expected = ForumSection { id: actual.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 20, count: 0, items: vec![], }, role_grants: vec![], this: ForumSectionSelf { roles: vec![] }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_empty_get_all_sections_as_guest() { inner_test_empty_get_all_sections_as_guest(make_test_api().await).await; } async fn inner_test_empty_get_all_sections_as_guest( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let actual = api .forum .get_sections(&AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, })) .await .unwrap(); let expected = ForumSectionListing { offset: 0, limit: 20, count: 0, items: Vec::new(), }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_upsert_section_then_get_all_sections_as_guest() { inner_test_upsert_section_then_get_all_sections_as_guest(make_test_api().await).await; } async fn inner_test_upsert_section_then_get_all_sections_as_guest( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .get_sections(&AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, })) .await .unwrap(); let expected = ForumSectionListing { offset: 0, limit: 20, count: 1, items: vec![ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ListingCount { count: 0 }, this: ForumSectionSelf { roles: vec![] }, }], }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_upsert_section_then_get_it_as_guest() { inner_test_upsert_section_then_get_it_as_guest(make_test_api().await).await; } async fn inner_test_upsert_section_then_get_it_as_guest( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .get_section( &AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, }), &GetForumSectionOptions { section: section.as_ref().into(), thread_offset: 0, thread_limit: 10, }, ) .await .unwrap(); let expected = ForumSection { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 10, count: 0, items: Vec::new(), }, role_grants: vec![], this: ForumSectionSelf { roles: vec![] }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_create_thread_in_the_main_section() { inner_test_create_thread_in_the_main_section(make_test_api().await).await; } async fn inner_test_create_thread_in_the_main_section( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: true, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .create_thread( &alice_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let expected = ForumThread { id: actual.id, key: None, title: "Hello".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), is_locked: false, is_pinned: false, section: ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ListingCount { count: 1 }, this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }, posts: Listing { offset: 0, limit: 10, count: 1, items: vec![ShortForumPost { id: actual.posts.items[0].id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), revisions: LatestForumPostRevisionListing { count: 1, last: ForumPostRevision { id: actual.posts.items[0].revisions.last.id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: None, comment: None, }, }, }], }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_create_two_sections_but_create_a_thread_in_only_one_of_them() { inner_test_create_two_sections_but_create_a_thread_in_only_one_of_them(make_test_api().await).await; } async fn inner_test_create_two_sections_but_create_a_thread_in_only_one_of_them( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let en_section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "en_main".parse().unwrap(), display_name: "Main Forum".parse().unwrap(), locale: Some(LocaleId::EnUs), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: true, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .create_thread( &alice_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let actual = api.forum.get_sections(&alice_acx).await.unwrap(); let expected = ForumSectionListing { count: 2, offset: 0, limit: 20, items: vec![ ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ListingCount { count: 1 }, this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }, ForumSectionMeta { id: en_section.id, key: Some("en_main".parse().unwrap()), display_name: "Main Forum".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 1), locale: Some(LocaleId::EnUs), threads: ListingCount { count: 0 }, this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }, ], }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_create_thread_in_the_main_section_and_post_10_messages() { inner_test_create_thread_in_the_main_section_and_post_10_messages(make_test_api().await).await; } async fn inner_test_create_thread_in_the_main_section_and_post_10_messages( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: true, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread: ForumThread = api .forum .create_thread( &alice_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "Original post".to_string(), }, ) .await .unwrap(); let mut posts: Vec = Vec::new(); for post_idx in 0..10 { api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .create_post( &alice_acx, &CreatePostOptions { thread: thread.as_ref().into(), body: format!("Reply {}", post_idx).parse().unwrap(), }, ) .await .unwrap(); posts.push(post); } assert_eq!(posts.len(), 10); let actual = api .forum .get_thread( &alice_acx, &GetThreadOptions { thread: thread.id.into(), post_offset: 7, post_limit: 5, }, ) .await .unwrap(); let expected = ForumThread { id: thread.id, key: None, title: "Hello".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), is_locked: false, is_pinned: false, section: ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), threads: ListingCount { count: 1 }, this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }, posts: ForumPostListing { count: 11, offset: 7, limit: 5, items: vec![ ShortForumPost { id: posts[6].id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 9), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), revisions: LatestForumPostRevisionListing { count: 1, last: ForumPostRevision { id: posts[6].revisions.items[0].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 9), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), content: Some(ForumPostRevisionContent { marktwin: "Reply 6".parse().unwrap(), html: "Reply 6".parse().unwrap(), }), moderation: None, comment: None, }, }, }, ShortForumPost { id: posts[7].id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 10), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), revisions: LatestForumPostRevisionListing { count: 1, last: ForumPostRevision { id: posts[7].revisions.items[0].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 10), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), content: Some(ForumPostRevisionContent { marktwin: "Reply 7".parse().unwrap(), html: "Reply 7".parse().unwrap(), }), moderation: None, comment: None, }, }, }, ShortForumPost { id: posts[8].id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 11), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), revisions: LatestForumPostRevisionListing { count: 1, last: ForumPostRevision { id: posts[8].revisions.items[0].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 11), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), content: Some(ForumPostRevisionContent { marktwin: "Reply 8".parse().unwrap(), html: "Reply 8".parse().unwrap(), }), moderation: None, comment: None, }, }, }, ShortForumPost { id: posts[9].id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 12), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), revisions: LatestForumPostRevisionListing { count: 1, last: ForumPostRevision { id: posts[9].revisions.items[0].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 12), author: ForumActor::UserForumActor(UserForumActor { role: None, user: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }), content: Some(ForumPostRevisionContent { marktwin: "Reply 9".parse().unwrap(), html: "Reply 9".parse().unwrap(), }), moderation: None, comment: None, }, }, }, ], }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_create_a_few_threads_with_messages_in_the_main_forum_section() { inner_test_create_a_few_threads_with_messages_in_the_main_forum_section(make_test_api().await).await; } async fn inner_test_create_a_few_threads_with_messages_in_the_main_forum_section( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: true, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread1 = api .forum .create_thread( &alice_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Thread 1".parse().unwrap(), body: "This is the first thread".to_string(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .create_thread( &alice_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Thread 2".parse().unwrap(), body: "This is the second thread".to_string(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .create_post( &alice_acx, &CreatePostOptions { thread: thread1.as_ref().into(), body: "Reply to thread 1".parse().unwrap(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread3 = api .forum .create_thread( &alice_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Thread 3".parse().unwrap(), body: "This is the third thread".to_string(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .create_post( &alice_acx, &CreatePostOptions { thread: thread1.as_ref().into(), body: "Another reply to thread 1".parse().unwrap(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .get_section( &alice_acx, &GetForumSectionOptions { section: ForumSectionKey::from_str("fr_main").unwrap().into(), thread_offset: 0, thread_limit: 2, }, ) .await .unwrap(); let expected = ForumSection { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ForumThreadListing { offset: 0, limit: 2, count: 3, items: vec![ ForumThreadMeta { id: thread1.id, key: None, title: "Thread 1".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), is_pinned: false, is_locked: false, posts: ListingCount { count: 3 }, }, ForumThreadMeta { id: thread3.id, key: None, title: "Thread 3".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), is_pinned: false, is_locked: false, posts: ListingCount { count: 1 }, }, ], }, role_grants: vec![], this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn guests_cant_add_moderators() { inner_guests_cant_add_moderators(make_test_api().await).await; } async fn inner_guests_cant_add_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(!bob.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual: AddModeratorError = api .forum .add_moderator( &AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap_err(); assert!(matches!(actual, AddModeratorError::Forbidden)); } #[tokio::test] #[serial] async fn regular_user_cant_add_moderators() { inner_regular_user_cant_add_moderators(make_test_api().await).await; } async fn inner_regular_user_cant_add_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(!bob.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual: AddModeratorError = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }), &AddModeratorOptions { section: section.into(), user: alice.id.into(), }, ) .await .unwrap_err(); assert!(matches!(actual, AddModeratorError::Forbidden)); } #[tokio::test] #[serial] async fn administrators_can_add_moderators() { inner_administrators_can_add_moderators(make_test_api().await).await; } async fn inner_administrators_can_add_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual: ForumSection = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); let expected = ForumSection { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 10, count: 0, items: vec![], }, role_grants: vec![ForumRoleGrant { role: ForumRole::Moderator, user: ShortUser { id: bob.id, display_name: bob.display_name.clone(), }, start_time: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), granted_by: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }], this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn moderator_addition_is_idempotent() { inner_moderator_addition_is_idempotent(make_test_api().await).await; } async fn inner_moderator_addition_is_idempotent( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let first: ForumSection = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let second: ForumSection = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); assert_eq!(second, first); } #[tokio::test] #[serial] async fn moderators_cant_add_other_moderators() { inner_moderators_cant_add_other_moderators(make_test_api().await).await; } async fn inner_moderators_cant_add_other_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }), &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap_err(); assert!(matches!(actual, AddModeratorError::Forbidden)); } #[tokio::test] #[serial] async fn there_can_be_multiple_moderators_for_a_section() { inner_there_can_be_multiple_moderators_for_a_section(make_test_api().await).await; } async fn inner_there_can_be_multiple_moderators_for_a_section( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap(); let expected = ForumSection { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 10, count: 0, items: vec![], }, role_grants: vec![ ForumRoleGrant { role: ForumRole::Moderator, user: ShortUser { id: bob.id, display_name: bob.display_name.clone(), }, start_time: Instant::ymd_hms(2021, 1, 1, 0, 0, 4), granted_by: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }, ForumRoleGrant { role: ForumRole::Moderator, user: ShortUser { id: charlie.id, display_name: charlie.display_name.clone(), }, start_time: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), granted_by: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }, ], this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn guests_cant_delete_moderators() { inner_guests_cant_delete_moderators(make_test_api().await).await; } async fn inner_guests_cant_delete_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .delete_moderator( &AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, }), &DeleteModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap_err(); assert!(matches!(actual, DeleteModeratorError::Forbidden)); } #[tokio::test] #[serial] async fn users_cant_delete_moderators() { inner_users_cant_delete_moderators(make_test_api().await).await; } async fn inner_users_cant_delete_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .delete_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: charlie.clone().into(), is_administrator: charlie.is_administrator, }), &DeleteModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap_err(); assert!(matches!(actual, DeleteModeratorError::Forbidden)); } #[tokio::test] #[serial] async fn moderators_cant_delete_other_moderators() { inner_moderators_cant_delete_other_moderators(make_test_api().await).await; } async fn inner_moderators_cant_delete_other_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .delete_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: charlie.clone().into(), is_administrator: charlie.is_administrator, }), &DeleteModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap_err(); assert!(matches!(actual, DeleteModeratorError::Forbidden)); } #[tokio::test] #[serial] async fn moderators_can_delete_themselves() { inner_moderators_can_delete_themselves(make_test_api().await).await; } async fn inner_moderators_can_delete_themselves( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let section = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .delete_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }), &DeleteModeratorOptions { section: section.as_ref().into(), user: bob.id.into(), }, ) .await .unwrap(); let expected = ForumSection { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 10, count: 0, items: vec![], }, role_grants: vec![ForumRoleGrant { role: ForumRole::Moderator, user: ShortUser { id: charlie.id, display_name: charlie.display_name.clone(), }, start_time: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), granted_by: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }], this: ForumSectionSelf { roles: vec![] }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn administrators_can_delete_moderators() { inner_administrators_can_delete_moderators(make_test_api().await).await; } async fn inner_administrators_can_delete_moderators( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let section = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .delete_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &DeleteModeratorOptions { section: section.as_ref().into(), user: bob.id.into(), }, ) .await .unwrap(); let expected = ForumSection { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: Listing { offset: 0, limit: 10, count: 0, items: vec![], }, role_grants: vec![ForumRoleGrant { role: ForumRole::Moderator, user: ShortUser { id: charlie.id, display_name: charlie.display_name.clone(), }, start_time: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), granted_by: ShortUser { id: alice.id, display_name: alice.display_name.clone(), }, }], this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn moderator_deletion_is_idempotent() { inner_moderator_deletion_is_idempotent(make_test_api().await).await; } async fn inner_moderator_deletion_is_idempotent( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let section = api .forum .add_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let first = api .forum .delete_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &DeleteModeratorOptions { section: section.as_ref().into(), user: bob.id.into(), }, ) .await .unwrap(); let second = api .forum .delete_moderator( &AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }), &DeleteModeratorOptions { section: section.as_ref().into(), user: bob.id.into(), }, ) .await .unwrap(); assert_eq!(second, first); } #[tokio::test] #[serial] async fn administrators_can_delete_posts() { inner_administrators_can_delete_posts(make_test_api().await).await; } async fn inner_administrators_can_delete_posts( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &bob_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .delete_post( &alice_acx, &DeletePostOptions { post: post.id, revision: post.revisions.last.id, comment: Some("Deletion comment".parse().unwrap()), }, ) .await .unwrap(); let expected = ForumPost { id: post.id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), author: ForumActor::UserForumActor(UserForumActor { role: None, user: bob.clone().into(), }), revisions: ForumPostRevisionListing { offset: 0, limit: 100, count: 2, items: vec![ ForumPostRevision { id: post.revisions.last.id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), author: ForumActor::UserForumActor(UserForumActor { role: None, user: bob.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: None, comment: None, }, ForumPostRevision { id: actual.revisions.items[1].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 4), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), content: None, moderation: None, comment: Some("Deletion comment".parse().unwrap()), }, ], }, thread: ForumThreadMetaWithSection { id: thread.id, key: None, title: "Hello".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), is_pinned: false, is_locked: false, posts: ListingCount { count: 1 }, section: ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ListingCount { count: 1 }, this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }, }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn administrators_cant_edit_other_peoples_post_content() { inner_administrators_cant_edit_other_peoples_post_content(make_test_api().await).await; } async fn inner_administrators_cant_edit_other_peoples_post_content( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &bob_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &alice_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: Some(Some("New content".parse().unwrap())), moderation: None, comment: Some("Edit content".parse().unwrap()), }, ) .await .unwrap_err(); assert!(matches!(actual, UpdatePostError::Forbidden)); } #[tokio::test] #[serial] async fn moderators_cant_edit_other_peoples_post_content() { inner_moderators_cant_edit_other_peoples_post_content(make_test_api().await).await; } async fn inner_moderators_cant_edit_other_peoples_post_content( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let charlie_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: charlie.clone().into(), is_administrator: charlie.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &alice_acx, &AddModeratorOptions { section: section.as_ref().into(), user: bob.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &charlie_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &bob_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: Some(Some("New content".parse().unwrap())), moderation: None, comment: Some("Edit content".parse().unwrap()), }, ) .await .unwrap_err(); assert!(matches!(actual, UpdatePostError::Forbidden)); } #[tokio::test] #[serial] async fn regular_users_cant_edit_other_peoples_post_content() { inner_regular_users_cant_edit_other_peoples_post_content(make_test_api().await).await; } async fn inner_regular_users_cant_edit_other_peoples_post_content( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let charlie_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: charlie.clone().into(), is_administrator: charlie.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &charlie_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &bob_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: Some(Some("New content".parse().unwrap())), moderation: None, comment: Some("Edit content".parse().unwrap()), }, ) .await .unwrap_err(); assert!(matches!(actual, UpdatePostError::Forbidden)); } #[tokio::test] #[serial] async fn guests_cant_edit_other_peoples_post_content() { inner_guests_cant_edit_other_peoples_post_content(make_test_api().await).await; } async fn inner_guests_cant_edit_other_peoples_post_content( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &bob_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, }), &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: Some(Some("New content".parse().unwrap())), moderation: None, comment: Some("Edit content".parse().unwrap()), }, ) .await .unwrap_err(); assert!(matches!(actual, UpdatePostError::Forbidden)); } #[tokio::test] #[serial] async fn administrators_can_edit_post_moderation_multiple_times() { inner_administrators_can_edit_post_moderation_multiple_times(make_test_api().await).await; } async fn inner_administrators_can_edit_post_moderation_multiple_times( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &bob_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .update_post( &alice_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: None, moderation: Some(Some("First moderation".parse().unwrap())), comment: Some("Add moderation".parse().unwrap()), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .update_post( &alice_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.items[1].id, content: None, moderation: Some(Some("Second moderation".parse().unwrap())), comment: Some("Update moderation".parse().unwrap()), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .update_post( &alice_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.items[2].id, content: None, moderation: Some(None), comment: Some("Delete moderation".parse().unwrap()), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &alice_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.items[3].id, content: None, moderation: Some(Some("Last moderation".parse().unwrap())), comment: Some("Re-add moderation".parse().unwrap()), }, ) .await .unwrap(); let expected = ForumPost { id: post.id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), author: ForumActor::UserForumActor(UserForumActor { role: None, user: bob.clone().into(), }), revisions: ForumPostRevisionListing { offset: 0, limit: 100, count: 5, items: vec![ ForumPostRevision { id: post.revisions.items[0].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), author: ForumActor::UserForumActor(UserForumActor { role: None, user: bob.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: None, comment: None, }, ForumPostRevision { id: actual.revisions.items[1].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 4), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: Some(ForumPostRevisionContent { marktwin: "First moderation".to_string(), html: "First moderation".to_string(), }), comment: Some("Add moderation".parse().unwrap()), }, ForumPostRevision { id: actual.revisions.items[2].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: Some(ForumPostRevisionContent { marktwin: "Second moderation".to_string(), html: "Second moderation".to_string(), }), comment: Some("Update moderation".parse().unwrap()), }, ForumPostRevision { id: actual.revisions.items[3].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 6), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: None, comment: Some("Delete moderation".parse().unwrap()), }, ForumPostRevision { id: actual.revisions.items[4].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 7), author: ForumActor::UserForumActor(UserForumActor { role: None, user: alice.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: Some(ForumPostRevisionContent { marktwin: "Last moderation".to_string(), html: "Last moderation".to_string(), }), comment: Some("Re-add moderation".parse().unwrap()), }, ], }, thread: ForumThreadMetaWithSection { id: thread.id, key: None, title: "Hello".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 3), is_pinned: false, is_locked: false, posts: ListingCount { count: 1 }, section: ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ListingCount { count: 1 }, this: ForumSectionSelf { roles: vec![ForumRole::Administrator], }, }, }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn moderators_can_edit_post_moderation_multiple_times() { inner_moderators_can_edit_post_moderation_multiple_times(make_test_api().await).await; } async fn inner_moderators_can_edit_post_moderation_multiple_times( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user let alice = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let alice_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: alice.clone().into(), is_administrator: alice.is_administrator, }); assert!(alice.is_administrator); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let charlie_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: charlie.clone().into(), is_administrator: charlie.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); api .forum .add_moderator( &alice_acx, &AddModeratorOptions { section: section.into(), user: charlie.id.into(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &bob_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .update_post( &charlie_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: None, moderation: Some(Some("First moderation".parse().unwrap())), comment: Some("Add moderation".parse().unwrap()), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .update_post( &charlie_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.items[1].id, content: None, moderation: Some(Some("Second moderation".parse().unwrap())), comment: Some("Update moderation".parse().unwrap()), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let post = api .forum .update_post( &charlie_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.items[2].id, content: None, moderation: Some(None), comment: Some("Delete moderation".parse().unwrap()), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &charlie_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.items[3].id, content: None, moderation: Some(Some("Last moderation".parse().unwrap())), comment: Some("Re-add moderation".parse().unwrap()), }, ) .await .unwrap(); let expected = ForumPost { id: post.id, ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), author: ForumActor::UserForumActor(UserForumActor { role: None, user: bob.clone().into(), }), revisions: ForumPostRevisionListing { offset: 0, limit: 100, count: 5, items: vec![ ForumPostRevision { id: post.revisions.items[0].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), author: ForumActor::UserForumActor(UserForumActor { role: None, user: bob.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: None, comment: None, }, ForumPostRevision { id: actual.revisions.items[1].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 6), author: ForumActor::UserForumActor(UserForumActor { role: None, user: charlie.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: Some(ForumPostRevisionContent { marktwin: "First moderation".to_string(), html: "First moderation".to_string(), }), comment: Some("Add moderation".parse().unwrap()), }, ForumPostRevision { id: actual.revisions.items[2].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 7), author: ForumActor::UserForumActor(UserForumActor { role: None, user: charlie.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: Some(ForumPostRevisionContent { marktwin: "Second moderation".to_string(), html: "Second moderation".to_string(), }), comment: Some("Update moderation".parse().unwrap()), }, ForumPostRevision { id: actual.revisions.items[3].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 8), author: ForumActor::UserForumActor(UserForumActor { role: None, user: charlie.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: None, comment: Some("Delete moderation".parse().unwrap()), }, ForumPostRevision { id: actual.revisions.items[4].id, time: Instant::ymd_hms(2021, 1, 1, 0, 0, 9), author: ForumActor::UserForumActor(UserForumActor { role: None, user: charlie.clone().into(), }), content: Some(ForumPostRevisionContent { marktwin: "**First** discussion thread".to_string(), html: "First discussion thread".to_string(), }), moderation: Some(ForumPostRevisionContent { marktwin: "Last moderation".to_string(), html: "Last moderation".to_string(), }), comment: Some("Re-add moderation".parse().unwrap()), }, ], }, thread: ForumThreadMetaWithSection { id: thread.id, key: None, title: "Hello".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 5), is_pinned: false, is_locked: false, posts: ListingCount { count: 1 }, section: ForumSectionMeta { id: section.id, key: Some("fr_main".parse().unwrap()), display_name: "Forum Général".parse().unwrap(), ctime: Instant::ymd_hms(2021, 1, 1, 0, 0, 0), locale: Some(LocaleId::FrFr), threads: ListingCount { count: 1 }, this: ForumSectionSelf { roles: vec![ForumRole::Moderator], }, }, }, }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn regular_users_cant_edit_post_moderation() { inner_regular_users_cant_edit_post_moderation(make_test_api().await).await; } async fn inner_regular_users_cant_edit_post_moderation( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let charlie = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Charlie".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let charlie_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: charlie.clone().into(), is_administrator: charlie.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &charlie_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &bob_acx, &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: None, moderation: Some(Some("New moderation".parse().unwrap())), comment: Some("Edit moderation".parse().unwrap()), }, ) .await .unwrap_err(); assert!(matches!(actual, UpdatePostError::Forbidden)); } #[tokio::test] #[serial] async fn guests_cant_edit_post_moderation() { inner_guests_cant_edit_post_moderation(make_test_api().await).await; } async fn inner_guests_cant_edit_post_moderation( api: TestApi, ) where TyForum: SyncRef, TyForumStore, TyUserStore>>, TyForumStore: ForumStoreRef, TyUserStore: UserStoreRef, { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let section = api .forum .upsert_system_section(&UpsertSystemSectionOptions { key: "fr_main".parse().unwrap(), display_name: "Forum Général".parse().unwrap(), locale: Some(LocaleId::FrFr), }) .await .unwrap(); let section = §ion; api.clock.as_ref().advance_by(Duration::from_seconds(1)); // Create admin, so the next user is a regular user api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Alice".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let bob = api .user_store .user_store() .create_user(&CreateUserOptions { display_name: "Bob".parse().unwrap(), email: None, username: None, password: None, }) .await .unwrap(); let bob_acx = AuthContext::User(UserAuthContext { scope: AuthScope::Default, user: bob.clone().into(), is_administrator: bob.is_administrator, }); api.clock.as_ref().advance_by(Duration::from_seconds(1)); let thread = api .forum .create_thread( &bob_acx, &CreateThreadOptions { section: section.as_ref().into(), title: "Hello".parse().unwrap(), body: "**First** discussion thread".to_string(), }, ) .await .unwrap(); let post = &thread.posts.items[0]; api.clock.as_ref().advance_by(Duration::from_seconds(1)); let actual = api .forum .update_post( &AuthContext::Guest(GuestAuthContext { scope: AuthScope::Default, }), &UpdatePostOptions { post: post.id, revision: post.revisions.last.id, content: None, moderation: Some(Some("New moderation".parse().unwrap())), comment: Some("Edit moderation".parse().unwrap()), }, ) .await .unwrap_err(); assert!(matches!(actual, UpdatePostError::Forbidden)); }