diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 84d6193d..fc15488d 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -400,19 +400,48 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) + const seriesIds = [] + const authorIds = [] for (const libraryItem of libraryItemsInFolder) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Remove folder @@ -501,7 +530,7 @@ class LibraryController { mediaItemIds.push(libraryItem.mediaId) } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) } // Set PlaybackSessions libraryId to null @@ -580,6 +609,8 @@ class LibraryController { * DELETE: /api/libraries/:id/issues * Remove all library items missing or invalid * + * @this {import('../routers/ApiRouter')} + * * @param {LibraryControllerRequest} req * @param {Response} res */ @@ -605,6 +636,20 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) @@ -615,15 +660,30 @@ class LibraryController { } Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) + const authorIds = [] + const seriesIds = [] for (const libraryItem of libraryItemsWithIssues) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Set numIssues to 0 for library filter data diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 64069ac5..5aaacee0 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -96,6 +96,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -103,14 +105,36 @@ class LibraryItemController { const hardDelete = req.query.hard == 1 // Delete from file system const libraryItemPath = req.libraryItem.path - const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] - await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const authorIds = [] + const seriesIds = [] + if (req.libraryItem.isPodcast) { + mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(req.libraryItem.media.id) + if (req.libraryItem.media.metadata.authors?.length) { + authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) + } + if (req.libraryItem.media.metadata.series?.length) { + seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) + } + } + + await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) res.sendStatus(200) } @@ -212,15 +236,6 @@ class LibraryItemController { if (hasUpdates) { libraryItem.updatedAt = Date.now() - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - if (isPodcastAutoDownloadUpdated) { this.cronManager.checkUpdatePodcastCron(libraryItem) } @@ -232,10 +247,12 @@ class LibraryItemController { if (authorsRemoved.length) { // Check remove empty authors Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) - await this.checkRemoveAuthorsWithNoBooks( - libraryItem.libraryId, - authorsRemoved.map((au) => au.id) - ) + await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id)) + } + if (seriesRemoved.length) { + // Check remove empty series + Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) + await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id)) } } res.json({ @@ -450,6 +467,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -477,14 +496,33 @@ class LibraryItemController { for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) - const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const seriesIds = [] + const authorIds = [] + if (libraryItem.isPodcast) { + mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(libraryItem.media.id) + if (libraryItem.media.metadata.series?.length) { + seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) + } + if (libraryItem.media.metadata.authors?.length) { + authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) + } + } + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } } await Database.resetLibraryIssuesFilterData(libraryId) @@ -494,48 +532,74 @@ class LibraryItemController { /** * POST: /api/items/batch/update * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ async batchUpdate(req, res) { const updatePayloads = req.body - if (!updatePayloads?.length) { - return res.sendStatus(500) + if (!Array.isArray(updatePayloads) || !updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) + return res.sendStatus(400) + } + + // Ensure that each update payload has a unique library item id + const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))] + if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) + return res.sendStatus(400) + } + + // Get all library items to update + const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + id: libraryItemIds + }) + if (updatePayloads.length !== libraryItems.length) { + Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`) + return res.sendStatus(404) } let itemsUpdated = 0 + const seriesIdsRemoved = [] + const authorIdsRemoved = [] + for (const updatePayload of updatePayloads) { const mediaPayload = updatePayload.mediaPayload - const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) - if (!libraryItem) return null + const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) - let seriesRemoved = [] - if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) - seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + if (libraryItem.isBook) { + if (Array.isArray(mediaPayload.metadata?.series)) { + const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) + const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) + } + if (Array.isArray(mediaPayload.metadata?.authors)) { + const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) + const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) + } } if (libraryItem.media.update(mediaPayload)) { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) itemsUpdated++ } } + if (seriesIdsRemoved.length) { + await this.checkRemoveEmptySeries(seriesIdsRemoved) + } + if (authorIdsRemoved.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved) + } + res.json({ success: true, updates: itemsUpdated diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index f0375691..b44b65de 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -86,6 +86,7 @@ class CacheManager { } async purgeEntityCache(entityId, cachePath) { + if (!entityId || !cachePath) return [] return Promise.all( (await fs.readdir(cachePath)).reduce((promises, file) => { if (file.startsWith(entityId)) { diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 0259ee4c..84a37897 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -262,7 +262,7 @@ class LibraryItem { * @returns {Promise} null if not saved */ async saveMetadata() { - if (this.isSavingMetadata) return null + if (this.isSavingMetadata || !global.MetadataPath) return null this.isSavingMetadata = true diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 7f21c3ac..a92796e8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -348,11 +348,10 @@ class ApiRouter { // /** * Remove library item and associated entities - * @param {string} mediaType * @param {string} libraryItemId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId */ - async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { + async handleDeleteLibraryItem(libraryItemId, mediaItemIds) { const numProgressRemoved = await Database.mediaProgressModel.destroy({ where: { mediaItemId: mediaItemIds @@ -362,29 +361,6 @@ class ApiRouter { Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`) } - // TODO: Remove open sessions for library item - - // Remove series if empty - if (mediaType === 'book') { - // TODO: update filter data - const bookSeries = await Database.bookSeriesModel.findAll({ - where: { - bookId: mediaItemIds[0] - }, - include: { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) - } - } - } - // remove item from playlists const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) for (const playlist of playlistsWithItem) { @@ -423,10 +399,13 @@ class ApiRouter { // purge cover cache await CacheManager.purgeCoverCache(libraryItemId) - const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) - if (await fs.pathExists(itemMetadataPath)) { - Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) - await fs.remove(itemMetadataPath) + // Remove metadata file if in /metadata/items dir + if (global.MetadataPath) { + const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) + if (await fs.pathExists(itemMetadataPath)) { + Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) + await fs.remove(itemMetadataPath) + } } await Database.libraryItemModel.removeById(libraryItemId) @@ -437,32 +416,27 @@ class ApiRouter { } /** - * Used when a series is removed from a book - * Series is removed if it only has 1 book + * After deleting book(s), remove empty series * - * @param {string} bookId * @param {string[]} seriesIds */ - async checkRemoveEmptySeries(bookId, seriesIds) { + async checkRemoveEmptySeries(seriesIds) { if (!seriesIds?.length) return - const bookSeries = await Database.bookSeriesModel.findAll({ + const series = await Database.seriesModel.findAll({ where: { - bookId, - seriesId: seriesIds + id: seriesIds }, - include: [ - { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - ] + attributes: ['id', 'name', 'libraryId'], + include: { + model: Database.bookModel, + attributes: ['id'] + } }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) + + for (const s of series) { + if (!s.books.length) { + await this.removeEmptySeries(s) } } } @@ -471,11 +445,10 @@ class ApiRouter { * Remove authors with no books and unset asin, description and imagePath * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) * - * @param {string} libraryId * @param {string[]} authorIds * @returns {Promise} */ - async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { + async checkRemoveAuthorsWithNoBooks(authorIds) { if (!authorIds?.length) return const bookAuthorsToRemove = ( @@ -495,10 +468,10 @@ class ApiRouter { }, sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) ], - attributes: ['id', 'name'], + attributes: ['id', 'name', 'libraryId'], raw: true }) - ).map((au) => ({ id: au.id, name: au.name })) + ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId })) if (bookAuthorsToRemove.length) { await Database.authorModel.destroy({ @@ -506,7 +479,7 @@ class ApiRouter { id: bookAuthorsToRemove.map((au) => au.id) } }) - bookAuthorsToRemove.forEach(({ id, name }) => { + bookAuthorsToRemove.forEach(({ id, name, libraryId }) => { Database.removeAuthorFromFilterData(libraryId, id) // TODO: Clients were expecting full author in payload but its unnecessary SocketAuthority.emitter('author_removed', { id, libraryId }) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js new file mode 100644 index 00000000..3e7c58b2 --- /dev/null +++ b/test/server/controllers/LibraryItemController.test.js @@ -0,0 +1,202 @@ +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 RssFeedManager = require('../../../server/managers/RssFeedManager') +const Logger = require('../../../server/Logger') + +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({ + apiCacheManager: new ApiCacheManager(), + rssFeedManager: new RssFeedManager() + }) + + 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 oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + const fakeReq = { + query: {}, + libraryItem: oldLibraryItem + } + 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 oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + // Update library item 1 remove all authors and series + const fakeReq = { + query: {}, + body: { + metadata: { + authors: [], + series: [] + } + }, + libraryItem: oldLibraryItem + } + 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 + }) + }) +})