mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-09-01 13:51:27 +02:00
Added frontend + backend tests
This commit is contained in:
parent
db34ddd0a9
commit
89bd541247
@ -6,7 +6,7 @@ FROM node:20-alpine AS build-client
|
|||||||
|
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm ci && npm cache clean --force
|
RUN npm install && npm cache clean --force
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error widget -->
|
<!-- Error widget -->
|
||||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
|
<ui-tooltip cy-id="errorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
|
||||||
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||||
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +136,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import MoreMenu from '@/components/widgets/MoreMenu'
|
import MoreMenu from '@/components/widgets/MoreMenu.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
const { defineConfig } = require("cypress")
|
const { defineConfig } = require('cypress')
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
component: {
|
component: {
|
||||||
devServer: {
|
devServer: {
|
||||||
framework: "nuxt",
|
framework: 'nuxt',
|
||||||
bundler: "webpack"
|
bundler: 'vite'
|
||||||
},
|
},
|
||||||
specPattern: "cypress/tests/**/*.cy.js"
|
specPattern: 'cypress/tests/**/*.cy.js'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import LazyBookCard from '@/components/cards/LazyBookCard'
|
import LazyBookCard from '@/components/cards/LazyBookCard.vue'
|
||||||
import Tooltip from '@/components/ui/Tooltip.vue'
|
import Tooltip from '@/components/ui/Tooltip.vue'
|
||||||
import ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'
|
import ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'
|
||||||
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
|
||||||
@ -109,20 +109,19 @@ describe('LazyBookCard', () => {
|
|||||||
cy.get('&explicitIndicator').should('not.exist')
|
cy.get('&explicitIndicator').should('not.exist')
|
||||||
cy.get('&line2').should('have.text', 'J. R. R. Tolkien')
|
cy.get('&line2').should('have.text', 'J. R. R. Tolkien')
|
||||||
cy.get('&line3').should('not.exist')
|
cy.get('&line3').should('not.exist')
|
||||||
cy.get('seriesSequenceList').should('not.exist')
|
cy.get('&seriesSequenceList').should('not.exist')
|
||||||
cy.get('&booksInSeries').should('not.exist')
|
cy.get('&booksInSeries').should('not.exist')
|
||||||
cy.get('&placeholderTitle').should('be.visible')
|
cy.get('&placeholderTitle').should('be.visible')
|
||||||
cy.get('&placeholderTitleText').should('have.text', 'The Fellowship of the Ring')
|
cy.get('&placeholderTitleText').should('have.text', 'The Fellowship of the Ring')
|
||||||
cy.get('&placeholderAuthor').should('be.visible')
|
cy.get('&placeholderAuthor').should('be.visible')
|
||||||
cy.get('&placeholderAuthorText').should('have.text', 'J. R. R. Tolkien')
|
cy.get('&placeholderAuthorText').should('have.text', 'J. R. R. Tolkien')
|
||||||
cy.get('&progressBar').should('be.hidden')
|
cy.get('&progressBar').should('be.hidden')
|
||||||
cy.get('&finishedProgressBar').should('not.exist')
|
|
||||||
cy.get('&loadingSpinner').should('not.exist')
|
cy.get('&loadingSpinner').should('not.exist')
|
||||||
cy.get('&seriesNameOverlay').should('not.exist')
|
cy.get('&seriesNameOverlay').should('not.exist')
|
||||||
cy.get('&errorTooltip').should('not.exist')
|
cy.get('&errorTooltip').should('not.exist')
|
||||||
cy.get('&rssFeed').should('not.exist')
|
cy.get('&rssFeed').should('not.exist')
|
||||||
cy.get('&seriesSequence').should('not.exist')
|
cy.get('&seriesSequence').should('not.exist')
|
||||||
cy.get('&podcastEpisdeNumber').should('not.exist')
|
cy.get('&podcastEpisodeNumber').should('not.exist')
|
||||||
|
|
||||||
// this should actually fail, since the height does not cover
|
// this should actually fail, since the height does not cover
|
||||||
// the detailBottom element, currently rendered outside the card's area,
|
// the detailBottom element, currently rendered outside the card's area,
|
||||||
|
88
client/cypress/tests/components/ui/RatingInput.cy.js
Normal file
88
client/cypress/tests/components/ui/RatingInput.cy.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import RatingInput from '@/components/ui/RatingInput.vue'
|
||||||
|
import FlameIcon from '@/components/ui/FlameIcon.vue'
|
||||||
|
|
||||||
|
describe('<RatingInput />', () => {
|
||||||
|
it('renders with initial value', () => {
|
||||||
|
cy.mount(RatingInput, {
|
||||||
|
propsData: {
|
||||||
|
value: 3.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cy.get('.rating-input').should('be.visible')
|
||||||
|
cy.get('.star-filled').should('have.length', 5)
|
||||||
|
cy.get('span').should('contain.text', '3.5/5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates value on click', () => {
|
||||||
|
const onInput = cy.spy().as('onInput')
|
||||||
|
cy.mount(RatingInput, {
|
||||||
|
propsData: {
|
||||||
|
value: 0
|
||||||
|
},
|
||||||
|
listeners: {
|
||||||
|
input: onInput
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.star-container[data-star="4"]').click()
|
||||||
|
cy.get('@onInput').should('have.been.calledWith', 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles half-star clicks', () => {
|
||||||
|
const onInput = cy.spy().as('onInput')
|
||||||
|
cy.mount(RatingInput, {
|
||||||
|
propsData: {
|
||||||
|
value: 0
|
||||||
|
},
|
||||||
|
listeners: {
|
||||||
|
input: onInput
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clicking on the left half of the 3rd star
|
||||||
|
cy.get('.star-container[data-star="3"]').click('left')
|
||||||
|
cy.get('@onInput').should('have.been.calledWith', 2.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows hover value on mousemove', () => {
|
||||||
|
cy.mount(RatingInput, {
|
||||||
|
propsData: {
|
||||||
|
value: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.star-container[data-star="5"]').trigger('mousemove', 'center')
|
||||||
|
// After hover, the internal value should be 5, so the 5th star should be fully visible
|
||||||
|
cy.get('.star-filled').last().should('have.css', 'clip-path', 'inset(0px)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is readonly when prop is set', () => {
|
||||||
|
const onInput = cy.spy().as('onInput')
|
||||||
|
cy.mount(RatingInput, {
|
||||||
|
propsData: {
|
||||||
|
value: 2,
|
||||||
|
readOnly: true
|
||||||
|
},
|
||||||
|
listeners: {
|
||||||
|
input: onInput
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.star-container[data-star="4"]').click()
|
||||||
|
cy.get('@onInput').should('not.have.been.called')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders flame icons when specified', () => {
|
||||||
|
cy.mount(RatingInput, {
|
||||||
|
propsData: {
|
||||||
|
value: 4.5,
|
||||||
|
icon: 'flame'
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
'ui-flame-icon': FlameIcon
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('svg > path[d="M18.61,54.89C15.7,28.8,30.94,10.45,59.52,0C42.02,22.71,74.44,47.31,76.23,70.89 c4.19-7.15,6.57-16.69,7.04-29.45c21.43,33.62,3.66,88.57-43.5,80.67c-4.33-0.72-8.5-2.09-12.3-4.13C10.27,108.8,0,88.79,0,69.68 C0,57.5,5.21,46.63,11.95,37.99C12.85,46.45,14.77,52.76,18.61,54.89L18.61,54.89z"]').should('exist')
|
||||||
|
})
|
||||||
|
})
|
14473
client/package-lock.json
generated
14473
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -36,12 +36,19 @@
|
|||||||
"vuedraggable": "^2.24.3"
|
"vuedraggable": "^2.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cypress/webpack-dev-server": "~3.1.1",
|
||||||
"@nuxtjs/pwa": "^3.3.5",
|
"@nuxtjs/pwa": "^3.3.5",
|
||||||
"@tailwindcss/cli": "^4.0.14",
|
"@tailwindcss/cli": "^4.0.14",
|
||||||
|
"@vitejs/plugin-vue2": "^2.3.3",
|
||||||
|
"css-loader": "^4.3.0",
|
||||||
|
"cypress": "^12.17.4",
|
||||||
|
"html-webpack-plugin": "^4.5.2",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
"postcss": "^8.3.6",
|
"postcss": "^8.3.6",
|
||||||
"tailwindcss": "^4.0.13"
|
"style-loader": "^2.0.0",
|
||||||
},
|
"tailwindcss": "^4.0.13",
|
||||||
"optionalDependencies": {
|
"vite": "^5.4.19",
|
||||||
"cypress": "^13.7.3"
|
"webpack": "^4.46.0",
|
||||||
|
"webpack-dev-server": "^3.11.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import Path from 'path'
|
import Path from 'path-browserify'
|
||||||
import vClickOutside from 'v-click-outside'
|
import vClickOutside from 'v-click-outside'
|
||||||
import { formatDistance, format, addDays, isDate, setDefaultOptions } from 'date-fns'
|
import { formatDistance, format, addDays, isDate, setDefaultOptions } from 'date-fns'
|
||||||
import * as locale from 'date-fns/locale'
|
import * as locale from 'date-fns/locale'
|
||||||
@ -61,44 +61,36 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
|||||||
const MAX_FILENAME_BYTES = 255
|
const MAX_FILENAME_BYTES = 255
|
||||||
|
|
||||||
const replacement = ''
|
const replacement = ''
|
||||||
const illegalRe = /[\/\?<>\\:\*\|"]/g
|
const illegalRe = /[\\\?<>\\:\*\|\"]/g
|
||||||
const controlRe = /[\x00-\x1f\x80-\x9f]/g
|
const controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||||
const reservedRe = /^\.+$/
|
const reservedRe = /^\.+$/
|
||||||
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||||
const windowsTrailingRe = /[\. ]+$/
|
const windowsTrailingRe = /[\. ]+$/
|
||||||
const lineBreaks = /[\n\r]/g
|
const lineBreaks = /[\n\r]/g
|
||||||
|
|
||||||
let sanitized = filename
|
let sanitized = filename.replace(':', colonReplacement).replace(illegalRe, replacement).replace(controlRe, replacement).replace(reservedRe, replacement).replace(lineBreaks, replacement).replace(windowsReservedRe, replacement).replace(windowsTrailingRe, replacement).replace(/\s+/g, ' ')
|
||||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
|
||||||
.replace(illegalRe, replacement)
|
|
||||||
.replace(controlRe, replacement)
|
|
||||||
.replace(reservedRe, replacement)
|
|
||||||
.replace(lineBreaks, replacement)
|
|
||||||
.replace(windowsReservedRe, replacement)
|
|
||||||
.replace(windowsTrailingRe, replacement)
|
|
||||||
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
|
||||||
|
|
||||||
// Check if basename is too many bytes
|
|
||||||
const ext = Path.extname(sanitized) // separate out file extension
|
const ext = Path.extname(sanitized) // separate out file extension
|
||||||
const basename = Path.basename(sanitized, ext)
|
const basename = Path.basename(sanitized, ext)
|
||||||
const extByteLength = Buffer.byteLength(ext, 'utf16le')
|
if (typeof Buffer !== 'undefined') {
|
||||||
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
|
const extByteLength = Buffer.byteLength(ext, 'utf16le')
|
||||||
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
|
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
|
||||||
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
|
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
|
||||||
let totalBytes = 0
|
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
|
||||||
let trimmedBasename = ''
|
let totalBytes = 0
|
||||||
|
let trimmedBasename = ''
|
||||||
|
|
||||||
// Add chars until max bytes is reached
|
// Add chars until max bytes is reached
|
||||||
for (const char of basename) {
|
for (const char of basename) {
|
||||||
totalBytes += Buffer.byteLength(char, 'utf16le')
|
totalBytes += Buffer.byteLength(char, 'utf16le')
|
||||||
if (totalBytes > MaxBytesForBasename) break
|
if (totalBytes > MaxBytesForBasename) break
|
||||||
else trimmedBasename += char
|
else trimmedBasename += char
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedBasename = trimmedBasename.trim()
|
||||||
|
sanitized = trimmedBasename + ext
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmedBasename = trimmedBasename.trim()
|
|
||||||
sanitized = trimmedBasename + ext
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,9 +159,31 @@ function xmlToJson(xml) {
|
|||||||
}
|
}
|
||||||
Vue.prototype.$xmlToJson = xmlToJson
|
Vue.prototype.$xmlToJson = xmlToJson
|
||||||
|
|
||||||
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
// Polyfilled Base64 encode/decode for browser environment
|
||||||
|
function utf8ToBase64(str) {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(str)))
|
||||||
|
} catch (e) {
|
||||||
|
return btoa(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function base64ToUtf8(str) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(atob(str)))
|
||||||
|
} catch (e) {
|
||||||
|
return atob(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const encode = (text) => {
|
||||||
|
const base64 = typeof Buffer !== 'undefined' && Buffer.from ? Buffer.from(text).toString('base64') : utf8ToBase64(text)
|
||||||
|
return encodeURIComponent(base64)
|
||||||
|
}
|
||||||
Vue.prototype.$encode = encode
|
Vue.prototype.$encode = encode
|
||||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
const decode = (text) => {
|
||||||
|
const base64 = decodeURIComponent(text)
|
||||||
|
const decoded = typeof Buffer !== 'undefined' && Buffer.from ? Buffer.from(base64, 'base64').toString() : base64ToUtf8(base64)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
Vue.prototype.$decode = decode
|
Vue.prototype.$decode = decode
|
||||||
|
|
||||||
export { encode, decode }
|
export { encode, decode }
|
||||||
|
12
client/vite.config.js
Normal file
12
client/vite.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue2'
|
||||||
|
import path from 'path'
|
||||||
|
// Minimal Vite config for Cypress component testing
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
13
package-lock.json
generated
13
package-lock.json
generated
@ -37,7 +37,8 @@
|
|||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"nodemon": "^2.0.20",
|
"nodemon": "^2.0.20",
|
||||||
"nyc": "^15.1.0",
|
"nyc": "^15.1.0",
|
||||||
"sinon": "^17.0.1"
|
"sinon": "^17.0.1",
|
||||||
|
"sinon-express-mock": "^2.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@ -4725,6 +4726,16 @@
|
|||||||
"url": "https://opencollective.com/sinon"
|
"url": "https://opencollective.com/sinon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sinon-express-mock": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sinon-express-mock/-/sinon-express-mock-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-z1wqaPMwEnfn0SpigFhVYVS/ObX1tkqyRzRdccX99FgQaLkxGSo4684unr3NCqWeYZ1zchxPw7oFIDfzg1cAjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"sinon": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sinon/node_modules/@sinonjs/fake-timers": {
|
"node_modules/sinon/node_modules/@sinonjs/fake-timers": {
|
||||||
"version": "11.2.2",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",
|
||||||
|
@ -62,6 +62,7 @@
|
|||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"nodemon": "^2.0.20",
|
"nodemon": "^2.0.20",
|
||||||
"nyc": "^15.1.0",
|
"nyc": "^15.1.0",
|
||||||
"sinon": "^17.0.1"
|
"sinon": "^17.0.1",
|
||||||
|
"sinon-express-mock": "^2.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,51 +44,59 @@ class LibraryItemController {
|
|||||||
const item = libraryItem.toOldJSONExpanded()
|
const item = libraryItem.toOldJSONExpanded()
|
||||||
|
|
||||||
if (libraryItem.isBook) {
|
if (libraryItem.isBook) {
|
||||||
// Include users personal rating
|
if (global.ServerSettings.enableRating) {
|
||||||
const userBookRating = await Database.userBookRatingModel.findOne({
|
// Include users personal rating
|
||||||
where: { userId: user.id, bookId: libraryItem.media.id }
|
const userBookRating = await Database.userBookRatingModel.findOne({
|
||||||
})
|
where: { userId: user.id, bookId: libraryItem.media.id }
|
||||||
if (userBookRating) {
|
})
|
||||||
item.media.myRating = userBookRating.rating
|
if (userBookRating) {
|
||||||
}
|
item.media.myRating = userBookRating.rating
|
||||||
|
|
||||||
// Include all users ratings for community rating
|
|
||||||
const allBookRatings = await Database.userBookRatingModel.findAll({
|
|
||||||
where: {
|
|
||||||
bookId: libraryItem.media.id,
|
|
||||||
userId: { [Op.ne]: user.id }
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (allBookRatings.length > 0) {
|
if (global.ServerSettings.enableCommunityRating) {
|
||||||
const totalRating = allBookRatings.reduce((acc, cur) => acc + cur.rating, 0)
|
// Include all users ratings for community rating
|
||||||
item.media.communityRating = {
|
const allBookRatings = await Database.userBookRatingModel.findAll({
|
||||||
average: totalRating / allBookRatings.length,
|
where: {
|
||||||
count: allBookRatings.length
|
bookId: libraryItem.media.id,
|
||||||
|
userId: { [Op.ne]: user.id }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allBookRatings.length > 0) {
|
||||||
|
const totalRating = allBookRatings.reduce((acc, cur) => acc + cur.rating, 0)
|
||||||
|
item.media.communityRating = {
|
||||||
|
average: totalRating / allBookRatings.length,
|
||||||
|
count: allBookRatings.length
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include users personal explicit rating
|
if (global.ServerSettings.enableExplicitRating) {
|
||||||
const userBookExplicitRating = await Database.userBookExplicitRatingModel.findOne({
|
// Include users personal explicit rating
|
||||||
where: { userId: user.id, bookId: libraryItem.media.id }
|
const userBookExplicitRating = await Database.userBookExplicitRatingModel.findOne({
|
||||||
})
|
where: { userId: user.id, bookId: libraryItem.media.id }
|
||||||
if (userBookExplicitRating) {
|
})
|
||||||
item.media.myExplicitRating = userBookExplicitRating.rating
|
if (userBookExplicitRating) {
|
||||||
}
|
item.media.myExplicitRating = userBookExplicitRating.rating
|
||||||
|
|
||||||
// Include all users explicit ratings for community explicit rating
|
|
||||||
const allBookExplicitRatings = await Database.userBookExplicitRatingModel.findAll({
|
|
||||||
where: {
|
|
||||||
bookId: libraryItem.media.id,
|
|
||||||
userId: { [Op.ne]: user.id }
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (allBookExplicitRatings.length > 0) {
|
if (global.ServerSettings.enableCommunityRating) {
|
||||||
const totalExplicitRating = allBookExplicitRatings.reduce((acc, cur) => acc + cur.rating, 0)
|
// Include all users explicit ratings for community explicit rating
|
||||||
item.media.communityExplicitRating = {
|
const allBookExplicitRatings = await Database.userBookExplicitRatingModel.findAll({
|
||||||
average: totalExplicitRating / allBookExplicitRatings.length,
|
where: {
|
||||||
count: allBookExplicitRatings.length
|
bookId: libraryItem.media.id,
|
||||||
|
userId: { [Op.ne]: user.id }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allBookExplicitRatings.length > 0) {
|
||||||
|
const totalExplicitRating = allBookExplicitRatings.reduce((acc, cur) => acc + cur.rating, 0)
|
||||||
|
item.media.communityExplicitRating = {
|
||||||
|
average: totalExplicitRating / allBookExplicitRatings.length,
|
||||||
|
count: allBookExplicitRatings.length
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,7 +116,7 @@ class LibraryItemController {
|
|||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
const includeEntities = (req.query.include || '').split(',')
|
const includeEntities = (req.query.include || '').split(',')
|
||||||
if (req.query.expanded == 1) {
|
if (req.query.expanded == 1) {
|
||||||
const item = await this._getExpandedItemWithRatings(req.libraryItem, req.user)
|
const item = await LibraryItemController.prototype._getExpandedItemWithRatings(req.libraryItem, req.user)
|
||||||
|
|
||||||
// Include users media progress
|
// Include users media progress
|
||||||
if (includeEntities.includes('progress')) {
|
if (includeEntities.includes('progress')) {
|
||||||
@ -314,7 +322,7 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
||||||
const itemWithRatings = await this._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
const itemWithRatings = await LibraryItemController.prototype._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
updated: hasUpdates,
|
updated: hasUpdates,
|
||||||
@ -1262,6 +1270,9 @@ class LibraryItemController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async rate(req, res) {
|
async rate(req, res) {
|
||||||
|
if (!global.ServerSettings.enableRating) {
|
||||||
|
return res.status(403).json({ error: 'Rating is disabled' })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { rating } = req.body
|
const { rating } = req.body
|
||||||
if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) {
|
if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) {
|
||||||
@ -1274,7 +1285,7 @@ class LibraryItemController {
|
|||||||
await Database.userBookRatingModel.upsert({ userId, bookId, rating })
|
await Database.userBookRatingModel.upsert({ userId, bookId, rating })
|
||||||
|
|
||||||
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
||||||
const itemWithRatings = await this._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
const itemWithRatings = await LibraryItemController.prototype._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
||||||
|
|
||||||
res.status(200).json({ success: true, libraryItem: itemWithRatings })
|
res.status(200).json({ success: true, libraryItem: itemWithRatings })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1290,6 +1301,9 @@ class LibraryItemController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async rateExplicit(req, res) {
|
async rateExplicit(req, res) {
|
||||||
|
if (!global.ServerSettings.enableExplicitRating) {
|
||||||
|
return res.status(403).json({ error: 'Explicit rating is disabled' })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { rating } = req.body
|
const { rating } = req.body
|
||||||
if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) {
|
if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) {
|
||||||
@ -1302,7 +1316,7 @@ class LibraryItemController {
|
|||||||
await Database.userBookExplicitRatingModel.upsert({ userId, bookId, rating })
|
await Database.userBookExplicitRatingModel.upsert({ userId, bookId, rating })
|
||||||
|
|
||||||
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
||||||
const itemWithRatings = await this._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
const itemWithRatings = await LibraryItemController.prototype._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
||||||
|
|
||||||
res.status(200).json({ success: true, libraryItem: itemWithRatings })
|
res.status(200).json({ success: true, libraryItem: itemWithRatings })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -1314,11 +1328,4 @@ class LibraryItemController {
|
|||||||
|
|
||||||
const controller = new LibraryItemController()
|
const controller = new LibraryItemController()
|
||||||
|
|
||||||
// Manually bind 'this' for all methods
|
|
||||||
for (const methodName of Object.getOwnPropertyNames(LibraryItemController.prototype)) {
|
|
||||||
if (methodName !== 'constructor' && typeof controller[methodName] === 'function') {
|
|
||||||
controller[methodName] = controller[methodName].bind(controller)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = controller
|
module.exports = controller
|
||||||
|
@ -8,124 +8,137 @@ module.exports = {
|
|||||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
const transaction = await queryInterface.sequelize.transaction()
|
const transaction = await queryInterface.sequelize.transaction()
|
||||||
try {
|
try {
|
||||||
|
const booksTable = await queryInterface.describeTable('books')
|
||||||
logger.info(`${loggerPrefix} adding columns to books table`)
|
logger.info(`${loggerPrefix} adding columns to books table`)
|
||||||
await queryInterface.addColumn(
|
if (!booksTable.providerRating) {
|
||||||
'books',
|
await queryInterface.addColumn(
|
||||||
'providerRating',
|
'books',
|
||||||
{
|
'providerRating',
|
||||||
type: DataTypes.FLOAT
|
{
|
||||||
},
|
type: DataTypes.FLOAT
|
||||||
{ transaction }
|
},
|
||||||
)
|
{ transaction }
|
||||||
await queryInterface.addColumn(
|
)
|
||||||
'books',
|
}
|
||||||
'provider',
|
if (!booksTable.provider) {
|
||||||
{
|
await queryInterface.addColumn(
|
||||||
type: DataTypes.STRING
|
'books',
|
||||||
},
|
'provider',
|
||||||
{ transaction }
|
{
|
||||||
)
|
type: DataTypes.STRING
|
||||||
await queryInterface.addColumn(
|
},
|
||||||
'books',
|
{ transaction }
|
||||||
'providerId',
|
)
|
||||||
{
|
}
|
||||||
type: DataTypes.STRING
|
if (!booksTable.providerId) {
|
||||||
},
|
await queryInterface.addColumn(
|
||||||
{ transaction }
|
'books',
|
||||||
)
|
'providerId',
|
||||||
|
{
|
||||||
|
type: DataTypes.STRING
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
)
|
||||||
|
}
|
||||||
logger.info(`${loggerPrefix} added columns to books table`)
|
logger.info(`${loggerPrefix} added columns to books table`)
|
||||||
|
|
||||||
logger.info(`${loggerPrefix} creating userBookRatings table`)
|
const tables = await queryInterface.showAllTables()
|
||||||
await queryInterface.createTable(
|
|
||||||
'userBookRatings',
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
primaryKey: true,
|
|
||||||
autoIncrement: true
|
|
||||||
},
|
|
||||||
userId: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: 'users', key: 'id' },
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'CASCADE'
|
|
||||||
},
|
|
||||||
bookId: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: 'books', key: 'id' },
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'CASCADE'
|
|
||||||
},
|
|
||||||
rating: {
|
|
||||||
type: DataTypes.FLOAT,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ transaction }
|
|
||||||
)
|
|
||||||
await queryInterface.addConstraint('userBookRatings', {
|
|
||||||
fields: ['userId', 'bookId'],
|
|
||||||
type: 'unique',
|
|
||||||
name: 'user_book_ratings_unique_constraint',
|
|
||||||
transaction
|
|
||||||
})
|
|
||||||
logger.info(`${loggerPrefix} created userBookRatings table`)
|
|
||||||
|
|
||||||
logger.info(`${loggerPrefix} creating userBookExplicitRatings table`)
|
if (!tables.includes('userBookRatings')) {
|
||||||
await queryInterface.createTable(
|
logger.info(`${loggerPrefix} creating userBookRatings table`)
|
||||||
'userBookExplicitRatings',
|
await queryInterface.createTable(
|
||||||
{
|
'userBookRatings',
|
||||||
id: {
|
{
|
||||||
type: DataTypes.INTEGER,
|
id: {
|
||||||
primaryKey: true,
|
type: DataTypes.INTEGER,
|
||||||
autoIncrement: true
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: 'users', key: 'id' },
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
bookId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: 'books', key: 'id' },
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: DataTypes.FLOAT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
userId: {
|
{ transaction }
|
||||||
type: DataTypes.STRING,
|
)
|
||||||
allowNull: false,
|
await queryInterface.addConstraint('userBookRatings', {
|
||||||
references: { model: 'users', key: 'id' },
|
fields: ['userId', 'bookId'],
|
||||||
onUpdate: 'CASCADE',
|
type: 'unique',
|
||||||
onDelete: 'CASCADE'
|
name: 'user_book_ratings_unique_constraint',
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
logger.info(`${loggerPrefix} created userBookRatings table`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tables.includes('userBookExplicitRatings')) {
|
||||||
|
logger.info(`${loggerPrefix} creating userBookExplicitRatings table`)
|
||||||
|
await queryInterface.createTable(
|
||||||
|
'userBookExplicitRatings',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: 'users', key: 'id' },
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
bookId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: 'books', key: 'id' },
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: DataTypes.FLOAT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
bookId: {
|
{ transaction }
|
||||||
type: DataTypes.STRING,
|
)
|
||||||
allowNull: false,
|
await queryInterface.addConstraint('userBookExplicitRatings', {
|
||||||
references: { model: 'books', key: 'id' },
|
fields: ['userId', 'bookId'],
|
||||||
onUpdate: 'CASCADE',
|
type: 'unique',
|
||||||
onDelete: 'CASCADE'
|
name: 'user_book_explicit_ratings_unique_constraint',
|
||||||
},
|
transaction
|
||||||
rating: {
|
})
|
||||||
type: DataTypes.FLOAT,
|
logger.info(`${loggerPrefix} created userBookExplicitRatings table`)
|
||||||
allowNull: false
|
}
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: DataTypes.DATE,
|
|
||||||
allowNull: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ transaction }
|
|
||||||
)
|
|
||||||
await queryInterface.addConstraint('userBookExplicitRatings', {
|
|
||||||
fields: ['userId', 'bookId'],
|
|
||||||
type: 'unique',
|
|
||||||
name: 'user_book_explicit_ratings_unique_constraint',
|
|
||||||
transaction
|
|
||||||
})
|
|
||||||
logger.info(`${loggerPrefix} created userBookExplicitRatings table`)
|
|
||||||
|
|
||||||
await transaction.commit()
|
await transaction.commit()
|
||||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
@ -1,19 +1,33 @@
|
|||||||
const { expect } = require('chai')
|
const { expect } = require('chai')
|
||||||
const { Sequelize } = require('sequelize')
|
const { Sequelize } = require('sequelize')
|
||||||
const sinon = require('sinon')
|
const sinon = require('sinon')
|
||||||
|
const chai = require('chai')
|
||||||
|
const { mockReq, mockRes } = require('sinon-express-mock')
|
||||||
|
|
||||||
const Database = require('../../../server/Database')
|
const Database = require('../../../server/Database')
|
||||||
const ApiRouter = require('../../../server/routers/ApiRouter')
|
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||||
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
|
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
|
||||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
const Logger = require('../../../server/Logger')
|
const Logger = require('../../../server/Logger')
|
||||||
|
const ServerSettings = require('../../../server/objects/settings/ServerSettings')
|
||||||
|
const Book = require('../../../server/models/Book')
|
||||||
|
const User = require('../../../server/models/User')
|
||||||
|
const RssFeedManager = require('../../../server/managers/RssFeedManager')
|
||||||
|
const CacheManager = require('../../../server/managers/CacheManager')
|
||||||
|
const fs = require('../../../server/libs/fsExtra')
|
||||||
|
const SocketAuthority = require('../../../server/SocketAuthority')
|
||||||
|
|
||||||
describe('LibraryItemController', () => {
|
describe('LibraryItemController', () => {
|
||||||
/** @type {ApiRouter} */
|
/** @type {ApiRouter} */
|
||||||
let apiRouter
|
let apiRouter
|
||||||
|
let sandbox
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
global.ServerSettings = {}
|
sandbox = sinon.createSandbox()
|
||||||
|
sandbox.stub(Logger, 'info')
|
||||||
|
sandbox.stub(Logger, 'error')
|
||||||
|
global.MetadataPath = '/tmp/audiobookshelf-test'
|
||||||
|
global.ServerSettings = new ServerSettings()
|
||||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
await Database.buildModels()
|
await Database.buildModels()
|
||||||
@ -21,12 +35,15 @@ describe('LibraryItemController', () => {
|
|||||||
apiRouter = new ApiRouter({
|
apiRouter = new ApiRouter({
|
||||||
apiCacheManager: new ApiCacheManager()
|
apiCacheManager: new ApiCacheManager()
|
||||||
})
|
})
|
||||||
|
sandbox.stub(RssFeedManager, 'closeFeedForEntityId').resolves()
|
||||||
sinon.stub(Logger, 'info')
|
sandbox.stub(RssFeedManager, 'closeFeedsForEntityIds').resolves()
|
||||||
|
sandbox.stub(CacheManager, 'purgeCoverCache').resolves()
|
||||||
|
sandbox.stub(fs, 'remove').resolves()
|
||||||
|
sandbox.stub(SocketAuthority, 'emitter')
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
sinon.restore()
|
sandbox.restore()
|
||||||
|
|
||||||
// Clear all tables
|
// Clear all tables
|
||||||
await Database.sequelize.sync({ force: true })
|
await Database.sequelize.sync({ force: true })
|
||||||
@ -161,6 +178,7 @@ describe('LibraryItemController', () => {
|
|||||||
// Update library item 1 remove all authors and series
|
// Update library item 1 remove all authors and series
|
||||||
const fakeReq = {
|
const fakeReq = {
|
||||||
query: {},
|
query: {},
|
||||||
|
user: { id: 'test-user-id' },
|
||||||
body: {
|
body: {
|
||||||
metadata: {
|
metadata: {
|
||||||
authors: [],
|
authors: [],
|
||||||
@ -197,4 +215,191 @@ describe('LibraryItemController', () => {
|
|||||||
expect(series2Exists).to.be.true
|
expect(series2Exists).to.be.true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('_getExpandedItemWithRatings', () => {
|
||||||
|
let user, libraryItem
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = new User({ id: 'user1' })
|
||||||
|
libraryItem = {
|
||||||
|
isBook: true,
|
||||||
|
media: { id: 'book1' },
|
||||||
|
toOldJSONExpanded: () => ({ media: {} })
|
||||||
|
}
|
||||||
|
sandbox.stub(Database, 'userBookRatingModel').value({ findOne: () => {}, findAll: () => [] })
|
||||||
|
sandbox.stub(Database, 'userBookExplicitRatingModel').value({ findOne: () => {}, findAll: () => [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not add any rating if all rating settings are disabled', async () => {
|
||||||
|
global.ServerSettings.enableRating = false
|
||||||
|
global.ServerSettings.enableExplicitRating = false
|
||||||
|
const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user)
|
||||||
|
expect(result.media.myRating).to.be.undefined
|
||||||
|
expect(result.media.communityRating).to.be.undefined
|
||||||
|
expect(result.media.myExplicitRating).to.be.undefined
|
||||||
|
expect(result.media.communityExplicitRating).to.be.undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add personal rating if enabled', async () => {
|
||||||
|
global.ServerSettings.enableRating = true
|
||||||
|
sandbox.stub(Database.userBookRatingModel, 'findOne').resolves({ rating: 4 })
|
||||||
|
const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user)
|
||||||
|
expect(result.media.myRating).to.equal(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add community rating if enabled', async () => {
|
||||||
|
global.ServerSettings.enableRating = true
|
||||||
|
global.ServerSettings.enableCommunityRating = true
|
||||||
|
sandbox.stub(Database.userBookRatingModel, 'findAll').resolves([{ rating: 3 }, { rating: 5 }])
|
||||||
|
const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user)
|
||||||
|
expect(result.media.communityRating.average).to.equal(4)
|
||||||
|
expect(result.media.communityRating.count).to.equal(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add personal explicit rating if enabled', async () => {
|
||||||
|
global.ServerSettings.enableExplicitRating = true
|
||||||
|
sandbox.stub(Database.userBookExplicitRatingModel, 'findOne').resolves({ rating: 2 })
|
||||||
|
const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user)
|
||||||
|
expect(result.media.myExplicitRating).to.equal(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add community explicit rating if enabled', async () => {
|
||||||
|
global.ServerSettings.enableExplicitRating = true
|
||||||
|
global.ServerSettings.enableCommunityRating = true
|
||||||
|
sandbox.stub(Database.userBookExplicitRatingModel, 'findAll').resolves([{ rating: 1 }, { rating: 5 }])
|
||||||
|
const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user)
|
||||||
|
expect(result.media.communityExplicitRating.average).to.equal(3)
|
||||||
|
expect(result.media.communityExplicitRating.count).to.equal(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateMedia', () => {
|
||||||
|
let user, libraryItem, book, bookSaveStub
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
user = await Database.userModel.create({ username: 'test', password: 'password' })
|
||||||
|
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
|
||||||
|
book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
|
||||||
|
libraryItem.media = book
|
||||||
|
libraryItem.saveMetadataFile = sinon.stub()
|
||||||
|
bookSaveStub = sandbox.stub(Book.prototype, 'save').resolves()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update rating from metadata', async () => {
|
||||||
|
const req = mockReq({
|
||||||
|
user,
|
||||||
|
libraryItem,
|
||||||
|
body: { metadata: { rating: 4.5 } }
|
||||||
|
})
|
||||||
|
const res = mockRes()
|
||||||
|
|
||||||
|
await LibraryItemController.updateMedia.bind(apiRouter)(req, res)
|
||||||
|
|
||||||
|
expect(book.providerRating).to.equal(4.5)
|
||||||
|
expect(bookSaveStub.called).to.be.true
|
||||||
|
expect(res.json.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update rating from provider_data', async () => {
|
||||||
|
const req = mockReq({
|
||||||
|
user,
|
||||||
|
libraryItem,
|
||||||
|
body: {
|
||||||
|
provider_data: {
|
||||||
|
rating: 4.2,
|
||||||
|
provider: 'test-provider',
|
||||||
|
providerId: 'test-id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const res = mockRes()
|
||||||
|
|
||||||
|
await LibraryItemController.updateMedia.bind(apiRouter)(req, res)
|
||||||
|
|
||||||
|
expect(book.providerRating).to.equal(4.2)
|
||||||
|
expect(book.provider).to.equal('test-provider')
|
||||||
|
expect(book.providerId).to.equal('test-id')
|
||||||
|
expect(bookSaveStub.called).to.be.true
|
||||||
|
expect(res.json.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rate', () => {
|
||||||
|
let user, libraryItem, book
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
user = await Database.userModel.create({ username: 'test', password: 'password', id: 'user-1' })
|
||||||
|
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
|
||||||
|
book = await Database.bookModel.create({ id: 'book-1', title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
|
||||||
|
libraryItem.media = book
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 403 if rating is disabled', async () => {
|
||||||
|
global.ServerSettings.enableRating = false
|
||||||
|
const req = mockReq()
|
||||||
|
const res = mockRes()
|
||||||
|
await LibraryItemController.rate.bind(apiRouter)(req, res)
|
||||||
|
expect(res.status.args[0][0]).to.equal(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 for invalid rating', async () => {
|
||||||
|
global.ServerSettings.enableRating = true
|
||||||
|
const req = mockReq({ user, libraryItem, body: { rating: 6 } })
|
||||||
|
const res = mockRes()
|
||||||
|
await LibraryItemController.rate(req, res)
|
||||||
|
expect(res.status.args[0][0]).to.equal(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save a valid rating and return 200', async () => {
|
||||||
|
global.ServerSettings.enableRating = true
|
||||||
|
const req = mockReq({ user, libraryItem, body: { rating: 4 } })
|
||||||
|
const res = mockRes()
|
||||||
|
await LibraryItemController.rate(req, res)
|
||||||
|
expect(res.status.args[0][0]).to.equal(200)
|
||||||
|
const userRating = await Database.userBookRatingModel.findOne({ where: { userId: user.id, bookId: book.id } })
|
||||||
|
expect(userRating.rating).to.equal(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rateExplicit', () => {
|
||||||
|
let user, libraryItem, book
|
||||||
|
beforeEach(async () => {
|
||||||
|
user = await Database.userModel.create({ username: 'test', password: 'password', id: 'user-1' })
|
||||||
|
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
|
||||||
|
book = await Database.bookModel.create({ id: 'book-1', title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
|
||||||
|
libraryItem.media = book
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 403 if explicit rating is disabled', async () => {
|
||||||
|
global.ServerSettings.enableExplicitRating = false
|
||||||
|
const req = mockReq()
|
||||||
|
const res = mockRes()
|
||||||
|
await LibraryItemController.rateExplicit.bind(apiRouter)(req, res)
|
||||||
|
expect(res.status.args[0][0]).to.equal(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400 for invalid explicit rating', async () => {
|
||||||
|
global.ServerSettings.enableExplicitRating = true
|
||||||
|
const req = mockReq({ user, libraryItem, body: { rating: -1 } })
|
||||||
|
const res = mockRes()
|
||||||
|
await LibraryItemController.rateExplicit(req, res)
|
||||||
|
expect(res.status.args[0][0]).to.equal(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save a valid explicit rating and return 200', async () => {
|
||||||
|
global.ServerSettings.enableExplicitRating = true
|
||||||
|
const req = mockReq({ user, libraryItem, body: { rating: 5 } })
|
||||||
|
const res = mockRes()
|
||||||
|
await LibraryItemController.rateExplicit(req, res)
|
||||||
|
expect(res.status.args[0][0]).to.equal(200)
|
||||||
|
const userRating = await Database.userBookExplicitRatingModel.findOne({ where: { userId: user.id, bookId: book.id } })
|
||||||
|
expect(userRating.rating).to.equal(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
132
test/server/migrations/v2.25.2-add-book-ratings.test.js
Normal file
132
test/server/migrations/v2.25.2-add-book-ratings.test.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
const chai = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { expect } = chai
|
||||||
|
|
||||||
|
const { DataTypes, Sequelize } = require('sequelize')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
const { up, down } = require('../../../server/migrations/v2.25.2-add-book-ratings')
|
||||||
|
|
||||||
|
describe('Migration v2.25.2-add-book-ratings', () => {
|
||||||
|
let sequelize
|
||||||
|
let queryInterface
|
||||||
|
let loggerInfoStub
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
queryInterface = sequelize.getQueryInterface()
|
||||||
|
loggerInfoStub = sinon.stub(Logger, 'info')
|
||||||
|
|
||||||
|
await queryInterface.createTable('users', {
|
||||||
|
id: { type: DataTypes.STRING, primaryKey: true, allowNull: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryInterface.createTable('books', {
|
||||||
|
id: { type: DataTypes.STRING, primaryKey: true, allowNull: false }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('up', () => {
|
||||||
|
it('should add columns to books table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
const table = await queryInterface.describeTable('books')
|
||||||
|
expect(table.providerRating).to.exist
|
||||||
|
expect(table.provider).to.exist
|
||||||
|
expect(table.providerId).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create userBookRatings table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
const table = await queryInterface.describeTable('userBookRatings')
|
||||||
|
expect(table.id).to.exist
|
||||||
|
expect(table.userId).to.exist
|
||||||
|
expect(table.bookId).to.exist
|
||||||
|
expect(table.rating).to.exist
|
||||||
|
expect(table.createdAt).to.exist
|
||||||
|
expect(table.updatedAt).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create userBookExplicitRatings table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
const table = await queryInterface.describeTable('userBookExplicitRatings')
|
||||||
|
expect(table.id).to.exist
|
||||||
|
expect(table.userId).to.exist
|
||||||
|
expect(table.bookId).to.exist
|
||||||
|
expect(table.rating).to.exist
|
||||||
|
expect(table.createdAt).to.exist
|
||||||
|
expect(table.updatedAt).to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add unique constraints', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
const constraints1 = await queryInterface.showConstraint('userBookRatings')
|
||||||
|
expect(constraints1.some((c) => c.constraintName === 'user_book_ratings_unique_constraint')).to.be.true
|
||||||
|
|
||||||
|
const constraints2 = await queryInterface.showConstraint('userBookExplicitRatings')
|
||||||
|
expect(constraints2.some((c) => c.constraintName === 'user_book_explicit_ratings_unique_constraint')).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const table = await queryInterface.describeTable('books')
|
||||||
|
expect(table.providerRating).to.exist
|
||||||
|
|
||||||
|
const table2 = await queryInterface.describeTable('userBookRatings')
|
||||||
|
expect(table2.id).to.exist
|
||||||
|
|
||||||
|
const table3 = await queryInterface.describeTable('userBookExplicitRatings')
|
||||||
|
expect(table3.id).to.exist
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('down', () => {
|
||||||
|
it('should remove columns from books table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const table = await queryInterface.describeTable('books')
|
||||||
|
expect(table.providerRating).to.not.exist
|
||||||
|
expect(table.provider).to.not.exist
|
||||||
|
expect(table.providerId).to.not.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop userBookRatings table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
let error = null
|
||||||
|
try {
|
||||||
|
await queryInterface.describeTable('userBookRatings')
|
||||||
|
} catch (e) {
|
||||||
|
error = e
|
||||||
|
}
|
||||||
|
expect(error).to.not.be.null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop userBookExplicitRatings table', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
let error = null
|
||||||
|
try {
|
||||||
|
await queryInterface.describeTable('userBookExplicitRatings')
|
||||||
|
} catch (e) {
|
||||||
|
error = e
|
||||||
|
}
|
||||||
|
expect(error).to.not.be.null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be idempotent', async () => {
|
||||||
|
await up({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
await down({ context: { queryInterface, logger: Logger } })
|
||||||
|
|
||||||
|
const table = await queryInterface.describeTable('books')
|
||||||
|
expect(table.providerRating).to.not.exist
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user