Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
c20364ac6c
Bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-05 12:09:15 +00:00
grimsi
ecd369cd30 Migrate to Spring Boot 4 (#868)
* Switched from TomCat to Jetty
* Hibernate migrations
* Removed dependency on Spring-Boot-Content-FS
* Migrate to Jackson 3
* Migrate LegacyExtensionFinder -> IndexedExtensionFinder
* Fix code inspection issues
* Exclude Config classes from Sonar coverage calcualtion
* Add FileStorageServiceTest
* Add tests for (De-)serializers
* Exclude H2 package from Sonar coverage reporting
* Add Sonar scan
* Update JVM in CI
* Update dependency versions
2026-02-05 13:07:41 +01:00
grimsi
111e164fab Update feature request and bug issue forms 2026-02-05 13:06:41 +01:00
78 changed files with 1796 additions and 456 deletions

View File

@ -36,6 +36,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-aspectj")
implementation("org.springframework.boot:spring-boot-starter-jackson")
implementation("org.springframework.cloud:spring-cloud-starter")
implementation("jakarta.validation:jakarta.validation-api:${rootProject.extra["jakartaValidationVersion"]}")
@ -43,7 +44,10 @@ dependencies {
implementation(kotlin("reflect"))
// Reactive
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-webflux") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-reactor-netty")
}
implementation("org.springframework.boot:spring-boot-starter-jetty")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
@ -51,7 +55,9 @@ dependencies {
implementation("com.vaadin:vaadin-core") {
exclude("com.vaadin:flow-react")
}
implementation("com.vaadin:vaadin-spring-boot-starter")
implementation("com.vaadin:vaadin-spring-boot-starter") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
}
implementation("com.vaadin:hilla-spring-boot-starter")
// Logging
@ -59,8 +65,7 @@ dependencies {
// Persistence & I/O
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:${rootProject.extra["springContentVersion"]}")
implementation("org.flywaydb:flyway-core")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("commons-io:commons-io:${rootProject.extra["commonsIoVersion"]}")
implementation("com.google.guava:guava:${rootProject.extra["guavaVersion"]}")
@ -83,6 +88,7 @@ dependencies {
// Development
developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("com.vaadin:vaadin-dev")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
@ -123,10 +129,12 @@ tasks.named("sonar") {
sonar {
properties {
property("sonar.projectKey", "gameyfin_gameyfin")
property("sonar.organization", "gameyfin")
property("sonar.projectKey", "gameyfin_gameyfin")
property("sonar.projectName", "gameyfin")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml")
property("sonar.coverage.exclusions", "**/*Config.kt,**/org/gameyfin/db/h2/**")
}
}

Binary file not shown.

View File

@ -1,5 +1,6 @@
package org.gameyfin.app.collections.entities
import jakarta.persistence.Column
import jakarta.persistence.ElementCollection
import jakarta.persistence.Embeddable
import jakarta.persistence.FetchType
@ -11,5 +12,6 @@ class CollectionMetadata(
val displayOrder: Int = -1,
@ElementCollection(fetch = FetchType.EAGER)
@Column(nullable = false)
val gamesAddedAt: MutableMap<Long, Instant> = mutableMapOf()
)

View File

@ -331,6 +331,7 @@ sealed class ConfigProperties<T : Serializable>(
}
}
@Suppress("EnumEntryName")
enum class MatchUsersBy {
username, email
}

View File

@ -1,7 +1,5 @@
package org.gameyfin.app.config
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.dto.ConfigEntryDto
import org.gameyfin.app.config.dto.ConfigUpdateDto
@ -11,6 +9,8 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import tools.jackson.core.JacksonException
import tools.jackson.databind.ObjectMapper
import java.io.Serializable
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toJavaDuration
@ -186,7 +186,7 @@ class ConfigService(
return try {
val typeReference = objectMapper.typeFactory.constructType(configProperty.type.java)
objectMapper.readValue(value.toString(), typeReference) as T
} catch (e: JsonProcessingException) {
} catch (e: JacksonException) {
throw IllegalArgumentException(
"Failed to deserialize value '$value' for key '${configProperty.key}' to type ${configProperty.type.simpleName}: ${e.message}",
e
@ -209,7 +209,7 @@ class ConfigService(
private fun <T : Serializable> serializeValue(value: T, key: String): String {
return try {
objectMapper.writeValueAsString(value)
} catch (e: JsonProcessingException) {
} catch (e: JacksonException) {
throw IllegalArgumentException(
"Failed to serialize value for key '$key': ${e.message}",
e

View File

@ -1,7 +1,7 @@
package org.gameyfin.app.config.dto
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import org.gameyfin.app.core.serialization.ArrayDeserializer
import tools.jackson.databind.annotation.JsonDeserialize
import java.io.Serializable
data class ConfigUpdateDto(

View File

@ -1,11 +0,0 @@
package org.gameyfin.app.config.dto
data class CronExpressionVerificationResultDto(
val valid: Boolean,
val errorMessage: String? = null
) {
companion object {
val valid = CronExpressionVerificationResultDto(true)
fun invalid(errorMessage: String) = CronExpressionVerificationResultDto(false, errorMessage)
}
}

View File

@ -3,10 +3,6 @@ package org.gameyfin.app.core
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
import org.gameyfin.app.users.RoleService
import java.lang.Enum
import kotlin.IllegalArgumentException
import kotlin.Int
import kotlin.String
enum class Role(val roleName: String, val powerLevel: Int) {
@ -32,7 +28,7 @@ enum class Role(val roleName: String, val powerLevel: Int) {
if (type == null) return null
val enumString = type.removePrefix(RoleService.INTERNAL_ROLE_PREFIX)
return try {
Enum.valueOf(Role::class.java, enumString)
java.lang.Enum.valueOf(Role::class.java, enumString)
} catch (_: IllegalArgumentException) {
null
}

View File

@ -33,11 +33,14 @@ class SetupDataLoader(
val protocol = if (env.getProperty("server.ssl.key-store") != null) "https" else "http"
val rawAppUrl = env.getProperty("app.url")
@Suppress("HttpUrlsUsage")
val appUrl = when {
rawAppUrl.isNullOrBlank() -> null
rawAppUrl.startsWith("http://") || rawAppUrl.startsWith("https://") -> rawAppUrl
else -> "$protocol://$rawAppUrl"
}
val setupUrl =
appUrl ?: "${protocol}://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup"
log.info { "Visit $setupUrl to complete the setup" }

View File

@ -1,13 +1,16 @@
package org.gameyfin.app.core.config
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.vaadin.hilla.EndpointController.ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER
import com.vaadin.hilla.parser.jackson.ByteArrayModule
import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory
import org.gameyfin.app.core.serialization.*
import org.gameyfin.pluginapi.gamemetadata.*
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
import tools.jackson.databind.DeserializationFeature
import tools.jackson.databind.json.JsonMapper
import tools.jackson.databind.module.SimpleModule
/**
* Jackson configuration for custom serializers and deserializers.
@ -15,14 +18,21 @@ import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
@Configuration
class JacksonConfig {
@Bean
fun objectMapperCustomizer(): Jackson2ObjectMapperBuilder {
return Jackson2ObjectMapperBuilder()
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.modulesToInstall(JavaTimeModule(), displayableEnumModule())
@Bean(ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER)
fun jsonMapperFactory(): JacksonObjectMapperFactory {
return JacksonObjectMapperFactory {
JsonMapper.builder()
// Default Hilla options
.addModule(ByteArrayModule())
.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false)
.enable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
// Custom modules
.addModule(displayableEnumModule())
.build()
}
}
fun displayableEnumModule(): SimpleModule {
val module = SimpleModule("DisplayableEnumModule")

View File

@ -1,50 +0,0 @@
package org.gameyfin.app.core.config
import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.coyote.ProtocolHandler
import org.apache.coyote.http11.AbstractHttp11Protocol
import org.springframework.boot.tomcat.TomcatProtocolHandlerCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Tomcat configuration to optimize for concurrent connections
* and prevent download operations from blocking the server.
*/
@Configuration
class TomcatConfig {
companion object {
private val log = KotlinLogging.logger { }
}
@Bean
fun protocolHandlerCustomizer(): TomcatProtocolHandlerCustomizer<*> {
return TomcatProtocolHandlerCustomizer { protocolHandler: ProtocolHandler ->
if (protocolHandler is AbstractHttp11Protocol<*>) {
// Increase max connections to handle more concurrent users
protocolHandler.maxConnections = 10000
// Increase max threads to handle more concurrent requests
protocolHandler.maxThreads = 200
// Set minimum spare threads
protocolHandler.minSpareThreads = 10
// Set connection timeout (20 seconds)
protocolHandler.connectionTimeout = 20000
// Keep alive settings to reuse connections
protocolHandler.keepAliveTimeout = 60000
protocolHandler.maxKeepAliveRequests = 100
log.debug {
"Configured Tomcat connector: maxConnections=${protocolHandler.maxConnections}, " +
"maxThreads=${protocolHandler.maxThreads}, " +
"minSpareThreads=${protocolHandler.minSpareThreads}"
}
}
}
}
}

View File

@ -1,7 +1,6 @@
package org.gameyfin.app.core.download.bandwidth
import com.vaadin.hilla.Endpoint
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role
@ -17,11 +16,6 @@ import reactor.core.publisher.Flux
class BandwidthMonitoringEndpoint(
private val bandwidthMonitoringService: BandwidthMonitoringService
) {
companion object {
private val log = KotlinLogging.logger {}
}
@PermitAll
fun subscribe(): Flux<List<List<SessionStatsDto>>> {
return if (isCurrentUserAdmin()) BandwidthMonitoringService.subscribe()

View File

@ -29,7 +29,7 @@ class FilesystemService(
* @return A list of FileDto objects representing the files and directories.
*/
fun listContents(path: String?): List<FileDto> {
if (path == null || path.isEmpty()) {
if (path.isNullOrEmpty()) {
val roots = FileSystems.getDefault().rootDirectories.toList()
if (getHostOperatingSystem() == OperatingSystemType.WINDOWS) return roots.map {
@ -145,7 +145,7 @@ class FilesystemService(
if (file.isFile) {
file.length()
} else if (file.isDirectory) {
File(path).walkTopDown().filter { it.isFile }.map { it.length() }.sum()
File(path).walkTopDown().filter { it.isFile }.sumOf { it.length() }
} else {
0L
}

View File

@ -12,7 +12,7 @@ import org.hibernate.type.Type
import org.springframework.stereotype.Component
@Component
class EntityUpdateInterceptor() : Interceptor {
class EntityUpdateInterceptor : Interceptor {
override fun onFlushDirty(
entity: Any?,

View File

@ -1,9 +0,0 @@
package org.gameyfin.app.core.logging.dto
import org.springframework.boot.logging.LogLevel
data class LogConfigDto(
val logFolder: String,
val maxHistoryDays: Int,
val logLevel: LogLevel
)

View File

@ -136,7 +136,7 @@ class PluginService(
fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> {
log.debug { "Getting config for plugin ${pluginWrapper.pluginId}" }
return pluginConfigRepository.findAllById_PluginId(pluginWrapper.pluginId).associate { it.id.key to it.value }
return pluginConfigRepository.findAllByPluginId(pluginWrapper.pluginId).associate { it.id.key to it.value }
}
fun updateConfig(pluginId: String, config: Map<String, String>) {

View File

@ -1,8 +1,10 @@
package org.gameyfin.app.core.plugins.config
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface PluginConfigRepository : JpaRepository<PluginConfigEntry, PluginConfigEntryKey> {
fun findAllById_PluginId(pluginId: String): List<PluginConfigEntry>
fun findById_PluginIdAndId_Key(pluginId: String, key: String): PluginConfigEntry?
@Query("SELECT p FROM PluginConfigEntry p WHERE p.id.pluginId = :pluginId")
fun findAllByPluginId(pluginId: String): List<PluginConfigEntry>
}

View File

@ -10,9 +10,7 @@ class DatabasePluginStatusProvider(
) : PluginStatusProvider {
override fun isPluginDisabled(pluginId: String): Boolean {
val pluginManagement = pluginManagementRepository.findByIdOrNull(pluginId)
if (pluginManagement == null) return true
val pluginManagement = pluginManagementRepository.findByIdOrNull(pluginId) ?: return true
return !pluginManagement.enabled
}

View File

@ -3,10 +3,10 @@ package org.gameyfin.app.core.plugins.management
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.ExtensionDescriptor
import org.pf4j.ExtensionWrapper
import org.pf4j.LegacyExtensionFinder
import org.pf4j.IndexedExtensionFinder
import org.pf4j.PluginManager
class GameyfinExtensionFinder(pluginManager: PluginManager) : LegacyExtensionFinder(pluginManager) {
class GameyfinExtensionFinder(pluginManager: PluginManager) : IndexedExtensionFinder(pluginManager) {
companion object {
private val log = KotlinLogging.logger { }
}
@ -27,7 +27,7 @@ class GameyfinExtensionFinder(pluginManager: PluginManager) : LegacyExtensionFin
}
val classLoader =
if (pluginId != null) pluginManager.getPluginClassLoader(pluginId) else javaClass.getClassLoader()
if (pluginId != null) pluginManager.getPluginClassLoader(pluginId) else javaClass.classLoader
for (className in classNames) {
try {

View File

@ -3,7 +3,7 @@ package org.gameyfin.app.core.plugins.management
import org.pf4j.ManifestPluginDescriptorFinder
import java.util.jar.Manifest
class GameyfinManifestPluginDescriptorFinder() : ManifestPluginDescriptorFinder() {
class GameyfinManifestPluginDescriptorFinder : ManifestPluginDescriptorFinder() {
companion object {
const val PLUGIN_NAME: String = "Plugin-Name"

View File

@ -253,7 +253,7 @@ class GameyfinPluginManager(
}
private fun getConfig(pluginId: String): Map<String, String?> {
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
return pluginConfigRepository.findAllByPluginId(pluginId).associate { it.id.key to it.value }
}
private fun loadPluginSignaturePublicKey(): PublicKey {

View File

@ -1,8 +1,8 @@
package org.gameyfin.app.core.security
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
import tools.jackson.databind.ObjectMapper
@Converter
class EncryptionMapConverter : AttributeConverter<Map<String, String>, String> {

View File

@ -1,6 +1,5 @@
package org.gameyfin.app.core.security
import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration
import com.vaadin.flow.spring.security.VaadinSecurityConfigurer
import com.vaadin.hilla.route.RouteUtil
import org.gameyfin.app.config.ConfigProperties
@ -8,7 +7,6 @@ import org.gameyfin.app.config.ConfigService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Conditional
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.core.env.Environment
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity
@ -25,9 +23,6 @@ import org.springframework.security.web.authentication.logout.HttpStatusReturnin
@Configuration
@EnableWebSecurity
@Import(
VaadinAwareSecurityContextHolderStrategyConfiguration::class
)
class SecurityConfig(
private val environment: Environment,
private val config: ConfigService,
@ -41,6 +36,31 @@ class SecurityConfig(
@Bean
fun filterChain(http: HttpSecurity, routeUtil: RouteUtil): SecurityFilterChain {
// Apply Vaadin configuration first to properly configure CSRF and request matchers
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
// Redirect to SSO provider on logout
configurer.loginView("/login", config.get(ConfigProperties.SSO.OIDC.LogoutUrl))
}
// Use custom success handler to handle user registration
http.oauth2Login { oauth2Login ->
oauth2Login.successHandler(ssoAuthenticationSuccessHandler)
}
// Prevent unnecessary redirects
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
// Custom authentication entry point to support SSO and direct login
http.exceptionHandling { exceptionHandling ->
exceptionHandling.authenticationEntryPoint(CustomAuthenticationEntryPoint())
}
} else {
// Use default Vaadin login URLs
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
configurer.loginView("/login")
}
}
http.authorizeHttpRequests { auth ->
// Set default security policy that permits Hilla internal requests and denies all other
auth.requestMatchers(routeUtil::isRouteAllowed).permitAll()
@ -56,6 +76,13 @@ class SecurityConfig(
"/favicon.ico",
"/favicon.svg"
).permitAll()
// Client-side SPA routes - these need to pass through to serve index.html
// Authentication will be handled by Hilla on the client side
.requestMatchers(
"/administration/**",
"/settings/**",
"/collection/**"
).permitAll()
// Dynamic public access for certain endpoints
.requestMatchers(
"/",
@ -78,30 +105,6 @@ class SecurityConfig(
http.cors { cors -> cors.disable() }
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
// Redirect to SSO provider on logout
configurer.loginView("/login", config.get(ConfigProperties.SSO.OIDC.LogoutUrl))
}
// Use custom success handler to handle user registration
http.oauth2Login { oauth2Login ->
oauth2Login.successHandler(ssoAuthenticationSuccessHandler)
}
// Prevent unnecessary redirects
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
// Custom authentication entry point to support SSO and direct login
http.exceptionHandling { exceptionHandling ->
exceptionHandling.authenticationEntryPoint(CustomAuthenticationEntryPoint())
}
} else {
// Use default Vaadin login URLs
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
configurer.loginView("/login")
}
}
if ("dev" in environment.activeProfiles) {
http.authorizeHttpRequests { auth -> auth.requestMatchers("/h2-console/**").permitAll() }

View File

@ -13,5 +13,5 @@ fun isCurrentUserAdmin(): Boolean {
}
fun Authentication.isAdmin(): Boolean {
return this.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } ?: false
return this.authorities.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN }
}

View File

@ -23,7 +23,7 @@ class SsoAuthenticationSuccessHandler(
private val userService: UserService,
private val roleService: RoleService,
private val config: ConfigService,
private val roleHierarchy: RoleHierarchy,
roleHierarchy: RoleHierarchy,
) : AuthenticationSuccessHandler {
private val authoritiesMapper = RoleHierarchyAuthoritiesMapper(roleHierarchy)

View File

@ -1,6 +1,8 @@
package org.gameyfin.app.core.security
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.ConfigProperties
import org.springframework.beans.factory.getBean
import org.springframework.context.annotation.Condition
import org.springframework.context.annotation.ConditionContext
import org.springframework.core.env.Environment
@ -13,13 +15,24 @@ import java.sql.DriverManager
* So we are rawdogging the database connection and query execution here.
*/
class SsoEnabledCondition : Condition {
companion object {
private val log = KotlinLogging.logger { }
}
override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean {
try {
val environment = context.beanFactory!!.getBean(Environment::class.java);
val url = environment.getProperty("spring.datasource.url");
val user = environment.getProperty("spring.datasource.username");
val password = environment.getProperty("spring.datasource.password");
val connection = DriverManager.getConnection(url, user, password);
val environment = context.beanFactory?.getBean<Environment>()
if (environment == null) {
log.warn { "Environment hasn't been loaded yet, cannot determine if SSO is enabled." }
return false
}
val url = environment.getProperty("spring.datasource.url")
val user = environment.getProperty("spring.datasource.username")
val password = environment.getProperty("spring.datasource.password")
val connection = DriverManager.getConnection(url, user, password)
connection.use { c ->
val statement = c.prepareStatement("SELECT \"value\" FROM app_config WHERE \"key\" = ?")

View File

@ -1,18 +1,18 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.JsonNode
import tools.jackson.databind.ValueDeserializer
import java.io.Serializable
class ArrayDeserializer : JsonDeserializer<Serializable>() {
class ArrayDeserializer : ValueDeserializer<Serializable>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Serializable {
val node = p.codec.readTree<JsonNode>(p)
val node = p.objectReadContext().readTree<JsonNode>(p)
return if (node.isArray) {
node.map { it.asText() }.toTypedArray()
node.map { it.asString() }.toTypedArray()
} else {
p.codec.treeToValue(node, Serializable::class.java)
ctxt.readTreeAsValue(node, Serializable::class.java)
}
}
}

View File

@ -1,15 +1,16 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import tools.jackson.core.JsonGenerator
import tools.jackson.databind.SerializationContext
import tools.jackson.databind.ValueSerializer
/**
* A generic Jackson serializer for enums that have a displayName property.
* This serializer writes the displayName value instead of the enum constant name.
*/
class DisplayableSerializer : JsonSerializer<Any>() {
override fun serialize(value: Any?, gen: JsonGenerator, serializers: SerializerProvider) {
class DisplayableSerializer : ValueSerializer<Any>() {
override fun serialize(value: Any?, gen: JsonGenerator, serializers: SerializationContext) {
if (value == null) {
return
}

View File

@ -1,17 +1,17 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import org.gameyfin.pluginapi.gamemetadata.GameFeature
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
/**
* Jackson deserializer for GameFeature enum.
* Deserializes JSON strings by matching against the GameFeature's displayName property.
*/
class GameFeatureDeserializer : JsonDeserializer<GameFeature?>() {
class GameFeatureDeserializer : ValueDeserializer<GameFeature?>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): GameFeature? {
val displayName = p.text ?: return null
val displayName = p.string ?: return null
if (displayName.isEmpty()) {
return null

View File

@ -1,17 +1,17 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import org.gameyfin.pluginapi.gamemetadata.Genre
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
/**
* Jackson deserializer for Genre enum.
* Deserializes JSON strings by matching against the Genre's displayName property.
*/
class GenreDeserializer : JsonDeserializer<Genre?>() {
class GenreDeserializer : ValueDeserializer<Genre?>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Genre? {
val displayName = p.text ?: return null
val displayName = p.string ?: return null
if (displayName.isEmpty()) {
return null

View File

@ -1,17 +1,17 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import org.gameyfin.pluginapi.gamemetadata.Platform
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
/**
* Jackson deserializer for Platform enum.
* Deserializes JSON strings by matching against the Platform's displayName property.
*/
class PlatformDeserializer : JsonDeserializer<Platform?>() {
class PlatformDeserializer : ValueDeserializer<Platform?>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Platform? {
val displayName = p.text ?: return null
val displayName = p.string ?: return null
if (displayName.isEmpty()) {
return null

View File

@ -1,17 +1,17 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
/**
* Jackson deserializer for PlayerPerspective enum.
* Deserializes JSON strings by matching against the PlayerPerspective's displayName property.
*/
class PlayerPerspectiveDeserializer : JsonDeserializer<PlayerPerspective?>() {
class PlayerPerspectiveDeserializer : ValueDeserializer<PlayerPerspective?>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): PlayerPerspective? {
val displayName = p.text ?: return null
val displayName = p.string ?: return null
if (displayName.isEmpty()) {
return null

View File

@ -1,17 +1,17 @@
package org.gameyfin.app.core.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import org.gameyfin.pluginapi.gamemetadata.Theme
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.ValueDeserializer
/**
* Jackson deserializer for Theme enum.
* Deserializes JSON strings by matching against the Theme's displayName property.
*/
class ThemeDeserializer : JsonDeserializer<Theme?>() {
class ThemeDeserializer : ValueDeserializer<Theme?>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Theme? {
val displayName = p.text ?: return null
val displayName = p.string ?: return null
if (displayName.isEmpty()) {
return null

View File

@ -15,6 +15,10 @@ import kotlin.time.toJavaDuration
@Entity
class Token<T : TokenType>(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: String? = null,
@Column(unique = true, nullable = false)
@Convert(converter = EncryptionConverter::class)
val secret: String = UUID.randomUUID().toString(),

View File

@ -1,6 +1,6 @@
package org.gameyfin.app.core.token
import org.hibernate.engine.spi.SharedSessionContractImplementor
import org.hibernate.type.descriptor.WrapperOptions
import org.hibernate.usertype.UserType
import java.io.Serializable
import java.sql.PreparedStatement
@ -25,8 +25,7 @@ class TokenTypeUserType : UserType<TokenType> {
override fun nullSafeGet(
rs: ResultSet,
position: Int,
session: SharedSessionContractImplementor,
owner: Any?
options: WrapperOptions
): TokenType? {
val key = rs.getString(position) ?: return null
val tokenTypeClass = TokenType::class
@ -41,7 +40,7 @@ class TokenTypeUserType : UserType<TokenType> {
st: PreparedStatement,
value: TokenType?,
index: Int,
session: SharedSessionContractImplementor
options: WrapperOptions
) {
if (value == null) {
st.setNull(index, Types.VARCHAR)

View File

@ -1,5 +1,5 @@
package org.gameyfin.app.core.token
enum class TokenValidationResult() {
enum class TokenValidationResult {
VALID, INVALID, EXPIRED
}

View File

@ -120,7 +120,7 @@ class GameService(
imageService.downloadIfNew(it)
}
game.images.map {
game.images.forEach {
imageService.downloadIfNew(it)
}
} catch (e: Exception) {
@ -647,7 +647,7 @@ class GameService(
// (Optional) Step 0: Extract title from filename using regex
if (config.get(ConfigProperties.Libraries.Scan.ExtractTitleUsingRegex) == true) {
val regexString = config.get(ConfigProperties.Libraries.Scan.TitleExtractionRegex)
if (regexString != null && regexString.isNotEmpty()) {
if (!regexString.isNullOrEmpty()) {
try {
val regex = Regex(regexString)
val originalQuery = query

View File

@ -32,6 +32,7 @@ class Game(
@ElementCollection(targetClass = Platform::class, fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var platforms: MutableList<Platform> = mutableListOf(),
var title: String? = null,

View File

@ -1,8 +0,0 @@
package org.gameyfin.app.games.repositories
import org.gameyfin.app.media.Image
import org.springframework.content.commons.store.ContentStore
import org.springframework.stereotype.Repository
@Repository
interface ImageContentStore : ContentStore<Image, String>

View File

@ -9,9 +9,12 @@ class IgnoredPath(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@Column(unique = true, nullable = false, length = 1024)
val path: String,
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(nullable = false)
val source: IgnoredPathSource
) {
fun getType(): IgnoredPathSourceType {

View File

@ -29,6 +29,7 @@ class Library(
@ElementCollection(targetClass = Platform::class, fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var platforms: MutableList<Platform> = ArrayList(),
@OneToMany(mappedBy = "library", fetch = FetchType.EAGER, orphanRemoval = true)

View File

@ -0,0 +1,90 @@
package org.gameyfin.app.media
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.*
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteExisting
import kotlin.io.path.exists
private val logger = KotlinLogging.logger {}
/**
* Service for handling file storage operations.
* Files are stored in the filesystem under the specified root directory.
* The content ID is a UUID string used as the filename.
* Files are stored without extensions; MIME type is managed separately.
*
* Note: This is a drop-in replacement for Spring Content's filesystem storage (which has been discontinued).
*/
@Service
class FileStorageService(
@param:Value($$"${spring.content.fs.filesystem-root:./data/}") private val storageRoot: String
) {
private val rootPath: Path = Path.of(storageRoot)
init {
// Ensure storage directory exists
if (!rootPath.exists()) {
rootPath.createDirectories()
logger.info { "Created file storage directory: $rootPath" }
}
}
/**
* Stores a file and returns the generated content ID (UUID).
*/
fun saveFile(inputStream: InputStream): String {
val contentId = UUID.randomUUID().toString()
val filePath = rootPath.resolve(contentId)
inputStream.use { input ->
Files.copy(input, filePath, StandardCopyOption.REPLACE_EXISTING)
}
logger.debug { "Saved file with contentId: $contentId" }
return contentId
}
/**
* Retrieves a file by content ID.
* Returns null if the file doesn't exist.
*/
fun getFile(contentId: String?): InputStream? {
if (contentId == null) return null
val filePath = rootPath.resolve(contentId)
return if (filePath.exists()) {
Files.newInputStream(filePath)
} else {
logger.warn { "File not found for contentId: $contentId" }
null
}
}
/**
* Deletes a file by content ID.
*/
fun deleteFile(contentId: String?) {
if (contentId == null) return
val filePath = rootPath.resolve(contentId)
if (filePath.exists()) {
filePath.deleteExisting()
logger.debug { "Deleted file with contentId: $contentId" }
}
}
/**
* Checks if a file exists for the given content ID.
*/
fun fileExists(contentId: String?): Boolean {
if (contentId == null) return false
return rootPath.resolve(contentId).exists()
}
}

View File

@ -1,9 +1,6 @@
package org.gameyfin.app.media
import jakarta.persistence.*
import org.springframework.content.commons.annotations.ContentId
import org.springframework.content.commons.annotations.ContentLength
import org.springframework.content.commons.annotations.MimeType
@Entity
class Image(
@ -16,13 +13,10 @@ class Image(
val type: ImageType,
@ContentId
var contentId: String? = null,
@ContentLength
var contentLength: Long? = null,
@MimeType
var mimeType: String? = null,
var blurhash: String? = null

View File

@ -28,22 +28,22 @@ class ImageEndpoint(
) {
@GetMapping("/screenshot/{id}")
fun getScreenshot(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
fun getScreenshot(@PathVariable id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
}
@GetMapping("/cover/{id}")
fun getCover(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
fun getCover(@PathVariable id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
}
@GetMapping("/header/{id}")
fun getHeader(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
fun getHeader(@PathVariable id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
}
@GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
@GetMapping("/plugins/{pluginId}/logo")
fun getPluginLogo(@PathVariable pluginId: String): ResponseEntity<ByteArrayResource>? {
val logo = pluginService.getLogo(pluginId)
return Utils.inputStreamToResponseEntity(logo)
}

View File

@ -8,7 +8,6 @@ import org.gameyfin.app.core.events.GameUpdatedEvent
import org.gameyfin.app.core.events.UserDeletedEvent
import org.gameyfin.app.core.events.UserUpdatedEvent
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.games.repositories.ImageContentStore
import org.gameyfin.app.games.repositories.ImageRepository
import org.gameyfin.app.users.persistence.UserRepository
import org.springframework.dao.DataIntegrityViolationException
@ -28,7 +27,7 @@ import javax.imageio.ImageIO
@Service
class ImageService(
private val imageRepository: ImageRepository,
private val imageContentStore: ImageContentStore,
private val fileStorageService: FileStorageService,
private val gameRepository: GameRepository,
private val userRepository: UserRepository
) {
@ -39,6 +38,7 @@ class ImageService(
* Scale down image for faster blurhash calculation.
* Blurhash doesn't need full resolution - 100px width is plenty for a good blur.
*/
@Suppress("DuplicatedCode")
fun scaleImageForBlurhash(original: BufferedImage, maxWidth: Int = 100): BufferedImage {
val originalWidth = original.width
val originalHeight = original.height
@ -49,10 +49,9 @@ class ImageService(
}
val scale = maxWidth.toDouble() / originalWidth
val targetWidth = maxWidth
val targetHeight = (originalHeight * scale).toInt()
val scaled = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB)
val scaled = BufferedImage(maxWidth, targetHeight, BufferedImage.TYPE_INT_RGB)
val g2d = scaled.createGraphics()
// Use fast scaling for blurhash - quality doesn't matter much for a blur
@ -60,7 +59,7 @@ class ImageService(
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF)
g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null)
g2d.drawImage(original, 0, 0, maxWidth, targetHeight, null)
g2d.dispose()
return scaled
@ -152,7 +151,6 @@ class ImageService(
// If the existing image has valid content we can just associate it instead of downloading again
if (existingImageHasValidContent && existingImage.contentId != null) {
// Associate existing content with the current image entity reference
imageContentStore.associate(image, existingImage.contentId)
image.contentId = existingImage.contentId
image.contentLength = existingImage.contentLength
image.mimeType = existingImage.mimeType
@ -162,19 +160,7 @@ class ImageService(
// If no existing image or existing image has no valid content, download it
TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input ->
image.mimeType = tika.detect(input)
// Read the input stream into a byte array so we can use it twice
val imageBytes = input.readBytes()
// Calculate blurhash
ByteArrayInputStream(imageBytes).use { blurhashStream ->
image.blurhash = calculateBlurhash(blurhashStream)
}
// Store content
ByteArrayInputStream(imageBytes).use { contentStream ->
imageContentStore.setContent(image, contentStream)
}
processImageContent(image, input)
}
// Save or update the image to ensure it's persisted
@ -187,21 +173,7 @@ class ImageService(
fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image {
val image = Image(type = type, mimeType = mimeType)
// Read the input stream into a byte array so we can use it twice
val imageBytes = content.readBytes()
// Calculate blurhash
ByteArrayInputStream(imageBytes).use { blurhashStream ->
image.blurhash = calculateBlurhash(blurhashStream)
}
// Store content
ByteArrayInputStream(imageBytes).use { contentStream ->
imageContentStore.setContent(image, contentStream)
}
// Save with blurhash
processImageContent(image, content)
return imageRepository.save(image)
}
@ -210,8 +182,7 @@ class ImageService(
}
fun getFileContent(image: Image): InputStream? {
return imageContentStore.getContent(image)
return fileStorageService.getFile(image.contentId)
}
fun deleteImageIfUnused(image: Image) {
@ -221,13 +192,30 @@ class ImageService(
if (!isImageStillInUse) {
imageRepository.delete(image)
imageContentStore.unsetContent(image)
fileStorageService.deleteFile(image.contentId)
}
}
fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
mimeType?.let { image.mimeType = it }
// Delete old file if it exists
image.contentId?.let { fileStorageService.deleteFile(it) }
// Process and store new content
processImageContent(image, content)
return imageRepository.save(image)
}
private fun imageHasValidContent(image: Image): Boolean {
return image.contentId != null
&& fileStorageService.fileExists(image.contentId)
&& image.contentLength != null
&& image.contentLength!! > 0
}
private fun processImageContent(image: Image, content: InputStream) {
// Read the input stream into a byte array so we can use it twice
val imageBytes = content.readBytes()
@ -238,16 +226,9 @@ class ImageService(
// Store content
ByteArrayInputStream(imageBytes).use { contentStream ->
imageContentStore.setContent(image, contentStream)
image.contentId = fileStorageService.saveFile(contentStream)
image.contentLength = imageBytes.size.toLong()
}
// Save with blurhash
return imageRepository.save(image)
}
private fun imageHasValidContent(image: Image): Boolean {
val imageContent = imageContentStore.getContent(image)
return imageContent != null && image.contentLength != null && image.contentLength!! > 0
}
private fun calculateBlurhash(inputStream: InputStream): String? {

View File

@ -8,6 +8,7 @@ import org.gameyfin.app.messages.providers.AbstractMessageProvider
import org.gameyfin.app.messages.templates.MessageTemplateService
import org.gameyfin.app.messages.templates.MessageTemplates
import org.gameyfin.app.users.UserService
import org.springframework.beans.factory.getBeansOfType
import org.springframework.context.ApplicationContext
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
@ -27,7 +28,7 @@ class MessageService(
get() = providers.any { it.enabled }
private val providers: List<AbstractMessageProvider>
get() = applicationContext.getBeansOfType(AbstractMessageProvider::class.java).values.toList()
get() = applicationContext.getBeansOfType<AbstractMessageProvider>().values.toList()
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
val messageProvider = providers.find { it.providerKey == provider }

View File

@ -67,7 +67,7 @@ class EmailMessageProvider(
val transport = session.getTransport("smtp")
try {
transport.use { transport ->
transport.connect(
credentials["host"] as String,
credentials["port"] as Int,
@ -75,8 +75,6 @@ class EmailMessageProvider(
credentials["password"] as String
)
transport.sendMessage(mimeMessage, mimeMessage.allRecipients)
} finally {
transport.close()
}
}
}

View File

@ -7,18 +7,7 @@ import org.springframework.stereotype.Service
class SystemService(
private val restartEndpoint: RestartEndpoint,
) {
private var restartRequired = false;
fun restart() {
restartEndpoint.restart()
}
fun setRestartRequired() {
restartRequired = true
}
fun isRestartRequired(): Boolean {
return restartRequired
}
}

View File

@ -6,13 +6,11 @@ import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.token.TokenDto
import org.gameyfin.app.core.token.TokenValidationResult
import org.gameyfin.app.users.UserService
@Endpoint
@AnonymousAllowed
class PasswordResetEndpoint(
private val passwordResetService: PasswordResetService,
private val userService: UserService
private val passwordResetService: PasswordResetService
) {
fun requestPasswordReset(email: String) {

View File

@ -59,7 +59,7 @@ class PasswordResetService(
*/
fun requestPasswordReset(email: String) {
val maskedEmail = Utils.Companion.maskEmail(email)
val maskedEmail = Utils.maskEmail(email)
log.info { "Initiating password reset request for '${maskedEmail}'" }
@ -81,7 +81,7 @@ class PasswordResetService(
}
val token = generate(user)
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, Utils.Companion.getBaseUrl()))
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, Utils.getBaseUrl()))
// Simulate a delay to prevent timing attacks
Thread.sleep(secureRandom.nextLong(1024))

View File

@ -29,7 +29,7 @@ class UserPreferencesService(
return if (appConfig != null) {
getValue(appConfig.value, userPreference)
} else {
return null
null
}
}
@ -51,7 +51,7 @@ class UserPreferencesService(
return if (appConfig != null) {
getValue(appConfig.value, userPreference).toString()
} else {
return null
null
}
}

View File

@ -27,14 +27,14 @@ class InvitationService(
fun createInvitation(email: String): TokenDto {
if (userService.existsByEmail(email))
throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered")
throw IllegalStateException("User with email ${Utils.maskEmail(email)} is already registered")
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
val payload = mapOf(EMAIL_KEY to email)
val token = super.generateWithPayload(user, payload)
eventPublisher.publishEvent(UserInvitationEvent(this, token, Utils.Companion.getBaseUrl(), email))
eventPublisher.publishEvent(UserInvitationEvent(this, token, Utils.getBaseUrl(), email))
return TokenDto(token)
}
@ -52,8 +52,8 @@ class InvitationService(
try {
val user = userService.registerUserFromInvitation(registration, email)
super.delete(invitationToken)
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl()))
} catch (e: IllegalStateException) {
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
} catch (_: IllegalStateException) {
return UserInvitationAcceptanceResult.USERNAME_TAKEN
}

View File

@ -1,6 +1,7 @@
package org.gameyfin.app.util
import jakarta.persistence.EntityManager
import org.springframework.beans.factory.getBean
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Component
@ -10,7 +11,7 @@ object EntityManagerHolder : ApplicationContextAware {
private var entityManager: EntityManager? = null
override fun setApplicationContext(context: ApplicationContext) {
entityManager = context.getBean(EntityManager::class.java)
entityManager = context.getBean<EntityManager>()
}
fun getEntityManager(): EntityManager {

View File

@ -22,6 +22,7 @@ object BlurhashMigration {
* Scale down image for faster blurhash calculation.
* Blurhash doesn't need full resolution - 100px width is plenty for a good blur.
*/
@Suppress("DuplicatedCode")
private fun scaleImageForBlurhash(original: BufferedImage, maxWidth: Int = 100): BufferedImage {
val originalWidth = original.width
val originalHeight = original.height
@ -32,10 +33,9 @@ object BlurhashMigration {
}
val scale = maxWidth.toDouble() / originalWidth
val targetWidth = maxWidth
val targetHeight = (originalHeight * scale).toInt()
val scaled = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB)
val scaled = BufferedImage(maxWidth, targetHeight, BufferedImage.TYPE_INT_RGB)
val g2d = scaled.createGraphics()
// Use fast scaling for blurhash - quality doesn't matter much for a blur
@ -43,7 +43,7 @@ object BlurhashMigration {
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED)
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF)
g2d.drawImage(original, 0, 0, targetWidth, targetHeight, null)
g2d.drawImage(original, 0, 0, maxWidth, targetHeight, null)
g2d.dispose()
return scaled

View File

@ -1,7 +1,7 @@
package org.gameyfin.db.h2
import com.fasterxml.jackson.databind.ObjectMapper
import org.gameyfin.app.core.security.EncryptionUtils
import tools.jackson.databind.ObjectMapper
import java.sql.Connection
import java.sql.SQLException

View File

@ -7,8 +7,6 @@ logging.level:
org.gameyfin.GameyfinApplicationKt: warn
# Suppress false positive warnings from Spring Security 6
org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: error
# Hides an error log on the first aborted download
org.apache.catalina.core.ContainerBase: off
server:
port: 8080
@ -17,10 +15,10 @@ server:
tracking-modes: cookie
timeout: 24h
forward-headers-strategy: framework
tomcat:
remoteip:
protocol-header: X-Forwarded-Proto
remote-ip-header: X-Forwarded-For
jetty:
threads:
max: 200
min: 8
management:
server:

View File

@ -0,0 +1,30 @@
-- Flyway Migration: V2.4.0
-- Purpose: Refactor TOKEN table to support encryption on secret field by separating primary key from secret.
-- Context: Hibernate 6.x (Spring Boot 4) does not allow AttributeConverter on @Id fields.
-- The secret field contains sensitive token data (password reset tokens, etc.) that needs encryption.
-- Strategy:
-- Modify the existing TOKEN table in-place by adding a new ID column and restructuring constraints.
-- Step 1: Add new ID column (nullable initially to allow data population)
ALTER TABLE TOKEN ADD COLUMN ID CHARACTER VARYING(255);
-- Step 2: Populate ID column with new UUIDs for existing rows
UPDATE TOKEN SET ID = RANDOM_UUID() WHERE ID IS NULL;
-- Step 3: Make ID column non-null now that it has values
ALTER TABLE TOKEN ALTER COLUMN ID SET NOT NULL;
-- Step 4: Drop the primary key constraint on SECRET
-- H2 uses auto-generated constraint names, so we need to find and drop it
-- The primary key constraint is typically named PRIMARY_KEY_XXX or CONSTRAINT_XXX
ALTER TABLE TOKEN DROP PRIMARY KEY;
-- Step 5: Add primary key constraint on ID
ALTER TABLE TOKEN ADD PRIMARY KEY (ID);
-- Step 6: Add unique constraint on SECRET (it was previously the primary key, so it was already unique)
-- The SECRET column should remain unique for lookups
ALTER TABLE TOKEN ADD CONSTRAINT UK_TOKEN_SECRET UNIQUE (SECRET);
-- Step 7: Create index on SECRET for fast lookups
CREATE INDEX IDX_TOKEN_SECRET ON TOKEN(SECRET);

View File

@ -1,6 +1,5 @@
package org.gameyfin.app.config
import com.fasterxml.jackson.databind.ObjectMapper
import io.mockk.*
import org.gameyfin.app.config.entities.ConfigEntry
import org.gameyfin.app.config.persistence.ConfigRepository
@ -9,6 +8,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.boot.logging.LogLevel
import org.springframework.data.repository.findByIdOrNull
import tools.jackson.databind.ObjectMapper
import java.io.Serializable
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

View File

@ -425,5 +425,122 @@ class SessionBandwidthTrackerTest {
val afterRecordTime = tracker.lastActivityTime
assertTrue(afterRecordTime > initialTime)
}
@Test
fun `updateMonitoringStatistics should handle concurrent window rotation`() {
val threadCount = 10
val executor = Executors.newFixedThreadPool(threadCount)
val latch = CountDownLatch(threadCount)
// Set window start to 11 seconds ago to trigger rotation
val monitoringWindowStartField = tracker.javaClass.getDeclaredField("monitoringWindowStart")
monitoringWindowStartField.isAccessible = true
val elevenSecondsAgo = System.nanoTime() - 11_000_000_000L
monitoringWindowStartField.setLong(tracker, elevenSecondsAgo)
// Have multiple threads try to record bytes at the same time
// This should trigger concurrent window rotation attempts
repeat(threadCount) {
executor.submit {
try {
tracker.recordBytes(100)
} finally {
latch.countDown()
}
}
}
assertTrue(latch.await(5, TimeUnit.SECONDS))
executor.shutdown()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
// All bytes should be recorded despite concurrent rotation
assertEquals(1000, tracker.totalBytesTransferred)
}
@Test
fun `updateMonitoringStatistics should update totalBytesTransferred atomically`() {
tracker.recordBytes(1000)
assertEquals(1000, tracker.totalBytesTransferred)
tracker.recordBytes(2000)
assertEquals(3000, tracker.totalBytesTransferred)
tracker.recordBytes(500)
assertEquals(3500, tracker.totalBytesTransferred)
}
@Test
fun `updateMonitoringStatistics should update lastActivityTime on each call`() {
val time1 = tracker.lastActivityTime
Thread.sleep(10)
tracker.recordBytes(100)
val time2 = tracker.lastActivityTime
assertTrue(time2 > time1, "Activity time should increase after recordBytes")
Thread.sleep(10)
tracker.throttle(100)
val time3 = tracker.lastActivityTime
assertTrue(time3 > time2, "Activity time should increase after throttle")
}
@Test
fun `getCurrentBytesPerSecond should blend with previous window when current window is young`() {
// Record bytes in first window
tracker.recordBytes(10_000)
Thread.sleep(1100) // Wait over 1 second to ensure first window is mature
// Force window rotation by setting window start to 11 seconds ago
val monitoringWindowStartField = tracker.javaClass.getDeclaredField("monitoringWindowStart")
monitoringWindowStartField.isAccessible = true
val elevenSecondsAgo = System.nanoTime() - 11_000_000_000L
monitoringWindowStartField.setLong(tracker, elevenSecondsAgo)
// Record bytes to trigger rotation
tracker.recordBytes(5_000)
// Immediately check rate - should blend with previous window since current is young
Thread.sleep(100) // Sleep a bit but less than 1 second
val rate = tracker.getCurrentBytesPerSecond()
// The rate should be positive and influenced by both windows
assertTrue(rate > 0, "Rate should be positive with blended windows")
assertTrue(tracker.totalBytesTransferred == 15_000L, "Total should be 15,000 bytes")
}
@Test
fun `updateMonitoringStatistics should handle synchronized block correctly during rotation`() {
// Record initial bytes
tracker.recordBytes(1000)
// Set up for window rotation
val monitoringWindowStartField = tracker.javaClass.getDeclaredField("monitoringWindowStart")
monitoringWindowStartField.isAccessible = true
val elevenSecondsAgo = System.nanoTime() - 11_000_000_000L
monitoringWindowStartField.setLong(tracker, elevenSecondsAgo)
// Record more bytes - should trigger synchronized block for rotation
tracker.recordBytes(2000)
// Verify the bytes were recorded correctly
assertEquals(3000, tracker.totalBytesTransferred)
// Record more bytes in the new window
tracker.recordBytes(500)
assertEquals(3500, tracker.totalBytesTransferred)
}
@Test
fun `throttle should call updateMonitoringStatistics with correct byte count`() {
val bytes = 5000L
tracker.throttle(bytes)
// Verify bytes were recorded
assertEquals(bytes, tracker.totalBytesTransferred)
// Verify activity time was updated
assertTrue(tracker.lastActivityTime > 0)
}
}

View File

@ -42,7 +42,7 @@ class GameyfinPluginManagerTest {
pluginManagementRepository = mockk(relaxed = true)
// Set up default mocks
every { pluginConfigRepository.findAllById_PluginId(any()) } returns emptyList()
every { pluginConfigRepository.findAllByPluginId(any()) } returns emptyList()
every { pluginManagementRepository.findByIdOrNull(any()) } returns null
every { pluginManagementRepository.save(any()) } returnsArgument 0
every { pluginManagementRepository.findMaxPriority() } returns null
@ -233,7 +233,7 @@ class GameyfinPluginManagerTest {
every { pluginManager.getPlugin("test-plugin") } returns pluginWrapper
every { pluginManager.stopPlugin("test-plugin") } returns PluginState.STOPPED
every { pluginManager.startPlugin("test-plugin") } returns PluginState.STARTED
every { pluginConfigRepository.findAllById_PluginId("test-plugin") } returns configEntries
every { pluginConfigRepository.findAllByPluginId("test-plugin") } returns configEntries
pluginManager.restart("test-plugin")

View File

@ -5,6 +5,7 @@ import org.gameyfin.app.config.ConfigProperties
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.getBean
import org.springframework.context.annotation.ConditionContext
import org.springframework.core.env.Environment
import org.springframework.core.type.AnnotatedTypeMetadata
@ -30,7 +31,7 @@ class SsoEnabledConditionTest {
environment = mockk<Environment>()
every { context.beanFactory } returns mockk {
every { getBean(Environment::class.java) } returns environment
every { getBean<Environment>() } returns environment
}
}

View File

@ -0,0 +1,106 @@
package org.gameyfin.app.core.serialization
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonParser
import tools.jackson.core.ObjectReadContext
import tools.jackson.databind.DeserializationContext
import tools.jackson.databind.JsonNode
import java.io.Serializable
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ArrayDeserializerTest {
private lateinit var deserializer: ArrayDeserializer
private lateinit var jsonParser: JsonParser
private lateinit var deserializationContext: DeserializationContext
private lateinit var objectReadContext: ObjectReadContext
@BeforeEach
fun setup() {
deserializer = ArrayDeserializer()
jsonParser = mockk()
deserializationContext = mockk()
objectReadContext = mockk()
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `deserialize should convert JSON array to String array`() {
val textNode1 = mockk<JsonNode>()
val textNode2 = mockk<JsonNode>()
val textNode3 = mockk<JsonNode>()
every { textNode1.asString() } returns "item1"
every { textNode2.asString() } returns "item2"
every { textNode3.asString() } returns "item3"
val arrayNode = mockk<JsonNode>()
every { arrayNode.isArray } returns true
every { arrayNode.iterator() } returns mutableListOf(textNode1, textNode2, textNode3).iterator()
every { jsonParser.objectReadContext() } returns objectReadContext
every { objectReadContext.readTree<JsonNode>(jsonParser) } returns arrayNode
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertTrue(result is Array<*>)
assertEquals(3, result.size)
assertEquals("item1", result[0])
assertEquals("item2", result[1])
assertEquals("item3", result[2])
}
@Test
fun `deserialize should convert empty JSON array to empty String array`() {
val arrayNode = mockk<JsonNode>()
every { arrayNode.isArray } returns true
every { arrayNode.iterator() } returns mutableListOf<JsonNode>().iterator()
every { jsonParser.objectReadContext() } returns objectReadContext
every { objectReadContext.readTree<JsonNode>(jsonParser) } returns arrayNode
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertTrue(result is Array<*>)
assertEquals(0, result.size)
}
@Test
fun `deserialize should handle non-array JSON node`() {
val textNode = mockk<JsonNode>()
val serializable = "test string" as Serializable
every { textNode.isArray } returns false
every { jsonParser.objectReadContext() } returns objectReadContext
every { objectReadContext.readTree<JsonNode>(jsonParser) } returns textNode
every { deserializationContext.readTreeAsValue(textNode, Serializable::class.java) } returns serializable
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(serializable, result)
}
@Test
fun `deserialize should handle array with single element`() {
val textNode = mockk<JsonNode>()
every { textNode.asString() } returns "single"
val arrayNode = mockk<JsonNode>()
every { arrayNode.isArray } returns true
every { arrayNode.iterator() } returns mutableListOf(textNode).iterator()
every { jsonParser.objectReadContext() } returns objectReadContext
every { objectReadContext.readTree<JsonNode>(jsonParser) } returns arrayNode
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertTrue(result is Array<*>)
assertEquals(1, result.size)
assertEquals("single", result[0])
}
}

View File

@ -0,0 +1,157 @@
package org.gameyfin.app.core.serialization
import io.mockk.mockk
import io.mockk.unmockkAll
import io.mockk.verify
import org.gameyfin.pluginapi.gamemetadata.GameFeature
import org.gameyfin.pluginapi.gamemetadata.Genre
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import org.gameyfin.pluginapi.gamemetadata.Theme
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonGenerator
import tools.jackson.databind.SerializationContext
class DisplayableSerializerTest {
private lateinit var serializer: DisplayableSerializer
private lateinit var jsonGenerator: JsonGenerator
private lateinit var serializationContext: SerializationContext
@BeforeEach
fun setup() {
serializer = DisplayableSerializer()
jsonGenerator = mockk(relaxed = true)
serializationContext = mockk()
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `serialize should write displayName for valid theme`() {
val theme = Theme.SCIENCE_FICTION
serializer.serialize(theme, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Science Fiction") }
}
@Test
fun `serialize should handle null value`() {
serializer.serialize(null, jsonGenerator, serializationContext)
verify(exactly = 0) { jsonGenerator.writeString(any<String>()) }
}
@Test
fun `serialize should write displayName for valid genre`() {
val genre = Genre.ROLE_PLAYING
serializer.serialize(genre, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Role-Playing") }
}
@Test
fun `serialize should write displayName for valid game feature`() {
val feature = GameFeature.MULTIPLAYER
serializer.serialize(feature, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Multiplayer") }
}
@Test
fun `serialize should write displayName for valid player perspective`() {
val perspective = PlayerPerspective.FIRST_PERSON
serializer.serialize(perspective, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("First-Person") }
}
@Test
fun `serialize should handle theme with hyphens`() {
val theme = Theme.NON_FICTION
serializer.serialize(theme, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Non-Fiction") }
}
@Test
fun `serialize should handle genre with ampersand`() {
val genre = Genre.CARD_AND_BOARD_GAME
serializer.serialize(genre, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Card & Board Game") }
}
@Test
fun `serialize should handle genre with slash and apostrophe`() {
val genre = Genre.HACK_AND_SLASH_BEAT_EM_UP
serializer.serialize(genre, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Hack and Slash/Beat 'em up") }
}
@Test
fun `serialize should handle feature with hyphen`() {
val feature = GameFeature.CROSS_PLATFORM
serializer.serialize(feature, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Cross-Platform") }
}
@Test
fun `serialize should handle perspective with slash`() {
val perspective = PlayerPerspective.BIRD_VIEW_ISOMETRIC
serializer.serialize(perspective, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Bird View/Isometric") }
}
@Test
fun `serialize should handle all theme values correctly`() {
Theme.entries.forEach { theme ->
serializer.serialize(theme, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString(theme.displayName) }
}
}
@Test
fun `serialize should handle all genre values correctly`() {
Genre.entries.forEach { genre ->
serializer.serialize(genre, jsonGenerator, serializationContext)
verify(atLeast = 1) { jsonGenerator.writeString(genre.displayName) }
}
}
@Test
fun `serialize should handle all game feature values correctly`() {
GameFeature.entries.forEach { feature ->
serializer.serialize(feature, jsonGenerator, serializationContext)
verify(atLeast = 1) { jsonGenerator.writeString(feature.displayName) }
}
}
@Test
fun `serialize should handle all player perspective values correctly`() {
PlayerPerspective.entries.forEach { perspective ->
serializer.serialize(perspective, jsonGenerator, serializationContext)
verify(atLeast = 1) { jsonGenerator.writeString(perspective.displayName) }
}
}
}

View File

@ -0,0 +1,196 @@
package org.gameyfin.app.core.serialization
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.gameyfin.pluginapi.gamemetadata.GameFeature
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import kotlin.test.assertEquals
import kotlin.test.assertNull
class GameFeatureDeserializerTest {
private lateinit var deserializer: GameFeatureDeserializer
private lateinit var jsonParser: JsonParser
private lateinit var deserializationContext: DeserializationContext
@BeforeEach
fun setup() {
deserializer = GameFeatureDeserializer()
jsonParser = mockk()
deserializationContext = mockk()
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `deserialize should return correct feature for valid displayName`() {
every { jsonParser.string } returns "Singleplayer"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.SINGLEPLAYER, result)
}
@Test
fun `deserialize should return null for unknown displayName`() {
every { jsonParser.string } returns "Unknown Feature"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for empty string`() {
every { jsonParser.string } returns ""
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for null string`() {
every { jsonParser.string } returns null
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should be case-sensitive`() {
every { jsonParser.string } returns "multiplayer"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should handle Multiplayer feature`() {
every { jsonParser.string } returns "Multiplayer"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.MULTIPLAYER, result)
}
@Test
fun `deserialize should handle Co-op feature`() {
every { jsonParser.string } returns "Co-op"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.CO_OP, result)
}
@Test
fun `deserialize should handle Cross-Platform feature`() {
every { jsonParser.string } returns "Cross-Platform"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.CROSS_PLATFORM, result)
}
@Test
fun `deserialize should handle VR feature`() {
every { jsonParser.string } returns "VR"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.VR, result)
}
@Test
fun `deserialize should handle AR feature`() {
every { jsonParser.string } returns "AR"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.AR, result)
}
@Test
fun `deserialize should handle Cloud Saves feature`() {
every { jsonParser.string } returns "Cloud Saves"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.CLOUD_SAVES, result)
}
@Test
fun `deserialize should handle Controller Support feature`() {
every { jsonParser.string } returns "Controller Support"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.CONTROLLER_SUPPORT, result)
}
@Test
fun `deserialize should handle Local Multiplayer feature`() {
every { jsonParser.string } returns "Local Multiplayer"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.LOCAL_MULTIPLAYER, result)
}
@Test
fun `deserialize should handle Online Co-op feature`() {
every { jsonParser.string } returns "Online Co-op"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.ONLINE_CO_OP, result)
}
@Test
fun `deserialize should handle Online PvP feature`() {
every { jsonParser.string } returns "Online PvP"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.ONLINE_PVP, result)
}
@Test
fun `deserialize should handle Crossplay feature`() {
every { jsonParser.string } returns "Crossplay"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.CROSSPLAY, result)
}
@Test
fun `deserialize should handle Splitscreen feature`() {
every { jsonParser.string } returns "Splitscreen"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(GameFeature.SPLITSCREEN, result)
}
@Test
fun `deserialize should handle all valid feature displayNames correctly`() {
GameFeature.entries.forEach { feature ->
every { jsonParser.string } returns feature.displayName
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(feature, result, "Failed to deserialize ${feature.displayName}")
}
}
}

View File

@ -0,0 +1,178 @@
package org.gameyfin.app.core.serialization
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.gameyfin.pluginapi.gamemetadata.Genre
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import kotlin.test.assertEquals
import kotlin.test.assertNull
class GenreDeserializerTest {
private lateinit var deserializer: GenreDeserializer
private lateinit var jsonParser: JsonParser
private lateinit var deserializationContext: DeserializationContext
@BeforeEach
fun setup() {
deserializer = GenreDeserializer()
jsonParser = mockk()
deserializationContext = mockk()
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `deserialize should return correct genre for valid displayName`() {
every { jsonParser.string } returns "Action"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.ACTION, result)
}
@Test
fun `deserialize should return null for unknown displayName`() {
every { jsonParser.string } returns "Unknown Genre"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for empty string`() {
every { jsonParser.string } returns ""
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for null string`() {
every { jsonParser.string } returns null
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should be case-sensitive`() {
every { jsonParser.string } returns "action"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should handle Visual Novel genre`() {
every { jsonParser.string } returns "Visual Novel"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.VISUAL_NOVEL, result)
}
@Test
fun `deserialize should handle Card & Board Game genre`() {
every { jsonParser.string } returns "Card & Board Game"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.CARD_AND_BOARD_GAME, result)
}
@Test
fun `deserialize should handle Point-and-Click genre`() {
every { jsonParser.string } returns "Point-and-Click"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.POINT_AND_CLICK, result)
}
@Test
fun `deserialize should handle Real-Time Strategy genre`() {
every { jsonParser.string } returns "Real-Time Strategy"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.REAL_TIME_STRATEGY, result)
}
@Test
fun `deserialize should handle Turn-Based Strategy genre`() {
every { jsonParser.string } returns "Turn-Based Strategy"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.TURN_BASED_STRATEGY, result)
}
@Test
fun `deserialize should handle Hack and Slash Beat em up genre`() {
every { jsonParser.string } returns "Hack and Slash/Beat 'em up"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.HACK_AND_SLASH_BEAT_EM_UP, result)
}
@Test
fun `deserialize should handle Quiz Trivia genre`() {
every { jsonParser.string } returns "Quiz/Trivia"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.QUIZ_TRIVIA, result)
}
@Test
fun `deserialize should handle Role-Playing genre`() {
every { jsonParser.string } returns "Role-Playing"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.ROLE_PLAYING, result)
}
@Test
fun `deserialize should handle MOBA genre`() {
every { jsonParser.string } returns "MOBA"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.MOBA, result)
}
@Test
fun `deserialize should handle MMO genre`() {
every { jsonParser.string } returns "MMO"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Genre.MMO, result)
}
@Test
fun `deserialize should handle all valid genre displayNames correctly`() {
Genre.entries.forEach { genre ->
every { jsonParser.string } returns genre.displayName
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(genre, result, "Failed to deserialize ${genre.displayName}")
}
}
}

View File

@ -0,0 +1,151 @@
package org.gameyfin.app.core.serialization
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import kotlin.test.assertEquals
import kotlin.test.assertNull
class PlayerPerspectiveDeserializerTest {
private lateinit var deserializer: PlayerPerspectiveDeserializer
private lateinit var jsonParser: JsonParser
private lateinit var deserializationContext: DeserializationContext
@BeforeEach
fun setup() {
deserializer = PlayerPerspectiveDeserializer()
jsonParser = mockk()
deserializationContext = mockk()
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `deserialize should return correct perspective for valid displayName`() {
every { jsonParser.string } returns "First-Person"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.FIRST_PERSON, result)
}
@Test
fun `deserialize should return null for unknown displayName`() {
every { jsonParser.string } returns "Unknown Perspective"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for empty string`() {
every { jsonParser.string } returns ""
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for null string`() {
every { jsonParser.string } returns null
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should be case-sensitive`() {
every { jsonParser.string } returns "first-person"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should handle Third-Person perspective`() {
every { jsonParser.string } returns "Third-Person"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.THIRD_PERSON, result)
}
@Test
fun `deserialize should handle Bird View Isometric perspective`() {
every { jsonParser.string } returns "Bird View/Isometric"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.BIRD_VIEW_ISOMETRIC, result)
}
@Test
fun `deserialize should handle Side View perspective`() {
every { jsonParser.string } returns "Side View"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.SIDE_VIEW, result)
}
@Test
fun `deserialize should handle Text perspective`() {
every { jsonParser.string } returns "Text"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.TEXT, result)
}
@Test
fun `deserialize should handle Auditory perspective`() {
every { jsonParser.string } returns "Auditory"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.AUDITORY, result)
}
@Test
fun `deserialize should handle Virtual Reality perspective`() {
every { jsonParser.string } returns "Virtual Reality"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.VIRTUAL_REALITY, result)
}
@Test
fun `deserialize should handle Unknown perspective`() {
every { jsonParser.string } returns "Unknown"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(PlayerPerspective.UNKNOWN, result)
}
@Test
fun `deserialize should handle all valid perspective displayNames correctly`() {
PlayerPerspective.entries.forEach { perspective ->
every { jsonParser.string } returns perspective.displayName
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(perspective, result, "Failed to deserialize ${perspective.displayName}")
}
}
}

View File

@ -0,0 +1,151 @@
package org.gameyfin.app.core.serialization
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.gameyfin.pluginapi.gamemetadata.Theme
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ThemeDeserializerTest {
private lateinit var deserializer: ThemeDeserializer
private lateinit var jsonParser: JsonParser
private lateinit var deserializationContext: DeserializationContext
@BeforeEach
fun setup() {
deserializer = ThemeDeserializer()
jsonParser = mockk()
deserializationContext = mockk()
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `deserialize should return correct theme for valid displayName`() {
every { jsonParser.string } returns "Action"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.ACTION, result)
}
@Test
fun `deserialize should return null for unknown displayName`() {
every { jsonParser.string } returns "Unknown Theme"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for empty string`() {
every { jsonParser.string } returns ""
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should return null for null string`() {
every { jsonParser.string } returns null
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should be case-sensitive`() {
every { jsonParser.string } returns "action"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertNull(result)
}
@Test
fun `deserialize should handle Science Fiction theme`() {
every { jsonParser.string } returns "Science Fiction"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.SCIENCE_FICTION, result)
}
@Test
fun `deserialize should handle Non-Fiction theme`() {
every { jsonParser.string } returns "Non-Fiction"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.NON_FICTION, result)
}
@Test
fun `deserialize should handle 4X theme`() {
every { jsonParser.string } returns "4X"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.FOUR_X, result)
}
@Test
fun `deserialize should handle Open World theme`() {
every { jsonParser.string } returns "Open World"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.OPEN_WORLD, result)
}
@Test
fun `deserialize should handle Horror theme`() {
every { jsonParser.string } returns "Horror"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.HORROR, result)
}
@Test
fun `deserialize should handle Fantasy theme`() {
every { jsonParser.string } returns "Fantasy"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.FANTASY, result)
}
@Test
fun `deserialize should handle Survival theme`() {
every { jsonParser.string } returns "Survival"
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(Theme.SURVIVAL, result)
}
@Test
fun `deserialize should handle all valid theme displayNames correctly`() {
Theme.entries.forEach { theme ->
every { jsonParser.string } returns theme.displayName
val result = deserializer.deserialize(jsonParser, deserializationContext)
assertEquals(theme, result, "Failed to deserialize ${theme.displayName}")
}
}
}

View File

@ -18,6 +18,8 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import java.nio.file.Files
import java.nio.file.Path
import java.util.*
@ -169,6 +171,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `file create event should trigger quick scan`() {
val libraryDir = tempDir.resolve("watch-create")
libraryDir.createDirectories()
@ -197,6 +200,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `file delete event should trigger quick scan`() {
val libraryDir = tempDir.resolve("watch-delete")
libraryDir.createDirectories()
@ -227,6 +231,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `directory delete event should trigger quick scan`() {
val libraryDir = tempDir.resolve("watch-delete-dir")
libraryDir.createDirectories()
@ -256,6 +261,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `file modify event should update game file size`() {
val libraryDir = tempDir.resolve("watch-modify")
libraryDir.createDirectories()
@ -290,6 +296,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `should handle multiple rapid file changes`() {
val libraryDir = tempDir.resolve("watch-rapid")
libraryDir.createDirectories()
@ -320,6 +327,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `should handle library with multiple directories`() {
val dir1 = tempDir.resolve("lib-dir1")
val dir2 = tempDir.resolve("lib-dir2")
@ -354,6 +362,7 @@ class LibraryWatcherServiceTest {
}
@Test
@DisabledOnOs(OS.MAC, disabledReason = "File system watcher events are unreliable on macOS due to FSEvents latency")
fun `should handle directory creation`() {
val libraryDir = tempDir.resolve("watch-dir")
libraryDir.createDirectories()

View File

@ -0,0 +1,235 @@
package org.gameyfin.app.media
import io.mockk.clearAllMocks
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.*
class FileStorageServiceTest {
@TempDir
lateinit var tempDir: Path
private lateinit var fileStorageService: FileStorageService
@BeforeEach
fun setup() {
fileStorageService = FileStorageService(tempDir.toString())
}
@AfterEach
fun tearDown() {
clearAllMocks()
}
@Test
fun `init should create storage directory if it does not exist`() {
val newDir = tempDir.resolve("newStorage")
assertFalse(Files.exists(newDir))
FileStorageService(newDir.toString())
assertTrue(Files.exists(newDir))
}
@Test
fun `saveFile should store file and return content ID`() {
val testData = "test file content".toByteArray()
val inputStream = ByteArrayInputStream(testData)
val contentId = fileStorageService.saveFile(inputStream)
assertNotNull(contentId)
assertTrue(contentId.isNotEmpty())
val filePath = tempDir.resolve(contentId)
assertTrue(Files.exists(filePath))
val savedContent = Files.readAllBytes(filePath)
assertContentEquals(testData, savedContent)
}
@Test
fun `saveFile should generate unique content IDs for multiple files`() {
val testData1 = "first file".toByteArray()
val testData2 = "second file".toByteArray()
val contentId1 = fileStorageService.saveFile(ByteArrayInputStream(testData1))
val contentId2 = fileStorageService.saveFile(ByteArrayInputStream(testData2))
assertNotEquals(contentId1, contentId2)
assertTrue(Files.exists(tempDir.resolve(contentId1)))
assertTrue(Files.exists(tempDir.resolve(contentId2)))
}
@Test
fun `saveFile should replace existing file with same content ID`() {
val originalData = "original content".toByteArray()
val newData = "new content".toByteArray()
val contentId = fileStorageService.saveFile(ByteArrayInputStream(originalData))
// Manually save a file with a predictable name
val filePath = tempDir.resolve(contentId)
Files.write(filePath, newData)
val retrievedContent = Files.readAllBytes(filePath)
assertContentEquals(newData, retrievedContent)
}
@Test
fun `getFile should return input stream when file exists`() {
val testData = "test file content".toByteArray()
val contentId = fileStorageService.saveFile(ByteArrayInputStream(testData))
val inputStream = fileStorageService.getFile(contentId)
assertNotNull(inputStream)
val retrievedData = inputStream.readAllBytes()
assertContentEquals(testData, retrievedData)
}
@Test
fun `getFile should return null when file does not exist`() {
val nonExistentId = "non-existent-file-id"
val inputStream = fileStorageService.getFile(nonExistentId)
assertNull(inputStream)
}
@Test
fun `getFile should return null when content ID is null`() {
val inputStream = fileStorageService.getFile(null)
assertNull(inputStream)
}
@Test
fun `getFile should allow reading file multiple times`() {
val testData = "test file content".toByteArray()
val contentId = fileStorageService.saveFile(ByteArrayInputStream(testData))
val inputStream1 = fileStorageService.getFile(contentId)
assertNotNull(inputStream1)
val retrievedData1 = inputStream1.readAllBytes()
assertContentEquals(testData, retrievedData1)
val inputStream2 = fileStorageService.getFile(contentId)
assertNotNull(inputStream2)
val retrievedData2 = inputStream2.readAllBytes()
assertContentEquals(testData, retrievedData2)
}
@Test
fun `deleteFile should remove file when it exists`() {
val testData = "test file content".toByteArray()
val contentId = fileStorageService.saveFile(ByteArrayInputStream(testData))
assertTrue(Files.exists(tempDir.resolve(contentId)))
fileStorageService.deleteFile(contentId)
assertFalse(Files.exists(tempDir.resolve(contentId)))
}
@Test
fun `deleteFile should not throw exception when file does not exist`() {
val nonExistentId = "non-existent-file-id"
// Should not throw any exception
fileStorageService.deleteFile(nonExistentId)
assertFalse(Files.exists(tempDir.resolve(nonExistentId)))
}
@Test
fun `deleteFile should do nothing when content ID is null`() {
// Should not throw any exception
fileStorageService.deleteFile(null)
}
@Test
fun `fileExists should return true when file exists`() {
val testData = "test file content".toByteArray()
val contentId = fileStorageService.saveFile(ByteArrayInputStream(testData))
val exists = fileStorageService.fileExists(contentId)
assertTrue(exists)
}
@Test
fun `fileExists should return false when file does not exist`() {
val nonExistentId = "non-existent-file-id"
val exists = fileStorageService.fileExists(nonExistentId)
assertFalse(exists)
}
@Test
fun `fileExists should return false when content ID is null`() {
val exists = fileStorageService.fileExists(null)
assertFalse(exists)
}
@Test
fun `saveFile should handle large files`() {
val largeData = ByteArray(10 * 1024 * 1024) { it.toByte() } // 10 MB
val inputStream = ByteArrayInputStream(largeData)
val contentId = fileStorageService.saveFile(inputStream)
assertNotNull(contentId)
assertTrue(fileStorageService.fileExists(contentId))
val retrievedStream = fileStorageService.getFile(contentId)
assertNotNull(retrievedStream)
val retrievedData = retrievedStream.readAllBytes()
assertContentEquals(largeData, retrievedData)
}
@Test
fun `saveFile should handle empty files`() {
val emptyData = ByteArray(0)
val inputStream = ByteArrayInputStream(emptyData)
val contentId = fileStorageService.saveFile(inputStream)
assertNotNull(contentId)
assertTrue(fileStorageService.fileExists(contentId))
val retrievedStream = fileStorageService.getFile(contentId)
assertNotNull(retrievedStream)
val retrievedData = retrievedStream.readAllBytes()
assertEquals(0, retrievedData.size)
}
@Test
fun `integration test - save, retrieve, and delete file lifecycle`() {
val testData = "lifecycle test content".toByteArray()
// Save file
val contentId = fileStorageService.saveFile(ByteArrayInputStream(testData))
assertNotNull(contentId)
assertTrue(fileStorageService.fileExists(contentId))
// Retrieve file
val retrievedStream = fileStorageService.getFile(contentId)
assertNotNull(retrievedStream)
val retrievedData = retrievedStream.readAllBytes()
assertContentEquals(testData, retrievedData)
// Delete file
fileStorageService.deleteFile(contentId)
assertFalse(fileStorageService.fileExists(contentId))
assertNull(fileStorageService.getFile(contentId))
}
}

View File

@ -8,7 +8,6 @@ import org.gameyfin.app.core.events.UserDeletedEvent
import org.gameyfin.app.core.events.UserUpdatedEvent
import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.games.repositories.ImageContentStore
import org.gameyfin.app.games.repositories.ImageRepository
import org.gameyfin.app.users.entities.User
import org.gameyfin.app.users.persistence.UserRepository
@ -19,7 +18,6 @@ import org.junit.jupiter.api.Test
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@ -27,7 +25,7 @@ import kotlin.test.assertNull
class ImageServiceTest {
private lateinit var imageRepository: ImageRepository
private lateinit var imageContentStore: ImageContentStore
private lateinit var fileStorageService: FileStorageService
private lateinit var gameRepository: GameRepository
private lateinit var userRepository: UserRepository
private lateinit var imageService: ImageService
@ -35,10 +33,10 @@ class ImageServiceTest {
@BeforeEach
fun setup() {
imageRepository = mockk()
imageContentStore = mockk()
fileStorageService = mockk()
gameRepository = mockk()
userRepository = mockk()
imageService = ImageService(imageRepository, imageContentStore, gameRepository, userRepository)
imageService = ImageService(imageRepository, fileStorageService, gameRepository, userRepository)
}
@AfterEach
@ -181,19 +179,16 @@ class ImageServiceTest {
contentLength = 1024L,
mimeType = "image/jpeg"
)
val inputStream = ByteArrayInputStream("image data".toByteArray())
every { imageRepository.findAllByOriginalUrl(url) } returns listOf(existingImage)
every { imageContentStore.getContent(existingImage) } returns inputStream
every { imageContentStore.associate(image, "existing-content-id") } just Runs
every { fileStorageService.fileExists("existing-content-id") } returns true
imageService.downloadIfNew(image)
assertEquals("existing-content-id", image.contentId)
assertEquals(1024L, image.contentLength)
assertEquals("image/jpeg", image.mimeType)
verify(exactly = 1) { imageContentStore.associate(image, "existing-content-id") }
verify(exactly = 0) { imageContentStore.setContent(any(), any<InputStream>()) }
verify(exactly = 0) { fileStorageService.saveFile(any()) }
}
@Test
@ -216,14 +211,13 @@ class ImageServiceTest {
}
every { imageRepository.findAllByOriginalUrl(url) } returns listOf(existingImage)
every { imageContentStore.getContent(existingImage) } returns null
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returnsArgument 0
every { fileStorageService.fileExists(null) } returns false
every { fileStorageService.saveFile(any()) } returns "new-content-id"
every { imageRepository.save(image) } returns image
imageService.downloadIfNew(image)
verify(exactly = 0) { imageContentStore.associate(any(), any()) }
verify(exactly = 1) { imageContentStore.setContent(image, any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
verify(exactly = 1) { imageRepository.save(image) }
unmockkStatic(TikaInputStream::class)
}
@ -240,7 +234,6 @@ class ImageServiceTest {
contentLength = 0L,
mimeType = "image/jpeg"
)
val inputStream = ByteArrayInputStream("image data".toByteArray())
mockkStatic(TikaInputStream::class)
val testData = "test image data".toByteArray()
@ -249,14 +242,13 @@ class ImageServiceTest {
}
every { imageRepository.findAllByOriginalUrl(url) } returns listOf(existingImage)
every { imageContentStore.getContent(existingImage) } returns inputStream
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returnsArgument 0
every { fileStorageService.fileExists("existing-content-id") } returns true
every { fileStorageService.saveFile(any()) } returns "new-content-id"
every { imageRepository.save(image) } returns image
imageService.downloadIfNew(image)
verify(exactly = 0) { imageContentStore.associate(any(), any()) }
verify(exactly = 1) { imageContentStore.setContent(image, any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
verify(exactly = 1) { imageRepository.save(image) }
unmockkStatic(TikaInputStream::class)
}
@ -273,12 +265,12 @@ class ImageServiceTest {
}
every { imageRepository.findAllByOriginalUrl(url) } returns emptyList()
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returnsArgument 0
every { fileStorageService.saveFile(any()) } returns "new-content-id"
every { imageRepository.save(image) } returns image
imageService.downloadIfNew(image)
verify(exactly = 1) { imageContentStore.setContent(image, any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
verify(exactly = 1) { imageRepository.save(image) }
unmockkStatic(TikaInputStream::class)
}
@ -295,12 +287,12 @@ class ImageServiceTest {
}
every { imageRepository.findAllByOriginalUrl(url) } returns emptyList()
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returnsArgument 0
every { fileStorageService.saveFile(any()) } returns "new-content-id"
every { imageRepository.save(image) } throws DataIntegrityViolationException("Duplicate")
imageService.downloadIfNew(image)
verify(exactly = 1) { imageContentStore.setContent(image, any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
verify(exactly = 1) { imageRepository.save(image) }
unmockkStatic(TikaInputStream::class)
}
@ -311,13 +303,13 @@ class ImageServiceTest {
val savedImage = Image(id = 1L, type = ImageType.AVATAR, mimeType = "image/png")
every { imageRepository.save(any<Image>()) } returns savedImage
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returns savedImage
every { fileStorageService.saveFile(any()) } returns "content-id"
val result = imageService.createFromInputStream(ImageType.AVATAR, inputStream, "image/png")
assertNotNull(result)
verify(exactly = 1) { imageRepository.save(any<Image>()) }
verify(exactly = 1) { imageContentStore.setContent(any<Image>(), any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
}
@Test
@ -347,24 +339,24 @@ class ImageServiceTest {
val image = Image(id = 1L, type = ImageType.COVER, contentId = "content-id")
val inputStream = ByteArrayInputStream("image data".toByteArray())
every { imageContentStore.getContent(image) } returns inputStream
every { fileStorageService.getFile("content-id") } returns inputStream
val result = imageService.getFileContent(image)
assertEquals(inputStream, result)
verify(exactly = 1) { imageContentStore.getContent(image) }
verify(exactly = 1) { fileStorageService.getFile("content-id") }
}
@Test
fun `getFileContent should return null when content store returns null`() {
val image = Image(id = 1L, type = ImageType.COVER, contentId = "content-id")
every { imageContentStore.getContent(image) } returns null
every { fileStorageService.getFile("content-id") } returns null
val result = imageService.getFileContent(image)
assertNull(result)
verify(exactly = 1) { imageContentStore.getContent(image) }
verify(exactly = 1) { fileStorageService.getFile("content-id") }
}
@Test
@ -374,14 +366,14 @@ class ImageServiceTest {
every { gameRepository.existsByImage(1L) } returns false
every { userRepository.existsByAvatar(1L) } returns false
every { imageRepository.delete(image) } just Runs
every { imageContentStore.unsetContent(image) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.deleteImageIfUnused(image)
verify(exactly = 1) { gameRepository.existsByImage(1L) }
verify(exactly = 1) { userRepository.existsByAvatar(1L) }
verify(exactly = 1) { imageRepository.delete(image) }
verify(exactly = 1) { imageContentStore.unsetContent(image) }
verify(exactly = 1) { fileStorageService.deleteFile(any()) }
}
@Test
@ -395,7 +387,7 @@ class ImageServiceTest {
verify(exactly = 1) { gameRepository.existsByImage(1L) }
verify(exactly = 0) { userRepository.existsByAvatar(any()) }
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -410,7 +402,7 @@ class ImageServiceTest {
verify(exactly = 1) { gameRepository.existsByImage(1L) }
verify(exactly = 1) { userRepository.existsByAvatar(1L) }
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -422,7 +414,7 @@ class ImageServiceTest {
verify(exactly = 0) { gameRepository.existsByImage(any()) }
verify(exactly = 0) { userRepository.existsByAvatar(any()) }
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -431,13 +423,14 @@ class ImageServiceTest {
val inputStream = ByteArrayInputStream("new image data".toByteArray())
every { imageRepository.save(image) } returns image
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returns image
every { fileStorageService.deleteFile(any()) } just Runs
every { fileStorageService.saveFile(any()) } returns "new-content-id"
imageService.updateFileContent(image, inputStream, "image/jpeg")
assertEquals("image/jpeg", image.mimeType)
verify(exactly = 1) { imageRepository.save(image) }
verify(exactly = 1) { imageContentStore.setContent(image, any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
}
@Test
@ -446,13 +439,14 @@ class ImageServiceTest {
val inputStream = ByteArrayInputStream("new image data".toByteArray())
every { imageRepository.save(image) } returns image
every { imageContentStore.setContent(any<Image>(), any<InputStream>()) } returns image
every { fileStorageService.deleteFile(any()) } just Runs
every { fileStorageService.saveFile(any()) } returns "new-content-id"
imageService.updateFileContent(image, inputStream)
assertEquals("image/png", image.mimeType)
verify(exactly = 1) { imageRepository.save(image) }
verify(exactly = 1) { imageContentStore.setContent(image, any<InputStream>()) }
verify(exactly = 1) { fileStorageService.saveFile(any()) }
}
@Test
@ -474,7 +468,7 @@ class ImageServiceTest {
every { gameRepository.existsByImage(3L) } returns false
every { userRepository.existsByAvatar(3L) } returns false
every { imageRepository.delete(any()) } just Runs
every { imageContentStore.unsetContent(any()) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.onGameDeleted(event)
@ -496,12 +490,12 @@ class ImageServiceTest {
every { gameRepository.existsByImage(1L) } returns false
every { userRepository.existsByAvatar(1L) } returns false
every { imageRepository.delete(screenshot) } just Runs
every { imageContentStore.unsetContent(screenshot) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.onGameDeleted(event)
verify(exactly = 1) { imageRepository.delete(screenshot) }
verify(exactly = 1) { imageContentStore.unsetContent(screenshot) }
verify(exactly = 1) { fileStorageService.deleteFile(any()) }
}
@Test
@ -528,7 +522,7 @@ class ImageServiceTest {
every { gameRepository.existsByImage(3L) } returns false
every { userRepository.existsByAvatar(3L) } returns false
every { imageRepository.delete(any()) } just Runs
every { imageContentStore.unsetContent(any()) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.onGameUpdated(event)
@ -558,7 +552,7 @@ class ImageServiceTest {
imageService.onGameUpdated(event)
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -572,12 +566,12 @@ class ImageServiceTest {
every { gameRepository.existsByImage(1L) } returns false
every { userRepository.existsByAvatar(1L) } returns false
every { imageRepository.delete(avatar) } just Runs
every { imageContentStore.unsetContent(avatar) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.onAccountDeleted(event)
verify(exactly = 1) { imageRepository.delete(avatar) }
verify(exactly = 1) { imageContentStore.unsetContent(avatar) }
verify(exactly = 1) { fileStorageService.deleteFile(any()) }
}
@Test
@ -590,7 +584,7 @@ class ImageServiceTest {
imageService.onAccountDeleted(event)
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -608,12 +602,12 @@ class ImageServiceTest {
every { gameRepository.existsByImage(1L) } returns false
every { userRepository.existsByAvatar(1L) } returns false
every { imageRepository.delete(oldAvatar) } just Runs
every { imageContentStore.unsetContent(oldAvatar) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.onUserUpdated(event)
verify(exactly = 1) { imageRepository.delete(oldAvatar) }
verify(exactly = 1) { imageContentStore.unsetContent(oldAvatar) }
verify(exactly = 1) { fileStorageService.deleteFile(any()) }
}
@Test
@ -630,7 +624,7 @@ class ImageServiceTest {
imageService.onUserUpdated(event)
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -647,7 +641,7 @@ class ImageServiceTest {
imageService.onUserUpdated(event)
verify(exactly = 0) { imageRepository.delete(any()) }
verify(exactly = 0) { imageContentStore.unsetContent(any()) }
verify(exactly = 0) { fileStorageService.deleteFile(any()) }
}
@Test
@ -664,11 +658,11 @@ class ImageServiceTest {
every { gameRepository.existsByImage(1L) } returns false
every { userRepository.existsByAvatar(1L) } returns false
every { imageRepository.delete(oldAvatar) } just Runs
every { imageContentStore.unsetContent(oldAvatar) } returnsArgument 0
every { fileStorageService.deleteFile(any()) } just Runs
imageService.onUserUpdated(event)
verify(exactly = 1) { imageRepository.delete(oldAvatar) }
verify(exactly = 1) { imageContentStore.unsetContent(oldAvatar) }
verify(exactly = 1) { fileStorageService.deleteFile(any()) }
}
}

View File

@ -17,6 +17,7 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.getBeansOfType
import org.springframework.context.ApplicationContext
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContext
@ -55,7 +56,7 @@ class MessageServiceTest {
fun `enabled should return true when at least one provider is enabled`() {
every { mockProvider1.enabled } returns true
every { mockProvider2.enabled } returns false
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1,
"provider2" to mockProvider2
)
@ -69,7 +70,7 @@ class MessageServiceTest {
fun `enabled should return false when no providers are enabled`() {
every { mockProvider1.enabled } returns false
every { mockProvider2.enabled } returns false
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1,
"provider2" to mockProvider2
)
@ -81,7 +82,7 @@ class MessageServiceTest {
@Test
fun `enabled should return false when no providers exist`() {
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
val result = messageService.enabled
@ -95,7 +96,7 @@ class MessageServiceTest {
every { mockProvider1.providerKey } returns providerKey
every { mockProvider1.testCredentials(any()) } returns true
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
@ -112,7 +113,7 @@ class MessageServiceTest {
every { mockProvider1.providerKey } returns providerKey
every { mockProvider1.testCredentials(any()) } returns false
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
@ -127,7 +128,7 @@ class MessageServiceTest {
val credentials = mapOf("host" to "smtp.example.com")
every { mockProvider1.providerKey } returns "email"
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
@ -145,7 +146,7 @@ class MessageServiceTest {
every { mockProvider1.providerKey } returns providerKey
every { mockProvider1.testCredentials(any()) } returns true
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
@ -167,7 +168,7 @@ class MessageServiceTest {
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { mockProvider2.enabled } returns true
every { mockProvider2.supportedTemplateType } returns TemplateType.TEXT
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1,
"provider2" to mockProvider2
)
@ -197,7 +198,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { mockProvider2.enabled } returns false
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1,
"provider2" to mockProvider2
)
@ -218,7 +219,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns false
every { mockProvider2.enabled } returns false
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1,
"provider2" to mockProvider2
)
@ -231,7 +232,7 @@ class MessageServiceTest {
@Test
fun `sendTestNotification should return false when messaging is disabled`() {
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
val result = messageService.sendTestNotification("password-reset-request", emptyMap())
@ -249,7 +250,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every { userService.getByUsername("testuser") } returns user
@ -274,7 +275,7 @@ class MessageServiceTest {
val placeholders = mapOf("username" to "testuser", "resetLink" to "http://example.com/reset")
every { mockProvider1.enabled } returns true
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
@ -291,7 +292,7 @@ class MessageServiceTest {
setupSecurityContext("testuser")
every { mockProvider1.enabled } returns true
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every { userService.getByUsername("testuser") } returns null
@ -310,7 +311,7 @@ class MessageServiceTest {
setupSecurityContext("testuser")
every { mockProvider1.enabled } returns true
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every { userService.getByUsername("testuser") } returns user
@ -330,7 +331,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -358,7 +359,7 @@ class MessageServiceTest {
val token = Token(creator = user, secret = "secret123", type = TokenType.PasswordReset)
val event = PasswordResetRequestEvent(this, token, "http://example.com")
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onPasswordResetRequest(event)
@ -373,7 +374,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -394,7 +395,7 @@ class MessageServiceTest {
val user = User(username = "newuser", email = "new@example.com", password = "hash")
val event = UserRegistrationWaitingForApprovalEvent(this, user)
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onUserRegistrationWaitingForApproval(event)
@ -409,7 +410,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -439,7 +440,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -466,7 +467,7 @@ class MessageServiceTest {
val user = User(username = "testuser", email = "user@example.com", password = "hash")
val event = AccountStatusChangedEvent(this, user, "http://example.com")
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onAccountStatusChanged(event)
@ -481,7 +482,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -502,7 +503,7 @@ class MessageServiceTest {
val user = User(username = "existinguser", email = "existing@example.com", password = "hash")
val event = RegistrationAttemptWithExistingEmailEvent(this, user, "http://example.com")
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onRegistrationAttemptWithExistingEmail(event)
@ -518,7 +519,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -543,7 +544,7 @@ class MessageServiceTest {
val token = Token(creator = user, secret = "confirm123", type = TokenType.EmailConfirmation)
val event = EmailNeedsConfirmationEvent(this, token, "http://example.com")
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onEmailNeedsConfirmation(event)
@ -559,7 +560,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -587,7 +588,7 @@ class MessageServiceTest {
val token = Token(creator = user, secret = "invite123", type = TokenType.Invitation)
val event = UserInvitationEvent(this, token, "http://example.com", "invited@example.com")
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onUserInvitation(event)
@ -602,7 +603,7 @@ class MessageServiceTest {
every { mockProvider1.enabled } returns true
every { mockProvider1.supportedTemplateType } returns TemplateType.MJML
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns mapOf(
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns mapOf(
"provider1" to mockProvider1
)
every {
@ -629,7 +630,7 @@ class MessageServiceTest {
val user = User(username = "deleteduser", email = "deleted@example.com", password = "hash")
val event = UserDeletedEvent(this, user, "http://example.com")
every { applicationContext.getBeansOfType(AbstractMessageProvider::class.java) } returns emptyMap()
every { applicationContext.getBeansOfType<AbstractMessageProvider>() } returns emptyMap()
messageService.onAccountDeletion(event)

View File

@ -1,7 +1,5 @@
package org.gameyfin.app.platforms.serialization
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
@ -10,6 +8,8 @@ import org.gameyfin.pluginapi.gamemetadata.Platform
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonParser
import tools.jackson.databind.DeserializationContext
import kotlin.test.assertEquals
import kotlin.test.assertNull
@ -33,7 +33,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return correct platform for valid displayName`() {
every { jsonParser.text } returns "PC (Microsoft Windows)"
every { jsonParser.string } returns "PC (Microsoft Windows)"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -42,7 +42,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return null for unknown displayName`() {
every { jsonParser.text } returns "Unknown Platform"
every { jsonParser.string } returns "Unknown Platform"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -51,7 +51,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return null for empty string`() {
every { jsonParser.text } returns ""
every { jsonParser.string } returns ""
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -60,7 +60,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return correct platform for PlayStation 5`() {
every { jsonParser.text } returns "PlayStation 5"
every { jsonParser.string } returns "PlayStation 5"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -69,7 +69,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return correct platform for Xbox Series X S`() {
every { jsonParser.text } returns "Xbox Series X|S"
every { jsonParser.string } returns "Xbox Series X|S"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -78,7 +78,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return correct platform for Nintendo Switch`() {
every { jsonParser.text } returns "Nintendo Switch"
every { jsonParser.string } returns "Nintendo Switch"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -87,7 +87,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should be case-sensitive`() {
every { jsonParser.text } returns "playstation 5"
every { jsonParser.string } returns "playstation 5"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -96,7 +96,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle platforms with special characters`() {
every { jsonParser.text } returns "Odyssey 2 / Videopac G7000"
every { jsonParser.string } returns "Odyssey 2 / Videopac G7000"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -105,7 +105,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle platforms with numbers at start`() {
every { jsonParser.text } returns "3DO Interactive Multiplayer"
every { jsonParser.string } returns "3DO Interactive Multiplayer"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -114,7 +114,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle platforms with hyphens`() {
every { jsonParser.text } returns "Atari 8-bit"
every { jsonParser.string } returns "Atari 8-bit"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -123,7 +123,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle platforms with apostrophes`() {
every { jsonParser.text } returns "Super A'Can"
every { jsonParser.string } returns "Super A'Can"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -132,7 +132,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return null for whitespace-only string`() {
every { jsonParser.text } returns " "
every { jsonParser.string } returns " "
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -141,7 +141,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should not trim whitespace from displayName`() {
every { jsonParser.text } returns " PlayStation 5 "
every { jsonParser.string } returns " PlayStation 5 "
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -150,7 +150,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Arcade platform`() {
every { jsonParser.text } returns "Arcade"
every { jsonParser.string } returns "Arcade"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -159,7 +159,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Web browser platform`() {
every { jsonParser.text } returns "Web browser"
every { jsonParser.string } returns "Web browser"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -168,7 +168,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Android platform`() {
every { jsonParser.text } returns "Android"
every { jsonParser.string } returns "Android"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -177,7 +177,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle iOS platform`() {
every { jsonParser.text } returns "iOS"
every { jsonParser.string } returns "iOS"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -186,7 +186,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Linux platform`() {
every { jsonParser.text } returns "Linux"
every { jsonParser.string } returns "Linux"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -195,7 +195,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Mac platform`() {
every { jsonParser.text } returns "Mac"
every { jsonParser.string } returns "Mac"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -204,7 +204,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle DOS platform`() {
every { jsonParser.text } returns "DOS"
every { jsonParser.string } returns "DOS"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -213,7 +213,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Dreamcast platform`() {
every { jsonParser.text } returns "Dreamcast"
every { jsonParser.string } returns "Dreamcast"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -222,7 +222,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Virtual Boy platform`() {
every { jsonParser.text } returns "Virtual Boy"
every { jsonParser.string } returns "Virtual Boy"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -231,7 +231,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle ZX Spectrum platform`() {
every { jsonParser.text } returns "ZX Spectrum"
every { jsonParser.string } returns "ZX Spectrum"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -240,7 +240,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Game Boy platform`() {
every { jsonParser.text } returns "Game Boy"
every { jsonParser.string } returns "Game Boy"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -249,7 +249,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle PlayStation VR2 platform`() {
every { jsonParser.text } returns "PlayStation VR2"
every { jsonParser.string } returns "PlayStation VR2"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -258,7 +258,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Nintendo Entertainment System platform`() {
every { jsonParser.text } returns "Nintendo Entertainment System"
every { jsonParser.string } returns "Nintendo Entertainment System"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -267,7 +267,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Super Nintendo Entertainment System platform`() {
every { jsonParser.text } returns "Super Nintendo Entertainment System"
every { jsonParser.string } returns "Super Nintendo Entertainment System"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -276,7 +276,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle Sega Mega Drive Genesis platform`() {
every { jsonParser.text } returns "Sega Mega Drive/Genesis"
every { jsonParser.string } returns "Sega Mega Drive/Genesis"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -285,7 +285,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle platforms with long names`() {
every { jsonParser.text } returns "Call-A-Computer time-shared mainframe computer system"
every { jsonParser.string } returns "Call-A-Computer time-shared mainframe computer system"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -294,7 +294,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should return null for partial match`() {
every { jsonParser.text } returns "PlayStation"
every { jsonParser.string } returns "PlayStation"
val result = deserializer.deserialize(jsonParser, deserializationContext)
@ -304,7 +304,7 @@ class PlatformDeserializerTest {
@Test
fun `deserialize should handle all valid platform displayNames correctly`() {
Platform.entries.forEach { platform ->
every { jsonParser.text } returns platform.displayName
every { jsonParser.string } returns platform.displayName
val result = deserializer.deserialize(jsonParser, deserializationContext)

View File

@ -1,7 +1,5 @@
package org.gameyfin.app.platforms.serialization
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import io.mockk.mockk
import io.mockk.unmockkAll
import io.mockk.verify
@ -10,18 +8,20 @@ import org.gameyfin.pluginapi.gamemetadata.Platform
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import tools.jackson.core.JsonGenerator
import tools.jackson.databind.SerializationContext
class PlatformSerializerTest {
private lateinit var serializer: DisplayableSerializer
private lateinit var jsonGenerator: JsonGenerator
private lateinit var serializerProvider: SerializerProvider
private lateinit var serializationContext: SerializationContext
@BeforeEach
fun setup() {
serializer = DisplayableSerializer()
jsonGenerator = mockk(relaxed = true)
serializerProvider = mockk()
serializationContext = mockk()
}
@AfterEach
@ -33,14 +33,14 @@ class PlatformSerializerTest {
fun `serialize should write displayName for valid platform`() {
val platform = Platform.PC_MICROSOFT_WINDOWS
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("PC (Microsoft Windows)") }
}
@Test
fun `serialize should handle null platform value`() {
serializer.serialize(null, jsonGenerator, serializerProvider)
serializer.serialize(null, jsonGenerator, serializationContext)
verify(exactly = 0) { jsonGenerator.writeString(any<String>()) }
}
@ -49,7 +49,7 @@ class PlatformSerializerTest {
fun `serialize should write correct displayName for PlayStation 5`() {
val platform = Platform.PLAYSTATION_5
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("PlayStation 5") }
}
@ -58,7 +58,7 @@ class PlatformSerializerTest {
fun `serialize should write correct displayName for Xbox Series X S`() {
val platform = Platform.XBOX_SERIES_X_S
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Xbox Series X|S") }
}
@ -67,7 +67,7 @@ class PlatformSerializerTest {
fun `serialize should write correct displayName for Nintendo Switch`() {
val platform = Platform.NINTENDO_SWITCH
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Nintendo Switch") }
}
@ -76,7 +76,7 @@ class PlatformSerializerTest {
fun `serialize should handle platforms with special characters in name`() {
val platform = Platform.ODYSSEY_2_VIDEOPAC_G7000
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Odyssey 2 / Videopac G7000") }
}
@ -85,7 +85,7 @@ class PlatformSerializerTest {
fun `serialize should handle platforms with numbers in name`() {
val platform = Platform._3DO_INTERACTIVE_MULTIPLAYER
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("3DO Interactive Multiplayer") }
}
@ -94,7 +94,7 @@ class PlatformSerializerTest {
fun `serialize should handle platforms with hyphens in name`() {
val platform = Platform.ATARI_8_BIT
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Atari 8-bit") }
}
@ -103,7 +103,7 @@ class PlatformSerializerTest {
fun `serialize should handle platforms with apostrophes in name`() {
val platform = Platform.SUPER_ACAN
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Super A'Can") }
}
@ -112,7 +112,7 @@ class PlatformSerializerTest {
fun `serialize should handle arcade platform`() {
val platform = Platform.ARCADE
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Arcade") }
}
@ -121,7 +121,7 @@ class PlatformSerializerTest {
fun `serialize should handle web browser platform`() {
val platform = Platform.WEB_BROWSER
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Web browser") }
}
@ -130,7 +130,7 @@ class PlatformSerializerTest {
fun `serialize should handle Android platform`() {
val platform = Platform.ANDROID
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Android") }
}
@ -139,7 +139,7 @@ class PlatformSerializerTest {
fun `serialize should handle iOS platform`() {
val platform = Platform.IOS
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("iOS") }
}
@ -148,7 +148,7 @@ class PlatformSerializerTest {
fun `serialize should handle Linux platform`() {
val platform = Platform.LINUX
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Linux") }
}
@ -157,7 +157,7 @@ class PlatformSerializerTest {
fun `serialize should handle Mac platform`() {
val platform = Platform.MAC
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Mac") }
}
@ -166,7 +166,7 @@ class PlatformSerializerTest {
fun `serialize should handle DOS platform`() {
val platform = Platform.DOS
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("DOS") }
}
@ -175,7 +175,7 @@ class PlatformSerializerTest {
fun `serialize should handle Dreamcast platform`() {
val platform = Platform.DREAMCAST
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Dreamcast") }
}
@ -184,7 +184,7 @@ class PlatformSerializerTest {
fun `serialize should handle Virtual Boy platform`() {
val platform = Platform.VIRTUAL_BOY
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Virtual Boy") }
}
@ -193,7 +193,7 @@ class PlatformSerializerTest {
fun `serialize should handle ZX Spectrum platform`() {
val platform = Platform.ZX_SPECTRUM
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("ZX Spectrum") }
}
@ -202,7 +202,7 @@ class PlatformSerializerTest {
fun `serialize should handle Game Boy platform`() {
val platform = Platform.GAME_BOY
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("Game Boy") }
}
@ -211,7 +211,7 @@ class PlatformSerializerTest {
fun `serialize should handle PlayStation VR2 platform`() {
val platform = Platform.PLAYSTATION_VR2
serializer.serialize(platform, jsonGenerator, serializerProvider)
serializer.serialize(platform, jsonGenerator, serializationContext)
verify(exactly = 1) { jsonGenerator.writeString("PlayStation VR2") }
}

View File

@ -23,7 +23,7 @@ class PasswordResetEndpointTest {
fun setup() {
passwordResetService = mockk()
userService = mockk()
passwordResetEndpoint = PasswordResetEndpoint(passwordResetService, userService)
passwordResetEndpoint = PasswordResetEndpoint(passwordResetService)
}
@AfterEach

View File

@ -1,4 +1,4 @@
val jacksonVersion = "2.19.1"
val jacksonVersion = "3.0.4"
plugins {
kotlin("jvm")
@ -16,8 +16,8 @@ dependencies {
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
// JSON serialization
compileOnly("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
compileOnly("tools.jackson.core:jackson-databind:$jacksonVersion")
implementation("tools.jackson.module:jackson-module-kotlin:$jacksonVersion")
}
mavenPublishing {

View File

@ -132,11 +132,11 @@ abstract class ConfigurableGameyfinPlugin(wrapper: PluginWrapper) : GameyfinPlug
// Try to convert common types
try {
return when (expectedType) {
Int::class.java, Integer::class.java -> value.toString().toInt()
Float::class.java, java.lang.Float::class.java -> value.toString().toFloat()
Double::class.java, java.lang.Double::class.java -> value.toString().toDouble()
Long::class.java, java.lang.Long::class.java -> value.toString().toLong()
Boolean::class.java, java.lang.Boolean::class.java -> value.toString().toBooleanStrict()
Int::class.java -> value.toString().toInt()
Float::class.java -> value.toString().toFloat()
Double::class.java -> value.toString().toDouble()
Long::class.java -> value.toString().toLong()
Boolean::class.java -> value.toString().toBooleanStrict()
String::class.java -> value.toString()
else -> {
// Try valueOf(String) or parse(String) via reflection

View File

@ -1,9 +1,10 @@
package org.gameyfin.pluginapi.core.wrapper
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.pf4j.Plugin
import org.pf4j.PluginWrapper
import tools.jackson.databind.ObjectMapper
import tools.jackson.module.kotlin.jsonMapper
import tools.jackson.module.kotlin.kotlinModule
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
@ -48,7 +49,9 @@ abstract class GameyfinPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
/**
* JSON serializer for serializing and deserializing plugin state.
*/
val objectMapper: ObjectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
val objectMapper: ObjectMapper = jsonMapper {
addModule(kotlinModule())
}
/**
* Checks if the plugin contains a logo file in any supported format.

View File

@ -13,7 +13,7 @@ val keystorePasswordProperty = "gameyfin.keystorePassword"
val keystorePath: String = rootProject.file("certs/gameyfin.jks").absolutePath
val keystoreAlias = "gameyfin-plugins"
val keystorePasswordProvider = provider {
val keystorePasswordProvider: Provider<String> = provider {
(findProperty(keystorePasswordProperty) as String?)
?: System.getenv(keystorePasswordEnvironmentVariable)
?: ""