diff --git a/.github/workflows/global_workflow.yml b/.github/workflows/global_workflow.yml index 4436af55..af1fc2c8 100644 --- a/.github/workflows/global_workflow.yml +++ b/.github/workflows/global_workflow.yml @@ -2,6 +2,7 @@ name: Global workflow on: pull_request: + types: [ opened, synchronize, reopened ] push: branches: - dev @@ -33,7 +34,7 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - run: ./gradlew sonar --info -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} + run: ./gradlew sonar --info -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }} -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} -Dsonar.qualitygate.wait=true - name: Cache gradle dependencies uses: actions/cache@v4 diff --git a/build.gradle.kts b/build.gradle.kts index 871b26a2..2be69b25 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -114,6 +114,7 @@ sonar { properties { property("sonar.projectKey", "core") property("sonar.projectName", "core") + property("sonar.exclusions", "**/fr/shikkanime/socialnetworks/**") } } diff --git a/src/main/kotlin/fr/shikkanime/Application.kt b/src/main/kotlin/fr/shikkanime/Application.kt index cd05661c..eff68f2a 100644 --- a/src/main/kotlin/fr/shikkanime/Application.kt +++ b/src/main/kotlin/fr/shikkanime/Application.kt @@ -13,40 +13,50 @@ import fr.shikkanime.utils.LoggerFactory import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* +import java.util.concurrent.atomic.AtomicReference private val logger = LoggerFactory.getLogger(Constant.NAME) fun main() { logger.info("Starting ${Constant.NAME}...") + initAll(AtomicReference()) +} + +fun initAll(adminPassword: AtomicReference?, port: Int = 37100, wait: Boolean = true): NettyApplicationEngine { ImageService.loadCache() - val memberService = Constant.injector.getInstance(MemberService::class.java) + if (adminPassword != null) { + val memberService = Constant.injector.getInstance(MemberService::class.java) - try { - memberService.initDefaultAdminUser() - } catch (e: IllegalStateException) { - logger.info("Admin user already exists") + try { + adminPassword.set(memberService.initDefaultAdminUser()) + } catch (e: IllegalStateException) { + logger.info("Admin user already exists") + } } Constant.injector.getInstance(AnimeService::class.java).preIndex() ImageService.addAll() logger.info("Starting jobs...") + // Every 10 seconds JobManager.scheduleJob("*/10 * * * * ?", MetricJob::class.java) + // Every minute JobManager.scheduleJob("0 * * * * ?", FetchEpisodesJob::class.java) + // Every hour JobManager.scheduleJob("0 0 * * * ?", SavingImageCacheJob::class.java) - JobManager.scheduleJob("0 */10 * * * ?", GarbageCollectorJob::class.java) - JobManager.scheduleJob("0 0 0 * * ?", DeleteOldMetricsJob::class.java) JobManager.scheduleJob("0 0 * * * ?", FetchDeprecatedEpisodeJob::class.java) + // Every day at midnight + JobManager.scheduleJob("0 0 0 * * ?", DeleteOldMetricsJob::class.java) JobManager.start() logger.info("Starting server...") - embeddedServer( + return embeddedServer( Netty, - port = 37100, + port = port, host = "0.0.0.0", module = Application::module - ).start(wait = true) + ).start(wait = wait) } fun Application.module() { diff --git a/src/main/kotlin/fr/shikkanime/jobs/GarbageCollectorJob.kt b/src/main/kotlin/fr/shikkanime/jobs/GarbageCollectorJob.kt deleted file mode 100644 index b232ab66..00000000 --- a/src/main/kotlin/fr/shikkanime/jobs/GarbageCollectorJob.kt +++ /dev/null @@ -1,7 +0,0 @@ -package fr.shikkanime.jobs - -class GarbageCollectorJob : AbstractJob { - override fun run() { - System.gc() - } -} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/modules/Routing.kt b/src/main/kotlin/fr/shikkanime/modules/Routing.kt index 05a9fc2c..8c5534cc 100644 --- a/src/main/kotlin/fr/shikkanime/modules/Routing.kt +++ b/src/main/kotlin/fr/shikkanime/modules/Routing.kt @@ -253,7 +253,7 @@ private suspend fun handleRequest( } private suspend fun handleMultipartResponse(call: ApplicationCall, response: Response) { - val map = response.data as Map + val map = response.data as Map // NOSONAR call.respondBytes(map["image"] as ByteArray, map["contentType"] as ContentType) } @@ -266,8 +266,8 @@ private suspend fun handleTemplateResponse( val configCacheService = Constant.injector.getInstance(ConfigCacheService::class.java) val simulcastCacheService = Constant.injector.getInstance(SimulcastCacheService::class.java) - val map = response.data as Map - val modelMap = (map["model"] as Map).toMutableMap() + val map = response.data as Map // NOSONAR + val modelMap = (map["model"] as Map).toMutableMap() // NOSONAR val linkObjects = LinkObject.list() diff --git a/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt index d2a330f5..6809168a 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt @@ -9,16 +9,13 @@ import jakarta.persistence.TypedQuery import org.hibernate.ScrollMode import org.hibernate.jpa.AvailableHints import org.hibernate.query.Query -import java.lang.reflect.ParameterizedType import java.util.* abstract class AbstractRepository { @Inject protected lateinit var database: Database - protected fun getEntityClass(): Class { - return (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class - } + protected abstract fun getEntityClass(): Class protected fun inTransaction(block: (EntityManager) -> T): T { val entityManager = database.entityManager @@ -55,7 +52,7 @@ abstract class AbstractRepository { if (scrollableResults.first() && scrollableResults.scroll((limit * page) - limit)) { for (i in 0 until limit) { - list.add(scrollableResults.get() as E) + list.add(scrollableResults.get() as E) // NOSONAR if (!scrollableResults.next()) break } total = if (scrollableResults.last()) scrollableResults.rowNumber + 1L else 0 diff --git a/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt index 20f333fa..ace422ea 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/AnimeRepository.kt @@ -13,6 +13,8 @@ import org.hibernate.search.mapper.orm.Search import java.util.* class AnimeRepository : AbstractRepository() { + override fun getEntityClass() = Anime::class.java + private fun Anime.initialize(): Anime { Hibernate.initialize(this.simulcasts) return this @@ -112,7 +114,8 @@ class AnimeRepository : AbstractRepository() { fun findAllByName(name: String, countryCode: CountryCode?, page: Int, limit: Int): Pageable { val searchSession = Search.session(database.entityManager) - val searchResult = searchSession.search(Anime::class.java) + @Suppress("UNCHECKED_CAST") + val searchResult = searchSession.search(getEntityClass()) .where { w -> findWhere(w, name, countryCode) } .fetch((limit * page) - limit, limit) as SearchResult diff --git a/src/main/kotlin/fr/shikkanime/repositories/ConfigRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/ConfigRepository.kt index f9949bb2..e6927c17 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/ConfigRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/ConfigRepository.kt @@ -3,6 +3,8 @@ package fr.shikkanime.repositories import fr.shikkanime.entities.Config class ConfigRepository : AbstractRepository() { + override fun getEntityClass() = Config::class.java + fun findAllByName(name: String): List { return inTransaction { createReadOnlyQuery(it, "FROM Config c WHERE LOWER(c.propertyKey) LIKE :name", getEntityClass()) diff --git a/src/main/kotlin/fr/shikkanime/repositories/EpisodeRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/EpisodeRepository.kt index a79f25f3..8d1a7801 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/EpisodeRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/EpisodeRepository.kt @@ -14,6 +14,8 @@ import java.time.ZonedDateTime import java.util.* class EpisodeRepository : AbstractRepository() { + override fun getEntityClass() = Episode::class.java + private fun Episode.initialize(): Episode { Hibernate.initialize(this.anime?.simulcasts) return this @@ -137,7 +139,15 @@ class EpisodeRepository : AbstractRepository() { return inTransaction { createReadOnlyQuery( it, - "FROM Episode WHERE platform = :platform AND ((lastUpdateDateTime < :lastUpdateDateTime OR lastUpdateDateTime IS NULL) OR description IS NULL OR image = :defaultImage)", + """ + FROM Episode + WHERE platform = :platform + AND ( + (lastUpdateDateTime < :lastUpdateDateTime OR lastUpdateDateTime IS NULL) OR + (description IS NULL OR description = '') OR + image = :defaultImage + ) + """.trimIndent(), getEntityClass() ) .setParameter("platform", platform) diff --git a/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt index a3760a5d..48a7644f 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt @@ -6,6 +6,8 @@ import org.hibernate.Hibernate import java.util.* class MemberRepository : AbstractRepository() { + override fun getEntityClass() = Member::class.java + private fun Member.initialize(): Member { Hibernate.initialize(this.roles) return this diff --git a/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt index 9fcf14f2..97a3a263 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt @@ -4,9 +4,11 @@ import fr.shikkanime.entities.Metric import java.time.ZonedDateTime class MetricRepository : AbstractRepository() { + override fun getEntityClass() = Metric::class.java + fun findAllAfter(date: ZonedDateTime): List { return inTransaction { - createReadOnlyQuery(it, "FROM Metric WHERE date > :date ORDER BY date", Metric::class.java) + createReadOnlyQuery(it, "FROM Metric WHERE date > :date ORDER BY date", getEntityClass()) .setParameter("date", date) .resultList } diff --git a/src/main/kotlin/fr/shikkanime/repositories/SimulcastRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/SimulcastRepository.kt index 032a0233..e9d34a2c 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/SimulcastRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/SimulcastRepository.kt @@ -3,9 +3,11 @@ package fr.shikkanime.repositories import fr.shikkanime.entities.Simulcast class SimulcastRepository : AbstractRepository() { + override fun getEntityClass() = Simulcast::class.java + fun findBySeasonAndYear(season: String, year: Int): Simulcast? { return inTransaction { - createReadOnlyQuery(it, "FROM Simulcast WHERE season = :season AND year = :year", Simulcast::class.java) + createReadOnlyQuery(it, "FROM Simulcast WHERE season = :season AND year = :year", getEntityClass()) .setParameter("season", season) .setParameter("year", year) .resultList diff --git a/src/main/kotlin/fr/shikkanime/services/ImageService.kt b/src/main/kotlin/fr/shikkanime/services/ImageService.kt index 1b1d70c3..ebb2696e 100644 --- a/src/main/kotlin/fr/shikkanime/services/ImageService.kt +++ b/src/main/kotlin/fr/shikkanime/services/ImageService.kt @@ -39,38 +39,12 @@ object ImageService { var bytes: ByteArray = byteArrayOf(), var originalSize: Long = 0, var size: Long = 0, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Image - - if (uuid != other.uuid) return false - if (type != other.type) return false - if (url != other.url) return false - if (!bytes.contentEquals(other.bytes)) return false - if (originalSize != other.originalSize) return false - if (size != other.size) return false - - return true - } - - override fun hashCode(): Int { - var result = uuid.hashCode() - result = 31 * result + type.hashCode() - result = 31 * result + url.hashCode() - result = 31 * result + bytes.contentHashCode() - result = 31 * result + originalSize.hashCode() - result = 31 * result + size.hashCode() - return result - } - } + ) private val logger = LoggerFactory.getLogger(javaClass) private val threadPool = Executors.newFixedThreadPool(2) - private val cache = mutableListOf() - private val change = AtomicBoolean(false) + val cache = mutableListOf() + val change = AtomicBoolean(false) private const val CACHE_FILE_NUMBER = 5 private fun toHumanReadable(bytes: Long): String { @@ -117,7 +91,7 @@ object ImageService { cache.addAll(map) } - logger.info("Loaded images cache in $take ms (${cache.size} images)") + logger.info("Loaded images cache part in $take ms (${cache.size} images)") return cache } @@ -136,9 +110,15 @@ object ImageService { logger.info("Saving images cache...") val take = measureTimeMillis { - parts.parallelStream().forEach { part -> - val index = parts.indexOf(part) - saveCachePart(part, File(Constant.dataFolder, "images-cache-part-$index.shikk")) + if (parts.isNotEmpty()) { + parts.parallelStream().forEach { part -> + val index = parts.indexOf(part) + saveCachePart(part, File(Constant.dataFolder, "images-cache-part-$index.shikk")) + } + } else { + (0.. + saveCachePart(emptyList(), File(Constant.dataFolder, "images-cache-part-$index.shikk")) + } } } @@ -171,52 +151,69 @@ object ImageService { val image = Image(uuid.toString(), type, url) cache.add(image) - threadPool.submit { - val take = measureTimeMillis { - try { - val httpResponse = runBlocking { HttpRequest().get(url) } - val bytes = runBlocking { httpResponse.readBytes() } - - if (httpResponse.status != HttpStatusCode.OK || bytes.isEmpty()) { - logger.warning("Failed to load image $url") - remove(uuid, type) - return@measureTimeMillis - } - - val resized = ImageIO.read(ByteArrayInputStream(bytes)).resize(width, height) - val tmpFile = File.createTempFile("shikk", ".png").apply { - writeBytes(ByteArrayOutputStream().apply { ImageIO.write(resized, "png", this) }.toByteArray()) - } - val webp = FileManager.encodeToWebP(tmpFile.readBytes()) - - if (!tmpFile.delete()) - logger.warning("Can not delete tmp file image") - - if (webp.isEmpty()) { - logger.warning("Failed to encode image to WebP") - remove(uuid, type) - return@measureTimeMillis - } - - image.bytes = webp - image.originalSize = bytes.size.toLong() - image.size = webp.size.toLong() - cache[cache.indexOf(image)] = image - change.set(true) - } catch (e: Exception) { - logger.log(Level.SEVERE, "Failed to load image $url", e) + threadPool.submit { encodeImage(url, uuid, type, width, height, image) } + } + + private fun encodeImage( + url: String, + uuid: UUID, + type: Type, + width: Int, + height: Int, + image: Image + ) { + val take = measureTimeMillis { + try { + val httpResponse = runBlocking { HttpRequest().get(url) } + val bytes = runBlocking { httpResponse.readBytes() } + + if (httpResponse.status != HttpStatusCode.OK || bytes.isEmpty()) { + logger.warning("Failed to load image $url") remove(uuid, type) + return@measureTimeMillis } - } - logger.info( - "Encoded image to WebP in ${take}ms (${toHumanReadable(image.originalSize)} -> ${ - toHumanReadable( - image.size - ) - })" - ) + val resized = ImageIO.read(ByteArrayInputStream(bytes)).resize(width, height) + val tmpFile = File.createTempFile("shikk", ".png").apply { + writeBytes(ByteArrayOutputStream().apply { ImageIO.write(resized, "png", this) }.toByteArray()) + } + val webp = FileManager.encodeToWebP(tmpFile.readBytes()) + + if (!tmpFile.delete()) + logger.warning("Can not delete tmp file image") + + if (webp.isEmpty()) { + logger.warning("Failed to encode image to WebP") + remove(uuid, type) + return@measureTimeMillis + } + + image.bytes = webp + image.originalSize = bytes.size.toLong() + image.size = webp.size.toLong() + + val indexOf = cache.indexOf(image) + + if (indexOf == -1) { + logger.warning("Failed to find image in cache") + return@measureTimeMillis + } + + cache[indexOf] = image + change.set(true) + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to load image $url", e) + remove(uuid, type) + } } + + logger.info( + "Encoded image to WebP in ${take}ms (${toHumanReadable(image.originalSize)} -> ${ + toHumanReadable( + image.size + ) + })" + ) } fun remove(uuid: UUID, type: Type) { @@ -472,7 +469,11 @@ object ImageService { data class Tuple(val a: A, val b: B, val c: C, val d: D, val e: E) private fun loadResources(episode: EpisodeDto): Tuple { - val mediaImageFolder = File(Constant.dataFolder, "media-image") + val mediaImageFolder = + File(ClassLoader.getSystemClassLoader().getResource("media-image")!!.file).takeIf { it.exists() } ?: File( + Constant.dataFolder, + "media-image" + ) require(mediaImageFolder.exists()) { "Media image folder not found" } val backgroundsFolder = File(mediaImageFolder, "backgrounds") require(backgroundsFolder.exists()) { "Background folder not found" } diff --git a/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt b/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt index e6c0687f..7293c06f 100644 --- a/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt +++ b/src/main/kotlin/fr/shikkanime/socialnetworks/ThreadsSocialNetwork.kt @@ -11,6 +11,7 @@ import java.util.logging.Level class ThreadsSocialNetwork : AbstractSocialNetwork() { private val logger = LoggerFactory.getLogger(ThreadsSocialNetwork::class.java) + private val threadsWrapper = ThreadsWrapper() private var isInitialized = false private var initializedAt: ZonedDateTime? = null @@ -27,8 +28,8 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() { val username = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_USERNAME)) val password = requireNotNull(configCacheService.getValueAsString(ConfigPropertyKey.THREADS_PASSWORD)) if (username.isBlank() || password.isBlank()) throw Exception("Username or password is empty") - val generateDeviceId = ThreadsWrapper.generateDeviceId(username, password) - val (token, userId) = runBlocking { ThreadsWrapper.login(generateDeviceId, username, password) } + val generateDeviceId = threadsWrapper.generateDeviceId(username, password) + val (token, userId) = runBlocking { threadsWrapper.login(generateDeviceId, username, password) } this.username = username this.deviceId = generateDeviceId @@ -63,7 +64,7 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() { override fun sendMessage(message: String) { checkSession() if (!isInitialized) return - runBlocking { ThreadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message) } + runBlocking { threadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message) } } override fun platformAccount(platform: Platform): String { @@ -79,6 +80,6 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() { checkSession() if (!isInitialized) return val message = getEpisodeMessage(episodeDto, configCacheService.getValueAsString(ConfigPropertyKey.THREADS_MESSAGE) ?: "") - runBlocking { ThreadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message, mediaImage) } + runBlocking { threadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message, mediaImage) } } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/EncryptionManager.kt b/src/main/kotlin/fr/shikkanime/utils/EncryptionManager.kt index e96f30ed..f54947db 100644 --- a/src/main/kotlin/fr/shikkanime/utils/EncryptionManager.kt +++ b/src/main/kotlin/fr/shikkanime/utils/EncryptionManager.kt @@ -37,7 +37,7 @@ object EncryptionManager { } fun toMD5(source: String): String { - val digest = MessageDigest.getInstance("MD5") + val digest = MessageDigest.getInstance("MD5") // NOSONAR val hash = digest.digest(source.toByteArray(StandardCharsets.UTF_8)) return hash.fold("") { str, it -> str + "%02x".format(it) } } diff --git a/src/main/kotlin/fr/shikkanime/utils/JobManager.kt b/src/main/kotlin/fr/shikkanime/utils/JobManager.kt index 3c71b72b..15863e0b 100644 --- a/src/main/kotlin/fr/shikkanime/utils/JobManager.kt +++ b/src/main/kotlin/fr/shikkanime/utils/JobManager.kt @@ -25,6 +25,10 @@ object JobManager { scheduler.start() } + fun stop() { + scheduler.shutdown() + } + class JobExecutor : Job { private val logger = LoggerFactory.getLogger(javaClass) diff --git a/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt b/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt index 71960fad..986fce8a 100644 --- a/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt +++ b/src/main/kotlin/fr/shikkanime/wrappers/ThreadsWrapper.kt @@ -8,6 +8,7 @@ import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.utils.io.core.* +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream @@ -25,18 +26,21 @@ import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import kotlin.math.abs -object ThreadsWrapper { - private const val BASE_URL = "https://i.instagram.com" - private const val LATEST_APP_VERSION = "291.0.0.31.111" - private const val EXPERIMENTS = - "ig_android_fci_onboarding_friend_search,ig_android_device_detection_info_upload,ig_android_account_linking_upsell_universe,ig_android_direct_main_tab_universe_v2,ig_android_allow_account_switch_once_media_upload_finish_universe,ig_android_sign_in_help_only_one_account_family_universe,ig_android_sms_retriever_backtest_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_android_spatial_account_switch_universe,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_prefill_main_account_username_on_login_screen_universe,ig_android_login_identifier_fuzzy_match,ig_android_mas_remove_close_friends_entrypoint,ig_android_shared_email_reg_universe,ig_android_video_render_codec_low_memory_gc,ig_android_custom_transitions_universe,ig_android_push_fcm,multiple_account_recovery_universe,ig_android_show_login_info_reminder_universe,ig_android_email_fuzzy_matching_universe,ig_android_one_tap_aymh_redesign_universe,ig_android_direct_send_like_from_notification,ig_android_suma_landing_page,ig_android_prefetch_debug_dialog,ig_android_smartlock_hints_universe,ig_android_black_out,ig_activation_global_discretionary_sms_holdout,ig_android_video_ffmpegutil_pts_fix,ig_android_multi_tap_login_new,ig_save_smartlock_universe,ig_android_caption_typeahead_fix_on_o_universe,ig_android_enable_keyboardlistener_redesign,ig_android_sign_in_password_visibility_universe,ig_android_nux_add_email_device,ig_android_direct_remove_view_mode_stickiness_universe,ig_android_hide_contacts_list_in_nux,ig_android_new_users_one_tap_holdout_universe,ig_android_ingestion_video_support_hevc_decoding,ig_android_mas_notification_badging_universe,ig_android_secondary_account_in_main_reg_flow_universe,ig_android_secondary_account_creation_universe,ig_android_account_recovery_auto_login,ig_android_pwd_encrytpion,ig_android_bottom_sheet_keyboard_leaks,ig_android_sim_info_upload,ig_android_mobile_http_flow_device_universe,ig_android_hide_fb_button_when_not_installed_universe,ig_android_account_linking_on_concurrent_user_session_infra_universe,ig_android_targeted_one_tap_upsell_universe,ig_android_gmail_oauth_in_reg,ig_android_account_linking_flow_shorten_universe,ig_android_vc_interop_use_test_igid_universe,ig_android_notification_unpack_universe,ig_android_registration_confirmation_code_universe,ig_android_device_based_country_verification,ig_android_log_suggested_users_cache_on_error,ig_android_reg_modularization_universe,ig_android_device_verification_separate_endpoint,ig_android_universe_noticiation_channels,ig_android_account_linking_universe,ig_android_hsite_prefill_new_carrier,ig_android_one_login_toast_universe,ig_android_retry_create_account_universe,ig_android_family_apps_user_values_provider_universe,ig_android_reg_nux_headers_cleanup_universe,ig_android_mas_ui_polish_universe,ig_android_device_info_foreground_reporting,ig_android_shortcuts_2019,ig_android_device_verification_fb_signup,ig_android_onetaplogin_optimization,ig_android_passwordless_account_password_creation_universe,ig_android_black_out_toggle_universe,ig_video_debug_overlay,ig_android_ask_for_permissions_on_reg,ig_assisted_login_universe,ig_android_security_intent_switchoff,ig_android_device_info_job_based_reporting,ig_android_add_account_button_in_profile_mas_universe,ig_android_add_dialog_when_delinking_from_child_account_universe,ig_android_passwordless_auth,ig_radio_button_universe_2,ig_android_direct_main_tab_account_switch,ig_android_recovery_one_tap_holdout_universe,ig_android_modularized_dynamic_nux_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_fix_sms_read_lollipop,ig_android_access_flow_prefil" - private const val BLOKS_VERSION = "5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73" - private const val CONTENT_TYPE = "application/x-www-form-urlencoded; charset=UTF-8" - private const val USER_AGENT = "Barcelona $LATEST_APP_VERSION Android" +private const val BASE_URL = "https://i.instagram.com" +private const val LATEST_APP_VERSION = "291.0.0.31.111" +private const val EXPERIMENTS = + "ig_android_fci_onboarding_friend_search,ig_android_device_detection_info_upload,ig_android_account_linking_upsell_universe,ig_android_direct_main_tab_universe_v2,ig_android_allow_account_switch_once_media_upload_finish_universe,ig_android_sign_in_help_only_one_account_family_universe,ig_android_sms_retriever_backtest_universe,ig_android_direct_add_direct_to_android_native_photo_share_sheet,ig_android_spatial_account_switch_universe,ig_growth_android_profile_pic_prefill_with_fb_pic_2,ig_account_identity_logged_out_signals_global_holdout_universe,ig_android_prefill_main_account_username_on_login_screen_universe,ig_android_login_identifier_fuzzy_match,ig_android_mas_remove_close_friends_entrypoint,ig_android_shared_email_reg_universe,ig_android_video_render_codec_low_memory_gc,ig_android_custom_transitions_universe,ig_android_push_fcm,multiple_account_recovery_universe,ig_android_show_login_info_reminder_universe,ig_android_email_fuzzy_matching_universe,ig_android_one_tap_aymh_redesign_universe,ig_android_direct_send_like_from_notification,ig_android_suma_landing_page,ig_android_prefetch_debug_dialog,ig_android_smartlock_hints_universe,ig_android_black_out,ig_activation_global_discretionary_sms_holdout,ig_android_video_ffmpegutil_pts_fix,ig_android_multi_tap_login_new,ig_save_smartlock_universe,ig_android_caption_typeahead_fix_on_o_universe,ig_android_enable_keyboardlistener_redesign,ig_android_sign_in_password_visibility_universe,ig_android_nux_add_email_device,ig_android_direct_remove_view_mode_stickiness_universe,ig_android_hide_contacts_list_in_nux,ig_android_new_users_one_tap_holdout_universe,ig_android_ingestion_video_support_hevc_decoding,ig_android_mas_notification_badging_universe,ig_android_secondary_account_in_main_reg_flow_universe,ig_android_secondary_account_creation_universe,ig_android_account_recovery_auto_login,ig_android_pwd_encrytpion,ig_android_bottom_sheet_keyboard_leaks,ig_android_sim_info_upload,ig_android_mobile_http_flow_device_universe,ig_android_hide_fb_button_when_not_installed_universe,ig_android_account_linking_on_concurrent_user_session_infra_universe,ig_android_targeted_one_tap_upsell_universe,ig_android_gmail_oauth_in_reg,ig_android_account_linking_flow_shorten_universe,ig_android_vc_interop_use_test_igid_universe,ig_android_notification_unpack_universe,ig_android_registration_confirmation_code_universe,ig_android_device_based_country_verification,ig_android_log_suggested_users_cache_on_error,ig_android_reg_modularization_universe,ig_android_device_verification_separate_endpoint,ig_android_universe_noticiation_channels,ig_android_account_linking_universe,ig_android_hsite_prefill_new_carrier,ig_android_one_login_toast_universe,ig_android_retry_create_account_universe,ig_android_family_apps_user_values_provider_universe,ig_android_reg_nux_headers_cleanup_universe,ig_android_mas_ui_polish_universe,ig_android_device_info_foreground_reporting,ig_android_shortcuts_2019,ig_android_device_verification_fb_signup,ig_android_onetaplogin_optimization,ig_android_passwordless_account_password_creation_universe,ig_android_black_out_toggle_universe,ig_video_debug_overlay,ig_android_ask_for_permissions_on_reg,ig_assisted_login_universe,ig_android_security_intent_switchoff,ig_android_device_info_job_based_reporting,ig_android_add_account_button_in_profile_mas_universe,ig_android_add_dialog_when_delinking_from_child_account_universe,ig_android_passwordless_auth,ig_radio_button_universe_2,ig_android_direct_main_tab_account_switch,ig_android_recovery_one_tap_holdout_universe,ig_android_modularized_dynamic_nux_universe,ig_android_fb_account_linking_sampling_freq_universe,ig_android_fix_sms_read_lollipop,ig_android_access_flow_prefil" +private const val BLOKS_VERSION = "5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73" +private const val CONTENT_TYPE = "application/x-www-form-urlencoded; charset=UTF-8" +private const val USER_AGENT = "Barcelona $LATEST_APP_VERSION Android" + +class ThreadsWrapper( + private val context: CoroutineDispatcher = Dispatchers.IO, +) { private val httpRequest = HttpRequest() private val secureRandom = SecureRandom() - private suspend fun qeSync(): HttpResponse { + suspend fun qeSync(): HttpResponse { val uuid = UUID.randomUUID().toString() return httpRequest.post( @@ -54,7 +58,7 @@ object ThreadsWrapper { ) } - private suspend fun encryptPassword(password: String): Map { + suspend fun encryptPassword(password: String): Map { // https://github.com/instagram4j/instagram4j/blob/39635974c391e21a322ab3294275df99d7f75f84/src/main/java/com/github/instagram4j/instagram4j/utils/IGUtils.java#L176 val randKey = ByteArray(32).also { secureRandom.nextBytes(it) } val iv = ByteArray(12).also { secureRandom.nextBytes(it) } @@ -71,7 +75,7 @@ object ThreadsWrapper { Base64.getDecoder().decode(passwordEncryptionPubKey), StandardCharsets.UTF_8 ).replace("-(.*)-|\n".toRegex(), "") - val rsaCipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING") + val rsaCipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING") // NOSONAR rsaCipher.init( Cipher.ENCRYPT_MODE, KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(decodedPubKey))) @@ -89,7 +93,7 @@ object ThreadsWrapper { out.write(1) out.write(Integer.valueOf(passwordEncryptionKeyID)) - withContext(Dispatchers.IO) { + withContext(context) { out.write(iv) out.write( ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putChar(randKeyEncrypted.size.toChar()).array() diff --git a/src/test/kotlin/fr/shikkanime/controllers/admin/AdminControllerTest.kt b/src/test/kotlin/fr/shikkanime/controllers/admin/AdminControllerTest.kt index fa32a477..eaf1a95f 100644 --- a/src/test/kotlin/fr/shikkanime/controllers/admin/AdminControllerTest.kt +++ b/src/test/kotlin/fr/shikkanime/controllers/admin/AdminControllerTest.kt @@ -3,39 +3,46 @@ package fr.shikkanime.controllers.admin import com.google.inject.Inject import com.microsoft.playwright.Playwright import fr.shikkanime.entities.enums.Link -import fr.shikkanime.module +import fr.shikkanime.initAll import fr.shikkanime.services.MemberService import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.JobManager import io.ktor.server.engine.* -import io.ktor.server.netty.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.net.BindException +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicReference class AdminControllerTest { - private val port = (10000..65535).random() + private var port: Int = -1 private var server: ApplicationEngine? = null + private var password = AtomicReference() @Inject private lateinit var memberService: MemberService + private fun isPortInUse(port: Int): Boolean { + try { + val socket = ServerSocket(port) + socket.close() + return false + } catch (e: BindException) { + return true + } + } + @BeforeEach fun setUp() { - Constant.injector.injectMembers(this) - - val environment = applicationEngineEnvironment { - module { - module() - } + do { + port = (10000..65535).random() + } while (isPortInUse(port)) - connector { - host = "localhost" - port = this@AdminControllerTest.port - } - } - - server = embeddedServer(Netty, environment).start(false) + Constant.injector.injectMembers(this) + server = initAll(password, port, false) + JobManager.stop() } @AfterEach @@ -46,15 +53,13 @@ class AdminControllerTest { @Test fun `test admin login`() { - val password = memberService.initDefaultAdminUser() - val playwright = Playwright.create() val browser = playwright.chromium().launch() val page = browser.newPage() page.navigate("http://localhost:$port/admin") assertEquals("Login - Shikkanime", page.title()) page.fill("input[name=username]", "admin") - page.fill("input[name=password]", password) + page.fill("input[name=password]", password.get()) page.click("button[type=submit]") Link.entries.filter { it.href.startsWith("/admin") }.forEach { @@ -65,7 +70,7 @@ class AdminControllerTest { val currentA = allA[it.href]?.firstOrNull() assertEquals(true, currentA != null) currentA!!.click() - page.waitForTimeout(5000.0) + page.waitForTimeout(1000.0) val s = it.label + " - Shikkanime" println(s) assertEquals(s, page.title()) diff --git a/src/test/kotlin/fr/shikkanime/controllers/api/AttachmentControllerTest.kt b/src/test/kotlin/fr/shikkanime/controllers/api/AttachmentControllerTest.kt new file mode 100644 index 00000000..347ef602 --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/controllers/api/AttachmentControllerTest.kt @@ -0,0 +1,71 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.AnimeDto +import fr.shikkanime.entities.Anime +import fr.shikkanime.module +import fr.shikkanime.services.AnimeService +import fr.shikkanime.services.SimulcastService +import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.ObjectParser +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +class AttachmentControllerTest { + @Inject + private lateinit var animeService: AnimeService + + @Inject + private lateinit var simulcastService: SimulcastService + + @BeforeEach + fun setUp() { + Constant.injector.injectMembers(this) + + val listFiles = File(ClassLoader.getSystemClassLoader().getResource("animes")?.file).listFiles() + + listFiles + ?.sortedBy { it.name.lowercase() } + ?.forEach { + animeService.save( + AbstractConverter.convert( + ObjectParser.fromJson( + it.readText(), + AnimeDto::class.java + ), Anime::class.java + ) + ) + } + } + + @AfterEach + fun tearDown() { + animeService.deleteAll() + simulcastService.deleteAll() + animeService.preIndex() + } + + @Test + fun `get all sorted`() { + testApplication { + application { + module() + } + + client.get("/api/v1/attachments?uuid=${animeService.findAll().first().uuid}&type=IMAGE") + .apply { + assertEquals(HttpStatusCode.OK, status) + assertNotNull(body()) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/services/EpisodeServiceTest.kt b/src/test/kotlin/fr/shikkanime/services/EpisodeServiceTest.kt index 725fa46f..27d33e84 100644 --- a/src/test/kotlin/fr/shikkanime/services/EpisodeServiceTest.kt +++ b/src/test/kotlin/fr/shikkanime/services/EpisodeServiceTest.kt @@ -129,4 +129,109 @@ class EpisodeServiceTest { assertEquals("WINTER", episode.anime!!.simulcasts.first().season) assertEquals(2024, episode.anime!!.simulcasts.first().year) } + + @Test + fun findDeprecatedEpisodes() { + val releaseDateTime = ZonedDateTime.parse("2024-01-01T00:00:00Z") + + episodeService.save( + Episode( + platform = Platform.CRUN, + anime = Anime( + countryCode = CountryCode.FR, + name = "Test", + image = "https://www.shikkanime.com/image.png", + banner = "https://www.shikkanime.com/image.png", + releaseDateTime = releaseDateTime, + ), + episodeType = EpisodeType.EPISODE, + langType = LangType.SUBTITLES, + hash = "hash-1", + releaseDateTime = releaseDateTime, + season = 1, + number = 1, + url = "https://www.shikkanime.com/episode/1", + image = "https://www.shikkanime.com/image.png", + duration = 1420, + description = null, + lastUpdateDateTime = null, + ) + ) + + episodeService.save( + Episode( + platform = Platform.CRUN, + anime = Anime( + countryCode = CountryCode.FR, + name = "Test", + image = "https://www.shikkanime.com/image.png", + banner = "https://www.shikkanime.com/image.png", + releaseDateTime = releaseDateTime, + ), + episodeType = EpisodeType.EPISODE, + langType = LangType.SUBTITLES, + hash = "hash-2", + releaseDateTime = releaseDateTime, + season = 1, + number = 2, + url = "https://www.shikkanime.com/episode/1", + image = "https://www.shikkanime.com/image.png", + duration = 1420, + description = "Test", + lastUpdateDateTime = releaseDateTime.minusYears(1), + ) + ) + + episodeService.save( + Episode( + platform = Platform.CRUN, + anime = Anime( + countryCode = CountryCode.FR, + name = "Test", + image = "https://www.shikkanime.com/image.png", + banner = "https://www.shikkanime.com/image.png", + releaseDateTime = releaseDateTime, + ), + episodeType = EpisodeType.EPISODE, + langType = LangType.SUBTITLES, + hash = "hash-3", + releaseDateTime = releaseDateTime, + season = 1, + number = 3, + url = "https://www.shikkanime.com/episode/1", + image = "https://www.shikkanime.com/image.png", + duration = 1420, + description = "", + lastUpdateDateTime = releaseDateTime, + ) + ) + + episodeService.save( + Episode( + platform = Platform.CRUN, + anime = Anime( + countryCode = CountryCode.FR, + name = "Test", + image = "https://www.shikkanime.com/image.png", + banner = "https://www.shikkanime.com/image.png", + releaseDateTime = releaseDateTime, + ), + episodeType = EpisodeType.EPISODE, + langType = LangType.SUBTITLES, + hash = "hash-4", + releaseDateTime = releaseDateTime, + season = 1, + number = 4, + url = "https://www.shikkanime.com/episode/1", + image = Constant.DEFAULT_IMAGE_PREVIEW, + duration = 1420, + description = "test", + lastUpdateDateTime = releaseDateTime, + ) + ) + + val deprecatedEpisodes = + episodeService.findAllByPlatformDeprecatedEpisodes(Platform.CRUN, releaseDateTime.minusDays(30)) + assertEquals(4, deprecatedEpisodes.size) + } } \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/services/ImageServiceTest.kt b/src/test/kotlin/fr/shikkanime/services/ImageServiceTest.kt new file mode 100644 index 00000000..131a85b7 --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/services/ImageServiceTest.kt @@ -0,0 +1,85 @@ +package fr.shikkanime.services + +import fr.shikkanime.dtos.AnimeDto +import fr.shikkanime.dtos.EpisodeDto +import fr.shikkanime.dtos.SimulcastDto +import fr.shikkanime.dtos.enums.Status +import fr.shikkanime.entities.enums.CountryCode +import fr.shikkanime.entities.enums.EpisodeType +import fr.shikkanime.entities.enums.LangType +import fr.shikkanime.entities.enums.Platform +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.* + +class ImageServiceTest { + @BeforeEach + fun setUp() { + ImageService.cache.clear() + ImageService.change.set(true) + ImageService.saveCache() + ImageService.cache.clear() + ImageService.change.set(true) + } + + @Test + fun loadCache() { + ImageService.loadCache() + assertEquals(0, ImageService.cache.size) + } + + @Test + fun saveCache() { + ImageService.saveCache() + assertEquals(false, ImageService.change.get()) + } + + @Test + fun toEpisodeImage() { + val dto = EpisodeDto( + uuid = UUID.fromString("0335449b-87af-489e-9513-57cb2c854738"), + platform = Platform.CRUN, + anime = AnimeDto( + uuid = UUID.fromString("ebf540e4-42d2-4c35-92fc-6069b08d6db3"), + countryCode = CountryCode.FR, + name = "Frieren", + shortName = "Frieren", + releaseDateTime = "2024-01-05T16:00:00Z", + image = "https://www.crunchyroll.com/imgsrv/display/thumbnail/480x720/catalog/crunchyroll/f446d7a2a155c6120742978fb528fb82.jpe", + banner = "https://www.crunchyroll.com/imgsrv/display/thumbnail/1920x1080/catalog/crunchyroll/bcc213e8825420a85790049366d409fd.jpe", + description = "L’elfe Frieren a vaincu le roi des démons aux côtés du groupe mené par le jeune héros Himmel. Après dix années d’efforts, ils ont ramené la paix dans le royaume. Parce qu’elle est une elfe, Frieren peut vivre plus de mille ans. Elle part seule en voyage, promettant à ses amis qu'elle reviendra les voir. Cinquante ans plus tard, Frieren est de retour, elle n'a pas changé, mais ces retrouvailles sont aussi les derniers instants passés avec Himmel, devenu un vieillard qui s’éteint paisiblement sous ses yeux. Attristée de n’avoir pas passé plus de temps à connaître les gens qu’elle aime, elle décide de reprendre son voyage et de partir à la rencontre de nouvelles personnes…", + simulcasts = listOf( + SimulcastDto( + uuid = UUID.fromString("d1993c0c-4f65-474b-9c04-c577435aa7d5"), + season = "WINTER", + year = 2024, + slug = "winter-2024", + label = "Winter 2024", + ) + ), + status = Status.VALID, + slug = "frieren", + lastReleaseDateTime = "2024-03-01T20:30:00Z" + ), + episodeType = EpisodeType.EPISODE, + langType = LangType.VOICE, + hash = "FR-CRUN-922724-VOICE", + releaseDateTime = "2024-03-01T20:30:00Z", + season = 1, + number = 22, + title = "D'alliés à ennemis", + url = "https://www.crunchyroll.com/media-922724", + image = "https://www.crunchyroll.com/imgsrv/display/thumbnail/1920x1080/catalog/crunchyroll/9c5901edffefae3af8732ebad4a5a51b.jpe", + duration = 1470, + description = "Frieren et Fern retournent en ville après la première épreuve. Elles retrouvent Stark, affalé sur son lit alors que la nuit tombe, ce qui ne manque pas de mettre Fern en colère. Commence alors la quête d'un moyen de lui redonner le sourire...", + uncensored = false, + lastUpdateDateTime = "2024-03-01T20:30:00Z", + status = Status.VALID, + ) + + val image = ImageService.toEpisodeImage(dto) + assertNotNull(image) + } +} \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/utils/EncryptionManagerTest.kt b/src/test/kotlin/fr/shikkanime/utils/EncryptionManagerTest.kt new file mode 100644 index 00000000..2ee3c48b --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/utils/EncryptionManagerTest.kt @@ -0,0 +1,22 @@ +package fr.shikkanime.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EncryptionManagerTest { + @Test + fun toSHA512() { + assertEquals( + "374d794a95cdcfd8b35993185fef9ba368f160d8daf432d08ba9f1ed1e5abe6cc69291e0fa2fe0006a52570ef18c19def4e617c33ce52ef0a6e5fbe318cb0387", + EncryptionManager.toSHA512("Hello, World!") + ) + } + + @Test + fun toMD5() { + assertEquals( + "65a8e27d8879283831b664bd8b7f0ad4", + EncryptionManager.toMD5("Hello, World!") + ) + } +} \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/wrappers/ThreadsWrapperTest.kt b/src/test/kotlin/fr/shikkanime/wrappers/ThreadsWrapperTest.kt new file mode 100644 index 00000000..10b216fe --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/wrappers/ThreadsWrapperTest.kt @@ -0,0 +1,33 @@ +package fr.shikkanime.wrappers + +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +class ThreadsWrapperTest { + private val threadsWrapper = ThreadsWrapper() + + @Test + fun qeSync() { + val response = runBlocking { threadsWrapper.qeSync() } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun generateDeviceId() { + val username = "Hello" + val password = "World!" + assertEquals("android-6f36600bd3a8126c", threadsWrapper.generateDeviceId(username, password)) + } + + @Test + fun encryptPassword() { + val password = "World!" + val response = runBlocking { threadsWrapper.encryptPassword(password) } + assertNotNull(response) + assertNotNull(response["time"]) + assertNotNull(response["password"]) + } +} \ No newline at end of file diff --git a/src/test/resources/media-image/backgrounds/0.png b/src/test/resources/media-image/backgrounds/0.png new file mode 100644 index 00000000..d059f934 Binary files /dev/null and b/src/test/resources/media-image/backgrounds/0.png differ diff --git a/src/test/resources/media-image/banner.png b/src/test/resources/media-image/banner.png new file mode 100644 index 00000000..bd2888c3 Binary files /dev/null and b/src/test/resources/media-image/banner.png differ diff --git a/src/test/resources/media-image/font.ttf b/src/test/resources/media-image/font.ttf new file mode 100644 index 00000000..b423d3ab Binary files /dev/null and b/src/test/resources/media-image/font.ttf differ