tighten postgres compatibility tests and diagnostics

This commit is contained in:
Kevin Gatera 2026-03-03 15:12:38 -05:00
parent 06ad6a722f
commit 7d97d09191
No known key found for this signature in database
GPG key ID: F0D9F5932458CFB9
7 changed files with 366 additions and 3 deletions

View file

@ -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})`
)

View file

@ -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)
})
})
})

View file

@ -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', () => {

View file

@ -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',

View file

@ -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 () => {

View file

@ -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'
})
})
})

View file

@ -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)')
})
})