diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 62e2c55d9..35e0f9bb3 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -638,7 +638,7 @@ module.exports = { `filterGroup=${filterGroup || 'none'} filterValue=${filterValue || 'none'} sortBy=${sortBy}` ) if (!includedBookSeriesIds) { - Logger.warn( + Logger.debug( `[LibraryItemsBookFilters] collapse-series produced no include IDs; using library item title fallback ` + `(libraryId=${libraryId}, filterGroup=${filterGroup || 'none'}, filterValue=${filterValue || 'none'}, sortBy=${sortBy})` ) diff --git a/test/server/Database.test.js b/test/server/Database.test.js new file mode 100644 index 000000000..a70e1d204 --- /dev/null +++ b/test/server/Database.test.js @@ -0,0 +1,101 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const fs = require('../../server/libs/fsExtra') +const Database = require('../../server/Database') + +describe('Database', () => { + let originalDialect + let originalSequelize + let originalEnv + + beforeEach(() => { + originalDialect = Database.dialect + originalSequelize = Database.sequelize + originalEnv = { + DB_DIALECT: process.env.DB_DIALECT, + DATABASE_URL: process.env.DATABASE_URL + } + }) + + afterEach(() => { + Database.dialect = originalDialect + Database.sequelize = originalSequelize + + if (originalEnv.DB_DIALECT === undefined) delete process.env.DB_DIALECT + else process.env.DB_DIALECT = originalEnv.DB_DIALECT + + if (originalEnv.DATABASE_URL === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = originalEnv.DATABASE_URL + + sinon.restore() + }) + + describe('getConfiguredDialect', () => { + it('should default to sqlite when no env variables are set', () => { + delete process.env.DB_DIALECT + delete process.env.DATABASE_URL + + expect(Database.getConfiguredDialect()).to.equal('sqlite') + }) + + it('should use explicit DB_DIALECT value when valid', () => { + process.env.DB_DIALECT = 'postgres' + process.env.DATABASE_URL = 'sqlite:///tmp/abs.sqlite' + + expect(Database.getConfiguredDialect()).to.equal('postgres') + }) + + it('should infer postgres dialect from DATABASE_URL', () => { + delete process.env.DB_DIALECT + process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/abs' + + expect(Database.getConfiguredDialect()).to.equal('postgres') + }) + + it('should fallback to sqlite for unsupported DB_DIALECT', () => { + process.env.DB_DIALECT = 'mysql' + process.env.DATABASE_URL = 'sqlite:///tmp/abs.sqlite' + + expect(Database.getConfiguredDialect()).to.equal('sqlite') + }) + }) + + describe('checkHasDb', () => { + it('should not check sqlite file existence in postgres mode', async () => { + Database.dialect = 'postgres' + const pathExistsStub = sinon.stub(fs, 'pathExists') + + const hasDb = await Database.checkHasDb() + + expect(hasDb).to.equal(true) + expect(pathExistsStub.called).to.equal(false) + }) + }) + + describe('checkHasTables', () => { + it('should return true when at least one table exists', async () => { + Database.sequelize = { + getQueryInterface: () => ({ + showAllTables: sinon.stub().resolves(['users']) + }) + } + + const hasTables = await Database.checkHasTables() + + expect(hasTables).to.equal(true) + }) + + it('should return false when no tables exist', async () => { + Database.sequelize = { + getQueryInterface: () => ({ + showAllTables: sinon.stub().resolves([]) + }) + } + + const hasTables = await Database.checkHasTables() + + expect(hasTables).to.equal(false) + }) + }) +}) diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index af2e9da8f..a306eb5be 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -189,6 +189,52 @@ describe('MigrationManager', () => { expect(loggerInfoStub.calledWith(sinon.match('Restored the original database'))).to.be.true expect(processExitStub.calledOnce).to.be.true }) + + it('should skip sqlite backup workflow for postgres migrations', async () => { + // Arrange + migrationManager.serverVersion = '1.2.0' + migrationManager.databaseVersion = '1.1.0' + migrationManager.maxVersion = '1.1.0' + migrationManager.initialized = true + sequelizeStub.getDialect.returns('postgres') + + umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }]) + umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }]) + + // Act + await migrationManager.runMigrations() + + // Assert + expect(umzugStub.up.calledOnce).to.be.true + expect(fsCopyStub.called).to.be.false + expect(fsRemoveStub.called).to.be.false + }) + }) + + describe('tableExists', () => { + it('should use queryInterface.tableExists when available', async () => { + const tableExistsStub = sinon.stub().resolves(true) + const sequelize = sinon.createStubInstance(Sequelize) + sequelize.getQueryInterface.returns({ tableExists: tableExistsStub }) + const manager = new MigrationManager(sequelize, false, configPath) + + const exists = await manager.tableExists('migrationsMeta') + + expect(exists).to.equal(true) + expect(tableExistsStub.calledOnceWithExactly('migrationsMeta')).to.equal(true) + }) + + it('should fallback to showAllTables and support object table names', async () => { + const sequelize = sinon.createStubInstance(Sequelize) + sequelize.getQueryInterface.returns({ + showAllTables: sinon.stub().resolves([{ tableName: 'migrationsMeta' }]) + }) + const manager = new MigrationManager(sequelize, false, configPath) + + const exists = await manager.tableExists('migrationsMeta') + + expect(exists).to.equal(true) + }) }) describe('fetchVersionsFromDatabase', () => { @@ -280,6 +326,20 @@ describe('MigrationManager', () => { expect(error.message).to.equal('Database query failed') } }) + + it('should support lowercase maxversion alias from postgres drivers', async () => { + const sequelize = sinon.createStubInstance(Sequelize) + sequelize.query.onFirstCall().resolves([{ version: '1.1.0' }]) + sequelize.query.onSecondCall().resolves([{ maxversion: '1.2.0' }]) + + const manager = new MigrationManager(sequelize, false, configPath) + manager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves() + + await manager.fetchVersionsFromDatabase() + + expect(manager.databaseVersion).to.equal('1.1.0') + expect(manager.maxVersion).to.equal('1.2.0') + }) }) describe('updateMaxVersion', () => { diff --git a/test/server/models/User.test.js b/test/server/models/User.test.js index 82c289c45..5b935fee6 100644 --- a/test/server/models/User.test.js +++ b/test/server/models/User.test.js @@ -34,6 +34,26 @@ describe('User model', () => { expect(findOneStub.called).to.equal(false) }) + it('should resolve uppercase UUID ids via primary-key lookup on postgres', async () => { + User.sequelize = { + getDialect: () => 'postgres', + models: { + mediaProgress: {} + } + } + + const uppercaseUuid = 'E8E677B2-DA16-4220-AB67-443B7714CAF9' + const user = { id: uppercaseUuid } + const findByPkStub = sinon.stub(User, 'findByPk').resolves(user) + const findOneStub = sinon.stub(User, 'findOne').resolves(null) + + const result = await User.getUserByIdOrOldId(uppercaseUuid) + + expect(result).to.equal(user) + expect(findByPkStub.calledOnceWithExactly(uppercaseUuid, { include: User.sequelize.models.mediaProgress })).to.equal(true) + expect(findOneStub.called).to.equal(false) + }) + it('should query legacy oldUserId with postgres-safe JSON matcher', async () => { User.sequelize = { getDialect: () => 'postgres', diff --git a/test/server/utils/queries/libraryItemsBookFilters.test.js b/test/server/utils/queries/libraryItemsBookFilters.test.js index b014c1399..19b6a58bc 100644 --- a/test/server/utils/queries/libraryItemsBookFilters.test.js +++ b/test/server/utils/queries/libraryItemsBookFilters.test.js @@ -62,7 +62,7 @@ describe('libraryItemsBookFilters postgres query safety', () => { booksToExclude: [], bookSeriesToInclude: [] }) - const warnStub = sinon.stub(Logger, 'warn') + const debugStub = sinon.stub(Logger, 'debug') await libraryItemsBookFilters.getFilteredLibraryItems('library-1', { canAccessExplicitContent: true }, 'authors', 'author-1', 'media.metadata.publishedYear', true, true, [], 20, 0) @@ -71,7 +71,7 @@ describe('libraryItemsBookFilters postgres query safety', () => { expect(displayTitleExpression).to.include('COALESCE(NULL, libraryItem.title)') expect(displayTitleExpression).to.not.include('IN ()') - expect(warnStub.calledOnce).to.equal(true) + expect(debugStub.calledWithMatch('collapse-series produced no include IDs')).to.equal(true) }) it('should escape collapse-series ids safely for postgres subquery', async () => { diff --git a/test/server/utils/queries/libraryItemsPodcastFilters.test.js b/test/server/utils/queries/libraryItemsPodcastFilters.test.js new file mode 100644 index 000000000..79938abdc --- /dev/null +++ b/test/server/utils/queries/libraryItemsPodcastFilters.test.js @@ -0,0 +1,75 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const Database = require('../../../../server/Database') +const libraryItemsPodcastFilters = require('../../../../server/utils/queries/libraryItemsPodcastFilters') + +describe('libraryItemsPodcastFilters dialect behavior', () => { + let originalSequelize + let originalServerSettings + + beforeEach(() => { + originalSequelize = Database.sequelize + originalServerSettings = global.ServerSettings + global.ServerSettings = { sortingIgnorePrefix: false } + }) + + afterEach(() => { + Database.sequelize = originalSequelize + global.ServerSettings = originalServerSettings + sinon.restore() + }) + + it('should build postgres json tag permission predicate', () => { + Database.sequelize = { getDialect: () => 'postgres' } + + const { podcastWhere, replacements } = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery({ + canAccessExplicitContent: true, + permissions: { + accessAllTags: false, + itemTagsSelected: ['fiction'], + selectedTagsNotAccessible: false + } + }) + + expect(replacements.userTagsSelected).to.deep.equal(['fiction']) + expect(podcastWhere[0].attribute.val).to.include('jsonb_array_elements_text') + }) + + it('should build sqlite no-case author sort expression', () => { + Database.sequelize = { getDialect: () => 'sqlite' } + + const order = libraryItemsPodcastFilters.getOrder('media.metadata.author', false) + + expect(order[0][0].val).to.include('podcast.author COLLATE NOCASE') + }) + + it('should build postgres lower author sort expression', () => { + Database.sequelize = { getDialect: () => 'postgres' } + + const order = libraryItemsPodcastFilters.getOrder('media.metadata.author', false) + + expect(order[0][0].val).to.include('LOWER(podcast.author)') + }) + + it('should use postgres json duration extraction in podcast stats query', async () => { + const queryStub = sinon.stub() + queryStub.onFirstCall().resolves([[{ totalSize: 1000 }]]) + queryStub.onSecondCall().resolves([[{ totalDuration: '12.3', totalItems: '4', numAudioFiles: '9' }]]) + + Database.sequelize = { + getDialect: () => 'postgres', + query: queryStub + } + + const result = await libraryItemsPodcastFilters.getPodcastLibraryStats('library-1') + + expect(queryStub.secondCall.args[0]).to.include('NULLIF(pe.audioFile::jsonb #>>') + expect(result).to.deep.equal({ + totalSize: 1000, + totalDuration: '12.3', + numAudioFiles: '9', + totalItems: '4' + }) + }) +}) diff --git a/test/server/utils/queries/seriesFilters.test.js b/test/server/utils/queries/seriesFilters.test.js new file mode 100644 index 000000000..eb1c3cb54 --- /dev/null +++ b/test/server/utils/queries/seriesFilters.test.js @@ -0,0 +1,107 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +const Database = require('../../../../server/Database') +const seriesFilters = require('../../../../server/utils/queries/seriesFilters') +const libraryItemsBookFilters = require('../../../../server/utils/queries/libraryItemsBookFilters') + +function encodedFilter(group, value) { + return `${group}.${encodeURIComponent(Buffer.from(value).toString('base64'))}` +} + +describe('seriesFilters dialect behavior', () => { + let originalSequelize + let originalServerSettings + let modelStubs + + function setDialect(dialect) { + Database.sequelize = { + getDialect: () => dialect, + random: sinon.stub(), + models: modelStubs + } + } + + beforeEach(() => { + originalSequelize = Database.sequelize + originalServerSettings = global.ServerSettings + modelStubs = { + series: { + findAndCountAll: sinon.stub().resolves({ rows: [], count: 0 }) + }, + bookSeries: {}, + book: {}, + libraryItem: {}, + author: {}, + feed: {} + } + + global.ServerSettings = { sortingIgnorePrefix: false } + sinon.stub(libraryItemsBookFilters, 'getUserPermissionBookWhereQuery').returns({ + bookWhere: [], + replacements: {} + }) + }) + + afterEach(() => { + Database.sequelize = originalSequelize + global.ServerSettings = originalServerSettings + sinon.restore() + }) + + it('should build postgres progress filter with TRUE and FALSE literals', async () => { + setDialect('postgres') + + await seriesFilters.getFilteredSeries( + { id: 'library-1', settings: { hideSingleBookSeries: false } }, + { id: 'user-1', canAccessExplicitContent: true, permissions: { accessAllTags: true } }, + encodedFilter('progress', 'not-started'), + 'name', + false, + [], + 10, + 0 + ) + + const findOptions = modelStubs.series.findAndCountAll.firstCall.args[0] + const progressWhere = findOptions.where[1] + + expect(progressWhere.attribute.val).to.include('mp.isFinished = TRUE') + }) + + it('should build sqlite no-case name sort expression', async () => { + setDialect('sqlite') + + await seriesFilters.getFilteredSeries( + { id: 'library-1', settings: { hideSingleBookSeries: false } }, + { id: 'user-1', canAccessExplicitContent: true, permissions: { accessAllTags: true } }, + null, + 'name', + false, + [], + 10, + 0 + ) + + const findOptions = modelStubs.series.findAndCountAll.firstCall.args[0] + expect(findOptions.order[0][0].val).to.equal('series.name COLLATE NOCASE') + }) + + it('should build postgres lower-case name sort expression', async () => { + setDialect('postgres') + + await seriesFilters.getFilteredSeries( + { id: 'library-1', settings: { hideSingleBookSeries: false } }, + { id: 'user-1', canAccessExplicitContent: true, permissions: { accessAllTags: true } }, + null, + 'name', + false, + [], + 10, + 0 + ) + + const findOptions = modelStubs.series.findAndCountAll.firstCall.args[0] + expect(findOptions.order[0][0].val).to.equal('LOWER(series.name)') + }) +})