mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
tighten postgres compatibility tests and diagnostics
This commit is contained in:
parent
06ad6a722f
commit
7d97d09191
7 changed files with 366 additions and 3 deletions
|
|
@ -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})`
|
||||
)
|
||||
|
|
|
|||
101
test/server/Database.test.js
Normal file
101
test/server/Database.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
75
test/server/utils/queries/libraryItemsPodcastFilters.test.js
Normal file
75
test/server/utils/queries/libraryItemsPodcastFilters.test.js
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
107
test/server/utils/queries/seriesFilters.test.js
Normal file
107
test/server/utils/queries/seriesFilters.test.js
Normal 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)')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue