feat(syncing-server): add creating item dumps for revision service

This commit is contained in:
Karol Sójko 2022-11-21 09:34:14 +01:00
parent 1a16d2e4f4
commit 8d152ddfcb
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
11 changed files with 117 additions and 63 deletions

View file

@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { ItemDumpedEventPayload } from './ItemDumpedEventPayload'
export interface ItemDumpedEvent extends DomainEventInterface {
type: 'ITEM_DUMPED'
payload: ItemDumpedEventPayload
}

View file

@ -0,0 +1,3 @@
export interface ItemDumpedEventPayload {
fileDumpPath: string
}

View file

@ -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'

View file

@ -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'],
} }

View file

@ -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',

View file

@ -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
} }

View file

@ -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()
})
}) })

View file

@ -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)
} }
} }

View file

@ -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>
} }

View file

@ -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('')
})
})

View file

@ -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')