From 9b055f80a6c7112e6cffbb461a7b29e718dad59e Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 9 Nov 2026 19:09:36 +0200 Subject: [PATCH] Fix book edit crash when a series or book link already exists --- server/models/Book.js | 28 ++++++++++--- server/models/Series.js | 20 +++++++--- test/server/models/Book.test.js | 71 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 test/server/models/Book.test.js diff --git a/server/models/Book.js b/server/models/Book.js index df2014c4e..0446b3d90 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -469,7 +469,13 @@ class Book extends Model { const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) // findOrCreate matches case-insensitively, so a second spelling can resolve to an author just linked if (this.authors.some((au) => au.id === author.id)) continue - await bookAuthorModel.create({ bookId: this.id, authorId: author.id }) + try { + await bookAuthorModel.create({ bookId: this.id, authorId: author.id }) + } catch (error) { + if (error.name !== 'SequelizeUniqueConstraintError') throw error + this.authors.push(author) + continue + } if (created) { SocketAuthority.emitter('author_added', author.toOldJSON()) } else { @@ -525,11 +531,23 @@ class Book extends Model { } } else { const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId) - series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence }) + try { + series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence }) + seriesAdded.push(series) + hasUpdates = true + Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`) + } catch (error) { + if (error.name !== 'SequelizeUniqueConstraintError') throw error + series.bookSeries = await bookSeriesModel.findOne({ where: { bookId: this.id, seriesId: series.id } }) + if (!series.bookSeries) throw error + if (series.bookSeries.sequence !== seriesObjSequence) { + series.bookSeries.sequence = seriesObjSequence + await series.bookSeries.save() + hasUpdates = true + Logger.debug(`[Book] "${this.title}" Updated series "${series.name}" sequence ${seriesObjSequence}`) + } + } this.series.push(series) - seriesAdded.push(series) - hasUpdates = true - Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`) } } diff --git a/server/models/Series.js b/server/models/Series.js index 6ca288464..6ca10a9ba 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -75,11 +75,21 @@ class Series extends Model { static async findOrCreateByNameAndLibrary(seriesName, libraryId) { const series = await this.getByNameAndLibrary(seriesName, libraryId) if (series) return series - return this.create({ - name: seriesName, - nameIgnorePrefix: getTitleIgnorePrefix(seriesName), - libraryId - }) + try { + return await this.create({ + name: seriesName, + nameIgnorePrefix: getTitleIgnorePrefix(seriesName), + libraryId + }) + } catch (error) { + if (error.name !== 'SequelizeUniqueConstraintError') throw error + // The unique index is on the exact name. lower() can miss that row. + const existingSeries = await this.findOne({ + where: { name: seriesName, libraryId } + }) + if (!existingSeries) throw error + return existingSeries + } } /** diff --git a/test/server/models/Book.test.js b/test/server/models/Book.test.js new file mode 100644 index 000000000..991291cf2 --- /dev/null +++ b/test/server/models/Book.test.js @@ -0,0 +1,71 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const Logger = require('../../../server/Logger') + +describe('Book author and series updates', () => { + /** @type {import('../../../server/models/Library')} */ + let library + /** @type {import('../../../server/models/Book')} */ + let book + + 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() + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'debug') + sinon.stub(Logger, 'warn') + sinon.stub(Logger, 'error') + + library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + book.authors = [] + book.series = [] + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + it('does not throw when the bookSeries link already exists', async () => { + const series = await Database.seriesModel.create({ name: 'Crescent City', libraryId: library.id }) + await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '3' }) + + const result = await book.updateSeriesFromRequest([{ name: 'Crescent City', sequence: '3' }], library.id) + + expect(result.hasUpdates).to.be.false + const links = await Database.bookSeriesModel.findAll({ where: { bookId: book.id, seriesId: series.id } }) + expect(links).to.have.lengthOf(1) + }) + + it('reuses an existing series when create hits the name unique constraint', async () => { + const series = await Database.seriesModel.create({ name: 'Crescent City', libraryId: library.id }) + sinon.stub(Database.seriesModel, 'getByNameAndLibrary').resolves(null) + + const result = await book.updateSeriesFromRequest([{ name: 'Crescent City', sequence: '3' }], library.id) + + expect(result.seriesAdded).to.have.lengthOf(1) + expect(result.seriesAdded[0].id).to.equal(series.id) + const seriesRows = await Database.seriesModel.findAll({ where: { libraryId: library.id, name: 'Crescent City' } }) + expect(seriesRows).to.have.lengthOf(1) + const links = await Database.bookSeriesModel.findAll({ where: { bookId: book.id, seriesId: series.id } }) + expect(links).to.have.lengthOf(1) + }) + + it('does not throw when the bookAuthor link already exists', async () => { + const author = await Database.authorModel.create({ name: 'Sarah Maas', libraryId: library.id }) + await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id }) + + const result = await book.updateAuthorsFromRequest(['Sarah Maas'], library.id) + + expect(result.authorsAdded).to.have.lengthOf(0) + const links = await Database.bookAuthorModel.findAll({ where: { bookId: book.id, authorId: author.id } }) + expect(links).to.have.lengthOf(1) + }) +})