mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-28 13:46:20 +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
|
||||
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 ###
|
||||
|
@ -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: {
|
||||
|
@ -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'
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
|
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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -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
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",
|
||||
"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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}`)
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
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