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 fs = require('../../../server/libs/fsExtra') const Path = require('path') const { filePathToPOSIX, sanitizeFilename } = require('../../../server/utils/fileUtils') describe('LibraryItemController', () => { /** @type {ApiRouter} */ let apiRouter let tempPaths = [] 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 }) if (tempPaths.length) { await Promise.all(tempPaths.map((tempPath) => fs.remove(tempPath).catch(() => null))) tempPaths = [] } }) 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: {}, 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('updateMedia placeholder paths', () => { it('renames placeholder folder when title changes', async () => { const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`) tempPaths.push(tempRoot) const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id }) const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id }) const oldTitle = 'Old Title' const newTitle = 'New Title' const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series')) const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle)) await fs.ensureDir(oldPath) const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id, path: oldPath, relPath: `Test Series/${oldTitle}`, isFile: false, isPlaceholder: true }) const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) expandedItem.saveMetadataFile = sinon.stub().resolves() const fakeReq = { query: {}, body: { metadata: { title: newTitle } }, libraryItem: expandedItem } const fakeRes = { json: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.json.calledOnce).to.be.true const expectedPath = filePathToPOSIX(Path.join(seriesDir, sanitizeFilename(newTitle))) const oldPathExists = await fs.pathExists(oldPath) const newPathExists = await fs.pathExists(expectedPath) expect(oldPathExists).to.be.false expect(newPathExists).to.be.true const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id) expect(updatedItem.path).to.equal(expectedPath) expect(updatedItem.relPath).to.equal(`Test Series/${sanitizeFilename(newTitle)}`) }) it('returns 400 when placeholder title sanitizes to empty', async () => { const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`) tempPaths.push(tempRoot) const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id }) const oldTitle = 'Old Title' const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series', oldTitle)) await fs.ensureDir(oldPath) const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id, path: oldPath, relPath: `Test Series/${oldTitle}`, isFile: false, isPlaceholder: true }) const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) expandedItem.saveMetadataFile = sinon.stub().resolves() const fakeReq = { query: {}, body: { metadata: { title: '...' } }, libraryItem: expandedItem } const fakeRes = { json: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.status.calledWith(400)).to.be.true expect(fakeRes.send.calledWith('Invalid placeholder title')).to.be.true }) it('returns 400 when placeholder title update collides with existing path', async () => { const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`) tempPaths.push(tempRoot) const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id }) const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id }) const oldTitle = 'Old Title' const newTitle = 'New Title' const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series')) const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle)) const newPath = filePathToPOSIX(Path.join(seriesDir, sanitizeFilename(newTitle))) await fs.ensureDir(oldPath) await fs.ensureDir(newPath) const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id, path: oldPath, relPath: `Test Series/${oldTitle}`, isFile: false, isPlaceholder: true }) const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) expandedItem.saveMetadataFile = sinon.stub().resolves() const fakeReq = { query: {}, body: { metadata: { title: newTitle } }, libraryItem: expandedItem } const fakeRes = { json: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.status.calledWith(400)).to.be.true expect(fakeRes.send.calledWith('Library item already exists at that path')).to.be.true const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id) expect(updatedItem.path).to.equal(oldPath) expect(updatedItem.relPath).to.equal(`Test Series/${oldTitle}`) const updatedBook = await Database.bookModel.findByPk(book.id) expect(updatedBook.title).to.equal(oldTitle) }) it('creates placeholder folder when metadata is stored with items and original path is missing', async () => { global.ServerSettings = { storeMetadataWithItem: true } const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`) tempPaths.push(tempRoot) const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id }) const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id }) const oldTitle = 'Old Title' const newTitle = 'New Title' const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series')) const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle)) const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id, path: oldPath, relPath: `Test Series/${oldTitle}`, isFile: false, isPlaceholder: true }) const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) expandedItem.saveMetadataFile = sinon.stub().resolves() const fakeReq = { query: {}, body: { metadata: { title: newTitle } }, libraryItem: expandedItem } const fakeRes = { json: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) const expectedPath = filePathToPOSIX(Path.join(seriesDir, sanitizeFilename(newTitle))) const newPathExists = await fs.pathExists(expectedPath) expect(newPathExists).to.be.true }) it('moves placeholder folder when series changes', async () => { const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`) tempPaths.push(tempRoot) const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id }) const oldSeries = await Database.seriesModel.create({ name: 'Series A', libraryId: library.id }) const newSeries = await Database.seriesModel.create({ name: 'Series B', libraryId: library.id }) const title = 'Placeholder' const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Author A', oldSeries.name, title)) await fs.ensureDir(oldPath) const book = await Database.bookModel.create({ title, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) await Database.bookSeriesModel.create({ bookId: book.id, seriesId: oldSeries.id, sequence: '1' }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id, path: oldPath, relPath: `Author A/${oldSeries.name}/${title}`, authorNamesFirstLast: 'Author A', authorNamesLastFirst: 'A, Author', isFile: false, isPlaceholder: true }) const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) expandedItem.saveMetadataFile = sinon.stub().resolves() const fakeReq = { query: {}, body: { metadata: { series: [{ id: newSeries.id, name: newSeries.name }] } }, libraryItem: expandedItem } const fakeRes = { json: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.json.calledOnce).to.be.true const expectedPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Author A', newSeries.name, title)) const oldPathExists = await fs.pathExists(oldPath) const newPathExists = await fs.pathExists(expectedPath) expect(oldPathExists).to.be.false expect(newPathExists).to.be.true const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id) expect(updatedItem.path).to.equal(expectedPath) expect(updatedItem.relPath).to.equal(`Author A/${newSeries.name}/${title}`) }) it('moves series-origin placeholder folder without inferring author directory from relPath', async () => { const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`) tempPaths.push(tempRoot) const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id }) const oldSeries = await Database.seriesModel.create({ name: 'Series A', libraryId: library.id }) const newSeries = await Database.seriesModel.create({ name: 'Series B', libraryId: library.id }) const title = 'Placeholder' const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, oldSeries.name, title)) await fs.ensureDir(oldPath) const book = await Database.bookModel.create({ title, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) await Database.bookSeriesModel.create({ bookId: book.id, seriesId: oldSeries.id, sequence: '1' }) const libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id, path: oldPath, relPath: `${oldSeries.name}/${title}`, authorNamesFirstLast: '', authorNamesLastFirst: '', isFile: false, isPlaceholder: true }) const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) expandedItem.saveMetadataFile = sinon.stub().resolves() const fakeReq = { query: {}, body: { metadata: { series: [{ id: newSeries.id, name: newSeries.name }] } }, libraryItem: expandedItem } const fakeRes = { json: sinon.spy(), status: sinon.stub().returnsThis(), send: sinon.spy() } await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) expect(fakeRes.json.calledOnce).to.be.true const expectedPath = filePathToPOSIX(Path.join(libraryFolder.path, newSeries.name, title)) const invalidPath = filePathToPOSIX(Path.join(libraryFolder.path, oldSeries.name, newSeries.name, title)) const oldPathExists = await fs.pathExists(oldPath) const newPathExists = await fs.pathExists(expectedPath) const invalidPathExists = await fs.pathExists(invalidPath) expect(oldPathExists).to.be.false expect(newPathExists).to.be.true expect(invalidPathExists).to.be.false const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id) expect(updatedItem.path).to.equal(expectedPath) expect(updatedItem.relPath).to.equal(`${newSeries.name}/${title}`) }) }) })