Created
August 27, 2017 02:14
-
-
Save grahamlyus/e6b0facf6a718a31449dec3ee77c8060 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import firebase from "firebase"; | |
import uuidv1 from "uuid/v1" | |
// Wrapper to enable mocking of firebase functions | |
export const _private = { | |
storageRefForPath: (path) => firebase.storage().ref().child(path) | |
} | |
/** | |
* Uploads the a file for a resource field. | |
* | |
* A filename is generated from a uuid and the file type, | |
* and it is uploaded to the path `resource/field/filename`. | |
* | |
* @param {Object} options | |
* @param {string} options.resource Resource name, e.g. `posts` | |
* @param {string} options.field Field name, e.g. `image` | |
* @param {File} options.file File object to upload | |
* @returns {Promise} e.g. { field, path, url } | |
*/ | |
export const uploadFile = async ({resource, field, file}) => { | |
const extension = file.type.split('/')[1] | |
const filename = `${uuidv1()}.${extension}` | |
const path = `${resource}/${field}/${filename}` | |
console.log(`Upload starting for ${path}...`) | |
const ref = _private.storageRefForPath(path) | |
const snapshot = await ref.put(file) | |
console.log(`Upload finished for ${path}`) | |
const url = snapshot.downloadURL | |
return { field, path, url } | |
} | |
/** | |
* Uploads a list of files and maps field name to { path, url } | |
* | |
* @param {Object[]} options | |
* @param {string} options.resource Resource name, e.g. `posts` | |
* @param {string} options.field Field name, e.g. `image` | |
* @param {File} options.file File object to upload | |
* @returns {Promise} e.g. { image: { path, url }, audio: { path, url } } | |
*/ | |
export const uploadFiles = async (files) => { | |
const uploads = await Promise.all(files.map(uploadFile)) | |
return uploads.reduce((obj, { field, ...value }) => ({ ...obj, [field]: value }), {}) | |
} | |
/** | |
* Delete the file at path. | |
* | |
* @param {string} path Path to the file in the default bucket | |
* @returns {Promise} | |
*/ | |
export const deleteFile = async (path) => { | |
const ref = _private.storageRefForPath(path) | |
console.log(`Delete starting for ${path}...`) | |
const result = await ref.delete() | |
console.log(`Delete finished for ${path}`) | |
return result | |
} | |
/** | |
* Delete a list of files. | |
* | |
* @param {Object[]} files | |
* @param {string} files.data.path Path to the file in the default bucket | |
* @returns {Promise[]} | |
*/ | |
export const deleteFiles = async (files) => { | |
return await Promise.all(files.map(file => deleteFile(file.data.path))) | |
} | |
/** | |
* Find the first new file within a field i.e. with a `rawFile` property. | |
* | |
* @param {Object} data Field data may be an array of Files | |
* @returns {File} the first File object | |
*/ | |
const findFirstNewFile = (data) => data && data.length && data.find(o => o instanceof File) | |
/** | |
* Find the fields with new files. | |
* | |
* @param {string} resource Resource name, e.g. `posts` | |
* @param {string[]} fields File field names | |
* @param {Object} data Form field data | |
* @returns {Object[]} [{resource, field, file}] filtered files | |
*/ | |
export const findFilesToUpload = (resource, fields, data) => { | |
return fields | |
.filter(field => findFirstNewFile(data[field])) | |
.map(field => ({resource, field, file: findFirstNewFile(data[field])})) | |
} | |
/** | |
* Find the fields with existing files i.e. with a `path` property. | |
* | |
* @param {string[]} fields File field names | |
* @param {Object} data Field data | |
* @returns {Object[]} [{field, data: {path, url}}] filtered files | |
*/ | |
export const findExistingFiles = (fields, data) => { | |
return fields | |
.filter(field => data[field] && data[field].path) | |
.map(field => ({field, data: data[field]})) | |
} | |
/** | |
* Handles a `CREATE` operation by uploading new file fields and then | |
* delegating to the requestHandler as { path, url }. | |
* | |
* @param {Function} requestHandler original RestClient request handler | |
* @param {Object} fields file fields for each resource e.g. {posts: ['image']} | |
* @param {Object} type Operation type, i.e. `CREATE` | |
* @param {string} resource Resource name, e.g. `posts` | |
* @param {Object} params RestClient params | |
*/ | |
const handleCreate = async (requestHandler, fields, type, resource, params) => { | |
const filesToUpload = findFilesToUpload(resource, fields, params.data) | |
const uploadedFiles = await uploadFiles(filesToUpload) | |
return requestHandler(type, resource, { | |
...params, | |
data: { | |
...params.data, | |
...uploadedFiles | |
}, | |
}) | |
} | |
/** | |
* Handles an `UPDATE` operation by uploading new file fields and deleting | |
* replaced files and delegating to the requestHandler as { path, url }. | |
* | |
* @param {Function} requestHandler original RestClient request handler | |
* @param {Object} fields file fields for each resource e.g. {posts: ['image']} | |
* @param {Object} type Operation type, i.e. `UPDATE` | |
* @param {string} resource Resource name, e.g. `posts` | |
* @param {Object} params RestClient params | |
*/ | |
const handleUpdate = async (requestHandler, fields, type, resource, params) => { | |
const filesToUpload = findFilesToUpload(resource, fields, params.data) | |
const fieldsWithFilesToUpload = filesToUpload.map(file => file.field) | |
const existingFiles = findExistingFiles(fields, params.previousData) | |
const filesToDelete = existingFiles.filter(file => fieldsWithFilesToUpload.includes(file.field)) | |
const [uploadedFiles] = await Promise.all([ | |
uploadFiles(filesToUpload), | |
deleteFiles(filesToDelete) | |
]) | |
return requestHandler(type, resource, { | |
...params, | |
data: { | |
...params.data, | |
// ...deletedFiles // TODO remove old refs??? | |
...uploadedFiles | |
}, | |
}) | |
} | |
/** | |
* Handles a `DELETE` operation by deleting files before delegating to | |
* the requestHandler. | |
* | |
* @param {Function} requestHandler original RestClient request handler | |
* @param {Object} fields file fields for each resource e.g. {posts: ['image']} | |
* @param {Object} type Operation type, i.e. `DELETE` | |
* @param {string} resource Resource name, e.g. `posts` | |
* @param {Object} params RestClient params | |
*/ | |
const handleDelete = async (requestHandler, fields, type, resource, params) => { | |
const filesToDelete = findExistingFiles(fields, params.previousData) | |
await deleteFiles(filesToDelete) | |
return requestHandler(type, resource, params) | |
} | |
// Map of handlers. | |
const HANDLERS = { | |
CREATE: handleCreate, | |
UPDATE: handleUpdate, | |
DELETE: handleDelete | |
} | |
/** | |
* Wraps the rest client to handle file fields. | |
* File fields are uploaded to the default Firebase storage bucket - see `uploadFile` | |
* The value written to the database field is `{ path, url }`, | |
* where `url` is the `downloadURL` retrieved from the storage snapshot. | |
* Old files are removed on `UPDATE` and `DELETE`. | |
*/ | |
const restClientFileWrapper = (requestHandler, fileFields) => async (type, resource, params) => { | |
const fields = fileFields[resource] | |
const handler = HANDLERS[type] | |
if (fields && handler) { | |
return handler(requestHandler, fields, type, resource, params) | |
} | |
return requestHandler(type, resource, params) | |
} | |
export default restClientFileWrapper |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import uuidv1 from 'uuid/v1' | |
import * as module from './restClientFileWrapper' | |
const RESOURCE = 'post' | |
const CREATE = 'CREATE' | |
const UPDATE = 'UPDATE' | |
const DELETE = 'DELETE' | |
const GET_ONE = 'GET_ONE' | |
const FILE_FIELDS = ['audio', 'image'] | |
const DOWNLOAD_URL = 'https://www.example.com/uuid.mp3' | |
const PLAIN_DATA = { | |
title: 'adlkjasldj', | |
description: 'dkkhfksdhfk' | |
} | |
const NEW_AUDIO_FILE = new File(['foo'], 'foo.mp3', {type: 'audio/mp3'}) | |
const NEW_IMAGE_FILE = new File(['foo'], 'foo.png', {type: 'image/png'}) | |
const OLD_AUDIO_FILE = {path: `${RESOURCE}/audio/${uuidv1()}.mp3`, url: DOWNLOAD_URL} | |
const OLD_IMAGE_FILE = {path: `${RESOURCE}/image/${uuidv1()}.png`, url: DOWNLOAD_URL} | |
const MOCK_REF = { | |
delete: () => Promise.resolve(), | |
put: () => Promise.resolve({downloadURL: DOWNLOAD_URL}) | |
} | |
const UUID_REGEX = '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}' | |
const AUDIO_FILE_REGEX = `^${RESOURCE}/audio/${UUID_REGEX}\.mp3$` | |
const IMAGE_FILE_REGEX = `^${RESOURCE}/image/${UUID_REGEX}\.png$` | |
describe.only("restClientFileWrapper", () => { | |
let putSpy, deleteSpy | |
beforeEach(() => { | |
module._private.storageRefForPath = jest.fn() | |
module._private.storageRefForPath.mockReturnValue(MOCK_REF) | |
deleteSpy = jest.spyOn(MOCK_REF, 'delete') | |
putSpy = jest.spyOn(MOCK_REF, 'put') | |
}) | |
afterEach(() => { | |
module._private.storageRefForPath.mockReset() | |
deleteSpy.mockRestore() | |
putSpy.mockRestore() | |
}) | |
describe("uploadFile", () => { | |
it("is a function", () => { | |
expect(module.uploadFile).toBeInstanceOf(Function) | |
}) | |
it("builds a correct path", async () => { | |
const result = await module.uploadFile({ | |
resource: RESOURCE, | |
field: 'audio', | |
file: NEW_AUDIO_FILE | |
}) | |
expect(putSpy).toHaveBeenCalledTimes(1) | |
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(AUDIO_FILE_REGEX)) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(1) | |
expect(result).toEqual({ | |
field: 'audio', | |
path: expect.stringMatching(AUDIO_FILE_REGEX), | |
url: DOWNLOAD_URL | |
}) | |
}) | |
}) | |
describe("uploadFiles", () => { | |
it("is a function", () => { | |
expect(module.uploadFiles).toBeInstanceOf(Function) | |
}) | |
it("returns a map of files uploaded", async () => { | |
const result = await module.uploadFiles([{ | |
resource: RESOURCE, | |
field: 'audio', | |
file: NEW_AUDIO_FILE | |
}, { | |
resource: RESOURCE, | |
field: 'image', | |
file: NEW_IMAGE_FILE | |
}]) | |
expect(putSpy).toHaveBeenCalledTimes(2) | |
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(AUDIO_FILE_REGEX)) | |
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(IMAGE_FILE_REGEX)) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2) | |
expect(result).toEqual({ | |
audio: { | |
path: expect.stringMatching(AUDIO_FILE_REGEX), | |
url: DOWNLOAD_URL | |
}, | |
image: { | |
path: expect.stringMatching(IMAGE_FILE_REGEX), | |
url: DOWNLOAD_URL | |
} | |
}) | |
}) | |
}) | |
describe("deleteFile", () => { | |
it("is a function", () => { | |
expect(module.deleteFile).toBeInstanceOf(Function) | |
}) | |
it("deletes the file", async () => { | |
const result = await module.deleteFile(OLD_AUDIO_FILE.path) | |
expect(deleteSpy).toHaveBeenCalledTimes(1) | |
expect(module._private.storageRefForPath).toBeCalledWith(OLD_AUDIO_FILE.path) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(1) | |
expect(result).toBeUndefined() | |
}) | |
}) | |
describe("deleteFiles", () => { | |
it("is a function", () => { | |
expect(module.deleteFiles).toBeInstanceOf(Function) | |
}) | |
it("deletes files", async () => { | |
const result = await module.deleteFiles([ | |
{data: OLD_AUDIO_FILE}, | |
{data: OLD_IMAGE_FILE} | |
]) | |
expect(deleteSpy).toHaveBeenCalledTimes(2) | |
expect(module._private.storageRefForPath).toBeCalledWith(OLD_AUDIO_FILE.path) | |
expect(module._private.storageRefForPath).toBeCalledWith(OLD_IMAGE_FILE.path) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2) | |
expect(result).toEqual([undefined, undefined]) | |
}) | |
}) | |
describe("findFilesToUpload", () => { | |
it("is a function", () => { | |
expect(module.findFilesToUpload).toBeInstanceOf(Function) | |
}) | |
it("returns no files if no files are present", () => { | |
const result = module.findFilesToUpload(RESOURCE, FILE_FIELDS, PLAIN_DATA) | |
expect(result).toHaveLength(0) | |
}) | |
it("returns files if files are present", () => { | |
const result = module.findFilesToUpload(RESOURCE, FILE_FIELDS, { | |
...PLAIN_DATA, | |
audio: [NEW_AUDIO_FILE], | |
image: [NEW_IMAGE_FILE] | |
}) | |
expect(result).toHaveLength(2) | |
expect(result).toEqual([ | |
{resource: RESOURCE, field: 'audio', file: NEW_AUDIO_FILE}, | |
{resource: RESOURCE, field: 'image', file: NEW_IMAGE_FILE} | |
]) | |
}) | |
}) | |
describe("findExistingFiles", () => { | |
it("is a function", () => { | |
expect(module.findExistingFiles).toBeInstanceOf(Function) | |
}) | |
it("returns no files if no existing files are present", () => { | |
const result = module.findExistingFiles(FILE_FIELDS, { | |
title: 'adlkjasldj', | |
description: 'dkkhfksdhfk' | |
}) | |
expect(result).toHaveLength(0) | |
}) | |
it("returns files if existing files are present", () => { | |
const result = module.findExistingFiles(FILE_FIELDS, { | |
...PLAIN_DATA, | |
audio: OLD_AUDIO_FILE, | |
image: [NEW_IMAGE_FILE] | |
}) | |
expect(result).toHaveLength(1) | |
expect(result).toEqual([ | |
{field: 'audio', data: OLD_AUDIO_FILE} | |
]) | |
}) | |
}) | |
describe("restClientFileWrapper", () => { | |
const requestHandler = jest.fn() | |
const wrapper = module.default(requestHandler, {[RESOURCE]: FILE_FIELDS}) | |
afterEach(() => { | |
requestHandler.mockReset() | |
}) | |
it("is a function", () => { | |
expect(module.default).toBeInstanceOf(Function) | |
}) | |
describe("CREATE", () => { | |
it("uses the default handler if no files are present", async () => { | |
const params = { | |
basePath: '/posts', | |
data: PLAIN_DATA | |
} | |
await wrapper(CREATE, RESOURCE, params) | |
expect(requestHandler).toBeCalledWith(CREATE, RESOURCE, params) | |
}) | |
it("uploads and replaces the files with path/url pairs", async () => { | |
const params = { | |
basePath: '/posts', | |
data: { | |
...PLAIN_DATA, | |
image: [NEW_IMAGE_FILE] | |
} | |
} | |
await wrapper(CREATE, RESOURCE, params) | |
expect(deleteSpy).not.toHaveBeenCalled() | |
expect(putSpy).toHaveBeenCalledTimes(1) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(1) | |
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(IMAGE_FILE_REGEX)) | |
expect(requestHandler).toBeCalledWith(CREATE, RESOURCE, { | |
basePath: '/posts', | |
data: { | |
...PLAIN_DATA, | |
image: { | |
path: expect.stringMatching(IMAGE_FILE_REGEX), | |
url: DOWNLOAD_URL | |
} | |
} | |
}) | |
}) | |
}) | |
describe("UPDATE", () => { | |
it("uses the default handler if no files are present", async () => { | |
const params = { | |
basePath: '/posts', | |
data: PLAIN_DATA, | |
previousData: { | |
...PLAIN_DATA, | |
title: 'old title' | |
} | |
} | |
await wrapper(UPDATE, RESOURCE, params) | |
expect(requestHandler).toBeCalledWith(UPDATE, RESOURCE, params) | |
}) | |
it("replaces any updated files", async () => { | |
const params = { | |
basePath: '/posts', | |
data: { | |
...PLAIN_DATA, | |
audio: OLD_AUDIO_FILE, | |
image: [NEW_IMAGE_FILE] | |
}, | |
previousData: { | |
...PLAIN_DATA, | |
audio: OLD_AUDIO_FILE, | |
image: OLD_IMAGE_FILE | |
} | |
} | |
await wrapper(UPDATE, RESOURCE, params) | |
expect(deleteSpy).toHaveBeenCalledTimes(1) | |
expect(putSpy).toHaveBeenCalledTimes(1) | |
expect(module._private.storageRefForPath).toBeCalledWith(OLD_IMAGE_FILE.path) | |
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(IMAGE_FILE_REGEX)) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2) | |
expect(requestHandler).toBeCalledWith(UPDATE, RESOURCE, { | |
...params, | |
data: { | |
...params.data, | |
image: { | |
path: expect.stringMatching(IMAGE_FILE_REGEX), | |
url: DOWNLOAD_URL | |
} | |
} | |
}) | |
}) | |
}) | |
describe("DELETE", () => { | |
it("uses the default handler if no files are present", async () => { | |
const params = { | |
basePath: '/posts', | |
previousData: PLAIN_DATA | |
} | |
await wrapper(DELETE, RESOURCE, params) | |
expect(requestHandler).toBeCalledWith(DELETE, RESOURCE, params) | |
}) | |
it("deletes any present files", async () => { | |
const params = { | |
basePath: '/posts', | |
previousData: { | |
...PLAIN_DATA, | |
audio: OLD_AUDIO_FILE, | |
image: OLD_IMAGE_FILE | |
} | |
} | |
await wrapper(DELETE, RESOURCE, params) | |
expect(deleteSpy).toHaveBeenCalledTimes(2) | |
expect(putSpy).not.toHaveBeenCalled() | |
expect(module._private.storageRefForPath).toBeCalledWith(OLD_IMAGE_FILE.path) | |
expect(module._private.storageRefForPath).toBeCalledWith(OLD_AUDIO_FILE.path) | |
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2) | |
expect(requestHandler).toBeCalledWith(DELETE, RESOURCE, params) | |
}) | |
}) | |
describe("GET_ONE", () => { | |
it("uses the default handler", async () => { | |
const params = { | |
id: '123' | |
} | |
await wrapper(GET_ONE, RESOURCE, params) | |
expect(requestHandler).toBeCalledWith(GET_ONE, RESOURCE, params) | |
}) | |
}) | |
}) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment