audiobookshelf/test/server/controllers/LibraryItemController.test.js
wakamex 2892e99384 Add regression test for book duration with disabled tracks
Demonstrates #5311: disabling an audio track via updateTracks does not
recompute the stored book duration, so it keeps counting the excluded
track. This test fails until the fix is applied (expects duration to
drop to only the enabled track's length).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:57:31 -04:00

340 lines
14 KiB
JavaScript

const { expect } = require('chai')
const { Sequelize } = require('sequelize')
const sinon = require('sinon')
const Database = require('../../../server/Database')
const ApiRouter = require('../../../server/routers/ApiRouter')
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const Auth = require('../../../server/Auth')
const Logger = require('../../../server/Logger')
const SocketAuthority = require('../../../server/SocketAuthority')
describe('LibraryItemController', () => {
/** @type {ApiRouter} */
let apiRouter
beforeEach(async () => {
global.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({
auth: new Auth(),
apiCacheManager: new ApiCacheManager()
})
sinon.stub(Logger, 'info')
})
afterEach(async () => {
sinon.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: {
username: 'test',
canDelete: true,
checkCanAccessLibraryItem: () => 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: {},
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('batch item access control', () => {
let lib1Id
let itemLib1Id
let itemLib2Id
beforeEach(async () => {
const lib1 = await Database.libraryModel.create({ name: 'Lib 1', mediaType: 'book' })
const folder1 = await Database.libraryFolderModel.create({ path: '/l1', libraryId: lib1.id })
const book1 = await Database.bookModel.create({ title: 'B1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const li1 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book1.id,
mediaType: 'book',
libraryId: lib1.id,
libraryFolderId: folder1.id
})
lib1Id = lib1.id
itemLib1Id = li1.id
const lib2 = await Database.libraryModel.create({ name: 'Lib 2', mediaType: 'book' })
const folder2 = await Database.libraryFolderModel.create({ path: '/l2', libraryId: lib2.id })
const book2 = await Database.bookModel.create({ title: 'B2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const li2 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book2.id,
mediaType: 'book',
libraryId: lib2.id,
libraryFolderId: folder2.id
})
itemLib2Id = li2.id
})
const userLimitedToLib1 = () => ({
username: 'limited',
canDelete: true,
canUpdate: true,
checkCanAccessLibraryItem(li) {
return li.libraryId === lib1Id
}
})
it('batchGet returns 403 for a library item the user cannot access', async () => {
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
body: { libraryItemIds: [itemLib2Id] },
user: userLimitedToLib1()
}
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
it('batchGet returns items when the user can access them', async () => {
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
body: { libraryItemIds: [itemLib1Id] },
user: userLimitedToLib1()
}
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
const payload = fakeRes.json.firstCall.args[0]
expect(payload.libraryItems).to.have.length(1)
expect(payload.libraryItems[0].id).to.equal(itemLib1Id)
})
it('batchUpdate returns 403 for a library item the user cannot access', async () => {
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
user: userLimitedToLib1(),
body: [{ id: itemLib2Id, mediaPayload: {} }]
}
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
it('batchUpdate returns 403 when the user lacks canUpdate', async () => {
const u = userLimitedToLib1()
u.canUpdate = false
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
user: u,
body: [{ id: itemLib1Id, mediaPayload: {} }]
}
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
it('batchDelete returns 403 for a library item the user cannot access', async () => {
const fakeRes = { sendStatus: sinon.spy() }
const fakeReq = {
query: {},
user: userLimitedToLib1(),
body: { libraryItemIds: [itemLib2Id] }
}
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
})
describe('updateTracks', () => {
it('recomputes book duration to exclude disabled audio tracks', async () => {
const library = await Database.libraryModel.create({ name: 'Tracks Library', mediaType: 'book' })
const libraryFolder = await Database.libraryFolderModel.create({ path: '/tracks', libraryId: library.id })
// Book with two audio tracks (100s + 50s) and a stored duration of 150s
const audioFiles = [
{ index: 1, ino: 'file-1', duration: 100, exclude: false, metadata: { filename: 'track1.mp3', size: 1000 } },
{ index: 2, ino: 'file-2', duration: 50, exclude: false, metadata: { filename: 'track2.mp3', size: 1000 } }
]
const book = await Database.bookModel.create({ title: 'Two Track Book', duration: 150, audioFiles, tags: [], narrators: [], genres: [], chapters: [] })
const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id })
sinon.stub(SocketAuthority, 'libraryItemEmitter')
const expandedLibraryItem = await Database.libraryItemModel.getExpandedById(libraryItem.id)
const fakeReq = {
libraryItem: expandedLibraryItem,
body: {
orderedFileData: [
{ ino: 'file-1', exclude: false },
{ ino: 'file-2', exclude: true }
]
}
}
const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() }
await LibraryItemController.updateTracks.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
// Duration should now reflect only the enabled track (100s), not the original 150s
const updatedBook = await Database.bookModel.findByPk(book.id)
expect(updatedBook.duration).to.equal(100)
})
})
})