Added frontend + backend tests

This commit is contained in:
Peter BALIVET 2025-07-01 13:55:33 +02:00
parent db34ddd0a9
commit 89bd541247
15 changed files with 10887 additions and 4497 deletions

View File

@ -6,7 +6,7 @@ FROM node:20-alpine AS build-client
WORKDIR /client
COPY /client /client
RUN npm ci && npm cache clean --force
RUN npm install && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###

View File

@ -78,7 +78,7 @@
</div>
<!-- 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">
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
</div>
@ -136,7 +136,7 @@
<script>
import Vue from 'vue'
import MoreMenu from '@/components/widgets/MoreMenu'
import MoreMenu from '@/components/widgets/MoreMenu.vue'
export default {
props: {

View File

@ -1,11 +1,11 @@
const { defineConfig } = require("cypress")
const { defineConfig } = require('cypress')
module.exports = defineConfig({
component: {
devServer: {
framework: "nuxt",
bundler: "webpack"
framework: 'nuxt',
bundler: 'vite'
},
specPattern: "cypress/tests/**/*.cy.js"
specPattern: 'cypress/tests/**/*.cy.js'
}
})

View File

@ -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 ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
@ -109,20 +109,19 @@ describe('LazyBookCard', () => {
cy.get('&explicitIndicator').should('not.exist')
cy.get('&line2').should('have.text', 'J. R. R. Tolkien')
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('&placeholderTitle').should('be.visible')
cy.get('&placeholderTitleText').should('have.text', 'The Fellowship of the Ring')
cy.get('&placeholderAuthor').should('be.visible')
cy.get('&placeholderAuthorText').should('have.text', 'J. R. R. Tolkien')
cy.get('&progressBar').should('be.hidden')
cy.get('&finishedProgressBar').should('not.exist')
cy.get('&loadingSpinner').should('not.exist')
cy.get('&seriesNameOverlay').should('not.exist')
cy.get('&errorTooltip').should('not.exist')
cy.get('&rssFeed').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
// the detailBottom element, currently rendered outside the card's area,

View 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

File diff suppressed because it is too large Load Diff

View File

@ -36,12 +36,19 @@
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@cypress/webpack-dev-server": "~3.1.1",
"@nuxtjs/pwa": "^3.3.5",
"@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",
"tailwindcss": "^4.0.13"
},
"optionalDependencies": {
"cypress": "^13.7.3"
"style-loader": "^2.0.0",
"tailwindcss": "^4.0.13",
"vite": "^5.4.19",
"webpack": "^4.46.0",
"webpack-dev-server": "^3.11.3"
}
}

View File

@ -1,5 +1,5 @@
import Vue from 'vue'
import Path from 'path'
import Path from 'path-browserify'
import vClickOutside from 'v-click-outside'
import { formatDistance, format, addDays, isDate, setDefaultOptions } from 'date-fns'
import * as locale from 'date-fns/locale'
@ -61,44 +61,36 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
const MAX_FILENAME_BYTES = 255
const replacement = ''
const illegalRe = /[\/\?<>\\:\*\|"]/g
const illegalRe = /[\\\?<>\\:\*\|\"]/g
const controlRe = /[\x00-\x1f\x80-\x9f]/g
const reservedRe = /^\.+$/
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
const windowsTrailingRe = /[\. ]+$/
const lineBreaks = /[\n\r]/g
let sanitized = filename
.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
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, ' ')
// Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension
const basename = Path.basename(sanitized, ext)
const extByteLength = Buffer.byteLength(ext, 'utf16le')
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
let totalBytes = 0
let trimmedBasename = ''
if (typeof Buffer !== 'undefined') {
const extByteLength = Buffer.byteLength(ext, 'utf16le')
const basenameByteLength = Buffer.byteLength(basename, 'utf16le')
if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) {
const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength
let totalBytes = 0
let trimmedBasename = ''
// Add chars until max bytes is reached
for (const char of basename) {
totalBytes += Buffer.byteLength(char, 'utf16le')
if (totalBytes > MaxBytesForBasename) break
else trimmedBasename += char
// Add chars until max bytes is reached
for (const char of basename) {
totalBytes += Buffer.byteLength(char, 'utf16le')
if (totalBytes > MaxBytesForBasename) break
else trimmedBasename += char
}
trimmedBasename = trimmedBasename.trim()
sanitized = trimmedBasename + ext
}
trimmedBasename = trimmedBasename.trim()
sanitized = trimmedBasename + ext
}
return sanitized
}
@ -167,9 +159,31 @@ function xmlToJson(xml) {
}
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
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
export { encode, decode }

12
client/vite.config.js Normal file
View 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
View File

@ -37,7 +37,8 @@
"mocha": "^10.2.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"sinon": "^17.0.1"
"sinon": "^17.0.1",
"sinon-express-mock": "^2.2.1"
}
},
"node_modules/@ampproject/remapping": {
@ -4725,6 +4726,16 @@
"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": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",

View File

@ -62,6 +62,7 @@
"mocha": "^10.2.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"sinon": "^17.0.1"
"sinon": "^17.0.1",
"sinon-express-mock": "^2.2.1"
}
}

View File

@ -44,51 +44,59 @@ class LibraryItemController {
const item = libraryItem.toOldJSONExpanded()
if (libraryItem.isBook) {
// Include users personal rating
const userBookRating = await Database.userBookRatingModel.findOne({
where: { userId: user.id, bookId: libraryItem.media.id }
})
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 (global.ServerSettings.enableRating) {
// Include users personal rating
const userBookRating = await Database.userBookRatingModel.findOne({
where: { userId: user.id, bookId: libraryItem.media.id }
})
if (userBookRating) {
item.media.myRating = userBookRating.rating
}
})
if (allBookRatings.length > 0) {
const totalRating = allBookRatings.reduce((acc, cur) => acc + cur.rating, 0)
item.media.communityRating = {
average: totalRating / allBookRatings.length,
count: allBookRatings.length
if (global.ServerSettings.enableCommunityRating) {
// 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) {
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
const userBookExplicitRating = await Database.userBookExplicitRatingModel.findOne({
where: { userId: user.id, bookId: libraryItem.media.id }
})
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 (global.ServerSettings.enableExplicitRating) {
// Include users personal explicit rating
const userBookExplicitRating = await Database.userBookExplicitRatingModel.findOne({
where: { userId: user.id, bookId: libraryItem.media.id }
})
if (userBookExplicitRating) {
item.media.myExplicitRating = userBookExplicitRating.rating
}
})
if (allBookExplicitRatings.length > 0) {
const totalExplicitRating = allBookExplicitRatings.reduce((acc, cur) => acc + cur.rating, 0)
item.media.communityExplicitRating = {
average: totalExplicitRating / allBookExplicitRatings.length,
count: allBookExplicitRatings.length
if (global.ServerSettings.enableCommunityRating) {
// 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) {
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) {
const includeEntities = (req.query.include || '').split(',')
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
if (includeEntities.includes('progress')) {
@ -314,7 +322,7 @@ class LibraryItemController {
}
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({
updated: hasUpdates,
@ -1262,6 +1270,9 @@ class LibraryItemController {
* @param {Response} res
*/
async rate(req, res) {
if (!global.ServerSettings.enableRating) {
return res.status(403).json({ error: 'Rating is disabled' })
}
try {
const { rating } = req.body
if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) {
@ -1274,7 +1285,7 @@ class LibraryItemController {
await Database.userBookRatingModel.upsert({ userId, bookId, rating })
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 })
} catch (err) {
@ -1290,6 +1301,9 @@ class LibraryItemController {
* @param {Response} res
*/
async rateExplicit(req, res) {
if (!global.ServerSettings.enableExplicitRating) {
return res.status(403).json({ error: 'Explicit rating is disabled' })
}
try {
const { rating } = req.body
if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) {
@ -1302,7 +1316,7 @@ class LibraryItemController {
await Database.userBookExplicitRatingModel.upsert({ userId, bookId, rating })
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 })
} catch (err) {
@ -1314,11 +1328,4 @@ class 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

View File

@ -8,124 +8,137 @@ module.exports = {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
const transaction = await queryInterface.sequelize.transaction()
try {
const booksTable = await queryInterface.describeTable('books')
logger.info(`${loggerPrefix} adding columns to books table`)
await queryInterface.addColumn(
'books',
'providerRating',
{
type: DataTypes.FLOAT
},
{ transaction }
)
await queryInterface.addColumn(
'books',
'provider',
{
type: DataTypes.STRING
},
{ transaction }
)
await queryInterface.addColumn(
'books',
'providerId',
{
type: DataTypes.STRING
},
{ transaction }
)
if (!booksTable.providerRating) {
await queryInterface.addColumn(
'books',
'providerRating',
{
type: DataTypes.FLOAT
},
{ transaction }
)
}
if (!booksTable.provider) {
await queryInterface.addColumn(
'books',
'provider',
{
type: DataTypes.STRING
},
{ transaction }
)
}
if (!booksTable.providerId) {
await queryInterface.addColumn(
'books',
'providerId',
{
type: DataTypes.STRING
},
{ transaction }
)
}
logger.info(`${loggerPrefix} added columns to books table`)
logger.info(`${loggerPrefix} creating userBookRatings table`)
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`)
const tables = await queryInterface.showAllTables()
logger.info(`${loggerPrefix} creating userBookExplicitRatings table`)
await queryInterface.createTable(
'userBookExplicitRatings',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
if (!tables.includes('userBookRatings')) {
logger.info(`${loggerPrefix} creating userBookRatings table`)
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
}
},
userId: {
type: DataTypes.STRING,
allowNull: false,
references: { model: 'users', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
{ transaction }
)
await queryInterface.addConstraint('userBookRatings', {
fields: ['userId', 'bookId'],
type: 'unique',
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: {
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('userBookExplicitRatings', {
fields: ['userId', 'bookId'],
type: 'unique',
name: 'user_book_explicit_ratings_unique_constraint',
transaction
})
logger.info(`${loggerPrefix} created userBookExplicitRatings table`)
{ 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()
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)

View File

@ -1,19 +1,33 @@
const { expect } = require('chai')
const { Sequelize } = require('sequelize')
const sinon = require('sinon')
const chai = require('chai')
const { mockReq, mockRes } = require('sinon-express-mock')
const Database = require('../../../server/Database')
const ApiRouter = require('../../../server/routers/ApiRouter')
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
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', () => {
/** @type {ApiRouter} */
let apiRouter
let sandbox
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.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
await Database.buildModels()
@ -21,12 +35,15 @@ describe('LibraryItemController', () => {
apiRouter = new ApiRouter({
apiCacheManager: new ApiCacheManager()
})
sinon.stub(Logger, 'info')
sandbox.stub(RssFeedManager, 'closeFeedForEntityId').resolves()
sandbox.stub(RssFeedManager, 'closeFeedsForEntityIds').resolves()
sandbox.stub(CacheManager, 'purgeCoverCache').resolves()
sandbox.stub(fs, 'remove').resolves()
sandbox.stub(SocketAuthority, 'emitter')
})
afterEach(async () => {
sinon.restore()
sandbox.restore()
// Clear all tables
await Database.sequelize.sync({ force: true })
@ -161,6 +178,7 @@ describe('LibraryItemController', () => {
// Update library item 1 remove all authors and series
const fakeReq = {
query: {},
user: { id: 'test-user-id' },
body: {
metadata: {
authors: [],
@ -197,4 +215,191 @@ describe('LibraryItemController', () => {
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)
})
})
})

View 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
})
})
})