Added additional unit tests for construction of objects containing deviceId property

This commit is contained in:
Jason Axley 2025-08-21 10:36:04 -07:00
parent 3a4aacb7bf
commit 974e17ee3e
9 changed files with 333 additions and 124 deletions

View File

@ -71,10 +71,13 @@ class AudioFile {
}
}
/**
* @param {{ index: any; ino: any; deviceId: any; metadata: any; addedAt: any; updatedAt: any; manuallyVerified: any; exclude: any; error: null; trackNumFromMeta: any; discNumFromMeta: any; trackNumFromFilename: any; cdNumFromFilename: undefined; discNumFromFilename: any; format: any; duration: any; bitRate: any; language: any; codec: null; timeBase: any; channels: any; channelLayout: any; chapters: any[]; embeddedCoverArt: null; metaTags: any; }} data
*/
construct(data) {
this.index = data.index
this.ino = data.ino
this.deviceId = data.dev
this.deviceId = data.deviceId
this.metadata = new FileMetadata(data.metadata || {})
this.addedAt = data.addedAt
this.updatedAt = data.updatedAt

View File

@ -1,6 +1,9 @@
const FileMetadata = require('../metadata/FileMetadata')
class EBookFile {
/**
* @param {{ ino: any; deviceId: any; isSupplementary?: boolean; addedAt?: number; updatedAt?: number; metadata?: { filename: string; ext: string; path: string; relPath: string; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; }; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; }} file
*/
constructor(file) {
this.ino = null
this.deviceId = null
@ -14,9 +17,12 @@ class EBookFile {
}
}
/**
* @param {{ ino: any; deviceId: any; isSupplementary?: boolean | undefined; addedAt: any; updatedAt: any; metadata: any; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; ebookFormat?: any; }} file
*/
construct(file) {
this.ino = file.ino
this.deviceId = file.dev
this.deviceId = file.deviceId
this.metadata = new FileMetadata(file.metadata)
this.ebookFormat = file.ebookFormat || this.metadata.format
this.addedAt = file.addedAt

View File

@ -4,6 +4,9 @@ const globals = require('../../utils/globals')
const FileMetadata = require('../metadata/FileMetadata')
class LibraryFile {
/**
* @param {{ ino: any; deviceId: any; metadata?: { filename: any; ext: any; path: any; relPath: any; size: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; } | { filename: string; ext: string; path: string; relPath: string; size: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; } | null; isSupplementary?: any; addedAt?: any; updatedAt?: any; fileType?: string; libraryFolderId?: any; libraryId?: any; mediaType?: any; mtimeMs?: any; ctimeMs?: any; birthtimeMs?: any; path?: any; relPath?: any; isFile?: any; mediaMetadata?: any; libraryFiles?: any; } | undefined} [file]
*/
constructor(file) {
this.ino = null
this.deviceId = null

View File

@ -5,11 +5,11 @@ const globals = require('../utils/globals')
class LibraryItemScanData {
/**
* @typedef LibraryFileModifiedObject
* @typedef {Object} LibraryFileModifiedObject
* @property {LibraryItem.LibraryFileObject} old
* @property {LibraryItem.LibraryFileObject} new
* @param {{ libraryFolderId: any; libraryId: any; mediaType: any; ino: any; deviceId: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; path: any; relPath: any; isFile: any; mediaMetadata: any; libraryFiles: any; }} data
*/
constructor(data) {
/** @type {string} */
this.libraryFolderId = data.libraryFolderId
@ -20,7 +20,7 @@ class LibraryItemScanData {
/** @type {string} */
this.ino = data.ino
/** @type {string} */
this.deviceId = data.dev
this.deviceId = data.deviceId
/** @type {number} */
this.mtimeMs = data.mtimeMs
/** @type {number} */
@ -179,8 +179,8 @@ class LibraryItemScanData {
/**
*
* @param {LibraryItem} existingLibraryItem
* @param {import('./LibraryScan')} libraryScan
* @returns {boolean} true if changes found
* @param {import('./LibraryScan') | import('./ScanLogger')} libraryScan
* @returns {Promise<boolean>} true if changes found
*/
async checkLibraryItemData(existingLibraryItem, libraryScan) {
const keysToCompare = ['libraryFolderId', 'ino', 'deviceId', 'path', 'relPath', 'isFile']

View File

@ -23,7 +23,7 @@ class LibraryItemScanner {
* Scan single library item
*
* @param {string} libraryItemId
* @param {{relPath:string, path:string}} [updateLibraryItemDetails] used by watcher when item folder was renamed
* @param {{relPath:string, path:string, isFile: boolean}} [updateLibraryItemDetails] used by watcher when item folder was renamed
* @returns {number} ScanResult
*/
async scanLibraryItem(libraryItemId, updateLibraryItemDetails = null) {
@ -190,7 +190,7 @@ class LibraryItemScanner {
* @param {import('../models/Library')} library
* @param {import('../models/LibraryFolder')} folder
* @param {boolean} isSingleMediaItem
* @returns {Promise<LibraryItem>} ScanResult
* @returns {Promise<LibraryItem | null>} ScanResult
*/
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
@ -209,6 +209,13 @@ class LibraryItemScanner {
}
module.exports = new LibraryItemScanner()
/**
* @param {{ path?: any; relPath?: any; mediaMetadata?: any; }} libraryItemData
* @param {import("../models/LibraryFolder")} folder
* @param {import("../models/Library")} library
* @param {boolean} isSingleMediaItem
* @param {LibraryFile[]} libraryFiles
*/
async function buildLibraryItemScanData(libraryItemData, folder, library, isSingleMediaItem, libraryFiles) {
const libraryItemStats = await fileUtils.getFileTimestampsWithIno(libraryItemData.path)
return new LibraryItemScanData({

122
test/server/MockDatabase.js Normal file
View File

@ -0,0 +1,122 @@
const Database = require('../../server/Database')
const { Sequelize } = require('sequelize')
const LibraryFile = require('../../server/objects/files/LibraryFile')
const fileUtils = require('../../server/utils/fileUtils')
const sinon = require('sinon')
async function loadTestDatabase(mockFileInfo) {
let libraryItem1Id, libraryItem2Id
let fileInfo = mockFileInfo || getMockFileInfo()
let bookLibraryFiles = fileInfo.keys().reduce((acc, key) => {
let bookfile = new LibraryFile()
bookfile.setDataFromPath(key, key)
acc.push(bookfile)
return acc
}, [])
global.ServerSettings = {}
Database.sequelize = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
// Choose one of the logging options
logging: (...msg) => console.log(msg),
logQueryParameters: true
})
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
await Database.buildModels()
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
const newLibraryFolder2 = await Database.libraryFolderModel.create({ path: '/mnt/drive', libraryId: newLibrary.id })
const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[0], newBook.id, newLibrary.id, newLibraryFolder.id))
libraryItem1Id = newLibraryItem.id
const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem2 = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[1], newBook2.id, newLibrary.id, newLibraryFolder2.id))
libraryItem2Id = newLibraryItem2.id
return newLibrary
}
exports.loadTestDatabase = loadTestDatabase
/** @returns {Map<string, import('fs').Stats>} */
function getMockFileInfo() {
// @ts-ignore
return new Map([
['/test/file.pdf', { path: '/test/file.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }]
])
}
exports.getMockFileInfo = getMockFileInfo
/** @returns {Map<string, import('fs').Stats>} */
// this has the same data as above except one file has been renamed
function getRenamedMockFileInfo() {
// @ts-ignore
return new Map([
['/test/file-renamed.pdf', { path: '/test/file-renamed.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }]
])
}
exports.getRenamedMockFileInfo = getRenamedMockFileInfo
/**
* @param {LibraryFile} libraryFile
* @param {any} bookId
* @param {string} libraryId
* @param {any} libraryFolderId
*/
function buildBookLibraryItemParams(libraryFile, bookId, libraryId, libraryFolderId) {
return {
path: libraryFile.metadata?.path,
isFile: true,
ino: libraryFile.ino,
deviceId: libraryFile.deviceId,
libraryFiles: [libraryFile.toJSON()],
mediaId: bookId,
mediaType: 'book',
libraryId: libraryId,
libraryFolderId: libraryFolderId
}
}
exports.buildBookLibraryItemParams = buildBookLibraryItemParams
function stubFileUtils() {
let getInoStub, getDeviceIdStub, getFileTimestampsWithInoStub
getInoStub = sinon.stub(fileUtils, 'getIno')
getInoStub.callsFake((path) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = getMockFileInfo().get(normalizedPath)
if (stats) {
return stats.ino
} else {
return null
}
})
getDeviceIdStub = sinon.stub(fileUtils, 'getDeviceId')
getDeviceIdStub.callsFake(async (path) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = getMockFileInfo().get(normalizedPath)
if (stats) {
return stats.dev
} else {
return null
}
})
getFileTimestampsWithInoStub = sinon.stub(fileUtils, 'getFileTimestampsWithIno')
getFileTimestampsWithInoStub.callsFake(async (path) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = getMockFileInfo().get(normalizedPath)
if (stats) {
return stats
} else {
return null
}
})
}
exports.stubFileUtils = stubFileUtils

View File

@ -1,9 +1,115 @@
const chai = require('chai')
const expect = chai.expect
const sinon = require('sinon')
const Path = require('path')
const Database = require('../../../server/Database')
const { loadTestDatabase, stubFileUtils, getMockFileInfo } = require('../MockDatabase')
// TODO: all of these classes duplicate each other.
const LibraryFile = require('../../../server/objects/files/LibraryFile')
const EBookFile = require('../../../server/objects/files/EBookFile')
const AudioFile = require('../../../server/objects/files/AudioFile')
const LibraryItem = require('../../../server/models/LibraryItem')
const LibraryItemScanData = require('../../../server/scanner/LibraryItemScanData')
const FileMetadata = require('../../../server/objects/metadata/FileMetadata')
// TODO: all of these duplicate each other. Need to verify that deviceId is set on each when constructing. And that deviceId is populated when using toJSON()
const fileProperties = buildFileProperties()
const lf = new LibraryFile(fileProperties)
const ebf = new EBookFile(fileProperties)
const af = new AudioFile(fileProperties)
// TODO: check that any libraryFiles properties set to JSON contain a LibraryFile which has a deviceId property
describe('ObjectSetsDeviceIdWhenConstructed', function () {
this.timeout(0)
beforeEach(async () => {
stubFileUtils()
await loadTestDatabase()
})
afterEach(() => {
sinon.restore()
})
const lisd = new LibraryItemScanData(fileProperties)
const objects = [lf, ebf, af, lisd]
objects.forEach((obj) => {
it(`${obj.constructor.name}SetsDeviceIdWhenConstructed`, () => {
expect(obj.ino).to.equal(fileProperties.ino)
expect(obj.deviceId).to.equal(fileProperties.deviceId)
})
})
it('LibraryItemSetsDeviceIdWhenConstructed', async () => {
const mockFileInfo = getMockFileInfo().get('/test/file.pdf')
/** @type {import('../../../server/models/LibraryItem') | null} */
const li = await Database.libraryItemModel.findOneExpanded({
path: '/test/file.pdf'
})
expect(li?.ino).to.equal(mockFileInfo?.ino)
expect(li?.deviceId).to.equal(mockFileInfo?.dev)
})
it('LibraryFileJSONHasDeviceId', async () => {
const mockFileInfo = getMockFileInfo().get('/test/file.pdf')
/** @type {import('../../../server/models/LibraryItem') | null} */
const li = await Database.libraryItemModel.findOneExpanded({
path: '/test/file.pdf'
})
const lf_json = li?.libraryFiles[0]
expect(lf_json).to.not.be.null
expect(lf_json?.deviceId).to.equal(mockFileInfo?.dev)
})
})
describe('ObjectSetsDeviceIdWhenSerialized', () => {
const objects = [lf, ebf, af]
objects.forEach((obj) => {
it(`${obj.constructor.name}SetsDeviceIdWhenSerialized`, () => {
const obj_json = obj.toJSON()
expect(obj_json.ino).to.equal(fileProperties.ino)
expect(obj_json.deviceId).to.equal(fileProperties.deviceId)
})
})
})
/** @returns {{ libraryFolderId: any; libraryId: any; mediaType: any; ino: any; deviceId: any; mtimeMs: any; ctimeMs: any; birthtimeMs: any; path: any; relPath: any; isFile: any; mediaMetadata: any; libraryFiles: any; }} */
function buildFileProperties() {
const path = '/tmp/foo.epub'
const metadata = new FileMetadata()
metadata.filename = Path.basename(path)
metadata.path = path
metadata.relPath = path
metadata.ext = Path.extname(path)
return {
ino: '12345',
deviceId: '9876',
metadata: metadata,
isSupplementary: false,
addedAt: Date.now(),
updatedAt: Date.now()
}
}
function buildLibraryItemProperties(fileProperties) {
return {
id: '7792E90F-D526-4636-8A38-EA8342E71FEE',
path: fileProperties.path,
relPath: fileProperties.path,
isFile: true,
ino: fileProperties.ino,
deviceId: fileProperties.dev,
libraryFiles: [],
mediaId: '7195803A-9974-46E4-A7D1-7A6E1AD7FD4B',
mediaType: 'book',
libraryId: '907DA361-67E4-47CF-9C67-C8E2E5CA1B15',
libraryFolderId: 'E2216F60-8ABF-4E55-BA83-AD077EB907F3',
createdAt: Date.now(),
updatedAt: Date.now()
}
}

View File

@ -1 +1,70 @@
// TODO: test buildLibraryItemScanData
const chai = require('chai')
const expect = chai.expect
const sinon = require('sinon')
const rewire = require('rewire')
const Path = require('path')
const { stubFileUtils, getMockFileInfo, loadTestDatabase } = require('../MockDatabase')
const LibraryFile = require('../../../server/objects/files/LibraryFile')
const FileMetadata = require('../../../server/objects/metadata/FileMetadata')
const LibraryFolder = require('../../../server/models/LibraryFolder')
describe('buildLibraryItemScanData', () => {
let testLibrary = null
beforeEach(async () => {
stubFileUtils()
testLibrary = await loadTestDatabase()
})
afterEach(() => {
sinon.restore()
})
it('setsDeviceId', async () => {
const libraryItemScanner = rewire('../../../server/scanner/LibraryItemScanner')
/**
* @param {{ path?: any; relPath?: any; mediaMetadata?: any; }} libraryItemData
* @param {import("../../../server/models/LibraryFolder")} folder
* @param {import("../../../server/models/Library")} library
* @param {boolean} isSingleMediaItem
* @param {LibraryFile[]} libraryFiles
* @return {import('../../../server/scanner/LibraryItemScanData') | null}
* */
const buildLibraryItemScanData = libraryItemScanner.__get__('buildLibraryItemScanData')
const mockFileInfo = getMockFileInfo().get('/test/file.pdf')
const lf = new LibraryFile()
var fileMetadata = new FileMetadata()
fileMetadata.setData(mockFileInfo)
fileMetadata.filename = Path.basename(mockFileInfo?.path)
fileMetadata.path = mockFileInfo?.path
fileMetadata.relPath = mockFileInfo?.path
fileMetadata.ext = Path.extname(mockFileInfo?.path)
lf.ino = mockFileInfo?.ino
lf.deviceId = mockFileInfo?.dev
lf.metadata = fileMetadata
lf.addedAt = Date.now()
lf.updatedAt = Date.now()
lf.metadata = fileMetadata
const libraryItemData = {
path: mockFileInfo?.path, // full path
relPath: mockFileInfo?.path, // only filename
mediaMetadata: {
title: Path.basename(mockFileInfo?.path, Path.extname(mockFileInfo?.path))
}
}
const scanData = await buildLibraryItemScanData(libraryItemData, buildLibraryFolder(), testLibrary, true, [lf.toJSON()])
expect(scanData).to.not.be.null
expect(scanData.deviceId).to.equal(mockFileInfo?.dev)
})
})
/** @return {import("../../../server/models/LibraryFolder")} folder */
function buildLibraryFolder() {
return new LibraryFolder()
}

View File

@ -3,49 +3,18 @@ const expect = chai.expect
const sinon = require('sinon')
const rewire = require('rewire')
const fileUtils = require('../../../server/utils/fileUtils')
const Database = require('../../../server/Database')
const { Sequelize } = require('sequelize')
const LibraryFile = require('../../../server/objects/files/LibraryFile')
const LibraryItem = require('../../../server/models/LibraryItem')
const FileMetadata = require('../../../server/objects/metadata/FileMetadata')
const Path = require('path')
const Database = require('../../../server/Database')
const { stubFileUtils, loadTestDatabase, getMockFileInfo, getRenamedMockFileInfo, buildBookLibraryItemParams } = require('../MockDatabase')
describe('LibraryScanner', () => {
let getInoStub, getDeviceIdStub, getFileTimestampsWithInoStub, LibraryScanner, testLibrary
let LibraryScanner, testLibrary
beforeEach(async () => {
getInoStub = sinon.stub(fileUtils, 'getIno')
getInoStub.callsFake((path) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = getMockFileInfo().get(normalizedPath)
if (stats) {
return stats.ino
} else {
return null
}
})
getDeviceIdStub = sinon.stub(fileUtils, 'getDeviceId')
getDeviceIdStub.callsFake(async (path) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = getMockFileInfo().get(normalizedPath)
if (stats) {
return stats.dev
} else {
return null
}
})
getFileTimestampsWithInoStub = sinon.stub(fileUtils, 'getFileTimestampsWithIno')
getFileTimestampsWithInoStub.callsFake(async (path) => {
const normalizedPath = fileUtils.filePathToPOSIX(path).replace(/\/$/, '')
const stats = getMockFileInfo().get(normalizedPath)
if (stats) {
return stats
} else {
return null
}
})
stubFileUtils()
LibraryScanner = rewire('../../../server/scanner/LibraryScanner')
})
@ -245,79 +214,3 @@ describe('LibraryScanner', () => {
let ItemToItemInoMatch = LibraryScanner.__get__('ItemToItemInoMatch')
})
})
async function loadTestDatabase(mockFileInfo) {
let libraryItem1Id, libraryItem2Id
let fileInfo = mockFileInfo || getMockFileInfo()
let bookLibraryFiles = fileInfo.keys().reduce((acc, key) => {
let bookfile = new LibraryFile()
bookfile.setDataFromPath(key, key)
acc.push(bookfile)
return acc
}, [])
global.ServerSettings = {}
Database.sequelize = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
// Choose one of the logging options
logging: (...msg) => console.log(msg),
logQueryParameters: true
})
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
await Database.buildModels()
const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
const newLibraryFolder2 = await Database.libraryFolderModel.create({ path: '/mnt/drive', libraryId: newLibrary.id })
const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[0], newBook.id, newLibrary.id, newLibraryFolder.id))
libraryItem1Id = newLibraryItem.id
const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const newLibraryItem2 = await Database.libraryItemModel.create(buildBookLibraryItemParams(bookLibraryFiles[1], newBook2.id, newLibrary.id, newLibraryFolder2.id))
libraryItem2Id = newLibraryItem2.id
return newLibrary
}
/**
* @param {LibraryFile} libraryFile
* @param {any} bookId
* @param {string} libraryId
* @param {any} libraryFolderId
*/
function buildBookLibraryItemParams(libraryFile, bookId, libraryId, libraryFolderId) {
return {
path: libraryFile.metadata.path,
isFile: true,
ino: libraryFile.ino,
deviceId: libraryFile.deviceId,
libraryFiles: [libraryFile.toJSON()],
mediaId: bookId,
mediaType: 'book',
libraryId: libraryId,
libraryFolderId: libraryFolderId
}
}
/** @returns {Map<string, import('fs').Stats>} */
function getMockFileInfo() {
// @ts-ignore
return new Map([
['/test/file.pdf', { path: '/test/file.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }]
])
}
/** @returns {Map<string, import('fs').Stats>} */
// this has the same data as above except one file has been renamed
function getRenamedMockFileInfo() {
// @ts-ignore
return new Map([
['/test/file-renamed.pdf', { path: '/test/file-renamed.pdf', isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '1', dev: '100' }],
['/mnt/drive/file-same-ino-different-dev.pdf', { path: '/mnt/drive/file-same-ino-different-dev.pdf', isDirectory: () => false, size: 42, mtimeMs: Date.now(), ino: '1', dev: '200' }]
])
}