diff --git a/package-lock.json b/package-lock.json index 832bfd8..c8e4cd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "dotenv": "^16.4.5", "ffmetadata": "^1.7.0", "fpcalc": "^1.3.0", - "mime-types": "^2.1.35" + "mime-types": "^2.1.35", + "nock": "^13.5.4" }, "bin": { "music-meta-finder": "cli.js" @@ -3776,6 +3777,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -4091,6 +4098,20 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/nock": { + "version": "13.5.4", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.4.tgz", + "integrity": "sha512-yAyTfdeNJGGBFxWdzSKCBYxs5FxLbCg5X5Q4ets974hcQzG1+qCxvIyOo4j2Ry6MUlhWVMX4OoYDefAIIwupjw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4419,6 +4440,15 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/package.json b/package.json index 05ba7bf..c82f6cf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dotenv": "^16.4.5", "ffmetadata": "^1.7.0", "fpcalc": "^1.3.0", - "mime-types": "^2.1.35" + "mime-types": "^2.1.35", + "nock": "^13.5.4" }, "devDependencies": { "@eslint/eslintrc": "^3.0.2", diff --git a/test/utils/deleteDirectoryRecursively.test.js b/test/utils/deleteDirectoryRecursively.test.js new file mode 100644 index 0000000..4dd991d --- /dev/null +++ b/test/utils/deleteDirectoryRecursively.test.js @@ -0,0 +1,78 @@ +import { expect } from 'chai' +import sinon from 'sinon'; +import fs from 'fs/promises'; +import path from "path"; +import deleteDirectoryRecursively from "../../src/utils/deleteDirectoryRecursively.js"; + +describe('DeleteDirectoryRecursively', () => { + let readdirStub, unlinkStub, rmdirStub; + + beforeEach(() => { + readdirStub = sinon.stub(fs, 'readdir'); + unlinkStub = sinon.stub(fs, 'unlink'); + rmdirStub = sinon.stub(fs, 'rmdir'); + }); + + afterEach(() => { + readdirStub.restore(); + unlinkStub.restore(); + rmdirStub.restore(); + }); + + it('should delete an empty directory', async () => { + const dirPath = 'testDir'; + + readdirStub.withArgs(dirPath).resolves([]); + rmdirStub.withArgs(dirPath).resolves(); + + await deleteDirectoryRecursively(dirPath); + + expect(readdirStub.calledOnce).to.be.true; + expect(rmdirStub.calledOnceWith(dirPath)).to.be.true; + }); + + it('should delete a directory with files', async () => { + const dirPath = 'dir'; + const filePath = path.join(dirPath, 'file.txt'); + + readdirStub.withArgs(dirPath).resolves([{ name: 'file.txt', isDirectory: () => false }]); + unlinkStub.withArgs(filePath).resolves(); + rmdirStub.withArgs(dirPath).resolves(); + + await deleteDirectoryRecursively(dirPath); + + expect(readdirStub.calledOnce).to.be.true; + expect(unlinkStub.calledOnceWith(filePath)).to.be.true; + expect(rmdirStub.calledOnceWith(dirPath)).to.be.true; + }); + + it('should delete a nested directory with files', async () => { + const dirPath = 'dir'; + const nestedDirPath = path.join(dirPath, 'nestedDir'); + const filePath = path.join(dirPath, 'file.txt'); + const nestedFilePath = path.join(nestedDirPath, 'nestedFile.txt'); + + readdirStub.withArgs(dirPath).resolves([ + { name: 'file.txt', isDirectory: () => false }, + { name: 'nestedDir', isDirectory: () => true }, + ]); + + readdirStub.withArgs(nestedDirPath).resolves([ + { name: 'nestedFile.txt', isDirectory: () => false }, + ]); + + unlinkStub.withArgs(filePath).resolves(); + unlinkStub.withArgs(nestedFilePath).resolves(); + rmdirStub.withArgs(dirPath).resolves(); + rmdirStub.withArgs(nestedDirPath).resolves(); + + await deleteDirectoryRecursively(dirPath); + + expect(readdirStub.calledTwice).to.be.true; + expect(unlinkStub.calledTwice).to.be.true; + expect(unlinkStub.calledWith(filePath)).to.be.true; + expect(unlinkStub.calledWith(nestedFilePath)).to.be.true; + expect(rmdirStub.calledWith(nestedDirPath)).to.be.true; + expect(rmdirStub.calledWith(dirPath)).to.be.true; + }); +}) diff --git a/test/utils/downloadImage.test.js b/test/utils/downloadImage.test.js new file mode 100644 index 0000000..e69de29 diff --git a/test/utils/ensureDirectoryExists.test.js b/test/utils/ensureDirectoryExists.test.js new file mode 100644 index 0000000..38fdb01 --- /dev/null +++ b/test/utils/ensureDirectoryExists.test.js @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import fs from 'fs'; + +describe('ensureDirectoryExists', () => { + let ensureDirectoryExists; + let fsMock; + let sandbox; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + fsMock = { + existsSync: sandbox.stub(), + mkdirSync: sandbox.stub(), + }; + + // Dynamically import ensureDirectoryExists and replace fs with a mock + ensureDirectoryExists = await esmock( + '../../src/utils/ensureDirectoryExists.js', + { + 'fs': fsMock, + } + ); + }); + + afterEach(() => { + sandbox.restore(); + esmock.purge(ensureDirectoryExists); + }); + + it('should not create a directory if it already exists', () => { + const dirPath = 'path/to/existing/dir'; + + fsMock.existsSync.returns(true); + + ensureDirectoryExists(dirPath); + + expect(fsMock.existsSync.calledOnceWith(dirPath)).to.be.true; + expect(fsMock.mkdirSync.notCalled).to.be.true; + }); + + it('should create a directory if it does not exist', () => { + const dirPath = 'path/to/new/dir'; + + fsMock.existsSync.returns(false); + + ensureDirectoryExists(dirPath); + + expect(fsMock.existsSync.calledOnceWith(dirPath)).to.be.true; + expect(fsMock.mkdirSync.calledOnceWith(dirPath, { recursive: true })).to.be.true; + }); +}); + diff --git a/test/utils/fetchFiles.test.js b/test/utils/fetchFiles.test.js new file mode 100644 index 0000000..4126957 --- /dev/null +++ b/test/utils/fetchFiles.test.js @@ -0,0 +1,89 @@ +import path from 'path'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +function normalizeToForwardSlash(p) { + return p.split(path.sep).join('/'); +} + +describe('fetchFiles', () => { + let fetchFiles, fsStub; + + beforeEach(async () => { + fsStub = { + promises: { + stat: sinon.stub(), + readdir: sinon.stub() + } + }; + + fetchFiles = await esmock('../../src/utils/fetchFiles.js', { + fs: fsStub + }); + }); + + afterEach(() => { + esmock.purge(fetchFiles); + sinon.restore(); + }); + + it('should return an array with a single file when given a file path', async () => { + const filePath = '/path/to/file.txt'; + + fsStub.promises.stat.resolves({ isFile: () => true, isDirectory: () => false }); + + const result = await fetchFiles(filePath); + expect(result).to.deep.equal([normalizeToForwardSlash(filePath)]); + }); + + it('should return an array of files when given a directory path', async () => { + const dirPath = '/path/to/directory'; + const subdirPath = path.join(dirPath, 'subdir'); // Use path.join for consistency + + fsStub.promises.stat.withArgs(dirPath).resolves({ isFile: () => false, isDirectory: () => true }); + fsStub.promises.stat.withArgs(subdirPath).resolves({ isFile: () => false, isDirectory: () => true }); + fsStub.promises.stat.resolves({ isFile: () => true, isDirectory: () => false }); // Default for files + + fsStub.promises.readdir.withArgs(dirPath, { withFileTypes: true }).resolves([ + { name: 'file1.txt', isFile: () => true, isDirectory: () => false }, + { name: 'file2.txt', isFile: () => true, isDirectory: () => false }, + { name: 'subdir', isFile: () => false, isDirectory: () => true } + ]); + fsStub.promises.readdir.withArgs(subdirPath, { withFileTypes: true }).resolves([ + { name: 'file3.txt', isFile: () => true, isDirectory: () => false } + ]); + + const result = await fetchFiles(dirPath); + console.log('Test Result:', result); // Add logging to debug the result + expect(result).to.deep.equal([ + normalizeToForwardSlash(`${dirPath}/file1.txt`), + normalizeToForwardSlash(`${dirPath}/file2.txt`), + normalizeToForwardSlash(`${dirPath}/subdir/file3.txt`) + ]); + }); + + it('should throw an error if the path does not exist', async () => { + const invalidPath = '/invalid/path'; + + fsStub.promises.stat.rejects(new Error('ENOENT: no such file or directory')); + + try { + await fetchFiles(invalidPath); + } catch (error) { + expect(error.message).to.equal('Failed to fetch files: ENOENT: no such file or directory'); + } + }); + + it('should throw an error if the path is neither a file nor a directory', async () => { + const weirdPath = '/weird/path'; + + fsStub.promises.stat.resolves({ isFile: () => false, isDirectory: () => false }); + + try { + await fetchFiles(weirdPath); + } catch (error) { + expect(error.message).to.equal('Failed to fetch files: Input path is neither a file nor a directory: /weird/path'); + } + }); +}); diff --git a/test/utils/renameAudioFileTitle.test.js b/test/utils/renameAudioFileTitle.test.js new file mode 100644 index 0000000..dd10ea9 --- /dev/null +++ b/test/utils/renameAudioFileTitle.test.js @@ -0,0 +1,92 @@ +import path from 'path'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +// Utility function to normalize paths to use forward slashes +function normalizeToForwardSlash(p) { + return p.split(path.sep).join('/'); +} + +describe('renameFile', () => { + let renameFile, fsStub, generateUniqueFilenameStub; + + beforeEach(async () => { + fsStub = { + rename: sinon.stub() + }; + + generateUniqueFilenameStub = sinon.stub(); + + renameFile = await esmock('../../src/utils/renameAudioFileTitle.js', { + 'fs/promises': fsStub, + '../../src/utils/generateUniqueFilename.js': generateUniqueFilenameStub + }); + }); + + afterEach(() => { + esmock.purge(renameFile); + sinon.restore(); + }); + + it('should rename a file successfully with valid artist, title, and filePath', async () => { + const artist = 'Artist'; + const title = 'Title'; + const filePath = normalizeToForwardSlash('/path/to/file.mp3'); + const newFilePath = normalizeToForwardSlash('/path/to/Artist - Title.mp3'); + + generateUniqueFilenameStub.resolves(newFilePath); + fsStub.rename.resolves(); + + const result = await renameFile(artist, title, filePath); + + console.log('Arguments passed to generateUniqueFilename:', generateUniqueFilenameStub.args); + console.log('Arguments expected for generateUniqueFilename:', [normalizeToForwardSlash('/path/to'), normalizeToForwardSlash('Artist - Title.mp3')]); + + expect(result).to.equal(normalizeToForwardSlash(newFilePath)); + expect(generateUniqueFilenameStub.calledWith(normalizeToForwardSlash('/path/to'), 'Artist - Title.mp3')).to.be.true; + expect(fsStub.rename.calledWith(normalizeToForwardSlash(filePath), normalizeToForwardSlash(newFilePath))).to.be.true; + }); + + it('should throw an error if filePath, artist, or title are not provided', async () => { + try { + await renameFile('', 'Title', normalizeToForwardSlash('/path/to/file.mp3')); + throw new Error('Test failed - expected error not thrown'); + } catch (error) { + expect(error.message).to.equal('File path, artist, and title must be provided when renaming file.'); + } + + try { + await renameFile('Artist', '', normalizeToForwardSlash('/path/to/file.mp3')); + throw new Error('Test failed - expected error not thrown'); + } catch (error) { + expect(error.message).to.equal('File path, artist, and title must be provided when renaming file.'); + } + + try { + await renameFile('Artist', 'Title', ''); + throw new Error('Test failed - expected error not thrown'); + } catch (error) { + expect(error.message).to.equal('File path, artist, and title must be provided when renaming file.'); + } + }); + + it('should throw an error if the file rename operation fails', async () => { + const artist = 'Artist'; + const title = 'Title'; + const filePath = normalizeToForwardSlash('/path/to/file.mp3'); + const newFilePath = normalizeToForwardSlash('/path/to/Artist - Title.mp3'); + const renameError = new Error('Rename failed'); + + generateUniqueFilenameStub.resolves(newFilePath); + fsStub.rename.rejects(renameError); + + try { + await renameFile(artist, title, filePath); + throw new Error('Test failed - expected error not thrown'); + } catch (error) { + expect(error).to.equal(renameError); + } + }); +}); + diff --git a/test/utils/retryAxios.test.js b/test/utils/retryAxios.test.js new file mode 100644 index 0000000..d313242 --- /dev/null +++ b/test/utils/retryAxios.test.js @@ -0,0 +1,75 @@ +import { expect } from 'chai'; +import nock from 'nock'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +describe('retryAxios', () => { + let axiosInstance, axios, axiosRetry; + + beforeEach(async () => { + axios = { + create: sinon.stub().returnsThis(), + interceptors: { + response: { + use: sinon.stub() + } + } + }; + + axiosRetry = sinon.stub(); + + axiosInstance = await esmock('../../src/utils/retryAxios.js', { + 'axios': axios, + 'axios-retry': axiosRetry + }); + }); + + it('should create an axios instance and configure retry policy', async () => { + expect(axios.create.calledOnce).to.be.true; + expect(axiosRetry.calledOnce).to.be.true; + + const [instance, config] = axiosRetry.firstCall.args; + expect(instance).to.equal(axiosInstance); + expect(config.retries).to.equal(3); + expect(config.retryDelay(1)).to.equal(2000); + expect(config.retryDelay(2)).to.equal(4000); + expect(config.retryCondition({ response: { status: 429 }})).to.be.true; + expect(config.retryCondition({ response: { status: 500 }})).to.be.true; + expect(config.retryCondition({ response: { status: 400 }})).to.be.false; + }); + + it('should retry 3 times before failing for 500 status code', async () => { + nock('https://api.example.com') + .get('/resource') + .reply(500) + .persist(); + + let error; + try { + await axiosInstance.get('https://api.example.com/resource'); + } catch (err) { + error = err; + } + expect(error).to.exist; + + nock.cleanAll(); + }); + + it('should not retry for 400 status code', async () => { + nock('https://api.example.com') + .get('/resource') + .reply(400); + + let error; + try { + await axiosInstance.get('https://api.example.com/resource'); + } catch (err) { + error = err; + } + + expect(error).to.exist; + + nock.cleanAll(); + }); +}); + diff --git a/test/utils/validateAudioFiles.test.js b/test/utils/validateAudioFiles.test.js new file mode 100644 index 0000000..b6a81d7 --- /dev/null +++ b/test/utils/validateAudioFiles.test.js @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +describe('validateAudioFile', () => { + let validateAudioFile, fsStub, mimeStub; + + beforeEach(async () => { + fsStub = { + lstat: sinon.stub() + }; + + mimeStub = { + lookup: sinon.stub() + }; + + validateAudioFile = await esmock('../../src/utils/validateAudioFiles.js', { + 'fs/promises': fsStub, + 'mime-types': mimeStub + }); + }); + + afterEach(() => { + esmock.purge(validateAudioFile); + sinon.restore(); + }); + + it('should return null and log an error if the path is not a file', async () => { + const filePath = '/path/to/directory'; + const consoleErrorStub = sinon.stub(console, 'error'); + + fsStub.lstat.resolves({ isFile: () => false }); + + const result = await validateAudioFile(filePath); + expect(result).to.be.null; + expect(consoleErrorStub.calledWith(`The path ${filePath} is not a file and is ignored.`)).to.be.true; + + consoleErrorStub.restore(); + }); + + it('should return null and log an error if the file is not a recognized audio file type', async () => { + const filePath = '/path/to/file.txt'; + const consoleErrorStub = sinon.stub(console, 'error'); + + fsStub.lstat.resolves({ isFile: () => true }); + mimeStub.lookup.returns('text/plain'); + + const result = await validateAudioFile(filePath); + expect(result).to.be.null; + expect(consoleErrorStub.calledWith(`File ${filePath} is not an audio file and is ignored.`)).to.be.true; + + consoleErrorStub.restore(); + }); + + it('should return the file path if the file is a recognized audio file type', async () => { + const filePath = '/path/to/file.mp3'; + + fsStub.lstat.resolves({ isFile: () => true }); + mimeStub.lookup.returns('audio/mpeg'); + + const result = await validateAudioFile(filePath); + expect(result).to.equal(filePath); + }); + + it('should return null and log an error if an exception occurs during validation', async () => { + const filePath = '/path/to/file.mp3'; + const error = new Error('Test Error'); + const consoleErrorStub = sinon.stub(console, 'error'); + + fsStub.lstat.rejects(error); + + const result = await validateAudioFile(filePath); + expect(result).to.be.null; + expect(consoleErrorStub.calledWith(`Error validating file ${filePath}: ${error}`)).to.be.true; + + consoleErrorStub.restore(); + }); +});