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
107 changed files with 3714 additions and 2970 deletions

View File

@ -1,32 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**General info (please complete the following information):**
- Browser (if bug is in the Web UI) [e.g. chrome, safari]
- Gameyfin version [e.g. v2.0.0.beta3]
**Additional context**
Add any other context about the problem here.
Screenshots can also be attached here.

128
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@ -0,0 +1,128 @@
name: Bug report
description: Create a report to help us improve
type: bug
title: '[Bug] '
labels:
- Bug
assignees:
- grimsi
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> **Before submitting your bug report**
>
> To help us resolve your issue efficiently, please ensure you have reviewed our [FAQs](https://gameyfin.org/faq/) and [Getting started guide](https://gameyfin.org/installation/getting-started/).
>
> Issues that could have been resolved by following these resources may be closed to allow us to focus on genuine bugs.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm you have read and understood the following resources
options:
- label: I have read and understood the [FAQs](https://gameyfin.org/faq/)
required: true
- label: I have read and understood the [Getting started guide](https://gameyfin.org/installation/getting-started/)
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Describe the bug...
validations:
required: true
- type: input
id: version
attributes:
label: Gameyfin Version
description: What version of Gameyfin are you running?
placeholder: e.g. v2.0.0.beta3
validations:
required: true
- type: dropdown
id: installation-type
attributes:
label: Installation Type
description: How did you install Gameyfin?
options:
- Docker
- Unraid
- Other (please specify in Additional Context)
validations:
required: true
- type: input
id: browser
attributes:
label: Browser with Version
description: Which browser are you using?
placeholder: e.g. Chrome 120.0.6099.129, Firefox 121.0, Safari 17.2
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: How to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen
placeholder: What should have happened?
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: A clear and concise description of what actually happened
placeholder: What actually happened?
validations:
required: true
- type: textarea
id: logs
attributes:
label: Application Logs
description: Please provide relevant logs from the application. You can usually find these in the logs directory or container logs
placeholder: Paste your logs here
render: shell
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem
placeholder: Drag and drop images here or paste them
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here
placeholder: Any additional information that might be helpful
validations:
required: false

View File

@ -1,10 +0,0 @@
---
name: Feature request
about: Request a feature
title: ''
labels: ''
assignees: ''
---
Please use the [discussions](https://github.com/gameyfin/gameyfin/discussions/categories/feature-requests) for feature requests!

View File

@ -0,0 +1,24 @@
name: Feature request
description: Request a feature
title: 'Feature request'
labels: []
assignees: []
body:
- type: markdown
attributes:
value: |
> [!CAUTION]
> **Feature requests should not be submitted as issues!**
>
> Please use the [discussions](https://github.com/gameyfin/gameyfin/discussions/categories/feature-requests) for feature requests instead.
- type: input
id: acknowledgment
attributes:
label: I understand
description: Type "I will use discussions" to acknowledge
placeholder: I will use discussions
validations:
required: true

View File

@ -16,11 +16,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Run production build
env:

View File

@ -20,11 +20,11 @@ jobs:
with:
fetch-depth: 0
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5

View File

@ -75,11 +75,11 @@ jobs:
with:
name: modified-files
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
@ -162,11 +162,11 @@ jobs:
with:
name: modified-files
- name: Set up JDK 21
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5

44
.github/workflows/sonar.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Sonar Analysis
on:
push:
branches:
- main
pull_request:
types: [ opened, synchronize, reopened ]
jobs:
sonar:
name: Sonar Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Cache SonarCloud packages
uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Run tests and generate JaCoCo report
run: ./gradlew :app:test :app:jacocoTestReport
- name: SonarCloud Scan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew :app:sonar

View File

@ -12,6 +12,8 @@ plugins {
kotlin("plugin.jpa")
id("com.google.devtools.ksp")
application
jacoco
id("org.sonarqube")
}
application {
@ -33,15 +35,19 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-aop")
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:3.1.0")
implementation("jakarta.validation:jakarta.validation-api:${rootProject.extra["jakartaValidationVersion"]}")
// Kotlin extensions
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")
@ -49,17 +55,19 @@ 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
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("io.github.oshai:kotlin-logging-jvm:${rootProject.extra["kotlinLoggingVersion"]}")
// Persistence & I/O
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
implementation("org.flywaydb:flyway-core")
implementation("commons-io:commons-io:2.18.0")
implementation("com.google.guava:guava:33.5.0-jre")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("commons-io:commons-io:${rootProject.extra["commonsIoVersion"]}")
implementation("com.google.guava:guava:${rootProject.extra["guavaVersion"]}")
// SSO
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
@ -68,18 +76,19 @@ dependencies {
// Notifications
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("ch.digitalfondue.mjml4j:mjml4j:1.1.4")
implementation("ch.digitalfondue.mjml4j:mjml4j:${rootProject.extra["mjml4jVersion"]}")
// Plugins
implementation(project(":plugin-api"))
// Utils
implementation("org.apache.tika:tika-core:3.2.3")
implementation("me.xdrop:fuzzywuzzy:1.4.0")
implementation("com.vanniktech:blurhash:0.3.0")
implementation("org.apache.tika:tika-core:${rootProject.extra["tikaVersion"]}")
implementation("me.xdrop:fuzzywuzzy:${rootProject.extra["fuzzywuzzyVersion"]}")
implementation("com.vanniktech:blurhash:${rootProject.extra["blurhashVersion"]}")
// 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")
@ -103,6 +112,30 @@ dependencyManagement {
tasks.withType<Test> {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
xml.outputLocation = layout.buildDirectory.file("reports/jacoco/test/jacocoTestReport.xml")
}
}
tasks.named("sonar") {
dependsOn(tasks.jacocoTestReport)
}
sonar {
properties {
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/**")
}
}
tasks.named<ProcessResources>("processResources") {

3459
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,51 +5,47 @@
"dependencies": {
"@heroui/react": "^2.8.7",
"@phosphor-icons/react": "^2.1.10",
"@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0",
"@tailwindcss/vite": "4.1.13",
"@vaadin/bundles": "24.9.4",
"@vaadin/aura": "25.0.3",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.9.4",
"@vaadin/hilla-frontend": "24.9.4",
"@vaadin/hilla-lit-form": "24.9.4",
"@vaadin/hilla-react-auth": "24.9.4",
"@vaadin/hilla-react-crud": "24.9.4",
"@vaadin/hilla-react-form": "24.9.4",
"@vaadin/hilla-react-i18n": "24.9.4",
"@vaadin/hilla-react-signals": "24.9.4",
"@vaadin/polymer-legacy-adapter": "24.9.4",
"@vaadin/react-components": "24.9.4",
"@vaadin/hilla-file-router": "25.0.4",
"@vaadin/hilla-frontend": "25.0.4",
"@vaadin/hilla-lit-form": "25.0.4",
"@vaadin/hilla-react-auth": "25.0.4",
"@vaadin/hilla-react-crud": "25.0.4",
"@vaadin/hilla-react-form": "25.0.4",
"@vaadin/hilla-react-i18n": "25.0.4",
"@vaadin/hilla-react-signals": "25.0.4",
"@vaadin/react-components": "25.0.3",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.9.4",
"@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-lumo-styles": "25.0.3",
"@vaadin/vaadin-themable-mixin": "25.0.3",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"blurhash": "^2.0.5",
"classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"date-fns": "4.1.0",
"formik": "^2.4.6",
"framer-motion": "^12.23.22",
"fzf": "^0.5.2",
"http-status-codes": "^2.3.0",
"lit": "3.3.1",
"lit": "3.3.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"rand-seed": "^2.1.7",
"react": "19.1.1",
"react": "19.2.3",
"react-accessible-treeview": "^2.11.1",
"react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "19.1.1",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-realtime-chart": "^0.8.1",
"react-router": "7.6.3",
"react-router": "7.12.0",
"react-window": "^2.2.3",
"remark-breaks": "^4.0.0",
"swiper": "^11.2.6",
@ -57,59 +53,53 @@
"yup": "^1.6.1"
},
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@babel/preset-react": "7.28.5",
"@lit-labs/react": "^2.1.3",
"@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@types/node": "^22.4.0",
"@types/react": "19.1.17",
"@types/react-dom": "19.1.11",
"@types/node": "25.0.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react-window": "^1.8.8",
"@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
"@vaadin/hilla-generator-plugin-barrel": "24.9.4",
"@vaadin/hilla-generator-plugin-client": "24.9.4",
"@vaadin/hilla-generator-plugin-model": "24.9.4",
"@vaadin/hilla-generator-plugin-push": "24.9.4",
"@vaadin/hilla-generator-plugin-signals": "24.9.4",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
"@vaadin/hilla-generator-utils": "24.9.4",
"@vitejs/plugin-react": "4.7.0",
"@vaadin/hilla-generator-cli": "25.0.4",
"@vaadin/hilla-generator-core": "25.0.4",
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
"@vaadin/hilla-generator-plugin-client": "25.0.4",
"@vaadin/hilla-generator-plugin-model": "25.0.4",
"@vaadin/hilla-generator-plugin-push": "25.0.4",
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
"@vaadin/hilla-generator-utils": "25.0.4",
"@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react-swc": "^3.7.0",
"glob": "11.0.3",
"magic-string": "0.30.19",
"baseline-browser-mapping": "^2.9.19",
"magic-string": "0.30.21",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"rollup-plugin-visualizer": "6.0.5",
"strip-css-comments": "5.0.0",
"tailwindcss": "4.1.13",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.4.1",
"vite-plugin-checker": "0.10.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
"typescript": "5.9.3",
"vite": "7.3.1",
"vite-plugin-checker": "0.12.0",
"workbox-build": "7.4.0"
},
"overrides": {
"@react-aria/utils": "^3.28.1",
"classnames": "$classnames",
"react": "$react",
"react-dom": "$react-dom",
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"@phosphor-icons/react": "$@phosphor-icons/react",
"formik": "$formik",
"yup": "$yup",
"@heroui/react": "$@heroui/react",
"framer-motion": "$framer-motion",
"http-status-codes": "$http-status-codes",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
@ -127,7 +117,6 @@
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
"@react-types/shared": "$@react-types/shared",
"@react-stately/data": "$@react-stately/data",
"react-aria-components": "$react-aria-components",
@ -140,133 +129,128 @@
"remark-breaks": "$remark-breaks",
"valtio": "$valtio",
"fzf": "$fzf",
"@vaadin/router": "2.0.0",
"@tailwindcss/vite": "$@tailwindcss/vite",
"postcss": "$postcss",
"postcss-import": "$postcss-import",
"next-themes": "$next-themes",
"@vaadin/a11y-base": "24.9.4",
"@vaadin/accordion": "24.9.4",
"@vaadin/app-layout": "24.9.4",
"@vaadin/avatar": "24.9.4",
"@vaadin/avatar-group": "24.9.4",
"@vaadin/button": "24.9.4",
"@vaadin/card": "24.9.4",
"@vaadin/checkbox": "24.9.4",
"@vaadin/checkbox-group": "24.9.4",
"@vaadin/combo-box": "24.9.4",
"@vaadin/component-base": "24.9.4",
"@vaadin/confirm-dialog": "24.9.4",
"@vaadin/context-menu": "24.9.4",
"@vaadin/custom-field": "24.9.4",
"@vaadin/date-picker": "24.9.4",
"@vaadin/date-time-picker": "24.9.4",
"@vaadin/details": "24.9.4",
"@vaadin/dialog": "24.9.4",
"@vaadin/email-field": "24.9.4",
"@vaadin/field-base": "24.9.4",
"@vaadin/field-highlighter": "24.9.4",
"@vaadin/form-layout": "24.9.4",
"@vaadin/grid": "24.9.4",
"@vaadin/horizontal-layout": "24.9.4",
"@vaadin/icon": "24.9.4",
"@vaadin/icons": "24.9.4",
"@vaadin/input-container": "24.9.4",
"@vaadin/integer-field": "24.9.4",
"@vaadin/item": "24.9.4",
"@vaadin/list-box": "24.9.4",
"@vaadin/lit-renderer": "24.9.4",
"@vaadin/login": "24.9.4",
"@vaadin/markdown": "24.9.4",
"@vaadin/master-detail-layout": "24.9.4",
"@vaadin/menu-bar": "24.9.4",
"@vaadin/message-input": "24.9.4",
"@vaadin/message-list": "24.9.4",
"@vaadin/multi-select-combo-box": "24.9.4",
"@vaadin/notification": "24.9.4",
"@vaadin/number-field": "24.9.4",
"@vaadin/overlay": "24.9.4",
"@vaadin/password-field": "24.9.4",
"@vaadin/popover": "24.9.4",
"@vaadin/progress-bar": "24.9.4",
"@vaadin/radio-group": "24.9.4",
"@vaadin/scroller": "24.9.4",
"@vaadin/select": "24.9.4",
"@vaadin/side-nav": "24.9.4",
"@vaadin/split-layout": "24.9.4",
"@vaadin/tabs": "24.9.4",
"@vaadin/tabsheet": "24.9.4",
"@vaadin/text-area": "24.9.4",
"@vaadin/text-field": "24.9.4",
"@vaadin/time-picker": "24.9.4",
"@vaadin/tooltip": "24.9.4",
"@vaadin/upload": "24.9.4",
"@vaadin/vertical-layout": "24.9.4",
"@vaadin/virtual-list": "24.9.4",
"react-realtime-chart": "$react-realtime-chart",
"react-window": "$react-window",
"blurhash": "$blurhash"
"blurhash": "$blurhash",
"@vaadin/aura": "$@vaadin/aura",
"@vaadin/a11y-base": "25.0.3",
"@vaadin/accordion": "25.0.3",
"@vaadin/app-layout": "25.0.3",
"@vaadin/avatar": "25.0.3",
"@vaadin/avatar-group": "25.0.3",
"@vaadin/button": "25.0.3",
"@vaadin/card": "25.0.3",
"@vaadin/checkbox": "25.0.3",
"@vaadin/checkbox-group": "25.0.3",
"@vaadin/combo-box": "25.0.3",
"@vaadin/component-base": "25.0.3",
"@vaadin/confirm-dialog": "25.0.3",
"@vaadin/context-menu": "25.0.3",
"@vaadin/custom-field": "25.0.3",
"@vaadin/date-picker": "25.0.3",
"@vaadin/date-time-picker": "25.0.3",
"@vaadin/details": "25.0.3",
"@vaadin/dialog": "25.0.3",
"@vaadin/email-field": "25.0.3",
"@vaadin/field-base": "25.0.3",
"@vaadin/field-highlighter": "25.0.3",
"@vaadin/form-layout": "25.0.3",
"@vaadin/grid": "25.0.3",
"@vaadin/horizontal-layout": "25.0.3",
"@vaadin/icon": "25.0.3",
"@vaadin/icons": "25.0.3",
"@vaadin/input-container": "25.0.3",
"@vaadin/integer-field": "25.0.3",
"@vaadin/item": "25.0.3",
"@vaadin/list-box": "25.0.3",
"@vaadin/lit-renderer": "25.0.3",
"@vaadin/login": "25.0.3",
"@vaadin/markdown": "25.0.3",
"@vaadin/master-detail-layout": "25.0.3",
"@vaadin/menu-bar": "25.0.3",
"@vaadin/message-input": "25.0.3",
"@vaadin/message-list": "25.0.3",
"@vaadin/multi-select-combo-box": "25.0.3",
"@vaadin/notification": "25.0.3",
"@vaadin/number-field": "25.0.3",
"@vaadin/overlay": "25.0.3",
"@vaadin/password-field": "25.0.3",
"@vaadin/popover": "25.0.3",
"@vaadin/progress-bar": "25.0.3",
"@vaadin/radio-group": "25.0.3",
"@vaadin/scroller": "25.0.3",
"@vaadin/select": "25.0.3",
"@vaadin/side-nav": "25.0.3",
"@vaadin/split-layout": "25.0.3",
"@vaadin/tabs": "25.0.3",
"@vaadin/tabsheet": "25.0.3",
"@vaadin/text-area": "25.0.3",
"@vaadin/text-field": "25.0.3",
"@vaadin/time-picker": "25.0.3",
"@vaadin/tooltip": "25.0.3",
"@vaadin/upload": "25.0.3",
"@vaadin/router": "2.0.1",
"@vaadin/vertical-layout": "25.0.3",
"@vaadin/virtual-list": "25.0.3"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.9.4",
"@vaadin/aura": "25.0.3",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.9.4",
"@vaadin/hilla-frontend": "24.9.4",
"@vaadin/hilla-lit-form": "24.9.4",
"@vaadin/hilla-react-auth": "24.9.4",
"@vaadin/hilla-react-crud": "24.9.4",
"@vaadin/hilla-react-form": "24.9.4",
"@vaadin/hilla-react-i18n": "24.9.4",
"@vaadin/hilla-react-signals": "24.9.4",
"@vaadin/polymer-legacy-adapter": "24.9.4",
"@vaadin/react-components": "24.9.4",
"@vaadin/hilla-file-router": "25.0.4",
"@vaadin/hilla-frontend": "25.0.4",
"@vaadin/hilla-lit-form": "25.0.4",
"@vaadin/hilla-react-auth": "25.0.4",
"@vaadin/hilla-react-crud": "25.0.4",
"@vaadin/hilla-react-form": "25.0.4",
"@vaadin/hilla-react-i18n": "25.0.4",
"@vaadin/hilla-react-signals": "25.0.4",
"@vaadin/react-components": "25.0.3",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.9.4",
"@vaadin/vaadin-material-styles": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/vaadin-lumo-styles": "25.0.3",
"@vaadin/vaadin-themable-mixin": "25.0.3",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.3.1",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-router": "7.6.3"
"date-fns": "4.1.0",
"lit": "3.3.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-router": "7.12.0"
},
"devDependencies": {
"@babel/preset-react": "7.27.1",
"@babel/preset-react": "7.28.5",
"@preact/signals-react-transform": "0.6.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@types/react": "19.1.17",
"@types/react-dom": "19.1.11",
"@vaadin/hilla-generator-cli": "24.9.4",
"@vaadin/hilla-generator-core": "24.9.4",
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
"@vaadin/hilla-generator-plugin-barrel": "24.9.4",
"@vaadin/hilla-generator-plugin-client": "24.9.4",
"@vaadin/hilla-generator-plugin-model": "24.9.4",
"@vaadin/hilla-generator-plugin-push": "24.9.4",
"@vaadin/hilla-generator-plugin-signals": "24.9.4",
"@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
"@vaadin/hilla-generator-utils": "24.9.4",
"@vitejs/plugin-react": "4.7.0",
"glob": "11.0.3",
"magic-string": "0.30.19",
"@types/node": "25.0.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vaadin/hilla-generator-cli": "25.0.4",
"@vaadin/hilla-generator-core": "25.0.4",
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
"@vaadin/hilla-generator-plugin-client": "25.0.4",
"@vaadin/hilla-generator-plugin-model": "25.0.4",
"@vaadin/hilla-generator-plugin-push": "25.0.4",
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
"@vaadin/hilla-generator-utils": "25.0.4",
"@vitejs/plugin-react": "5.1.2",
"magic-string": "0.30.21",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"rollup-plugin-visualizer": "6.0.5",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.8.3",
"vite": "6.4.1",
"vite-plugin-checker": "0.10.3",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
"typescript": "5.9.3",
"vite": "7.3.1",
"vite-plugin-checker": "0.12.0",
"workbox-build": "7.4.0"
},
"disableUsageStatistics": true,
"hash": "760523c518e07bbe0567ae5d1b281ccf90326b285b5feb3c0f269c52ec774f88"
"hash": "d2c583f908a126db3f53ccbc87688b5089107afb58a87159631dc257a3a279ae"
}
}

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) {
@ -28,10 +24,11 @@ enum class Role(val roleName: String, val powerLevel: Int) {
return entries.find { it.roleName == enumString }
}
fun safeValueOf(type: String): Role? {
fun safeValueOf(type: String?): Role? {
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

@ -2,7 +2,7 @@ package org.gameyfin.app.core.config
import org.gameyfin.app.core.interceptors.EntityUpdateInterceptor
import org.hibernate.cfg.AvailableSettings
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

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.web.embedded.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

@ -12,7 +12,7 @@ class AppKeyValidator : CommandLineRunner {
val log = KotlinLogging.logger {}
}
override fun run(vararg args: String?) {
override fun run(vararg args: String) {
val base64Key = System.getenv("APP_KEY")
if (!hasValidAppKey(base64Key)) exitProcess(1)
}

View File

@ -11,7 +11,7 @@ class CustomAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException?
authException: AuthenticationException
) {
if (request.getParameter("direct") == "1") {
response.sendRedirect("/login")

View File

@ -12,23 +12,13 @@ import java.util.function.Supplier
class DynamicPublicAccessAuthorizationManager(
private val config: ConfigService
) : AuthorizationManager<RequestAuthorizationContext> {
@Deprecated("Deprecated in superclass")
override fun check(
authentication: Supplier<Authentication?>?,
`object`: RequestAuthorizationContext?
): AuthorizationDecision {
val auth = authentication?.get()
val allow = (auth?.isAuthenticated == true && auth.principal != "anonymousUser") ||
override fun authorize(
authentication: Supplier<out Authentication>,
`object`: RequestAuthorizationContext
): AuthorizationResult {
val auth = authentication.get()
val allow = (auth.isAuthenticated && auth.principal != "anonymousUser") ||
config.get(ConfigProperties.Security.AllowPublicAccess) == true
return AuthorizationDecision(allow)
}
override fun authorize(
authentication: Supplier<Authentication?>?,
`object`: RequestAuthorizationContext?
): AuthorizationResult {
@Suppress("DEPRECATION")
return check(authentication, `object`)
}
}

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)
@ -77,9 +77,13 @@ class SsoAuthenticationSuccessHandler(
// Update SecurityContext with expanded authorities through RoleHierarchy
val mappedAuthorities = authoritiesMapper.mapAuthorities(grantedAuthorities)
val newAuth =
UsernamePasswordAuthenticationToken(authentication.principal, authentication.credentials, mappedAuthorities)
SecurityContextHolder.getContext().authentication = newAuth
val authPrincipal = authentication.principal
val authCredentials = authentication.credentials
if (authPrincipal != null && authCredentials != null) {
val newAuth = UsernamePasswordAuthenticationToken(authPrincipal, authCredentials, mappedAuthorities)
SecurityContextHolder.getContext().authentication = newAuth
}
// Get the continue parameter from the request to redirect back to the original page
val continueUrl = request.getParameter("continue")

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

@ -47,12 +47,12 @@ class RoleService(
}
fun getRolesBelowAuth(auth: Authentication): List<Role> {
val highestUserRole = getHighestRole(auth.authorities.mapNotNull { Role.Companion.safeValueOf(it.authority) })
val highestUserRole = getHighestRole(auth.authorities.mapNotNull { Role.safeValueOf(it.authority) })
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
}
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): List<Role> {
return authorities.mapNotNull { Role.Companion.safeValueOf(it.authority) }
return authorities.mapNotNull { Role.safeValueOf(it.authority) }
}
/**

View File

@ -11,8 +11,8 @@ import org.springframework.stereotype.Service
class SessionService(private val sessionRegistry: SessionRegistry) {
fun logoutAllSessions() {
val auth = getCurrentAuth()
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth?.principal, false)
val authPrincipal = getCurrentAuth()?.principal ?: return
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(authPrincipal, false)
for (sessionInfo in sessions) {
sessionInfo.expireNow()
}

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 +0,0 @@
com.vaadin.experimental.react19=true

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

@ -1,6 +1,5 @@
package org.gameyfin.app.core.download.bandwidth
import com.helger.commons.mock.CommonsAssert.assertEquals
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -104,7 +103,7 @@ class SessionBandwidthManagerTest {
val stat1 = stats["session-1"]
assertNotNull(stat1)
assertEquals("session-1", stat1!!.sessionId)
assertEquals("session-1", stat1.sessionId)
assertEquals(1, stat1.activeDownloads)
assertEquals("user1", stat1.username)
assertEquals("192.168.1.1", stat1.remoteIp)

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

@ -56,7 +56,7 @@ class DownloadEndpointTest {
* Helper method to wait for DeferredResult to complete and get the result.
* Handles async processing with timeout.
*/
private fun <T> awaitDeferredResult(deferredResult: DeferredResult<T>, timeoutSeconds: Long = 5): T {
private fun <T : Any> awaitDeferredResult(deferredResult: DeferredResult<T>, timeoutSeconds: Long = 5): T {
val latch = CountDownLatch(1)
var result: T? = null
var error: Throwable? = null
@ -108,7 +108,7 @@ class DownloadEndpointTest {
assertEquals(HttpStatus.OK, response.statusCode)
assertNotNull(response.body)
assertTrue(response.headers.containsKey("Content-Disposition"))
assertTrue(response.headers.containsHeader("Content-Disposition"))
assertTrue(response.headers["Content-Disposition"]!![0].contains("Test Game.zip"))
verify(exactly = 1) { gameService.getById(gameId) }
@ -142,9 +142,9 @@ class DownloadEndpointTest {
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertTrue(response.headers.containsKey("Content-Disposition"))
assertTrue(response.headers.containsHeader("Content-Disposition"))
// Content-Length should not be present for directories
assertFalse(response.headers.containsKey("Content-Length"))
assertFalse(response.headers.containsHeader("Content-Length"))
}
@Test
@ -171,7 +171,7 @@ class DownloadEndpointTest {
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertFalse(response.headers.containsKey("Content-Length"))
assertFalse(response.headers.containsHeader("Content-Length"))
}
@Test
@ -198,7 +198,7 @@ class DownloadEndpointTest {
val response = awaitDeferredResult(deferredResult)
assertEquals(HttpStatus.OK, response.statusCode)
assertFalse(response.headers.containsKey("Content-Length"))
assertFalse(response.headers.containsHeader("Content-Length"))
}
@Test

View File

@ -131,7 +131,8 @@ class LogEndpointTest {
@Test
fun `getApplicationLogs should return empty flux when authentication is null`() {
val securityContext = SecurityContextImpl(null)
val mockAuthentication = mockk<Authentication>(relaxed = true)
val securityContext = SecurityContextImpl(mockAuthentication)
SecurityContextHolder.setContext(securityContext)
val result = logEndpoint.getApplicationLogs()

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

@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletResponse
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.core.AuthenticationException
import kotlin.test.assertEquals
@ -96,7 +97,7 @@ class CustomAuthenticationEntryPointTest {
every { request.getParameter("direct") } returns "1"
every { response.sendRedirect(any()) } just Runs
entryPoint.commence(request, response, null)
entryPoint.commence(request, response, AuthenticationCredentialsNotFoundException("Test"))
verify(exactly = 1) { response.sendRedirect("/login") }
}

View File

@ -46,7 +46,7 @@ class DynamicPublicAccessAuthorizationManagerTest {
"password",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
@ -63,7 +63,7 @@ class DynamicPublicAccessAuthorizationManagerTest {
"password",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
@ -79,7 +79,7 @@ class DynamicPublicAccessAuthorizationManagerTest {
every { authentication.isAuthenticated } returns false
every { authentication.principal } returns "anonymousUser"
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
@ -95,7 +95,7 @@ class DynamicPublicAccessAuthorizationManagerTest {
every { authentication.isAuthenticated } returns false
every { authentication.principal } returns "anonymousUser"
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
@ -107,7 +107,8 @@ class DynamicPublicAccessAuthorizationManagerTest {
fun `check should deny access when authentication is null and public access is disabled`() {
every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false
val authSupplier = Supplier<Authentication?> { null }
val mockAuthentication = mockk<Authentication>(relaxed = true)
val authSupplier = Supplier<Authentication> { mockAuthentication }
val decision = manager.authorize(authSupplier, context)
@ -119,7 +120,8 @@ class DynamicPublicAccessAuthorizationManagerTest {
fun `check should allow access when authentication is null and public access is enabled`() {
every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns true
val authSupplier = Supplier<Authentication?> { null }
val mockAuthentication = mockk<Authentication>(relaxed = true)
val authSupplier = Supplier<Authentication> { mockAuthentication }
val decision = manager.authorize(authSupplier, context)
@ -135,7 +137,7 @@ class DynamicPublicAccessAuthorizationManagerTest {
every { authentication.isAuthenticated } returns true
every { authentication.principal } returns "anonymousUser"
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
@ -151,7 +153,7 @@ class DynamicPublicAccessAuthorizationManagerTest {
every { authentication.isAuthenticated } returns true
every { authentication.principal } returns "john.doe"
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
@ -167,38 +169,11 @@ class DynamicPublicAccessAuthorizationManagerTest {
every { authentication.isAuthenticated } returns false
every { authentication.principal } returns "anonymousUser"
val authSupplier = Supplier<Authentication?> { authentication }
val authSupplier = Supplier<Authentication> { authentication }
val decision = manager.authorize(authSupplier, context)
assertNotNull(decision)
assertFalse(decision.isGranted)
}
@Test
fun `check should work when supplier is null`() {
every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false
val decision = manager.authorize(null, context)
assertNotNull(decision)
assertFalse(decision.isGranted)
}
@Test
fun `check should work when context is null`() {
every { configService.get(ConfigProperties.Security.AllowPublicAccess) } returns false
val authentication = UsernamePasswordAuthenticationToken(
"user",
"password",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
val authSupplier = Supplier<Authentication?> { authentication }
val decision = manager.authorize(authSupplier, null)
assertNotNull(decision)
assertTrue(decision.isGranted)
}
}

View File

@ -42,17 +42,6 @@ class PasswordEncoderConfigTest {
assertTrue(encoder.matches(password, encoded2))
}
@Test
fun `passwordEncoder should handle empty password`() {
val encoder = config.passwordEncoder()
val password = ""
val encoded = encoder.encode(password)
assertNotNull(encoded)
assertTrue(encoder.matches(password, encoded))
}
@Test
fun `passwordEncoder should handle long password`() {
val encoder = config.passwordEncoder()

View File

@ -116,17 +116,6 @@ class SecurityUtilsTest {
assertFalse(result)
}
@Test
fun `isCurrentUserAdmin should return false when authorities is null`() {
val authentication = mockk<Authentication>()
every { authentication.authorities } returns null
every { securityContext.authentication } returns authentication
val result = isCurrentUserAdmin()
assertFalse(result)
}
@Test
fun `isCurrentUserAdmin should return true when user has both ADMIN and USER roles`() {
val authentication = UsernamePasswordAuthenticationToken(
@ -188,14 +177,6 @@ class SecurityUtilsTest {
assertFalse(authentication.isAdmin())
}
@Test
fun `Authentication isAdmin should return false when authorities is null`() {
val authentication = mockk<Authentication>()
every { authentication.authorities } returns null
assertFalse(authentication.isAdmin())
}
@Test
fun `Authentication isAdmin should return true when user has SUPERADMIN among multiple roles`() {
val authentication = UsernamePasswordAuthenticationToken(

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

@ -10,7 +10,7 @@
"jsx": "react-jsx",
"inlineSources": true,
"module": "esNext",
"target": "es2022",
"target": "es2023",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,

View File

@ -29,16 +29,13 @@ import checker from 'vite-plugin-checker';
import postcssLit from './build/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
import vaadinI18n from './build/plugins/rollup-plugin-vaadin-i18n/rollup-plugin-vaadin-i18n.js';
import serviceWorkerPlugin from './build/plugins/vite-plugin-service-worker';
import { createRequire } from 'module';
import vaadinBundlesPlugin from './build/plugins/vite-plugin-vaadin-bundles';
import { visualizer } from 'rollup-plugin-visualizer';
import reactPlugin from '@vitejs/plugin-react';
import vitePluginFileSystemRouter from '@vaadin/hilla-file-router/vite-plugin.js';
// Make `require` compatible with ES modules
const require = createRequire(import.meta.url);
import vitePluginFileSystemRouter from '@vaadin/hilla-file-router/vite-plugin.js';
const frontendFolder = path.resolve(__dirname, settings.frontendFolder);
const themeFolder = path.resolve(frontendFolder, settings.themeFolder);
@ -78,14 +75,16 @@ const themeOptions = {
projectStaticAssetsOutputFolder: devBundle
? path.resolve(devBundleFolder, '../assets')
: path.resolve(__dirname, settings.staticOutput),
frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder)
frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder),
projectStaticOutput: path.resolve(__dirname, settings.staticOutput),
javaResourceFolder: settings.javaResourceFolder ? path.resolve(__dirname, settings.javaResourceFolder) : ''
};
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
const commercialBannerComponent = path.resolve(frontendFolder, settings.generatedFolder, 'commercial-banner.js');
const hasCommercialBanner = existsSync(commercialBannerComponent);
const target = ['safari15', 'es2022'];
const target = ['es2023'];
// Block debug and trace logs.
console.trace = () => {};
@ -183,6 +182,10 @@ function statsExtracterPlugin(): PluginOption {
path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'),
generatedImportsSet
);
parseImports(
path.resolve(themeOptions.frontendGeneratedFolder, 'app-shell-imports.js'),
generatedImportsSet
);
const generatedImports = Array.from(generatedImportsSet).sort();
const frontendFiles: Record<string, string> = {};
@ -303,159 +306,6 @@ function statsExtracterPlugin(): PluginOption {
}
};
}
function vaadinBundlesPlugin(): PluginOption {
type ExportInfo =
| string
| {
namespace?: string;
source: string;
};
type ExposeInfo = {
exports: ExportInfo[];
};
type PackageInfo = {
version: string;
exposes: Record<string, ExposeInfo>;
};
type BundleJson = {
packages: Record<string, PackageInfo>;
};
const disabledMessage = 'Vaadin component dependency bundles are disabled.';
const modulesDirectory = nodeModulesFolder.replace(/\\/g, '/');
let vaadinBundleJson: BundleJson;
function parseModuleId(id: string): { packageName: string; modulePath: string } {
const [scope, scopedPackageName] = id.split('/', 3);
const packageName = scope.startsWith('@') ? `${scope}/${scopedPackageName}` : scope;
const modulePath = `.${id.substring(packageName.length)}`;
return {
packageName,
modulePath
};
}
function getExports(id: string): string[] | undefined {
const { packageName, modulePath } = parseModuleId(id);
const packageInfo = vaadinBundleJson.packages[packageName];
if (!packageInfo) return;
const exposeInfo: ExposeInfo = packageInfo.exposes[modulePath];
if (!exposeInfo) return;
const exportsSet = new Set<string>();
for (const e of exposeInfo.exports) {
if (typeof e === 'string') {
exportsSet.add(e);
} else {
const { namespace, source } = e;
if (namespace) {
exportsSet.add(namespace);
} else {
const sourceExports = getExports(source);
if (sourceExports) {
sourceExports.forEach((e) => exportsSet.add(e));
}
}
}
}
return Array.from(exportsSet);
}
function getExportBinding(binding: string) {
return binding === 'default' ? '_default as default' : binding;
}
function getImportAssigment(binding: string) {
return binding === 'default' ? 'default: _default' : binding;
}
return {
name: 'vaadin:bundles',
enforce: 'pre',
apply(config, { command }) {
if (command !== 'serve') return false;
try {
const vaadinBundleJsonPath = require.resolve('@vaadin/bundles/vaadin-bundle.json');
vaadinBundleJson = JSON.parse(readFileSync(vaadinBundleJsonPath, { encoding: 'utf8' }));
} catch (e: unknown) {
if (typeof e === 'object' && (e as { code: string }).code === 'MODULE_NOT_FOUND') {
vaadinBundleJson = { packages: {} };
console.info(`@vaadin/bundles npm package is not found, ${disabledMessage}`);
return false;
} else {
throw e;
}
}
const versionMismatches: Array<{ name: string; bundledVersion: string; installedVersion: string }> = [];
for (const [name, packageInfo] of Object.entries(vaadinBundleJson.packages)) {
let installedVersion: string | undefined = undefined;
try {
const { version: bundledVersion } = packageInfo;
const installedPackageJsonFile = path.resolve(modulesDirectory, name, 'package.json');
const packageJson = JSON.parse(readFileSync(installedPackageJsonFile, { encoding: 'utf8' }));
installedVersion = packageJson.version;
if (installedVersion && installedVersion !== bundledVersion) {
versionMismatches.push({
name,
bundledVersion,
installedVersion
});
}
} catch (_) {
// ignore package not found
}
}
if (versionMismatches.length) {
console.info(`@vaadin/bundles has version mismatches with installed packages, ${disabledMessage}`);
console.info(`Packages with version mismatches: ${JSON.stringify(versionMismatches, undefined, 2)}`);
vaadinBundleJson = { packages: {} };
return false;
}
return true;
},
async config(config) {
return mergeConfig(
{
optimizeDeps: {
exclude: [
// Vaadin bundle
'@vaadin/bundles',
...Object.keys(vaadinBundleJson.packages),
'@vaadin/vaadin-material-styles'
]
}
},
config
);
},
load(rawId) {
const [path, params] = rawId.split('?');
if (!path.startsWith(modulesDirectory)) return;
const id = path.substring(modulesDirectory.length + 1);
const bindings = getExports(id);
if (bindings === undefined) return;
const cacheSuffix = params ? `?${params}` : '';
const bundlePath = `@vaadin/bundles/vaadin.js${cacheSuffix}`;
return `import { init as VaadinBundleInit, get as VaadinBundleGet } from '${bundlePath}';
await VaadinBundleInit('default');
const { ${bindings.map(getImportAssigment).join(', ')} } = (await VaadinBundleGet('./node_modules/${id}'))();
export { ${bindings.map(getExportBinding).join(', ')} };`;
}
};
}
function themePlugin(opts: { devMode: boolean }): PluginOption {
const fullThemeOptions = { ...themeOptions, devMode: opts.devMode };
@ -563,9 +413,12 @@ function preserveUsageStats() {
transform(src: string, id: string) {
if (id.includes('vaadin-usage-statistics')) {
if (src.includes('vaadin-dev-mode:start')) {
const newSrc = src.replace(DEV_MODE_START_REGEXP, '/*! vaadin-dev-mode:start');
const expectedComment = '/*! vaadin-dev-mode:start';
const newSrc = src.replace(DEV_MODE_START_REGEXP, expectedComment);
if (newSrc === src) {
console.error('Comment replacement failed to change anything');
if (!src.includes(expectedComment)) {
console.error('vaadin-dev-mode:start tag not found');
}
} else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) {
console.error('New comment fails to match original regexp');
} else {
@ -612,6 +465,9 @@ export const vaadinConfig: UserConfigFn = (env) => {
allow: allowedFrontendFolders
}
},
esbuild: {
legalComments: 'inline',
},
build: {
minify: productionMode,
outDir: buildOutputFolder,
@ -668,7 +524,9 @@ export const vaadinConfig: UserConfigFn = (env) => {
},
plugins: [
productionMode && brotli(),
devMode && vaadinBundlesPlugin(),
devMode && vaadinBundlesPlugin({
nodeModulesFolder
}),
devMode && showRecompileReason(),
settings.offlineEnabled && serviceWorkerPlugin({
srcPath: settings.clientServiceWorkerSource,
@ -714,6 +572,7 @@ export const vaadinConfig: UserConfigFn = (env) => {
].filter(Boolean)
}
}),
productionMode && vaadinI18n({
cwd: __dirname,
meta: {

Some files were not shown because too many files have changed in this diff Show More