audiobookshelf/test/server/controllers/LibraryItemController.test.js
2025-07-01 13:55:33 +02:00

405 lines
18 KiB
JavaScript

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 () => {
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()
apiRouter = new ApiRouter({
apiCacheManager: new ApiCacheManager()
})
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 () => {
sandbox.restore()
// Clear all tables
await Database.sequelize.sync({ force: true })
})
describe('checkRemoveAuthorsAndSeries', () => {
let libraryItem1Id
let libraryItem2Id
let author1Id
let author2Id
let author3Id
let series1Id
let series2Id
beforeEach(async () => {
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
libraryItem1Id = newLibraryItem.id
const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
libraryItem2Id = newLibraryItem2.id
const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id })
author1Id = newAuthor.id
const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id })
author2Id = newAuthor2.id
const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id })
author3Id = newAuthor3.id
// Book 1 has Author 1, Author 2 and Author 3
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id })
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id })
await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id })
// Book 2 has Author 2
await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id })
const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id })
series1Id = newSeries.id
const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id })
series2Id = newSeries2.id
// Book 1 is in Series 1 and Series 2
await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id })
await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id })
// Book 2 is in Series 2
await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id })
})
it('should remove authors and series with no books on library item delete', async () => {
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)
const fakeReq = {
query: {},
libraryItem
}
const fakeRes = {
sendStatus: sinon.spy()
}
await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
// Author 1 should be removed because it has no books
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
expect(author1Exists).to.be.false
// Author 2 should not be removed because it still has Book 2
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
expect(author2Exists).to.be.true
// Author 3 should not be removed because it has an image
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
expect(author3Exists).to.be.true
// Series 1 should be removed because it has no books
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
expect(series1Exists).to.be.false
// Series 2 should not be removed because it still has Book 2
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
expect(series2Exists).to.be.true
})
it('should remove authors and series with no books on library item batch delete', async () => {
// Batch delete library item 1
const fakeReq = {
query: {},
user: {
canDelete: true
},
body: {
libraryItemIds: [libraryItem1Id]
}
}
const fakeRes = {
sendStatus: sinon.spy()
}
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
// Author 1 should be removed because it has no books
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
expect(author1Exists).to.be.false
// Author 2 should not be removed because it still has Book 2
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
expect(author2Exists).to.be.true
// Author 3 should not be removed because it has an image
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
expect(author3Exists).to.be.true
// Series 1 should be removed because it has no books
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
expect(series1Exists).to.be.false
// Series 2 should not be removed because it still has Book 2
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
expect(series2Exists).to.be.true
})
it('should remove authors and series with no books on library item update media', async () => {
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id)
libraryItem.saveMetadataFile = sinon.stub()
// Update library item 1 remove all authors and series
const fakeReq = {
query: {},
user: { id: 'test-user-id' },
body: {
metadata: {
authors: [],
series: []
}
},
libraryItem
}
const fakeRes = {
json: sinon.spy()
}
await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
// Author 1 should be removed because it has no books
const author1Exists = await Database.authorModel.checkExistsById(author1Id)
expect(author1Exists).to.be.false
// Author 2 should not be removed because it still has Book 2
const author2Exists = await Database.authorModel.checkExistsById(author2Id)
expect(author2Exists).to.be.true
// Author 3 should not be removed because it has an image
const author3Exists = await Database.authorModel.checkExistsById(author3Id)
expect(author3Exists).to.be.true
// Series 1 should be removed because it has no books
const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
expect(series1Exists).to.be.false
// Series 2 should not be removed because it still has Book 2
const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
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)
})
})
})