use chrono::Duration; use etwin_client::http::HttpEternaltwinClient; use serial_test::serial; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use sqlx::PgPool; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use eternalfest_auth_store::pg::PgAuthStore; use eternalfest_blob_store::pg::PgBlobStore; use eternalfest_buffer_store::fs::FsBufferStore; use eternalfest_core::auth::{AuthContext, AuthStore}; use eternalfest_core::blob::{Blob, BlobStore, CreateBlobOptions}; use eternalfest_core::buffer::BufferStore; use eternalfest_core::clock::VirtualClock; use eternalfest_core::core::{BoundedVec, Instant, Listing}; use eternalfest_core::file::FileStore; use eternalfest_core::game::requests::{CreateGame, CreateGameBuild, GetGame, GetGames, UpdateGameChannel}; use eternalfest_core::game::{ ActiveGameChannel, Game, GameBuildI18n, GameCategory, GameChannelListing, GameChannelPatch, GameChannelPermission, GameEngine, GameModeSpec, GameModeSpecI18n, GameOptionKey, GameOptionSpec, GameOptionSpecI18n, GamePatcher, GameRef, GameResource, GameRevision, GameStore, InputGameBuild, InputGameBuildI18n, InputGameChannel, InputGameEngine, InputGamePatcher, InputGameResource, InputPeriodLower, JsonValue, PatcherFramework, ShortGame, ShortGameBuildI18n, ShortGameChannel, ShortGameRevision, }; use eternalfest_core::user::{ShortUser, UserStore}; use eternalfest_core::uuid::Uuid4Generator; use eternalfest_db_schema::force_create_latest; use eternalfest_file_store::pg::PgFileStore; use eternalfest_game_store::pg::PgGameStore; use eternalfest_services::auth::{AuthService, DynAuthService}; use eternalfest_services::file::{DynFileService, FileService}; use eternalfest_services::game::{DynGameService, GameService}; use eternalfest_user_store::pg::PgUserStore; use etwin_core::clock::Clock; use etwin_core::core::LocaleId; use etwin_core::user::UserId; use once_cell::sync::Lazy; async fn make_test_api() -> TestApi { let config = eternalfest_config::find_config(std::env::current_dir().unwrap()).unwrap(); let admin_database: PgPool = PgPoolOptions::new() .max_connections(5) .connect_with( PgConnectOptions::new() .host(&config.db.host) .port(config.db.port) .database(&config.db.name) .username(&config.db.admin_user) .password(&config.db.admin_password), ) .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.db.host) .port(config.db.port) .database(&config.db.name) .username(&config.db.user) .password(&config.db.password), ) .await .unwrap(); let database = Arc::new(database); let clock = Arc::new(VirtualClock::new(Instant::ymd_hms(2020, 1, 1, 0, 0, 0))); let uuid_generator = Arc::new(Uuid4Generator); let buffer_store: Arc = Arc::new(FsBufferStore::new(Arc::clone(&uuid_generator), config.data.root.to_file_path().unwrap()).await); let blob_store: Arc = Arc::new(PgBlobStore::new( Arc::clone(&buffer_store), Arc::clone(&clock), Arc::clone(&database), Arc::clone(&uuid_generator), )); let file_store: Arc = Arc::new(PgFileStore::new( Arc::clone(&blob_store), Arc::clone(&clock), Arc::clone(&database), Arc::clone(&uuid_generator), )); let game_store: Arc = Arc::new(PgGameStore::new(Arc::clone(&clock), Arc::clone(&database), Arc::clone(&uuid_generator)).await); let user_store: Arc = Arc::new(PgUserStore::new(Arc::clone(&clock), Arc::clone(&database))); let game = Arc::new(GameService::new( Arc::clone(&blob_store), Arc::clone(&clock) as Arc, Arc::clone(&game_store), Arc::clone(&user_store), )); let auth_store: Arc = Arc::new(PgAuthStore::new( Arc::clone(&clock), Arc::clone(&database), Arc::clone(&uuid_generator), )); // TODO: `with_etwin_test_server`, etc. let etwin_client = HttpEternaltwinClient::new(Arc::clone(&clock), "http://eternaltwin.localhost".parse().unwrap()).unwrap(); let auth: Arc = Arc::new(AuthService::new( auth_store, Arc::clone(&clock) as Arc, Arc::new(etwin_client), Arc::clone(&user_store), )); let file: Arc = Arc::new(FileService::new(blob_store, file_store, user_store)); TestApi { auth, clock, file, game, } } struct TestApi { pub(crate) auth: Arc, pub(crate) clock: Arc, pub(crate) file: Arc, pub(crate) game: Arc, // pub(crate) user: Arc, } #[tokio::test] #[serial] async fn test_read_empty() { inner_test_read_empty(make_test_api().await).await; } async fn inner_test_read_empty(api: TestApi) { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let actual = api .game .as_ref() .get_games( &AuthContext::guest(), &GetGames { offset: 0, limit: 10, favorite: false, time: None, }, ) .await .unwrap(); let expected = Listing { offset: 0, limit: 10, count: 0, is_count_exact: false, items: vec![], }; assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_create_game() { inner_test_create_game(make_test_api().await).await; } async fn inner_test_create_game(api: TestApi) { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let alice_id = UserId::from_str("00000000-0000-0000-0001-000000000001").unwrap(); let alice_acx = api .auth .as_ref() .etwin_oauth(alice_id, &"Alice".parse().unwrap()) .await .unwrap(); api.clock.as_ref().advance_by(Duration::seconds(1)); let icon: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/icon.png").await; let icon_en: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/icon.en-US.png").await; let engine: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/game.swf").await; let patcher: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/patchman.swf").await; let debug: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/debug.json").await; let content: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/game.xml").await; let music: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/music/rourou.mp3").await; let lang: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/lang.fr-FR.xml").await; let lang_en: Blob = upload_test_resource(&api, &alice_acx, "games/sous-la-colline/lang.en-US.xml").await; api.clock.as_ref().advance_by(Duration::seconds(1)); let game = api .game .as_ref() .create_game( &alice_acx, &CreateGame { owner: None, key: None, build: InputGameBuild { version: "2.0.0".parse().unwrap(), git_commit_ref: Some("ca11ab1ef01dab1ef005ba11ba5eba11b01dface".parse().unwrap()), main_locale: LocaleId::FrFr, display_name: "Sous la colline".parse().unwrap(), description: "Aidez Igor".parse().unwrap(), icon: Some(icon.as_ref()), loader: "4.1.0".parse().unwrap(), engine: InputGameEngine::custom(engine.as_ref()), patcher: Some(InputGamePatcher { blob: patcher.as_ref(), framework: PatcherFramework { name: "patchman".parse().unwrap(), version: "0.10.11".parse().unwrap(), }, meta: Some(JsonValue::Object(Default::default())), }), debug: Some(debug.as_ref()), content: Some(content.as_ref()), content_i18n: Some(lang.as_ref()), musics: vec![InputGameResource { blob: music.as_ref(), display_name: Some("Chanson de rou²".parse().unwrap()), }], modes: [( "solo".parse().unwrap(), GameModeSpec { display_name: "Aventure".parse().unwrap(), is_visible: true, options: [( GameOptionKey::from_str("boost").unwrap(), GameOptionSpec { display_name: "Tornade".parse().unwrap(), is_visible: true, is_enabled: false, default_value: true, }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), families: "1,2,3,4".parse().unwrap(), category: GameCategory::Lab, i18n: [( LocaleId::EnUs, InputGameBuildI18n { display_name: Some("Under the hill".parse().unwrap()), description: Some("Help Igor".parse().unwrap()), icon: Some(icon_en.as_ref()), content_i18n: Some(lang_en.as_ref()), modes: [( "solo".parse().unwrap(), GameModeSpecI18n { display_name: Some("Adventure".parse().unwrap()), options: [( "boost".parse().unwrap(), GameOptionSpecI18n { display_name: Some("Tornado".parse().unwrap()), }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), }, channels: BoundedVec::new(vec![InputGameChannel { key: "main".parse().unwrap(), is_enabled: true, default_permission: GameChannelPermission::None, is_pinned: false, publication_date: None, sort_update_date: None, version: "2.0.0".parse().unwrap(), patches: vec![], }]) .unwrap(), }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::seconds(1)); let actual = api .game .as_ref() .get_games( &alice_acx, &GetGames { offset: 0, limit: 10, favorite: false, time: None, }, ) .await .unwrap(); let expected = Listing { offset: 0, limit: 10, count: 1, is_count_exact: false, items: vec![Some(ShortGame { id: game.id, created_at: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), key: None, owner: ShortUser { id: alice_id, display_name: "Alice".parse().unwrap(), }, channels: Listing { offset: 0, limit: 1, count: 1, is_count_exact: false, items: vec![ShortGameChannel { key: "main".parse().unwrap(), is_enabled: true, is_pinned: false, publication_date: None, sort_update_date: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), default_permission: GameChannelPermission::None, build: ShortGameRevision { version: "2.0.0".parse().unwrap(), git_commit_ref: None, main_locale: LocaleId::FrFr, display_name: "Sous la colline".parse().unwrap(), description: "Aidez Igor".parse().unwrap(), icon: Some(icon.clone()), i18n: [( LocaleId::EnUs, ShortGameBuildI18n { display_name: Some("Under the hill".parse().unwrap()), description: Some("Help Igor".parse().unwrap()), icon: Some(icon_en.clone()), }, )] .into_iter() .collect(), }, }], }, })], }; assert_eq!(actual, expected); api.clock.as_ref().advance_by(Duration::seconds(1)); let actual = api .game .as_ref() .get_game( &alice_acx, &GetGame { game: game.id.into(), channel: None, time: None, }, ) .await .unwrap(); let expected = Some(Game { id: game.id, created_at: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), key: None, owner: ShortUser { id: alice_id, display_name: "Alice".parse().unwrap(), }, channels: GameChannelListing { offset: 0, limit: 1, count: 1, is_count_exact: true, active: ActiveGameChannel { key: "main".parse().unwrap(), is_enabled: true, is_pinned: false, publication_date: None, sort_update_date: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), default_permission: GameChannelPermission::None, build: GameRevision { version: "2.0.0".parse().unwrap(), created_at: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), git_commit_ref: Some("ca11ab1ef01dab1ef005ba11ba5eba11b01dface".parse().unwrap()), main_locale: LocaleId::FrFr, display_name: "Sous la colline".parse().unwrap(), description: "Aidez Igor".parse().unwrap(), icon: Some(icon.clone()), loader: "4.1.0".parse().unwrap(), engine: GameEngine::custom(engine.clone()), patcher: Some(GamePatcher { blob: patcher.clone(), framework: PatcherFramework { name: "patchman".parse().unwrap(), version: "0.10.11".parse().unwrap(), }, meta: Some(JsonValue::Object(Default::default())), }), debug: Some(debug.clone()), content: Some(content.clone()), content_i18n: Some(lang.clone()), musics: vec![GameResource { blob: music.clone(), display_name: Some("Chanson de rou²".parse().unwrap()), }], modes: [( "solo".parse().unwrap(), GameModeSpec { display_name: "Aventure".parse().unwrap(), is_visible: true, options: [( GameOptionKey::from_str("boost").unwrap(), GameOptionSpec { display_name: "Tornade".parse().unwrap(), is_visible: true, is_enabled: false, default_value: true, }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), families: "1,2,3,4".parse().unwrap(), category: GameCategory::Lab, i18n: [( LocaleId::EnUs, GameBuildI18n { display_name: Some("Under the hill".parse().unwrap()), description: Some("Help Igor".parse().unwrap()), icon: Some(icon_en.clone()), content_i18n: Some(lang_en.clone()), modes: [( "solo".parse().unwrap(), GameModeSpecI18n { display_name: Some("Adventure".parse().unwrap()), options: [( "boost".parse().unwrap(), GameOptionSpecI18n { display_name: Some("Tornado".parse().unwrap()), }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), }, }, items: vec![], }, }); assert_eq!(actual, expected); } #[tokio::test] #[serial] async fn test_update_game() { inner_test_update_game(make_test_api().await).await; } async fn inner_test_update_game(api: TestApi) { api.clock.as_ref().advance_to(Instant::ymd_hms(2021, 1, 1, 0, 0, 0)); let alice_id = UserId::from_str("00000000-0000-0000-0001-000000000001").unwrap(); let alice_acx = api .auth .as_ref() .etwin_oauth(alice_id, &"Alice".parse().unwrap()) .await .unwrap(); api.clock.as_ref().advance_by(Duration::seconds(1)); let game = create_sous_la_colline(&api, &alice_acx).await; let old_build = game.channels.active.build; api.clock.as_ref().advance_by(Duration::seconds(1)); let game_build = api .game .as_ref() .create_build( &alice_acx, &CreateGameBuild { game: GameRef::Id(game.id.into()), build: InputGameBuild { version: "2.0.0".parse().unwrap(), git_commit_ref: Some("2222222222222222222222222222222222222222".parse().unwrap()), main_locale: LocaleId::FrFr, display_name: "Sous la colline 2".parse().unwrap(), description: "Aidez Igor à nouveau".parse().unwrap(), icon: Some(old_build.icon.unwrap().as_ref()), loader: "4.1.0".parse().unwrap(), engine: InputGameEngine::custom(old_build.engine.as_custom().unwrap().blob.as_ref()), patcher: Some(InputGamePatcher { blob: old_build.patcher.unwrap().blob.as_ref(), framework: PatcherFramework { name: "patchman".parse().unwrap(), version: "0.10.11".parse().unwrap(), }, meta: Some(JsonValue::Object(Default::default())), }), debug: Some(old_build.debug.unwrap().as_ref()), content: Some(old_build.content.unwrap().as_ref()), content_i18n: Some(old_build.content_i18n.unwrap().as_ref()), musics: vec![InputGameResource { blob: old_build.musics[0].blob.as_ref(), display_name: Some("Chanson de rou²".parse().unwrap()), }], modes: [( "solo".parse().unwrap(), GameModeSpec { display_name: "Aventure".parse().unwrap(), is_visible: true, options: [( GameOptionKey::from_str("boost").unwrap(), GameOptionSpec { display_name: "Tornade".parse().unwrap(), is_visible: true, is_enabled: false, default_value: true, }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), families: "1,2,3,4".parse().unwrap(), category: GameCategory::Lab, i18n: BTreeMap::new(), }, }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::seconds(1)); api .game .as_ref() .update_channel( &alice_acx, &UpdateGameChannel { actor: None, game: GameRef::id(game.id), channel_key: "main".parse().unwrap(), patches: vec![GameChannelPatch { period: InputPeriodLower::NOW_TO_FOREVER, is_enabled: true, default_permission: GameChannelPermission::Play, is_pinned: true, publication_date: Some(api.clock.as_ref().now()), sort_update_date: api.clock.as_ref().now(), version: "2.0.0".parse().unwrap(), }], }, ) .await .unwrap(); api.clock.as_ref().advance_by(Duration::seconds(1)); let actual = api .game .as_ref() .get_game( &alice_acx, &GetGame { game: game.id.into(), channel: None, time: None, }, ) .await .unwrap(); let expected = Some(Game { id: game.id, created_at: Instant::ymd_hms(2021, 1, 1, 0, 0, 2), key: None, owner: ShortUser { id: alice_id, display_name: "Alice".parse().unwrap(), }, channels: GameChannelListing { offset: 0, limit: 1, count: 1, is_count_exact: true, active: ActiveGameChannel { key: "main".parse().unwrap(), is_enabled: true, is_pinned: true, publication_date: Some(Instant::ymd_hms(2021, 1, 1, 0, 0, 4)), sort_update_date: Instant::ymd_hms(2021, 1, 1, 0, 0, 4), default_permission: GameChannelPermission::Play, build: game_build, }, items: vec![], }, }); assert_eq!(actual, expected); } async fn upload_test_resource>(api: &TestApi, acx: &AuthContext, path: P) -> Blob { pub static RESOURCES_ROOT: Lazy = Lazy::new(|| PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("../../test-resources")); let path = RESOURCES_ROOT.join(path); let path = path.as_path(); let media_type = match path.extension().map(|e| e.to_str().expect("malformed file extension")) { Some("json") => "application/json", Some("mp3") => "audio/mp3", Some("png") => "image/png", Some("swf") => "application/x-shockwave-flash", Some("xml") => "application/xml", Some(_) => panic!("unknown file extension"), None => panic!("file extension not found"), }; let data = match std::fs::read(path) { Ok(data) => data, Err(e) => panic!("failed to read {path:?}: {e:?}"), }; let blob = api .file .as_ref() .create_blob( acx, &CreateBlobOptions { media_type: media_type.parse().unwrap(), data, }, ) .await .unwrap(); blob } async fn create_sous_la_colline(api: &TestApi, acx: &AuthContext) -> Game { let icon: Blob = upload_test_resource(api, acx, "games/sous-la-colline/icon.png").await; let icon_en: Blob = upload_test_resource(api, acx, "games/sous-la-colline/icon.en-US.png").await; let engine: Blob = upload_test_resource(api, acx, "games/sous-la-colline/game.swf").await; let patcher: Blob = upload_test_resource(api, acx, "games/sous-la-colline/patchman.swf").await; let debug: Blob = upload_test_resource(api, acx, "games/sous-la-colline/debug.json").await; let content: Blob = upload_test_resource(api, acx, "games/sous-la-colline/game.xml").await; let music: Blob = upload_test_resource(api, acx, "games/sous-la-colline/music/rourou.mp3").await; let lang: Blob = upload_test_resource(api, acx, "games/sous-la-colline/lang.fr-FR.xml").await; let lang_en: Blob = upload_test_resource(api, acx, "games/sous-la-colline/lang.en-US.xml").await; api.clock.as_ref().advance_by(Duration::seconds(1)); api .game .as_ref() .create_game( acx, &CreateGame { owner: None, key: None, build: InputGameBuild { version: "1.0.0".parse().unwrap(), git_commit_ref: Some("ca11ab1ef01dab1ef005ba11ba5eba11b01dface".parse().unwrap()), main_locale: LocaleId::FrFr, display_name: "Sous la colline".parse().unwrap(), description: "Aidez Igor".parse().unwrap(), icon: Some(icon.as_ref()), loader: "4.1.0".parse().unwrap(), engine: InputGameEngine::custom(engine.as_ref()), patcher: Some(InputGamePatcher { blob: patcher.as_ref(), framework: PatcherFramework { name: "patchman".parse().unwrap(), version: "0.10.11".parse().unwrap(), }, meta: Some(JsonValue::Object(Default::default())), }), debug: Some(debug.as_ref()), content: Some(content.as_ref()), content_i18n: Some(lang.as_ref()), musics: vec![InputGameResource { blob: music.as_ref(), display_name: Some("Chanson de rou²".parse().unwrap()), }], modes: [( "solo".parse().unwrap(), GameModeSpec { display_name: "Aventure".parse().unwrap(), is_visible: true, options: [( GameOptionKey::from_str("boost").unwrap(), GameOptionSpec { display_name: "Tornade".parse().unwrap(), is_visible: true, is_enabled: false, default_value: true, }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), families: "1,2,3,4".parse().unwrap(), category: GameCategory::Lab, i18n: [( LocaleId::EnUs, InputGameBuildI18n { display_name: Some("Under the hill".parse().unwrap()), description: Some("Help Igor".parse().unwrap()), icon: Some(icon_en.as_ref()), content_i18n: Some(lang_en.as_ref()), modes: [( "solo".parse().unwrap(), GameModeSpecI18n { display_name: Some("Adventure".parse().unwrap()), options: [( "boost".parse().unwrap(), GameOptionSpecI18n { display_name: Some("Tornado".parse().unwrap()), }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), }, )] .into_iter() .collect(), }, channels: BoundedVec::new(vec![InputGameChannel { key: "main".parse().unwrap(), is_enabled: false, default_permission: GameChannelPermission::None, is_pinned: false, publication_date: None, sort_update_date: None, version: "1.0.0".parse().unwrap(), patches: vec![], }]) .unwrap(), }, ) .await .unwrap() }