Skip to content

Commit

Permalink
✨ chore(core): Add downloadFile function (#897)
Browse files Browse the repository at this point in the history
  • Loading branch information
duckception authored Oct 2, 2023
1 parent 9a338dc commit 92019e9
Show file tree
Hide file tree
Showing 6 changed files with 1,012 additions and 30 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ node_modules
# Compiled output
dist
types

# Vitest
coverage
9 changes: 8 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"ignore": ["**/node_modules", "**/dist", "**/types", ".vscode", ".idea"]
"ignore": [
"**/node_modules",
".vscode",
".idea",
"**/dist",
"**/types",
"**/coverage"
]
},
"formatter": {
"indentStyle": "space",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,20 @@
"lint:check": "biome check . --verbose",
"lint:unsafe": "biome check . --apply-unsafe",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch",
"types:check": "tsc --noEmit"
},
"dependencies": {
"fs-extra": "^11.1.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.2",
"@types/node": "^20.8.0",
"@vitest/coverage-v8": "1.0.0-beta.0",
"axios": "^1.4.0",
"memfs": "^4.5.0",
"msw": "^1.3.2",
"rimraf": "^5.0.1",
"tsconfig": "workspace:*",
"tsup": "^7.2.0",
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/downloadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import path from 'node:path'
import axios from 'axios'
import fs from 'fs-extra'

type DownloaderOptions = {
url: string
outputDir: string
fileName: string
overrideFile?: boolean
}

type DownloadFileResult = {
filePath: string
downloadSkipped: boolean
}

export async function downloadFile(options: DownloaderOptions) {
const { url, outputDir, fileName, overrideFile } = options

const returnPromise = new Promise<DownloadFileResult>((resolve, reject) => {
const filePath = path.join(outputDir, fileName)

const fileExists = fs.existsSync(filePath)
if (fileExists && !overrideFile) {
resolve({
filePath,
downloadSkipped: true
})
}

axios
.get(url, {
responseType: 'stream'
})
.then((response) => {
const writer = fs.createWriteStream(filePath)

response.data.pipe(writer)

writer.on('finish', () => {
resolve({
filePath,
downloadSkipped: false
})
})
writer.on('error', (error) => {
// TODO: Handle errors in a more sophisticated way
reject(new Error(`[Writer] ${error.message}`))
})
})
.catch((error) => {
// TODO: Handle errors in a more sophisticated way
reject(new Error(`[Axios] ${error.message}`))
})
})

// TODO: This is a workaround to handle errors from both `writer` and `axios` and get 100% test coverage.
return returnPromise.catch((error: Error) => {
throw new Error(
`[DownloadFile] Error downloading the file - ${error.message}`
)
})
}
149 changes: 149 additions & 0 deletions packages/core/test/downloadFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import axios from 'axios'
import { fs, vol } from 'memfs'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { downloadFile } from '../src/downloadFile'

const MOCK_OUTPUT_DIR = '/tmp'
const MOCK_FILE_NAME = 'duck.txt'
const MOCK_FILE_CONTENT = 'Quack! 🐾'
const MOCK_FILE_PATH = `${MOCK_OUTPUT_DIR}/${MOCK_FILE_NAME}`
const MOCK_URL = `https://example.com/${MOCK_FILE_NAME}`

const server = setupServer(
rest.get(MOCK_URL, (_, res, ctx) => {
return res(ctx.text(MOCK_FILE_CONTENT))
})
)

vi.mock('fs-extra', async () => {
return {
default: fs
}
})

describe('downloadFile', () => {
beforeAll(() => {
server.listen()
})

afterAll(() => {
server.close()
vi.resetAllMocks()
})

beforeEach(() => {
vol.mkdirSync(MOCK_OUTPUT_DIR)
})

afterEach(() => {
server.resetHandlers()
vol.reset() // Clear the in-memory file system after each test
vi.clearAllMocks()
})

it('calls axios.get with the correct arguments', async () => {
const axiosSpy = vi.spyOn(axios, 'get')

await downloadFile({
url: MOCK_URL,
outputDir: MOCK_OUTPUT_DIR,
fileName: MOCK_FILE_NAME
})

expect(axiosSpy).toHaveBeenCalledOnce()
expect(axiosSpy).toHaveBeenCalledWith(MOCK_URL, {
responseType: 'stream'
})
})

describe('when the file does not exist', () => {
it('downloads a file', async () => {
const result = await downloadFile({
url: MOCK_URL,
outputDir: MOCK_OUTPUT_DIR,
fileName: MOCK_FILE_NAME
})

expect(result).to.deep.equal({
filePath: MOCK_FILE_PATH,
downloadSkipped: false
})
expect(fs.existsSync(result.filePath)).toBe(true)
expect(fs.readFileSync(result.filePath, 'utf8')).toBe(MOCK_FILE_CONTENT)
})

it('throws an error if the file cannot be downloaded', async () => {
server.use(
rest.get(MOCK_URL, (_, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal server error' })
)
})
)

const downloadFileFuncCall = downloadFile({
url: MOCK_URL,
outputDir: MOCK_OUTPUT_DIR,
fileName: MOCK_FILE_NAME
})

await expect(() => downloadFileFuncCall).rejects.toThrowError(
'[DownloadFile] Error downloading the file - [Axios] Request failed with status code 500'
)
})
})

describe('when the file already exists', () => {
const existingMockFileContent = 'This is an existing duck file! 🦆'

beforeEach(() => {
fs.writeFileSync(MOCK_FILE_PATH, existingMockFileContent)
expect(fs.existsSync(MOCK_FILE_PATH)).toBe(true)
})

it('skips download if the file exists', async () => {
const result = await downloadFile({
url: MOCK_URL,
outputDir: MOCK_OUTPUT_DIR,
fileName: MOCK_FILE_NAME
})

expect(result).to.deep.equal({
filePath: MOCK_FILE_PATH,
downloadSkipped: true
})
expect(fs.existsSync(result.filePath)).toBe(true)
expect(fs.readFileSync(result.filePath, 'utf8')).toBe(
existingMockFileContent
)
})

it('overwrites the existing file if the `overrideFile` flag is present', async () => {
const result = await downloadFile({
url: MOCK_URL,
outputDir: MOCK_OUTPUT_DIR,
fileName: MOCK_FILE_NAME,
overrideFile: true
})

expect(result).to.deep.equal({
filePath: MOCK_FILE_PATH,
downloadSkipped: false
})
expect(fs.existsSync(result.filePath)).toBe(true)
expect(fs.readFileSync(result.filePath, 'utf8')).toBe(MOCK_FILE_CONTENT)
})
})
})
Loading

0 comments on commit 92019e9

Please sign in to comment.