audiobookshelf/test/server/controllers/LibraryController.largeLibraryBrowse.test.js

407 lines
15 KiB
JavaScript

const { expect } = require('chai')
const { Sequelize } = require('sequelize')
const sinon = require('sinon')
const Database = require('../../../server/Database')
const LibraryController = require('../../../server/controllers/LibraryController')
const libraryFilters = require('../../../server/utils/queries/libraryFilters')
const { decodeBrowseCursor } = require('../../../server/utils/queries/libraryBrowseCursor')
const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy')
const libraryItemsBookFilters = require('../../../server/utils/queries/libraryItemsBookFilters')
const libraryItemsPodcastFilters = require('../../../server/utils/queries/libraryItemsPodcastFilters')
describe('LibraryController large-library browse contract', () => {
const getLiteralSql = (predicate) => String(predicate?.attribute?.val || predicate?.attribute || predicate?.left?.val || predicate?.left || '')
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()
})
afterEach(async () => {
sinon.restore()
await Database.sequelize.sync({ force: true })
})
it('returns pagination metadata with deferred count semantics for title browse', async () => {
const getByFilterAndSortStub = sinon.stub(Database.libraryItemModel, 'getByFilterAndSort').resolves({
libraryItems: [{ id: 'item_1' }],
count: 123,
nextCursor: 'cursor-2',
paginationMode: 'keyset',
countMode: 'deferred-exact',
isCountDeferred: true
})
const req = {
query: { limit: '40', sort: 'media.metadata.title', minified: '1', cursor: 'cursor-1', pageMode: 'endless' },
library: { id: 'lib_1', mediaType: 'book', isVirtual: false },
user: { id: 'user_1' }
}
const res = { json: sinon.spy() }
await LibraryController.getLibraryItems(req, res)
expect(getByFilterAndSortStub.firstCall.args[2]).to.include({
cursor: 'cursor-1',
pageMode: 'endless'
})
expect(res.json.firstCall.args[0]).to.include({
total: 123,
cursor: 'cursor-1',
pageMode: 'endless',
nextCursor: 'cursor-2',
paginationMode: 'keyset',
isCountDeferred: true,
countMode: 'deferred-exact'
})
})
it('defaults pageMode to paged even when a cursor is present', async () => {
const getByFilterAndSortStub = sinon.stub(Database.libraryItemModel, 'getByFilterAndSort').resolves({
libraryItems: [],
count: 0,
nextCursor: null,
paginationMode: 'offset',
countMode: 'exact-on-initial-page',
isCountDeferred: false
})
const req = {
query: { limit: '40', sort: 'media.metadata.title', cursor: 'cursor-1' },
library: { id: 'lib_1', mediaType: 'book', isVirtual: false },
user: { id: 'user_1' }
}
const res = { json: sinon.spy() }
await LibraryController.getLibraryItems(req, res)
expect(getByFilterAndSortStub.firstCall.args[2]).to.include({
cursor: 'cursor-1',
pageMode: 'paged'
})
expect(res.json.firstCall.args[0]).to.include({
cursor: 'cursor-1',
pageMode: 'paged'
})
})
it('forwards cursor and pageMode through LibraryItem.getByFilterAndSort into libraryFilters', async () => {
const getFilteredLibraryItemsStub = sinon.stub(libraryFilters, 'getFilteredLibraryItems').resolves({
libraryItems: [],
count: 0,
nextCursor: null,
paginationMode: 'offset',
countMode: 'exact-on-initial-page',
isCountDeferred: false
})
await Database.libraryItemModel.getByFilterAndSort(
{ id: 'lib_1', mediaType: 'book' },
{ id: 'user_1' },
{
sortBy: 'media.metadata.title',
limit: 40,
offset: 0,
cursor: 'cursor-1',
pageMode: 'endless',
include: [],
collapseseries: true,
mediaType: 'book'
}
)
expect(getFilteredLibraryItemsStub.firstCall.args[2]).to.include({
cursor: 'cursor-1',
pageMode: 'endless',
collapseseries: true
})
})
it('does not mutate caller-owned forwarded browse options in the podcast query helper', async () => {
const forwardedBrowseOptions = { cursor: 'cursor-9' }
sinon.stub(libraryItemsPodcastFilters, 'getMediaGroupQuery').returns({ mediaWhere: {}, replacements: {} })
sinon.stub(libraryItemsPodcastFilters, 'getUserPermissionPodcastWhereQuery').returns({ podcastWhere: [], replacements: {} })
sinon.stub(libraryItemsPodcastFilters, 'getOrder').returns([])
sinon.stub(Database.podcastModel, 'findAndCountAll').resolves({ rows: [], count: 0 })
await libraryItemsPodcastFilters.getFilteredLibraryItems(
'lib_2',
{ id: 'user_2', canAccessExplicitContent: true, accessAllTags: true },
null,
null,
'media.metadata.title',
false,
[],
40,
0,
forwardedBrowseOptions
)
expect(forwardedBrowseOptions).to.deep.equal({ cursor: 'cursor-9' })
})
it('forwards cursor, pageMode, and collapseseries through libraryFilters into the book query helper', async () => {
const getFilteredLibraryItemsStub = sinon.stub(libraryItemsBookFilters, 'getFilteredLibraryItems').resolves({
libraryItems: [],
count: 0,
nextCursor: 'cursor-2',
paginationMode: 'keyset',
countMode: 'deferred-exact',
isCountDeferred: true
})
await Database.libraryItemModel.getByFilterAndSort(
{ id: 'lib_1', mediaType: 'book' },
{ id: 'user_1' },
{
sortBy: 'media.metadata.title',
limit: 40,
offset: 0,
cursor: 'cursor-1',
pageMode: 'endless',
include: [],
collapseseries: true,
mediaType: 'book'
}
)
expect(getFilteredLibraryItemsStub.firstCall.args[11]).to.deep.equal({
cursor: 'cursor-1',
pageMode: 'endless',
collapseseries: true
})
})
it('forwards cursor, pageMode, and collapseseries through libraryFilters into the podcast query helper', async () => {
const getFilteredLibraryItemsStub = sinon.stub(libraryItemsPodcastFilters, 'getFilteredLibraryItems').resolves({
libraryItems: [],
count: 0,
nextCursor: 'cursor-2',
paginationMode: 'keyset',
countMode: 'deferred-exact',
isCountDeferred: true
})
await Database.libraryItemModel.getByFilterAndSort(
{ id: 'lib_2', mediaType: 'podcast' },
{ id: 'user_2' },
{
sortBy: 'media.metadata.title',
limit: 40,
offset: 0,
cursor: 'cursor-9',
pageMode: 'endless',
include: [],
collapseseries: false,
mediaType: 'podcast'
}
)
expect(getFilteredLibraryItemsStub.firstCall.args[9]).to.deep.equal({
cursor: 'cursor-9',
pageMode: 'endless',
collapseseries: false
})
})
it('does not run exact count work or joined findAndCountAll again on follow-up keyset chunks', async () => {
const countStub = sinon.stub(Database.bookModel, 'count').resolves(123)
const findAllStub = sinon.stub(Database.bookModel, 'findAll').resolves([])
const findAndCountAllStub = sinon.stub(Database.bookModel, 'findAndCountAll').resolves({ rows: [], count: 123 })
const result = await libraryItemsBookFilters.getFilteredLibraryItems(
'lib_1',
{ id: 'user_1', canAccessExplicitContent: true, accessAllTags: true },
null,
null,
'media.metadata.title',
false,
false,
[],
40,
0,
false,
{ cursor: 'cursor-2', pageMode: 'endless' }
)
expect(result.count).to.equal(null)
expect(findAllStub.calledOnce).to.equal(true)
expect(countStub.called).to.equal(false)
expect(findAndCountAllStub.called).to.equal(false)
})
it('falls back to offset mode for endless progress browse because null-aware keyset is unsupported', () => {
const strategy = getLibraryBrowseStrategy({
mediaType: 'book',
sortBy: 'progress',
filterGroup: 'progress',
pageMode: 'endless'
})
expect(strategy.paginationMode).to.equal('offset')
expect(strategy.countMode).to.equal('exact-on-initial-page')
expect(strategy.deepScrollAllowed).to.equal(false)
})
it('falls back to offset mode for collapsed-series endless title browse because the SQL sort key differs', () => {
global.ServerSettings = { sortingIgnorePrefix: true }
const strategy = getLibraryBrowseStrategy({
mediaType: 'book',
sortBy: 'media.metadata.title',
filterGroup: 'series',
pageMode: 'endless',
collapseseries: true
})
expect(strategy.paginationMode).to.equal('offset')
expect(strategy.countMode).to.equal('exact-on-initial-page')
})
it('adds a deterministic libraryItem.id tie-breaker for keyset title browse with duplicate titles', async () => {
global.ServerSettings = { sortingIgnorePrefix: true }
const countStub = sinon.stub(Database.bookModel, 'count').resolves(123)
const findAndCountAllStub = sinon.stub(Database.bookModel, 'findAndCountAll').resolves({ rows: [], count: 123 })
const findAllStub = sinon.stub(Database.bookModel, 'findAll').resolves([
{ id: 'book-1', title: 'Alpha', libraryItem: { id: 'item-1', titleIgnorePrefix: 'Alpha', dataValues: { titleIgnorePrefix: 'Alpha' } } },
{ id: 'book-2', title: 'Alpha', libraryItem: { id: 'item-2', titleIgnorePrefix: 'Alpha', dataValues: { titleIgnorePrefix: 'Alpha' } } },
{ id: 'book-3', title: 'Beta', libraryItem: { id: 'item-3', titleIgnorePrefix: 'Beta', dataValues: { titleIgnorePrefix: 'Beta' } } }
])
const result = await libraryItemsBookFilters.getFilteredLibraryItems(
'lib_1',
{ id: 'user_1', canAccessExplicitContent: true, accessAllTags: true },
null,
null,
'media.metadata.title',
false,
false,
[],
2,
0,
false,
{ pageMode: 'endless' }
)
expect(result.paginationMode).to.equal('keyset')
expect(result.nextCursor).to.be.a('string')
expect(findAllStub.firstCall.args[0].order[1][0].val || findAllStub.firstCall.args[0].order[1][0]).to.include('libraryItem')
expect(findAllStub.firstCall.args[0].order[1][0].val || findAllStub.firstCall.args[0].order[1][0]).to.include('id')
expect(countStub.calledOnce).to.equal(true)
expect(findAndCountAllStub.called).to.equal(false)
})
it('encodes case-insensitive title cursor values to match the executed browse order', async () => {
global.ServerSettings = { sortingIgnorePrefix: true }
sinon.stub(Database.bookModel, 'count').resolves(123)
sinon.stub(Database.bookModel, 'findAndCountAll').resolves({ rows: [], count: 123 })
sinon.stub(Database.bookModel, 'findAll').resolves([
{ id: 'book-1', title: 'Alpha', libraryItem: { id: 'item-1', titleIgnorePrefix: 'Alpha', dataValues: { titleIgnorePrefix: 'Alpha' } } },
{ id: 'book-2', title: 'aLPHa', libraryItem: { id: 'item-2', titleIgnorePrefix: 'aLPHa', dataValues: { titleIgnorePrefix: 'aLPHa' } } },
{ id: 'book-3', title: 'Beta', libraryItem: { id: 'item-3', titleIgnorePrefix: 'Beta', dataValues: { titleIgnorePrefix: 'Beta' } } }
])
const result = await libraryItemsBookFilters.getFilteredLibraryItems(
'lib_1',
{ id: 'user_1', canAccessExplicitContent: true, accessAllTags: true },
null,
null,
'media.metadata.title',
false,
false,
[],
2,
0,
false,
{ pageMode: 'endless' }
)
const decodedCursor = decodeBrowseCursor(result.nextCursor)
expect(decodedCursor.values).to.deep.equal(['alpha', 'item-2'])
})
it('defaults omitted sort to deterministic title ordering for endless browse requests', async () => {
global.ServerSettings = { sortingIgnorePrefix: true }
const countStub = sinon.stub(Database.bookModel, 'count').resolves(10)
const findAndCountAllStub = sinon.stub(Database.bookModel, 'findAndCountAll').resolves({ rows: [], count: 10 })
const findAllStub = sinon.stub(Database.bookModel, 'findAll').resolves([
{ id: 'book-1', title: 'Alpha', libraryItem: { id: 'item-1', titleIgnorePrefix: 'Alpha', dataValues: { titleIgnorePrefix: 'Alpha' } } }
])
const result = await libraryItemsBookFilters.getFilteredLibraryItems(
'lib_1',
{ id: 'user_1', canAccessExplicitContent: true, accessAllTags: true },
null,
null,
null,
false,
false,
[],
20,
0,
false,
{ pageMode: 'endless' }
)
expect(result.paginationMode).to.equal('keyset')
expect(findAllStub.calledOnce).to.equal(true)
expect(findAndCountAllStub.called).to.equal(false)
expect(countStub.calledOnce).to.equal(true)
expect(findAllStub.firstCall.args[0].order).to.have.length(2)
expect(findAllStub.firstCall.args[0].order[0][0].val || findAllStub.firstCall.args[0].order[0][0]).to.include('titleIgnorePrefix')
expect(findAllStub.firstCall.args[0].order[1][0].val || findAllStub.firstCall.args[0].order[1][0]).to.include('libraryItem')
expect(findAllStub.firstCall.args[0].order[1][0].val || findAllStub.firstCall.args[0].order[1][0]).to.include('id')
})
it('uses offset fallback for progress browse requests with null-valued sort fields', async () => {
const countStub = sinon.stub(Database.bookModel, 'count').resolves(123)
const findAllStub = sinon.stub(Database.bookModel, 'findAll').resolves([])
const findAndCountAllStub = sinon.stub(Database.bookModel, 'findAndCountAll').resolves({ rows: [], count: 7 })
const result = await libraryItemsBookFilters.getFilteredLibraryItems(
'lib_1',
{ id: 'user_1', canAccessExplicitContent: true, accessAllTags: true },
'progress',
'in-progress',
'progress',
true,
false,
[],
20,
0,
false,
{ cursor: 'cursor-2', pageMode: 'endless' }
)
expect(result.paginationMode).to.equal('offset')
expect(result.countMode).to.equal('exact-on-initial-page')
expect(findAndCountAllStub.calledOnce).to.equal(true)
expect(findAllStub.called).to.equal(false)
expect(countStub.called).to.equal(false)
})
it('builds EXISTS predicates for browse filters and permission-tag checks instead of correlated count subqueries', () => {
const { mediaWhere: tagWhere } = libraryItemsBookFilters.getMediaGroupQuery('tags', 'Fantasy')
const { mediaWhere: genreWhere } = libraryItemsBookFilters.getMediaGroupQuery('genres', 'Fantasy')
const { mediaWhere: narratorWhere } = libraryItemsBookFilters.getMediaGroupQuery('narrators', 'Jane Doe')
const { mediaWhere: trackWhere } = libraryItemsBookFilters.getMediaGroupQuery('tracks', 'multi')
const { mediaWhere: ebookWhere } = libraryItemsBookFilters.getMediaGroupQuery('ebooks', 'ebook')
const permissionWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery({
canAccessExplicitContent: true,
accessAllTags: false,
itemTagsSelected: ['Fantasy'],
selectedTagsNotAccessible: false
}).bookWhere[0]
;[tagWhere, genreWhere, narratorWhere, trackWhere, ebookWhere, permissionWhere].forEach((predicate) => {
const sql = getLiteralSql(predicate)
expect(sql).to.include('EXISTS')
expect(sql).to.not.include('count(*)')
})
})
})