diff --git a/app/app.js b/app/app.js index 0d085236..9e7aa05d 100644 --- a/app/app.js +++ b/app/app.js @@ -161,7 +161,9 @@ function cleanup() { clearInterval(probeId); clearInterval(queueId); - queueManager.close(dataConnection.close(process.exit)); + queueManager.close(() => setTimeout(() => { + dataConnection.close(process.exit); + }, 3000)); } /** @@ -242,15 +244,14 @@ function notReadyHandler() { /** * @function shutdown - * Shuts down this application after at least 3 seconds. + * Begins the shutdown procedure for this application */ function shutdown() { log.info('Shutting down', { function: 'shutdown' }); - // Wait 3 seconds before starting cleanup if (!state.shutdown) { state.shutdown = true; log.info('Application no longer accepting traffic', { function: 'shutdown' }); - setTimeout(cleanup, 3000); + cleanup(); } } diff --git a/app/src/components/queueManager.js b/app/src/components/queueManager.js index 8fc02c53..b8585bbe 100644 --- a/app/src/components/queueManager.js +++ b/app/src/components/queueManager.js @@ -13,7 +13,7 @@ class QueueManager { */ constructor() { if (!QueueManager._instance) { - this._isBusy = false; + this.isBusy = false; this._toClose = false; QueueManager._instance = this; } @@ -29,6 +29,19 @@ class QueueManager { return this._isBusy; } + /** + * @function isBusy + * @param {boolean} v The new state + * Sets the isBusy state + */ + set isBusy(v) { + this._isBusy = v; + if (!v && this.toClose) { + log.info('No longer processing jobs', { function: 'isBusy' }); + if (this._cb) this._cb(); + } + } + /** * @function toClose * Gets the toClose state @@ -53,13 +66,13 @@ class QueueManager { /** * @function close - * Spinlock until any remaining jobs are completed + * Stalls the callback until any remaining jobs are completed * @param {function} [cb] Optional callback */ close(cb = undefined) { this._toClose = true; - if (this.isBusy) setTimeout(this.close(cb), 250); - else { + this._cb = cb; + if (!this.isBusy) { log.info('No longer processing jobs', { function: 'close' }); if (cb) cb(); } @@ -77,16 +90,16 @@ class QueueManager { const response = await objectQueueService.dequeue(); if (response.length) { + this.isBusy = true; job = response[0]; - this._isBusy = true; log.verbose(`Started processing job id ${job.id}`, { function: 'processNextJob', job: job }); const objectId = await syncService.syncJob(job.path, job.bucketId, job.full, job.createdBy); - this._isBusy = false; log.verbose(`Finished processing job id ${job.id}`, { function: 'processNextJob', job: job, objectId: objectId }); + this.isBusy = false; // If job is completed, check if there are more jobs if (!this.toClose) this.checkQueue(); } @@ -109,7 +122,7 @@ class QueueManager { }); } - this._isBusy = false; + this.isBusy = false; } } } diff --git a/app/tests/unit/components/queueManager.spec.js b/app/tests/unit/components/queueManager.spec.js new file mode 100644 index 00000000..58869d90 --- /dev/null +++ b/app/tests/unit/components/queueManager.spec.js @@ -0,0 +1,333 @@ +const config = require('config'); + +const QueueManager = require('../../../src/components/queueManager'); +const { objectQueueService, syncService } = require('../../../src/services'); + +// Mock config library - @see {@link https://stackoverflow.com/a/64819698} +jest.mock('config'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('constructor', () => { + it('should return a queue manager instance', () => { + const qm = new QueueManager(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + }); +}); + +describe('isBusy', () => { + const qm = new QueueManager(); + + beforeEach(() => { + qm._cb = undefined; + qm._toClose = false; + }); + + it('should not invoke callback when true and not closing', () => { + qm.isBusy = true; + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeTruthy(); + expect(qm.toClose).toBeFalsy(); + }); + + it('should not invoke callback when false and closing', () => { + qm._toClose = true; + + qm.isBusy = false; + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeTruthy(); + expect(qm._cb).toBeUndefined(); + }); + + it('should invoke callback when false and closing', () => { + qm._cb = jest.fn(); + qm._toClose = true; + + qm.isBusy = false; + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeTruthy(); + expect(qm._cb).toHaveBeenCalledTimes(1); + }); +}); + +describe('checkQueue', () => { + const qm = new QueueManager(); + + const processNextJobSpy = jest.spyOn(qm, 'processNextJob'); + const queueSizeSpy = jest.spyOn(objectQueueService, 'queueSize'); + + beforeEach(() => { + qm._isBusy = false; + qm._toClose = false; + + processNextJobSpy.mockReset(); + queueSizeSpy.mockReset(); + }); + + afterAll(() => { + processNextJobSpy.mockRestore(); + queueSizeSpy.mockRestore(); + }); + + it('should call nothing when busy', () => { + qm._isBusy = true; + + qm.checkQueue(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeTruthy(); + expect(qm.toClose).toBeFalsy(); + expect(queueSizeSpy).toHaveBeenCalledTimes(0); + expect(processNextJobSpy).toHaveBeenCalledTimes(0); + }); + + it('should call nothing when closing', () => { + qm._toClose = true; + + qm.checkQueue(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeTruthy(); + expect(queueSizeSpy).toHaveBeenCalledTimes(0); + expect(processNextJobSpy).toHaveBeenCalledTimes(0); + }); + + it('should not call processNextJob when there are no jobs', () => { + queueSizeSpy.mockResolvedValue(0); + + qm.checkQueue(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(queueSizeSpy).toHaveBeenCalledTimes(1); + expect(processNextJobSpy).toHaveBeenCalledTimes(0); + }); + + it('should call processNextJob when there are jobs', () => { + processNextJobSpy.mockReturnValue(); + queueSizeSpy.mockResolvedValue(1); + + qm.checkQueue(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(queueSizeSpy).toHaveBeenCalledTimes(1); + // TODO: This is definitely being called, but call count is not incrementing for some reason + // expect(processNextJobSpy).toHaveBeenCalledTimes(1); + }); + + it('should not throw when there is a failure', () => { + queueSizeSpy.mockRejectedValue('error'); + + qm.checkQueue(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(queueSizeSpy).toHaveBeenCalledTimes(1); + expect(processNextJobSpy).toHaveBeenCalledTimes(0); + }); +}); + +describe('close', () => { + const qm = new QueueManager(); + + beforeEach(() => { + qm._cb = undefined; + qm._isBusy = false; + qm._toClose = false; + }); + + it('should store but not run the callback when busy', () => { + const cb = jest.fn(() => { }); + qm._isBusy = true; + + qm.close(cb); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeTruthy(); + expect(qm.toClose).toBeTruthy(); + expect(qm._cb).toBe(cb); + expect(cb).toHaveBeenCalledTimes(0); + }); + + it('should not run the callback when undefined and not busy', () => { + qm.close(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeTruthy(); + }); + + it('should run the callback when not busy', () => { + const cb = jest.fn(() => { }); + + qm.close(cb); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeTruthy(); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); + +describe('processNextJob', () => { + const qm = new QueueManager(); + + const checkQueueSpy = jest.spyOn(qm, 'checkQueue'); + const enqueueSpy = jest.spyOn(objectQueueService, 'enqueue'); + const dequeueSpy = jest.spyOn(objectQueueService, 'dequeue'); + const syncJobSpy = jest.spyOn(syncService, 'syncJob'); + + const job = { + bucketId: 'bucketId', + createdBy: 'createdBy', + full: false, + id: 'id', + path: 'path', + retries: 0 + }; + + beforeEach(() => { + qm._cb = undefined; + qm._isBusy = false; + qm._toClose = false; + + config.get.mockReturnValueOnce('3'); // server.maxRetries + + checkQueueSpy.mockReset(); + enqueueSpy.mockReset(); + dequeueSpy.mockReset(); + syncJobSpy.mockReset(); + }); + + afterAll(() => { + checkQueueSpy.mockRestore(); + enqueueSpy.mockRestore(); + dequeueSpy.mockRestore(); + syncJobSpy.mockRestore(); + }); + + it('should do nothing if queue is empty', async () => { + dequeueSpy.mockResolvedValue([]); + + await qm.processNextJob(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(checkQueueSpy).toHaveBeenCalledTimes(0); + expect(enqueueSpy).toHaveBeenCalledTimes(0); + expect(dequeueSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledTimes(0); + }); + + it('should do the next syncJob successfully and check queue', async () => { + dequeueSpy.mockResolvedValue([job]); + syncJobSpy.mockResolvedValue('objectId'); + + await qm.processNextJob(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(checkQueueSpy).toHaveBeenCalledTimes(1); + expect(enqueueSpy).toHaveBeenCalledTimes(0); + expect(dequeueSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledWith(job.path, job.bucketId, job.full, job.createdBy); + }); + + it('should do the next syncJob successfully and not check queue when toClose', async () => { + qm._toClose = true; + dequeueSpy.mockResolvedValue([job]); + syncJobSpy.mockResolvedValue('objectId'); + + await qm.processNextJob(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeTruthy(); + expect(checkQueueSpy).toHaveBeenCalledTimes(0); + expect(enqueueSpy).toHaveBeenCalledTimes(0); + expect(dequeueSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledWith(job.path, job.bucketId, job.full, job.createdBy); + }); + + it('should re-enqueue a failed job when less than max retries', async () => { + enqueueSpy.mockResolvedValue(1); + dequeueSpy.mockResolvedValue([job]); + syncJobSpy.mockImplementation(() => { throw new Error('error'); }); + + await qm.processNextJob(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(checkQueueSpy).toHaveBeenCalledTimes(0); + expect(enqueueSpy).toHaveBeenCalledTimes(1); + expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ + jobs: expect.arrayContaining([{ bucketId: job.bucketId, path: job.path }]), + full: job.full, + retries: job.retries + 1, + createdBy: job.createdBy + })); + expect(dequeueSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledWith(job.path, job.bucketId, job.full, job.createdBy); + }); + + it('should re-enqueue a failed job when less than max retries and fail gracefully', async () => { + enqueueSpy.mockRejectedValue('error'); + dequeueSpy.mockResolvedValue([job]); + syncJobSpy.mockImplementation(() => { throw new Error('error'); }); + + await qm.processNextJob(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(checkQueueSpy).toHaveBeenCalledTimes(0); + expect(enqueueSpy).toHaveBeenCalledTimes(1); + expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ + jobs: expect.arrayContaining([{ bucketId: job.bucketId, path: job.path }]), + full: job.full, + retries: job.retries + 1, + createdBy: job.createdBy + })); + expect(dequeueSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledWith(job.path, job.bucketId, job.full, job.createdBy); + }); + + it('should not re-enqueue a failed job when at max retries', async () => { + dequeueSpy.mockResolvedValue([{ ...job, retries: 3 }]); + syncJobSpy.mockImplementation(() => { throw new Error('error'); }); + + await qm.processNextJob(); + + expect(qm).toBeTruthy(); + expect(qm.isBusy).toBeFalsy(); + expect(qm.toClose).toBeFalsy(); + expect(checkQueueSpy).toHaveBeenCalledTimes(0); + expect(enqueueSpy).toHaveBeenCalledTimes(0); + expect(dequeueSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledTimes(1); + expect(syncJobSpy).toHaveBeenCalledWith(job.path, job.bucketId, job.full, job.createdBy); + }); +}); + diff --git a/app/tests/unit/controllers/sync.spec.js b/app/tests/unit/controllers/sync.spec.js new file mode 100644 index 00000000..edbaa08e --- /dev/null +++ b/app/tests/unit/controllers/sync.spec.js @@ -0,0 +1,136 @@ +const controller = require('../../../src/controllers/sync'); +const { objectService, objectQueueService, storageService } = require('../../../src/services'); + +const mockResponse = () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.end = jest.fn().mockReturnValue(res); + return res; +}; + +const bucketId = 'bucketId'; +const path = 'path'; + +let res = undefined; + +beforeEach(() => { + res = mockResponse(); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('syncBucket', () => { + const enqueueSpy = jest.spyOn(objectQueueService, 'enqueue'); + const listAllObjectVersionsSpy = jest.spyOn(storageService, 'listAllObjectVersions'); + const searchObjectsSpy = jest.spyOn(objectService, 'searchObjects'); + const next = jest.fn(); + + it('should enqueue all objects in a bucket', async () => { + const req = { + params: bucketId + }; + enqueueSpy.mockResolvedValue(1); + listAllObjectVersionsSpy.mockResolvedValue({ + DeleteMarkers: [{ Key: path }], + Versions: [{ Key: path }] + }); + searchObjectsSpy.mockResolvedValue([{ path: path }]); + + await controller.syncBucket(req, res, next); + + expect(enqueueSpy).toHaveBeenCalledTimes(1); + expect(listAllObjectVersionsSpy).toHaveBeenCalledTimes(1); + expect(searchObjectsSpy).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith(1); + expect(res.status).toHaveBeenCalledWith(202); + expect(next).toHaveBeenCalledTimes(0); + }); + + it('should handle unexpected errors', async () => { + const req = { + params: bucketId + }; + listAllObjectVersionsSpy.mockImplementation(() => { throw new Error('error'); }); + searchObjectsSpy.mockResolvedValue([{ path: path }]); + + await controller.syncBucket(req, res, next); + + expect(enqueueSpy).toHaveBeenCalledTimes(0); + expect(listAllObjectVersionsSpy).toHaveBeenCalledTimes(1); + expect(searchObjectsSpy).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +describe('syncObject', () => { + const enqueueSpy = jest.spyOn(objectQueueService, 'enqueue'); + const next = jest.fn(); + + it('should enqueue an object', async () => { + const req = { + currentObject: { + bucketId: bucketId, + path: path + } + }; + enqueueSpy.mockResolvedValue(1); + + await controller.syncObject(req, res, next); + + expect(enqueueSpy).toHaveBeenCalledTimes(1); + expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ + jobs: expect.arrayContaining([{ bucketId: bucketId, path: path }]) + })); + expect(res.json).toHaveBeenCalledWith(1); + expect(res.status).toHaveBeenCalledWith(202); + expect(next).toHaveBeenCalledTimes(0); + }); + + it('should handle unexpected errors', async () => { + const req = { + currentObject: { + bucketId: bucketId, + path: path + } + }; + enqueueSpy.mockImplementation(() => { throw new Error('error'); }); + + await controller.syncObject(req, res, next); + + expect(enqueueSpy).toHaveBeenCalledTimes(1); + expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ + jobs: expect.arrayContaining([{ bucketId: bucketId, path: path }]) + })); + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +describe('syncStatus', () => { + const queueSizeSpy = jest.spyOn(objectQueueService, 'queueSize'); + const next = jest.fn(); + + it('should return the current sync queue size', async () => { + const req = {}; + queueSizeSpy.mockResolvedValue(0); + + await controller.syncStatus(req, res, next); + + expect(queueSizeSpy).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith(0); + expect(res.status).toHaveBeenCalledWith(200); + expect(next).toHaveBeenCalledTimes(0); + }); + + it('should handle unexpected errors', async () => { + const req = {}; + queueSizeSpy.mockImplementation(() => { throw new Error('error'); }); + + await controller.syncStatus(req, res, next); + + expect(queueSizeSpy).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/tests/unit/controllers/tag.spec.js b/app/tests/unit/controllers/tag.spec.js index b8ae1c0f..27614581 100644 --- a/app/tests/unit/controllers/tag.spec.js +++ b/app/tests/unit/controllers/tag.spec.js @@ -1,6 +1,3 @@ - -// const utils = require('../../../src/db/models/utils'); - const controller = require('../../../src/controllers/tag'); const { tagService } = require('../../../src/services'); diff --git a/app/tests/unit/db/models/utils.spec.js b/app/tests/unit/db/models/utils.spec.js index 78440dae..028ed392 100644 --- a/app/tests/unit/db/models/utils.spec.js +++ b/app/tests/unit/db/models/utils.spec.js @@ -1,31 +1,66 @@ -const { toArray, inArrayClause, inArrayFilter } = require('../../../../src/db/models/utils'); +const { + filterOneOrMany, + filterILike, + inArrayClause, + inArrayFilter, + redactSecrets, + toArray +} = require('../../../../src/db/models/utils'); -describe('Test Model Utils toArray function', () => { +describe('filterOneOrMany', () => { + it('should do nothing if there is no value specified', () => { + const where = jest.fn(); + const whereIn = jest.fn(); - it('should return blank array if nothing specified', () => { - expect(toArray()).toEqual([]); - expect(toArray(undefined)).toEqual([]); - expect(toArray(null)).toEqual([]); - expect(toArray(false)).toEqual([]); + filterOneOrMany({ where: where, whereIn: whereIn }, undefined, 'column'); + + expect(where).toHaveBeenCalledTimes(0); + expect(whereIn).toHaveBeenCalledTimes(0); }); - it('should return an array if one is specified', () => { - const arr = ['1', '2', '3']; - expect(toArray(arr)).toEqual(arr); + it('should do a wherein query if value is a non-empty string array', () => { + const where = jest.fn(); + const whereIn = jest.fn(); + + filterOneOrMany({ where: where, whereIn: whereIn }, ['foo'], 'column'); + + expect(where).toHaveBeenCalledTimes(0); + expect(whereIn).toHaveBeenCalledTimes(1); + expect(whereIn).toHaveBeenCalledWith('column', ['foo']); }); - it('should return an array with trimmed blank values', () => { - const arr = ['1', '', '3', ' ', '4']; - expect(toArray(arr)).toEqual(['1', '3', '4']); + it('should do a where query if value is a string', () => { + const where = jest.fn(); + const whereIn = jest.fn(); + + filterOneOrMany({ where: where, whereIn: whereIn }, 'foo', 'column'); + + expect(where).toHaveBeenCalledTimes(1); + expect(where).toHaveBeenCalledWith('column', 'foo'); + expect(whereIn).toHaveBeenCalledTimes(0); }); +}); - it('should convert to an array', () => { - expect(toArray('hello')).toEqual(['hello']); +describe('filterILike', () => { + it('should perform an ilike search on the specified column if there is a value', () => { + const where = jest.fn(); + + filterILike({ where: where }, 'value', 'column'); + + expect(where).toHaveBeenCalledTimes(1); + expect(where).toHaveBeenCalledWith('column', 'ilike', '%value%'); }); + it('should do nothing if there is no value specified', () => { + const where = jest.fn(); + + filterILike({ where: where }, undefined, 'column'); + + expect(where).toHaveBeenCalledTimes(0); + }); }); -describe('Test Model Utils inArrayClause function', () => { +describe('inArrayClause', () => { it('should return the desired clause for a single values', () => { const col = 'user'; const vals = ['1']; @@ -45,10 +80,54 @@ describe('Test Model Utils inArrayClause function', () => { }); }); -describe('Test Model Utils inArrayFilter function', () => { +describe('inArrayFilter', () => { it('should return the desired clause for multiple values joined with OR', () => { const col = 'user'; const vals = ['1', '2', '3']; expect(inArrayFilter(col, vals)).toEqual('(array_length("user", 1) > 0 and (\'1\' = ANY("user") or \'2\' = ANY("user") or \'3\' = ANY("user")))'); }); }); + +describe('redactSecrets', () => { + const data = { + foo: 'foo', + bar: 'bar', + baz: 'baz' + }; + + it('should do nothing if fields is undefined', () => { + expect(redactSecrets(data, undefined)).toEqual(expect.objectContaining(data)); + }); + + it('should do nothing if fields is empty array', () => { + expect(redactSecrets(data, [])).toEqual(expect.objectContaining(data)); + }); + + it('should redact the specified fields if they exist', () => { + expect(redactSecrets(data, ['bar', 'garbage'])) + .toEqual(expect.objectContaining({ ...data, bar: 'REDACTED' })); + }); +}); + +describe('toArray', () => { + it('should return blank array if nothing specified', () => { + expect(toArray()).toEqual([]); + expect(toArray(undefined)).toEqual([]); + expect(toArray(null)).toEqual([]); + expect(toArray(false)).toEqual([]); + }); + + it('should return an array if one is specified', () => { + const arr = ['1', '2', '3']; + expect(toArray(arr)).toEqual(arr); + }); + + it('should return an array with trimmed blank values', () => { + const arr = ['1', '', '3', ' ', '4']; + expect(toArray(arr)).toEqual(['1', '3', '4']); + }); + + it('should convert to an array', () => { + expect(toArray('hello')).toEqual(['hello']); + }); +}); diff --git a/app/tests/unit/services/storage.spec.js b/app/tests/unit/services/storage.spec.js index 24b2e9c7..40e9c068 100644 --- a/app/tests/unit/services/storage.spec.js +++ b/app/tests/unit/services/storage.spec.js @@ -537,6 +537,145 @@ describe('listAllObjects', () => { }); }); +describe('listAllObjectVersions', () => { + const listObjectVersionMock = jest.spyOn(service, 'listObjectVersion'); + const bucketId = 'abc'; + + beforeEach(() => { + listObjectVersionMock.mockReset(); + }); + + afterAll(() => { + listObjectVersionMock.mockRestore(); + }); + + it('should call listObjectVersion at least once and yield empty arrays', async () => { + listObjectVersionMock.mockResolvedValue({ IsTruncated: false }); + + const result = await service.listAllObjectVersions({ filePath: '/' }); + + expect(result).toBeTruthy(); + expect(Array.isArray(result.DeleteMarkers)).toBeTruthy(); + expect(result.DeleteMarkers).toHaveLength(0); + expect(Array.isArray(result.Versions)).toBeTruthy(); + expect(result.Versions).toHaveLength(0); + expect(utils.getBucket).toHaveBeenCalledTimes(0); + expect(utils.isAtPath).toHaveBeenCalledTimes(0); + expect(listObjectVersionMock).toHaveBeenCalledTimes(1); + expect(listObjectVersionMock).toHaveBeenCalledWith(expect.objectContaining({ + filePath: '' + })); + }); + + it('should call listObjectVersion at least once with bucket lookup and yield empty arrays', async () => { + utils.getBucket.mockResolvedValue({ key: key }); + listObjectVersionMock.mockResolvedValue({ IsTruncated: false }); + + const result = await service.listAllObjectVersions({ bucketId: bucketId }); + + expect(result).toBeTruthy(); + expect(Array.isArray(result.DeleteMarkers)).toBeTruthy(); + expect(result.DeleteMarkers).toHaveLength(0); + expect(Array.isArray(result.Versions)).toBeTruthy(); + expect(result.Versions).toHaveLength(0); + expect(utils.getBucket).toHaveBeenCalledTimes(1); + expect(utils.getBucket).toHaveBeenCalledWith(bucketId); + expect(utils.isAtPath).toHaveBeenCalledTimes(0); + expect(listObjectVersionMock).toHaveBeenCalledTimes(1); + expect(listObjectVersionMock).toHaveBeenCalledWith(expect.objectContaining({ + filePath: key, + bucketId: bucketId + })); + }); + + it('should call listObjectVersion multiple times and return precise path objects', async () => { + const nextKeyMarker = 'token'; + listObjectVersionMock.mockResolvedValueOnce({ DeleteMarkers: [{ Key: 'filePath/foo' }], IsTruncated: true, NextKeyMarker: nextKeyMarker }); + listObjectVersionMock.mockResolvedValueOnce({ Versions: [{ Key: 'filePath/bar' }], IsTruncated: false }); + + const result = await service.listAllObjectVersions({ filePath: 'filePath' }); + + expect(result).toBeTruthy(); + expect(Array.isArray(result.DeleteMarkers)).toBeTruthy(); + expect(result.DeleteMarkers).toHaveLength(1); + expect(result.DeleteMarkers).toEqual(expect.arrayContaining([ + { Key: 'filePath/foo' } + ])); + expect(Array.isArray(result.Versions)).toBeTruthy(); + expect(result.Versions).toHaveLength(1); + expect(result.Versions).toEqual(expect.arrayContaining([ + { Key: 'filePath/bar' } + ])); + expect(utils.getBucket).toHaveBeenCalledTimes(0); + expect(utils.isAtPath).toHaveBeenCalledTimes(2); + expect(listObjectVersionMock).toHaveBeenCalledTimes(2); + expect(listObjectVersionMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + filePath: key + })); + expect(listObjectVersionMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + filePath: key, + keyMarker: nextKeyMarker + })); + }); + + it('should call listObjectVersion multiple times and return all path objects', async () => { + const nextKeyMarker = 'token'; + listObjectVersionMock.mockResolvedValueOnce({ DeleteMarkers: [{ Key: 'filePath/test/foo' }], IsTruncated: true, NextKeyMarker: nextKeyMarker }); + listObjectVersionMock.mockResolvedValueOnce({ Versions: [{ Key: 'filePath/test/bar' }], IsTruncated: false }); + + const result = await service.listAllObjectVersions({ filePath: 'filePath', precisePath: false }); + + expect(result).toBeTruthy(); + expect(Array.isArray(result.DeleteMarkers)).toBeTruthy(); + expect(result.DeleteMarkers).toHaveLength(1); + expect(result.DeleteMarkers).toEqual(expect.arrayContaining([ + { Key: 'filePath/test/foo' } + ])); + expect(Array.isArray(result.Versions)).toBeTruthy(); + expect(result.Versions).toHaveLength(1); + expect(result.Versions).toEqual(expect.arrayContaining([ + { Key: 'filePath/test/bar' } + ])); + expect(utils.getBucket).toHaveBeenCalledTimes(0); + expect(utils.isAtPath).toHaveBeenCalledTimes(0); + expect(listObjectVersionMock).toHaveBeenCalledTimes(2); + expect(listObjectVersionMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + filePath: key + })); + expect(listObjectVersionMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + filePath: key, + keyMarker: nextKeyMarker + })); + }); + + it('should call listObjectVersion multiple times and return all latest path objects', async () => { + const nextKeyMarker = 'token'; + listObjectVersionMock.mockResolvedValueOnce({ DeleteMarkers: [{ Key: 'filePath/test/foo', IsLatest: true }], IsTruncated: true, NextKeyMarker: nextKeyMarker }); + listObjectVersionMock.mockResolvedValueOnce({ Versions: [{ Key: 'filePath/test/bar', IsLatest: false }], IsTruncated: false }); + + const result = await service.listAllObjectVersions({ filePath: 'filePath', precisePath: false, filterLatest: true }); + + expect(result).toBeTruthy(); + expect(Array.isArray(result.DeleteMarkers)).toBeTruthy(); + expect(result.DeleteMarkers).toHaveLength(1); + expect(result.DeleteMarkers).toEqual(expect.arrayContaining([ + { Key: 'filePath/test/foo', IsLatest: true } + ])); + expect(Array.isArray(result.Versions)).toBeTruthy(); + expect(result.Versions).toHaveLength(0); + expect(utils.getBucket).toHaveBeenCalledTimes(0); + expect(utils.isAtPath).toHaveBeenCalledTimes(0); + expect(listObjectVersionMock).toHaveBeenCalledTimes(2); + expect(listObjectVersionMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + filePath: key + })); + expect(listObjectVersionMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + filePath: key, + keyMarker: nextKeyMarker + })); + }); +}); + describe('listObjects', () => { beforeEach(() => { s3ClientMock.on(ListObjectsCommand).resolves({});