audiobookshelf/test/server/controllers/AuthorController.placeholders.test.js
Jens Bickel 5ae65d31dd fix(placeholders): avoid false promotion on upload
Only promote and rescan placeholder items when uploaded files include
supported audio or ebook media types, so artwork-only uploads do not
convert placeholders prematurely.

Also clean up newly created placeholder directories when placeholder
creation rolls back in author and series flows, while preserving
pre-existing directories.

Add controller tests covering media/non-media upload behavior and
rollback directory cleanup conditions.
2026-02-11 10:48:53 +00:00

228 lines
6.6 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 AuthorController = require('../../../server/controllers/AuthorController')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const Auth = require('../../../server/Auth')
const Logger = require('../../../server/Logger')
const User = require('../../../server/models/User')
const fs = require('../../../server/libs/fsExtra')
describe('AuthorController placeholders', () => {
/** @type {ApiRouter} */
let apiRouter
let library
let libraryFolder
let secondaryLibraryFolder
let author
let user
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, 'warn')
sinon.stub(fs, 'pathExists').resolves(false)
sinon.stub(fs, 'ensureDir').resolves()
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
secondaryLibraryFolder = await Database.libraryFolderModel.create({ path: '/secondary', libraryId: library.id })
author = await Database.authorModel.create({ name: 'Jane Doe', lastFirst: 'Doe, Jane', libraryId: library.id })
user = await Database.userModel.create({
username: 'Admin',
type: 'admin',
isActive: true,
permissions: User.getDefaultPermissionsForUserType('admin'),
bookmarks: [],
extraData: { seriesHideFromContinueListening: [] }
})
})
afterEach(async () => {
sinon.restore()
await Database.sequelize.sync({ force: true })
})
it('creates placeholder items for an author', async () => {
const fakeReq = {
params: {
id: author.id
},
author,
user,
body: {}
}
const fakeRes = {
status: sinon.stub().returnsThis(),
json: sinon.spy(),
sendStatus: sinon.spy()
}
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
const payload = fakeRes.json.firstCall.args[0]
expect(payload).to.include({ mediaType: 'book', isPlaceholder: true })
expect(payload.media.metadata.authors.some((entry) => entry.id === author.id)).to.be.true
})
it('creates author placeholder directories on disk', async () => {
const fakeReq = {
params: {
id: author.id
},
author,
user,
body: {}
}
const fakeRes = {
status: sinon.stub().returnsThis(),
json: sinon.spy(),
sendStatus: sinon.spy(),
send: sinon.spy()
}
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
expect(fs.ensureDir.calledOnce).to.be.true
expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Jane Doe/Placeholder')
})
it('rejects cover url payloads for author placeholder creation', async () => {
const fakeReq = {
params: {
id: author.id
},
author,
user,
body: {
url: 'http://example.com/cover.jpg'
}
}
const fakeRes = {
status: sinon.stub().returnsThis(),
json: sinon.spy(),
send: sinon.spy(),
sendStatus: sinon.spy()
}
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.status.calledWith(400)).to.be.true
expect(fakeRes.send.calledWith('Cover uploads are not supported for placeholders')).to.be.true
})
it('uses the authors library folder when creating a placeholder', async () => {
const existingBook = await Database.bookModel.create({
title: 'Existing Book',
audioFiles: [],
tags: [],
narrators: [],
genres: [],
chapters: []
})
await Database.bookAuthorModel.create({ bookId: existingBook.id, authorId: author.id })
await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: existingBook.id,
mediaType: 'book',
libraryId: library.id,
libraryFolderId: secondaryLibraryFolder.id
})
const fakeReq = {
params: {
id: author.id
},
author,
user,
body: {}
}
const fakeRes = {
status: sinon.stub().returnsThis(),
json: sinon.spy(),
sendStatus: sinon.spy(),
send: sinon.spy()
}
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
const payload = fakeRes.json.firstCall.args[0]
expect(payload.folderId).to.equal(secondaryLibraryFolder.id)
expect(payload.path.startsWith(secondaryLibraryFolder.path)).to.be.true
})
it('removes placeholder directory when creation fails after directory is created', async () => {
sinon.stub(fs, 'remove').resolves()
fs.pathExists.resolves(false)
sinon.stub(Database.libraryItemModel, 'create').rejects(new Error('library item create failed'))
const fakeReq = {
params: {
id: author.id
},
author,
user,
body: {}
}
const fakeRes = {
status: sinon.stub().returnsThis(),
json: sinon.spy(),
sendStatus: sinon.spy(),
send: sinon.spy()
}
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.status.calledWith(500)).to.be.true
expect(fakeRes.send.calledWith('Failed to create placeholder')).to.be.true
expect(fs.remove.calledOnce).to.be.true
expect(fs.remove.firstCall.args[0]).to.equal('/test/Jane Doe/Placeholder')
})
it('does not remove placeholder directory on rollback when it already existed', async () => {
sinon.stub(fs, 'remove').resolves()
fs.pathExists.resolves(true)
sinon.stub(Database.libraryItemModel, 'create').rejects(new Error('library item create failed'))
const fakeReq = {
params: {
id: author.id
},
author,
user,
body: {}
}
const fakeRes = {
status: sinon.stub().returnsThis(),
json: sinon.spy(),
sendStatus: sinon.spy(),
send: sinon.spy()
}
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.status.calledWith(500)).to.be.true
expect(fakeRes.send.calledWith('Failed to create placeholder')).to.be.true
expect(fs.remove.called).to.be.false
})
})