feat(syncing-server): add creating item dumps for revision service
This commit is contained in:
parent
1a16d2e4f4
commit
8d152ddfcb
11 changed files with 117 additions and 63 deletions
|
@ -0,0 +1,7 @@
|
||||||
|
import { DomainEventInterface } from './DomainEventInterface'
|
||||||
|
import { ItemDumpedEventPayload } from './ItemDumpedEventPayload'
|
||||||
|
|
||||||
|
export interface ItemDumpedEvent extends DomainEventInterface {
|
||||||
|
type: 'ITEM_DUMPED'
|
||||||
|
payload: ItemDumpedEventPayload
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface ItemDumpedEventPayload {
|
||||||
|
fileDumpPath: string
|
||||||
|
}
|
|
@ -46,6 +46,8 @@ export * from './Event/GoogleDriveBackupFailedEvent'
|
||||||
export * from './Event/GoogleDriveBackupFailedEventPayload'
|
export * from './Event/GoogleDriveBackupFailedEventPayload'
|
||||||
export * from './Event/InvoiceGeneratedEvent'
|
export * from './Event/InvoiceGeneratedEvent'
|
||||||
export * from './Event/InvoiceGeneratedEventPayload'
|
export * from './Event/InvoiceGeneratedEventPayload'
|
||||||
|
export * from './Event/ItemDumpedEvent'
|
||||||
|
export * from './Event/ItemDumpedEventPayload'
|
||||||
export * from './Event/ItemRevisionCreationRequestedEvent'
|
export * from './Event/ItemRevisionCreationRequestedEvent'
|
||||||
export * from './Event/ItemRevisionCreationRequestedEventPayload'
|
export * from './Event/ItemRevisionCreationRequestedEventPayload'
|
||||||
export * from './Event/ItemsSyncedEvent'
|
export * from './Event/ItemsSyncedEvent'
|
||||||
|
|
|
@ -7,6 +7,6 @@ module.exports = {
|
||||||
transform: {
|
transform: {
|
||||||
...tsjPreset.transform,
|
...tsjPreset.transform,
|
||||||
},
|
},
|
||||||
coveragePathIgnorePatterns: ['/Bootstrap/', 'HealthCheckController'],
|
coveragePathIgnorePatterns: ['/Bootstrap/', 'HealthCheckController', '/Infra/'],
|
||||||
setupFilesAfterEnv: ['./test-setup.ts'],
|
setupFilesAfterEnv: ['./test-setup.ts'],
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
EmailArchiveExtensionSyncedEvent,
|
EmailArchiveExtensionSyncedEvent,
|
||||||
EmailBackupAttachmentCreatedEvent,
|
EmailBackupAttachmentCreatedEvent,
|
||||||
GoogleDriveBackupFailedEvent,
|
GoogleDriveBackupFailedEvent,
|
||||||
|
ItemDumpedEvent,
|
||||||
ItemRevisionCreationRequestedEvent,
|
ItemRevisionCreationRequestedEvent,
|
||||||
ItemsSyncedEvent,
|
ItemsSyncedEvent,
|
||||||
OneDriveBackupFailedEvent,
|
OneDriveBackupFailedEvent,
|
||||||
|
@ -20,6 +21,23 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
|
||||||
export class DomainEventFactory implements DomainEventFactoryInterface {
|
export class DomainEventFactory implements DomainEventFactoryInterface {
|
||||||
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
|
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
|
||||||
|
|
||||||
|
createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent {
|
||||||
|
return {
|
||||||
|
type: 'ITEM_DUMPED',
|
||||||
|
createdAt: this.timer.getUTCDate(),
|
||||||
|
meta: {
|
||||||
|
correlation: {
|
||||||
|
userIdentifier: userUuid,
|
||||||
|
userIdentifierType: 'uuid',
|
||||||
|
},
|
||||||
|
origin: DomainEventService.SyncingServer,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
fileDumpPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent {
|
createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent {
|
||||||
return {
|
return {
|
||||||
type: 'ITEM_REVISION_CREATION_REQUESTED',
|
type: 'ITEM_REVISION_CREATION_REQUESTED',
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
EmailArchiveExtensionSyncedEvent,
|
EmailArchiveExtensionSyncedEvent,
|
||||||
EmailBackupAttachmentCreatedEvent,
|
EmailBackupAttachmentCreatedEvent,
|
||||||
GoogleDriveBackupFailedEvent,
|
GoogleDriveBackupFailedEvent,
|
||||||
|
ItemDumpedEvent,
|
||||||
ItemRevisionCreationRequestedEvent,
|
ItemRevisionCreationRequestedEvent,
|
||||||
ItemsSyncedEvent,
|
ItemsSyncedEvent,
|
||||||
OneDriveBackupFailedEvent,
|
OneDriveBackupFailedEvent,
|
||||||
|
@ -33,4 +34,5 @@ export interface DomainEventFactoryInterface {
|
||||||
}): EmailBackupAttachmentCreatedEvent
|
}): EmailBackupAttachmentCreatedEvent
|
||||||
createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent
|
createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent
|
||||||
createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent
|
createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent
|
||||||
|
createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,34 @@
|
||||||
import 'reflect-metadata'
|
import 'reflect-metadata'
|
||||||
|
|
||||||
import { ItemRevisionCreationRequestedEvent } from '@standardnotes/domain-events'
|
import {
|
||||||
|
DomainEventPublisherInterface,
|
||||||
|
DomainEventService,
|
||||||
|
ItemRevisionCreationRequestedEvent,
|
||||||
|
} from '@standardnotes/domain-events'
|
||||||
import { Item } from '../Item/Item'
|
import { Item } from '../Item/Item'
|
||||||
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
|
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
|
||||||
import { ItemRevisionCreationRequestedEventHandler } from './ItemRevisionCreationRequestedEventHandler'
|
import { ItemRevisionCreationRequestedEventHandler } from './ItemRevisionCreationRequestedEventHandler'
|
||||||
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
|
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
|
||||||
|
import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
|
||||||
|
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
|
||||||
|
|
||||||
describe('ItemRevisionCreationRequestedEventHandler', () => {
|
describe('ItemRevisionCreationRequestedEventHandler', () => {
|
||||||
let itemRepository: ItemRepositoryInterface
|
let itemRepository: ItemRepositoryInterface
|
||||||
let revisionService: RevisionServiceInterface
|
let revisionService: RevisionServiceInterface
|
||||||
let event: ItemRevisionCreationRequestedEvent
|
let event: ItemRevisionCreationRequestedEvent
|
||||||
let item: Item
|
let item: Item
|
||||||
|
let itemBackupService: ItemBackupServiceInterface
|
||||||
|
let domainEventFactory: DomainEventFactoryInterface
|
||||||
|
let domainEventPublisher: DomainEventPublisherInterface
|
||||||
|
|
||||||
const createHandler = () => new ItemRevisionCreationRequestedEventHandler(itemRepository, revisionService)
|
const createHandler = () =>
|
||||||
|
new ItemRevisionCreationRequestedEventHandler(
|
||||||
|
itemRepository,
|
||||||
|
revisionService,
|
||||||
|
itemBackupService,
|
||||||
|
domainEventFactory,
|
||||||
|
domainEventPublisher,
|
||||||
|
)
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
item = {
|
item = {
|
||||||
|
@ -31,12 +47,30 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
|
||||||
event.payload = {
|
event.payload = {
|
||||||
itemUuid: '2-3-4',
|
itemUuid: '2-3-4',
|
||||||
}
|
}
|
||||||
|
event.meta = {
|
||||||
|
correlation: {
|
||||||
|
userIdentifier: '1-2-3',
|
||||||
|
userIdentifierType: 'uuid',
|
||||||
|
},
|
||||||
|
origin: DomainEventService.SyncingServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
itemBackupService = {} as jest.Mocked<ItemBackupServiceInterface>
|
||||||
|
itemBackupService.dump = jest.fn().mockReturnValue('foo://bar')
|
||||||
|
|
||||||
|
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
|
||||||
|
domainEventFactory.createItemDumpedEvent = jest.fn()
|
||||||
|
|
||||||
|
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
|
||||||
|
domainEventPublisher.publish = jest.fn()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create a revision for an item', async () => {
|
it('should create a revision for an item', async () => {
|
||||||
await createHandler().handle(event)
|
await createHandler().handle(event)
|
||||||
|
|
||||||
expect(revisionService.createRevision).toHaveBeenCalled()
|
expect(revisionService.createRevision).toHaveBeenCalled()
|
||||||
|
expect(domainEventPublisher.publish).toHaveBeenCalled()
|
||||||
|
expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not create a revision for an item that does not exist', async () => {
|
it('should not create a revision for an item that does not exist', async () => {
|
||||||
|
@ -46,4 +80,13 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
|
||||||
|
|
||||||
expect(revisionService.createRevision).not.toHaveBeenCalled()
|
expect(revisionService.createRevision).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not create a revision for an item if the dump was not created', async () => {
|
||||||
|
itemBackupService.dump = jest.fn().mockReturnValue('')
|
||||||
|
|
||||||
|
await createHandler().handle(event)
|
||||||
|
|
||||||
|
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
|
||||||
|
expect(domainEventFactory.createItemDumpedEvent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import { ItemRevisionCreationRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
|
import {
|
||||||
|
ItemRevisionCreationRequestedEvent,
|
||||||
|
DomainEventHandlerInterface,
|
||||||
|
DomainEventPublisherInterface,
|
||||||
|
} from '@standardnotes/domain-events'
|
||||||
import { inject, injectable } from 'inversify'
|
import { inject, injectable } from 'inversify'
|
||||||
|
|
||||||
import TYPES from '../../Bootstrap/Types'
|
import TYPES from '../../Bootstrap/Types'
|
||||||
|
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
|
||||||
|
import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
|
||||||
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
|
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
|
||||||
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
|
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
|
||||||
|
|
||||||
|
@ -10,6 +16,9 @@ export class ItemRevisionCreationRequestedEventHandler implements DomainEventHan
|
||||||
constructor(
|
constructor(
|
||||||
@inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface,
|
@inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface,
|
||||||
@inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface,
|
@inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface,
|
||||||
|
@inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface,
|
||||||
|
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
|
||||||
|
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: ItemRevisionCreationRequestedEvent): Promise<void> {
|
async handle(event: ItemRevisionCreationRequestedEvent): Promise<void> {
|
||||||
|
@ -18,6 +27,13 @@ export class ItemRevisionCreationRequestedEventHandler implements DomainEventHan
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileDumpPath = await this.itemBackupService.dump(item)
|
||||||
|
if (fileDumpPath) {
|
||||||
|
await this.domainEventPublisher.publish(
|
||||||
|
this.domainEventFactory.createItemDumpedEvent(fileDumpPath, event.meta.correlation.userIdentifier),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
await this.revisionService.createRevision(item)
|
await this.revisionService.createRevision(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,4 +3,5 @@ import { Item } from './Item'
|
||||||
|
|
||||||
export interface ItemBackupServiceInterface {
|
export interface ItemBackupServiceInterface {
|
||||||
backup(items: Array<Item>, authParams: KeyParamsData): Promise<string>
|
backup(items: Array<Item>, authParams: KeyParamsData): Promise<string>
|
||||||
|
dump(item: Item): Promise<string>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,59 +0,0 @@
|
||||||
import 'reflect-metadata'
|
|
||||||
|
|
||||||
import { KeyParamsData } from '@standardnotes/responses'
|
|
||||||
import { S3 } from 'aws-sdk'
|
|
||||||
import { Logger } from 'winston'
|
|
||||||
import { Item } from '../../Domain/Item/Item'
|
|
||||||
import { S3ItemBackupService } from './S3ItemBackupService'
|
|
||||||
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
|
|
||||||
import { ItemProjection } from '../../Projection/ItemProjection'
|
|
||||||
|
|
||||||
describe('S3ItemBackupService', () => {
|
|
||||||
let s3Client: S3 | undefined
|
|
||||||
let itemProjector: ProjectorInterface<Item, ItemProjection>
|
|
||||||
let s3BackupBucketName = 'backup-bucket'
|
|
||||||
let logger: Logger
|
|
||||||
let item: Item
|
|
||||||
let keyParams: KeyParamsData
|
|
||||||
|
|
||||||
const createService = () => new S3ItemBackupService(s3BackupBucketName, itemProjector, logger, s3Client)
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
s3Client = {} as jest.Mocked<S3>
|
|
||||||
s3Client.upload = jest.fn().mockReturnValue({
|
|
||||||
promise: jest.fn().mockReturnValue(Promise.resolve({ Key: 'test' })),
|
|
||||||
})
|
|
||||||
|
|
||||||
logger = {} as jest.Mocked<Logger>
|
|
||||||
logger.warn = jest.fn()
|
|
||||||
|
|
||||||
item = {} as jest.Mocked<Item>
|
|
||||||
|
|
||||||
keyParams = {} as jest.Mocked<KeyParamsData>
|
|
||||||
|
|
||||||
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
|
|
||||||
itemProjector.projectFull = jest.fn().mockReturnValue({ foo: 'bar' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should upload items to S3 as a backup file', async () => {
|
|
||||||
await createService().backup([item], keyParams)
|
|
||||||
|
|
||||||
expect((<S3>s3Client).upload).toHaveBeenCalledWith({
|
|
||||||
Body: '{"items":[{"foo":"bar"}],"auth_params":{}}',
|
|
||||||
Bucket: 'backup-bucket',
|
|
||||||
Key: expect.any(String),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not upload items to S3 if bucket name is not configured', async () => {
|
|
||||||
s3BackupBucketName = ''
|
|
||||||
await createService().backup([item], keyParams)
|
|
||||||
|
|
||||||
expect((<S3>s3Client).upload).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not upload items to S3 if S3 client is not configured', async () => {
|
|
||||||
s3Client = undefined
|
|
||||||
expect(await createService().backup([item], keyParams)).toEqual('')
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -3,6 +3,7 @@ import { KeyParamsData } from '@standardnotes/responses'
|
||||||
import { S3 } from 'aws-sdk'
|
import { S3 } from 'aws-sdk'
|
||||||
import { inject, injectable } from 'inversify'
|
import { inject, injectable } from 'inversify'
|
||||||
import { Logger } from 'winston'
|
import { Logger } from 'winston'
|
||||||
|
|
||||||
import TYPES from '../../Bootstrap/Types'
|
import TYPES from '../../Bootstrap/Types'
|
||||||
import { Item } from '../../Domain/Item/Item'
|
import { Item } from '../../Domain/Item/Item'
|
||||||
import { ItemBackupServiceInterface } from '../../Domain/Item/ItemBackupServiceInterface'
|
import { ItemBackupServiceInterface } from '../../Domain/Item/ItemBackupServiceInterface'
|
||||||
|
@ -18,6 +19,26 @@ export class S3ItemBackupService implements ItemBackupServiceInterface {
|
||||||
@inject(TYPES.S3) private s3Client?: S3,
|
@inject(TYPES.S3) private s3Client?: S3,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async dump(item: Item): Promise<string> {
|
||||||
|
if (!this.s3BackupBucketName || this.s3Client === undefined) {
|
||||||
|
this.logger.warn('S3 backup not configured')
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = await this.s3Client
|
||||||
|
.upload({
|
||||||
|
Bucket: this.s3BackupBucketName,
|
||||||
|
Key: uuid.v4(),
|
||||||
|
Body: JSON.stringify({
|
||||||
|
item: await this.itemProjector.projectFull(item),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.promise()
|
||||||
|
|
||||||
|
return uploadResult.Key
|
||||||
|
}
|
||||||
|
|
||||||
async backup(items: Item[], authParams: KeyParamsData): Promise<string> {
|
async backup(items: Item[], authParams: KeyParamsData): Promise<string> {
|
||||||
if (!this.s3BackupBucketName || this.s3Client === undefined) {
|
if (!this.s3BackupBucketName || this.s3Client === undefined) {
|
||||||
this.logger.warn('S3 backup not configured')
|
this.logger.warn('S3 backup not configured')
|
||||||
|
|
Loading…
Reference in a new issue