feat(syncing-server): refactor syncing to decouple getting and saving items (#659)

* feat(syncing-server): refactor syncing to decouple getting and saving items

* fix(syncing-server): item hash http representation mapping

* fix(syncing-server): remove redundant specs for inversify express controller
This commit is contained in:
Karol Sójko
2023-07-17 13:28:50 +02:00
committed by GitHub
parent af8f12c33a
commit cb74b23e45
71 changed files with 1366 additions and 1616 deletions
@@ -1,6 +0,0 @@
import { Item } from './Item'
export type GetItemsResult = {
items: Array<Item>
cursorToken?: string
}
@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { ItemProps } from './ItemProps'
export class Item extends Entity<ItemProps> {
get id(): UniqueEntityId {
return this._id
}
private constructor(props: ItemProps, id?: UniqueEntityId) {
super(props, id)
}
@@ -1,14 +1,13 @@
export type ItemHash = {
uuid: string
content?: string
content_type: string | null
deleted?: boolean
duplicate_of?: string | null
auth_hash?: string
enc_item_key?: string
items_key_id?: string
created_at?: string
created_at_timestamp?: number
updated_at?: string
updated_at_timestamp?: number
import { Result, ValueObject } from '@standardnotes/domain-core'
import { ItemHashProps } from './ItemHashProps'
export class ItemHash extends ValueObject<ItemHashProps> {
private constructor(props: ItemHashProps) {
super(props)
}
static create(props: ItemHashProps): Result<ItemHash> {
return Result.ok<ItemHash>(new ItemHash(props))
}
}
@@ -0,0 +1,17 @@
export interface ItemHashProps {
uuid: string
user_uuid: string
content?: string
content_type: string | null
deleted?: boolean
duplicate_of?: string | null
auth_hash?: string
enc_item_key?: string
items_key_id?: string
key_system_identifier: string | null
shared_vault_uuid: string | null
created_at?: string
created_at_timestamp?: number
updated_at?: string
updated_at_timestamp?: number
}
@@ -1,785 +0,0 @@
import 'reflect-metadata'
import { Timer, TimerInterface } from '@standardnotes/time'
import { Logger } from 'winston'
import { Item } from './Item'
import { ItemHash } from './ItemHash'
import { ItemRepositoryInterface } from './ItemRepositoryInterface'
import { ItemService } from './ItemService'
import { ApiVersion } from '../Api/ApiVersion'
import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
import { ItemConflict } from './ItemConflict'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { SaveNewItem } from '../UseCase/Syncing/SaveNewItem/SaveNewItem'
import { UpdateExistingItem } from '../UseCase/Syncing/UpdateExistingItem/UpdateExistingItem'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId, Result } from '@standardnotes/domain-core'
describe('ItemService', () => {
let itemRepository: ItemRepositoryInterface
const contentSizeTransferLimit = 100
let timer: TimerInterface
let item1: Item
let item2: Item
let itemHash1: ItemHash
let itemHash2: ItemHash
let syncToken: string
let logger: Logger
let itemSaveValidator: ItemSaveValidatorInterface
let newItem: Item
let timeHelper: Timer
let itemTransferCalculator: ItemTransferCalculatorInterface
let saveNewItemUseCase: SaveNewItem
let updateExistingItemUseCase: UpdateExistingItem
const maxItemsSyncLimit = 300
const createService = () =>
new ItemService(
itemSaveValidator,
itemRepository,
contentSizeTransferLimit,
itemTransferCalculator,
timer,
maxItemsSyncLimit,
saveNewItemUseCase,
updateExistingItemUseCase,
logger,
)
beforeEach(() => {
timeHelper = new Timer()
item1 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: null,
deleted: false,
dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
item2 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: null,
deleted: false,
dates: Dates.create(new Date(1616164633241312), new Date(1616164633241312)).getValue(),
timestamps: Timestamps.create(1616164633241312, 1616164633241312).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
itemHash1 = {
uuid: '1-2-3',
content: 'asdqwe1',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe1',
items_key_id: 'asdasd1',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(item1.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
itemHash2 = {
uuid: '2-3-4',
content: 'asdqwe2',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe2',
items_key_id: 'asdasd2',
created_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(item2.props.timestamps.createdAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
updated_at: timeHelper.formatDate(
new Date(timeHelper.convertMicrosecondsToMilliseconds(item2.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
itemTransferCalculator.computeItemUuidsToFetch = jest
.fn()
.mockReturnValue([item1.id.toString(), item2.id.toString()])
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item1, item2])
itemRepository.countAll = jest.fn().mockReturnValue(2)
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1616164633241568)
timer.getUTCDate = jest.fn().mockReturnValue(new Date())
timer.convertStringDateToDate = jest
.fn()
.mockImplementation((date: string) => timeHelper.convertStringDateToDate(date))
timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(600)
timer.convertStringDateToMicroseconds = jest
.fn()
.mockImplementation((date: string) => timeHelper.convertStringDateToMicroseconds(date))
timer.convertMicrosecondsToDate = jest
.fn()
.mockImplementation((microseconds: number) => timeHelper.convertMicrosecondsToDate(microseconds))
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
logger.warn = jest.fn()
syncToken = Buffer.from('2:1616164633.241564', 'utf-8').toString('base64')
itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
itemSaveValidator.validate = jest.fn().mockReturnValue({ passed: true })
newItem = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: null,
deleted: false,
dates: Dates.create(new Date(1616164633241313), new Date(1616164633241313)).getValue(),
timestamps: Timestamps.create(1616164633241313, 1616164633241313).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000002'),
).getValue()
saveNewItemUseCase = {} as jest.Mocked<SaveNewItem>
saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(newItem))
updateExistingItemUseCase = {} as jest.Mocked<UpdateExistingItem>
updateExistingItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(item1))
})
it('should retrieve all items for a user from last sync with sync token version 1', async () => {
syncToken = Buffer.from('1:2021-03-15 07:00:00', 'utf-8').toString('base64')
expect(
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1615791600000000,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortOrder: 'ASC',
sortBy: 'updated_at_timestamp',
})
})
it('should retrieve all items for a user from last sync', async () => {
expect(
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should retrieve all items for a user from last sync with upper bound items limit', async () => {
expect(
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.TYPES.Note,
limit: 1000,
}),
).toEqual({
items: [item1, item2],
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 300,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should retrieve no items for a user if there are none from last sync', async () => {
itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockReturnValue([])
expect(
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [],
})
expect(itemRepository.findAll).not.toHaveBeenCalled()
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 150,
})
})
it('should return a cursor token if there are more items than requested with limit', async () => {
itemRepository.findAll = jest.fn().mockReturnValue([item1])
const itemsResponse = await createService().getItems({
userUuid: '1-2-3',
syncToken,
limit: 1,
contentType: ContentType.TYPES.Note,
})
expect(itemsResponse).toEqual({
cursorToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMQ==',
items: [item1],
})
expect(Buffer.from(<string>itemsResponse.cursorToken, 'base64').toString('utf-8')).toEqual('2:1616164633.241311')
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 1,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should retrieve all items for a user from cursor token', async () => {
const cursorToken = Buffer.from('2:1616164633.241123', 'utf-8').toString('base64')
expect(
await createService().getItems({
userUuid: '1-2-3',
syncToken,
cursorToken,
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241123,
syncTimeComparison: '>=',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should retrieve all undeleted items for a user without cursor or sync token', async () => {
expect(
await createService().getItems({
userUuid: '1-2-3',
contentType: ContentType.TYPES.Note,
}),
).toEqual({
items: [item1, item2],
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
deleted: false,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
syncTimeComparison: '>',
userUuid: '1-2-3',
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should retrieve all items with default limit if not defined', async () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.TYPES.Note,
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortOrder: 'ASC',
sortBy: 'updated_at_timestamp',
})
})
it('should retrieve all items with non-positive limit if not defined', async () => {
await createService().getItems({
userUuid: '1-2-3',
syncToken,
limit: 0,
contentType: ContentType.TYPES.Note,
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 150,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should throw an error if the sync token is missing time', async () => {
let error = null
try {
await createService().getItems({
userUuid: '1-2-3',
syncToken: '2:',
limit: 0,
contentType: ContentType.TYPES.Note,
})
} catch (e) {
error = e
}
expect(error).not.toBeNull()
})
it('should throw an error if the sync token is missing version', async () => {
let error = null
try {
await createService().getItems({
userUuid: '1-2-3',
syncToken: '1234567890',
limit: 0,
contentType: ContentType.TYPES.Note,
})
} catch (e) {
error = e
}
expect(error).not.toBeNull()
})
it('should front load keys items to top of the collection for better client performance', async () => {
const item3 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: null,
deleted: false,
dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000003'),
).getValue()
const item4 = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: null,
deleted: false,
dates: Dates.create(new Date(1616164633241312), new Date(1616164633241312)).getValue(),
timestamps: Timestamps.create(1616164633241312, 1616164633241312).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000004'),
).getValue()
itemRepository.findAll = jest.fn().mockReturnValue([item3, item4])
await createService().frontLoadKeysItemsToTop('1-2-3', [item1, item2])
})
it('should save new items', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [],
savedItems: [newItem],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxNA==',
})
expect(saveNewItemUseCase.execute).toHaveBeenCalled()
})
it('should not save new items in read only access mode', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: true,
sessionUuid: null,
})
expect(result).toEqual({
conflicts: [
{
type: 'readonly_error',
unsavedItem: itemHash1,
},
],
savedItems: [],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
expect(saveNewItemUseCase.execute).not.toHaveBeenCalled()
})
it('should save new items that are duplicates', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const duplicateItem = Item.create(
{
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
updatedWithSession: null,
content: 'foobar1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: null,
authHash: null,
itemsKeyId: null,
duplicateOf: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
deleted: false,
dates: Dates.create(new Date(1616164633241570), new Date(1616164633241570)).getValue(),
timestamps: Timestamps.create(1616164633241570, 1616164633241570).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000005'),
).getValue()
saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.ok(duplicateItem))
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [],
savedItems: [duplicateItem],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU3MQ==',
})
})
it('should skip items that are conflicting on validation', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const conflict = {} as jest.Mocked<ItemConflict>
const validationResult = { passed: false, conflict }
itemSaveValidator.validate = jest.fn().mockReturnValue(validationResult)
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [conflict],
savedItems: [],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should mark items as saved that are skipped on validation', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const skipped = item1
const validationResult = { passed: false, skipped }
itemSaveValidator.validate = jest.fn().mockReturnValue(validationResult)
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [],
savedItems: [skipped],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMg==',
})
})
it('should calculate the sync token based on last updated date of saved items incremented with 1 microsecond to avoid returning same object in subsequent sync', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
const itemHash3 = {
uuid: '3-4-5',
content: 'asdqwe3',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
enc_item_key: 'qweqwe3',
items_key_id: 'asdasd3',
created_at: '2021-02-19T11:35:45.652Z',
updated_at: '2021-03-25T09:37:37.943Z',
} as jest.Mocked<ItemHash>
const saveProcedureStartTimestamp = 1616164633241580
const item1Timestamp = 1616164633241570
const item2Timestamp = 1616164633241568
const item3Timestamp = 1616164633241569
timer.getTimestampInMicroseconds = jest.fn().mockReturnValueOnce(saveProcedureStartTimestamp)
saveNewItemUseCase.execute = jest
.fn()
.mockReturnValueOnce(
Result.ok(
Item.create(
{
...item1.props,
timestamps: Timestamps.create(item1Timestamp, item1Timestamp).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue(),
),
)
.mockReturnValueOnce(
Result.ok(
Item.create(
{
...item2.props,
timestamps: Timestamps.create(item2Timestamp, item2Timestamp).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000002'),
).getValue(),
),
)
.mockReturnValueOnce(
Result.ok(
Item.create(
{
...item2.props,
timestamps: Timestamps.create(item3Timestamp, item3Timestamp).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000003'),
).getValue(),
),
)
const result = await createService().saveItems({
itemHashes: [itemHash1, itemHash3, itemHash2],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result.syncToken).toEqual('MjoxNjE2MTY0NjMzLjI0MTU3MQ==')
expect(Buffer.from(result.syncToken, 'base64').toString('utf-8')).toEqual('2:1616164633.241571')
})
it('should update existing items', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [],
savedItems: [item1],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTMxMg==',
})
})
it('should mark as skipped existing items that failed to update', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(item1)
updateExistingItemUseCase.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const result = await createService().saveItems({
itemHashes: [itemHash1],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [
{
type: 'uuid_conflict',
unsavedItem: itemHash1,
},
],
savedItems: [],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should skip saving conflicting items and mark them as sync conflicts when saving fails', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
saveNewItemUseCase.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const result = await createService().saveItems({
itemHashes: [itemHash1, itemHash2],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [
{
type: 'uuid_conflict',
unsavedItem: itemHash1,
},
{
type: 'uuid_conflict',
unsavedItem: itemHash2,
},
],
savedItems: [],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
it('should skip saving conflicting items and mark them as sync conflicts when saving throws an error', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
saveNewItemUseCase.execute = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
const result = await createService().saveItems({
itemHashes: [itemHash1, itemHash2],
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
readOnlyAccess: false,
sessionUuid: '2-3-4',
})
expect(result).toEqual({
conflicts: [
{
type: 'uuid_conflict',
unsavedItem: itemHash1,
},
{
type: 'uuid_conflict',
unsavedItem: itemHash2,
},
],
savedItems: [],
syncToken: 'MjoxNjE2MTY0NjMzLjI0MTU2OQ==',
})
})
})
@@ -1,239 +0,0 @@
import { Time, TimerInterface } from '@standardnotes/time'
import { Logger } from 'winston'
import { GetItemsDTO } from './GetItemsDTO'
import { GetItemsResult } from './GetItemsResult'
import { Item } from './Item'
import { ItemConflict } from './ItemConflict'
import { ItemQuery } from './ItemQuery'
import { ItemRepositoryInterface } from './ItemRepositoryInterface'
import { ItemServiceInterface } from './ItemServiceInterface'
import { SaveItemsDTO } from './SaveItemsDTO'
import { SaveItemsResult } from './SaveItemsResult'
import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
import { ConflictType } from '@standardnotes/responses'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { SaveNewItem } from '../UseCase/Syncing/SaveNewItem/SaveNewItem'
import { ContentType } from '@standardnotes/domain-core'
import { UpdateExistingItem } from '../UseCase/Syncing/UpdateExistingItem/UpdateExistingItem'
export class ItemService implements ItemServiceInterface {
private readonly DEFAULT_ITEMS_LIMIT = 150
private readonly SYNC_TOKEN_VERSION = 2
constructor(
private itemSaveValidator: ItemSaveValidatorInterface,
private itemRepository: ItemRepositoryInterface,
private contentSizeTransferLimit: number,
private itemTransferCalculator: ItemTransferCalculatorInterface,
private timer: TimerInterface,
private maxItemsSyncLimit: number,
private saveNewItem: SaveNewItem,
private updateExistingItem: UpdateExistingItem,
private logger: Logger,
) {}
async getItems(dto: GetItemsDTO): Promise<GetItemsResult> {
const lastSyncTime = this.getLastSyncTime(dto)
const syncTimeComparison = dto.cursorToken ? '>=' : '>'
const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
const itemQuery: ItemQuery = {
userUuid: dto.userUuid,
lastSyncTime,
syncTimeComparison,
contentType: dto.contentType,
deleted: lastSyncTime ? undefined : false,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
limit: upperBoundLimit,
}
const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
itemQuery,
this.contentSizeTransferLimit,
)
let items: Array<Item> = []
if (itemUuidsToFetch.length > 0) {
items = await this.itemRepository.findAll({
uuids: itemUuidsToFetch,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
}
const totalItemsCount = await this.itemRepository.countAll(itemQuery)
let cursorToken = undefined
if (totalItemsCount > upperBoundLimit) {
const lastSyncTime = items[items.length - 1].props.timestamps.updatedAt / Time.MicrosecondsInASecond
cursorToken = Buffer.from(`${this.SYNC_TOKEN_VERSION}:${lastSyncTime}`, 'utf-8').toString('base64')
}
return {
items,
cursorToken,
}
}
async saveItems(dto: SaveItemsDTO): Promise<SaveItemsResult> {
const savedItems: Array<Item> = []
const conflicts: Array<ItemConflict> = []
const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
for (const itemHash of dto.itemHashes) {
if (dto.readOnlyAccess) {
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.ReadOnlyError,
})
continue
}
const existingItem = await this.itemRepository.findByUuid(itemHash.uuid)
const processingResult = await this.itemSaveValidator.validate({
userUuid: dto.userUuid,
apiVersion: dto.apiVersion,
itemHash,
existingItem,
})
if (!processingResult.passed) {
if (processingResult.conflict) {
conflicts.push(processingResult.conflict)
}
if (processingResult.skipped) {
savedItems.push(processingResult.skipped)
}
continue
}
if (existingItem) {
const udpatedItemOrError = await this.updateExistingItem.execute({
existingItem,
itemHash,
sessionUuid: dto.sessionUuid,
})
if (udpatedItemOrError.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Updating item ${itemHash.uuid} failed. Error: ${udpatedItemOrError.getError()}`,
)
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
})
continue
}
const updatedItem = udpatedItemOrError.getValue()
savedItems.push(updatedItem)
} else {
try {
const newItemOrError = await this.saveNewItem.execute({
userUuid: dto.userUuid,
itemHash,
sessionUuid: dto.sessionUuid,
})
if (newItemOrError.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Saving item ${itemHash.uuid} failed. Error: ${newItemOrError.getError()}`,
)
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
})
continue
}
const newItem = newItemOrError.getValue()
savedItems.push(newItem)
} catch (error) {
this.logger.error(`[${dto.userUuid}] Saving item ${itemHash.uuid} failed. Error: ${(error as Error).message}`)
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
})
continue
}
}
}
const syncToken = this.calculateSyncToken(lastUpdatedTimestamp, savedItems)
return {
savedItems,
conflicts,
syncToken,
}
}
async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
const itemsKeys = await this.itemRepository.findAll({
userUuid,
contentType: ContentType.TYPES.ItemsKey,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
const retrievedItemsIds: Array<string> = retrievedItems.map((item: Item) => item.id.toString())
itemsKeys.forEach((itemKey: Item) => {
if (retrievedItemsIds.indexOf(itemKey.id.toString()) === -1) {
retrievedItems.unshift(itemKey)
}
})
return retrievedItems
}
private calculateSyncToken(lastUpdatedTimestamp: number, savedItems: Array<Item>): string {
if (savedItems.length) {
const sortedItems = savedItems.sort((itemA: Item, itemB: Item) => {
return itemA.props.timestamps.updatedAt > itemB.props.timestamps.updatedAt ? 1 : -1
})
lastUpdatedTimestamp = sortedItems[sortedItems.length - 1].props.timestamps.updatedAt
}
const lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles = lastUpdatedTimestamp + 1
return Buffer.from(
`${this.SYNC_TOKEN_VERSION}:${
lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles / Time.MicrosecondsInASecond
}`,
'utf-8',
).toString('base64')
}
private getLastSyncTime(dto: GetItemsDTO): number | undefined {
let token = dto.syncToken
if (dto.cursorToken !== undefined && dto.cursorToken !== null) {
token = dto.cursorToken
}
if (!token) {
return undefined
}
const decodedToken = Buffer.from(token, 'base64').toString('utf-8')
const tokenParts = decodedToken.split(':')
const version = tokenParts.shift()
switch (version) {
case '1':
return this.timer.convertStringDateToMicroseconds(tokenParts.join(':'))
case '2':
return +tokenParts[0] * Time.MicrosecondsInASecond
default:
throw Error('Sync token is missing version part')
}
}
}
@@ -1,11 +0,0 @@
import { GetItemsDTO } from './GetItemsDTO'
import { GetItemsResult } from './GetItemsResult'
import { Item } from './Item'
import { SaveItemsDTO } from './SaveItemsDTO'
import { SaveItemsResult } from './SaveItemsResult'
export interface ItemServiceInterface {
getItems(dto: GetItemsDTO): Promise<GetItemsResult>
saveItems(dto: SaveItemsDTO): Promise<SaveItemsResult>
frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>>
}
@@ -1,8 +0,0 @@
import { Item } from './Item'
import { ItemConflict } from './ItemConflict'
export type SaveItemsResult = {
savedItems: Array<Item>
conflicts: Array<ItemConflict>
syncToken: string
}
@@ -5,6 +5,7 @@ import { Item } from '../Item'
import { ContentFilter } from './ContentFilter'
import { ContentType } from '@standardnotes/domain-core'
import { ItemHash } from '../ItemHash'
describe('ContentFilter', () => {
let existingItem: Item
@@ -14,25 +15,25 @@ describe('ContentFilter', () => {
const invalidContents = [[], { foo: 'bar' }, [{ foo: 'bar' }], 123, new Date(1)]
for (const invalidContent of invalidContents) {
const itemHash = ItemHash.create({
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: invalidContent as unknown as string,
content_type: ContentType.TYPES.Note,
user_uuid: '1-2-3',
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: invalidContent as unknown as string,
content_type: ContentType.TYPES.Note,
},
itemHash,
existingItem: null,
})
expect(result).toEqual({
passed: false,
conflict: {
unsavedItem: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: invalidContent,
content_type: ContentType.TYPES.Note,
},
unsavedItem: itemHash,
type: 'content_error',
},
})
@@ -46,11 +47,14 @@ describe('ContentFilter', () => {
const result = await createFilter().check({
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
itemHash: {
itemHash: ItemHash.create({
uuid: '123e4567-e89b-12d3-a456-426655440000',
content: validContent as unknown as string,
content_type: ContentType.TYPES.Note,
},
user_uuid: '1-2-3',
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue(),
existingItem,
})
@@ -5,13 +5,13 @@ import { ConflictType } from '@standardnotes/responses'
export class ContentFilter implements ItemSaveRuleInterface {
async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
if (dto.itemHash.content === undefined || dto.itemHash.content === null) {
if (dto.itemHash.props.content === undefined || dto.itemHash.props.content === null) {
return {
passed: true,
}
}
const validContent = typeof dto.itemHash.content === 'string'
const validContent = typeof dto.itemHash.props.content === 'string'
if (!validContent) {
return {
@@ -4,6 +4,7 @@ import { ApiVersion } from '../../Api/ApiVersion'
import { Item } from '../Item'
import { ContentTypeFilter } from './ContentTypeFilter'
import { ItemHash } from '../ItemHash'
describe('ContentTypeFilter', () => {
let existingItem: Item
@@ -22,23 +23,24 @@ describe('ContentTypeFilter', () => {
]
for (const invalidContentType of invalidContentTypes) {
const itemHash = ItemHash.create({
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: invalidContentType,
user_uuid: '1-2-3',
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: invalidContentType,
},
itemHash,
existingItem: null,
})
expect(result).toEqual({
passed: false,
conflict: {
unsavedItem: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: invalidContentType,
},
unsavedItem: itemHash,
type: 'content_type_error',
},
})
@@ -49,13 +51,18 @@ describe('ContentTypeFilter', () => {
const validContentTypes = ['Note', 'SN|ItemsKey', 'SN|Component', 'SN|Editor', 'SN|ExtensionRepo', 'Tag']
for (const validContentType of validContentTypes) {
const itemHash = ItemHash.create({
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: validContentType,
user_uuid: '1-2-3',
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '123e4567-e89b-12d3-a456-426655440000',
content_type: validContentType,
},
itemHash,
existingItem,
})
@@ -7,7 +7,7 @@ import { ItemSaveRuleInterface } from './ItemSaveRuleInterface'
export class ContentTypeFilter implements ItemSaveRuleInterface {
async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
if (contentTypeOrError.isFailed()) {
return {
passed: false,
@@ -5,6 +5,7 @@ import { Item } from '../Item'
import { OwnershipFilter } from './OwnershipFilter'
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
import { ItemHash } from '../ItemHash'
describe('OwnershipFilter', () => {
let existingItem: Item
@@ -30,23 +31,29 @@ describe('OwnershipFilter', () => {
})
it('should filter out items belonging to a different user', async () => {
const itemHash = ItemHash.create({
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
user_uuid: '00000000-0000-0000-0000-000000000000',
content: 'foobar',
created_at: '2020-01-01T00:00:00.000Z',
updated_at: '2020-01-01T00:00:00.000Z',
created_at_timestamp: 123,
updated_at_timestamp: 123,
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue()
const result = await createFilter().check({
userUuid: '00000000-0000-0000-0000-000000000001',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
itemHash,
existingItem,
})
expect(result).toEqual({
passed: false,
conflict: {
unsavedItem: {
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
unsavedItem: itemHash,
type: 'uuid_conflict',
},
})
@@ -56,10 +63,18 @@ describe('OwnershipFilter', () => {
const result = await createFilter().check({
userUuid: '00000000-0000-0000-0000-000000000000',
apiVersion: ApiVersion.v20200115,
itemHash: {
itemHash: ItemHash.create({
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
user_uuid: '00000000-0000-0000-0000-000000000000',
content: 'foobar',
created_at: '2020-01-01T00:00:00.000Z',
updated_at: '2020-01-01T00:00:00.000Z',
created_at_timestamp: 123,
updated_at_timestamp: 123,
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue(),
existingItem,
})
@@ -72,10 +87,18 @@ describe('OwnershipFilter', () => {
const result = await createFilter().check({
userUuid: '00000000-0000-0000-0000-000000000000',
apiVersion: ApiVersion.v20200115,
itemHash: {
itemHash: ItemHash.create({
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
user_uuid: '00000000-0000-0000-0000-000000000000',
content: 'foobar',
created_at: '2020-01-01T00:00:00.000Z',
updated_at: '2020-01-01T00:00:00.000Z',
created_at_timestamp: 123,
updated_at_timestamp: 123,
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue(),
existingItem: null,
})
@@ -85,23 +108,29 @@ describe('OwnershipFilter', () => {
})
it('should return an error if the user uuid is invalid', async () => {
const itemHash = ItemHash.create({
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
user_uuid: '00000000-0000-0000-0000-000000000000',
content: 'foobar',
created_at: '2020-01-01T00:00:00.000Z',
updated_at: '2020-01-01T00:00:00.000Z',
created_at_timestamp: 123,
updated_at_timestamp: 123,
key_system_identifier: null,
shared_vault_uuid: null,
}).getValue()
const result = await createFilter().check({
userUuid: 'invalid',
apiVersion: ApiVersion.v20200115,
itemHash: {
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
itemHash,
existingItem,
})
expect(result).toEqual({
passed: false,
conflict: {
unsavedItem: {
uuid: '2-3-4',
content_type: ContentType.TYPES.Note,
},
unsavedItem: itemHash,
type: 'uuid_error',
},
})
@@ -42,8 +42,11 @@ describe('TimeDifferenceFilter', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
itemHash = {
itemHash = ItemHash.create({
uuid: '1-2-3',
user_uuid: '00000000-0000-0000-0000-000000000000',
key_system_identifier: null,
shared_vault_uuid: null,
content: 'asdqwe1',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
@@ -57,7 +60,7 @@ describe('TimeDifferenceFilter', () => {
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
}).getValue()
})
it('should leave non existing items', async () => {
@@ -74,8 +77,11 @@ describe('TimeDifferenceFilter', () => {
})
it('should leave items from legacy clients', async () => {
delete itemHash.updated_at
delete itemHash.updated_at_timestamp
itemHash = ItemHash.create({
...itemHash.props,
updated_at: undefined,
updated_at_timestamp: undefined,
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -90,7 +96,10 @@ describe('TimeDifferenceFilter', () => {
})
it('should filter out items having update at timestamp different in microseconds precision', async () => {
itemHash.updated_at_timestamp = existingItem.props.timestamps.updatedAt + 1
itemHash = ItemHash.create({
...itemHash.props,
updated_at_timestamp: existingItem.props.timestamps.updatedAt + 1,
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -109,7 +118,10 @@ describe('TimeDifferenceFilter', () => {
})
it('should leave items having update at timestamp same in microseconds precision', async () => {
itemHash.updated_at_timestamp = existingItem.props.timestamps.updatedAt
itemHash = ItemHash.create({
...itemHash.props,
updated_at_timestamp: existingItem.props.timestamps.updatedAt,
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -124,14 +136,17 @@ describe('TimeDifferenceFilter', () => {
})
it('should filter out items having update at timestamp different by a second for legacy clients', async () => {
itemHash.updated_at = timeHelper.formatDate(
new Date(
timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
Time.MicrosecondsInASecond +
1,
itemHash = ItemHash.create({
...itemHash.props,
updated_at: timeHelper.formatDate(
new Date(
timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
Time.MicrosecondsInASecond +
1,
),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -150,10 +165,13 @@ describe('TimeDifferenceFilter', () => {
})
it('should leave items having update at timestamp different by less then a second for legacy clients', async () => {
itemHash.updated_at = timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
itemHash = ItemHash.create({
...itemHash.props,
updated_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -168,14 +186,17 @@ describe('TimeDifferenceFilter', () => {
})
it('should filter out items having update at timestamp different by a millisecond', async () => {
itemHash.updated_at = timeHelper.formatDate(
new Date(
timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
Time.MicrosecondsInAMillisecond +
1,
itemHash = ItemHash.create({
...itemHash.props,
updated_at: timeHelper.formatDate(
new Date(
timeHelper.convertMicrosecondsToMilliseconds(existingItem.props.timestamps.updatedAt) +
Time.MicrosecondsInAMillisecond +
1,
),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -194,10 +215,13 @@ describe('TimeDifferenceFilter', () => {
})
it('should leave items having update at timestamp different by less than a millisecond', async () => {
itemHash.updated_at = timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
)
itemHash = ItemHash.create({
...itemHash.props,
updated_at: timeHelper.formatDate(
timeHelper.convertMicrosecondsToDate(existingItem.props.timestamps.updatedAt),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
}).getValue()
const result = await createFilter().check({
userUuid: '1-2-3',
@@ -17,11 +17,11 @@ export class TimeDifferenceFilter implements ItemSaveRuleInterface {
}
}
let incomingUpdatedAtTimestamp = dto.itemHash.updated_at_timestamp
let incomingUpdatedAtTimestamp = dto.itemHash.props.updated_at_timestamp
if (incomingUpdatedAtTimestamp === undefined) {
incomingUpdatedAtTimestamp =
dto.itemHash.updated_at !== undefined
? this.timer.convertStringDateToMicroseconds(dto.itemHash.updated_at)
dto.itemHash.props.updated_at !== undefined
? this.timer.convertStringDateToMicroseconds(dto.itemHash.props.updated_at)
: this.timer.convertStringDateToMicroseconds(new Date(0).toString())
}
@@ -66,7 +66,7 @@ export class TimeDifferenceFilter implements ItemSaveRuleInterface {
}
private itemHashHasMicrosecondsPrecision(itemHash: ItemHash) {
return itemHash.updated_at_timestamp !== undefined
return itemHash.props.updated_at_timestamp !== undefined
}
private getMinimalConflictIntervalMicroseconds(apiVersion?: string): number {
@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { MessageProps } from './MessageProps'
export class Message extends Entity<MessageProps> {
get id(): UniqueEntityId {
return this._id
}
private constructor(props: MessageProps, id?: UniqueEntityId) {
super(props, id)
}
@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { NotificationProps } from './NotificationProps'
export class Notification extends Entity<NotificationProps> {
get id(): UniqueEntityId {
return this._id
}
private constructor(props: NotificationProps, id?: UniqueEntityId) {
super(props, id)
}
@@ -0,0 +1,18 @@
import { Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { SharedVaultItem } from './SharedVaultItem'
describe('SharedVaultItem', () => {
it('should create an entity', () => {
const entityOrError = SharedVaultItem.create({
itemId: new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
sharedVaultId: new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
keySystemIdentifier: 'key-system-identifier',
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123456789, 123456789).getValue(),
})
expect(entityOrError.isFailed()).toBeFalsy()
expect(entityOrError.getValue().id).not.toBeNull()
})
})
@@ -0,0 +1,13 @@
import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { SharedVaultItemProps } from './SharedVaultItemProps'
export class SharedVaultItem extends Entity<SharedVaultItemProps> {
private constructor(props: SharedVaultItemProps, id?: UniqueEntityId) {
super(props, id)
}
static create(props: SharedVaultItemProps, id?: UniqueEntityId): Result<SharedVaultItem> {
return Result.ok<SharedVaultItem>(new SharedVaultItem(props, id))
}
}
@@ -0,0 +1,9 @@
import { Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
export interface SharedVaultItemProps {
sharedVaultId: UniqueEntityId
itemId: UniqueEntityId
keySystemIdentifier: string
lastEditedBy: Uuid
timestamps: Timestamps
}
@@ -0,0 +1,8 @@
import { UniqueEntityId } from '@standardnotes/domain-core'
import { SharedVaultItem } from './SharedVaultItem'
export interface SharedVaultItemRepositoryInterface {
save(sharedVaultItem: SharedVaultItem): Promise<void>
findBySharedVaultId(sharedVaultId: UniqueEntityId): Promise<SharedVaultItem[]>
}
@@ -3,12 +3,13 @@ import { Timestamps, Uuid } from '@standardnotes/domain-core'
import { SharedVault } from './SharedVault'
describe('SharedVault', () => {
it('should create an entity', () => {
it('should create an aggregate', () => {
const entityOrError = SharedVault.create({
fileUploadBytesLimit: 1_000_000,
fileUploadBytesUsed: 0,
timestamps: Timestamps.create(123456789, 123456789).getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sharedVaultItems: [],
})
expect(entityOrError.isFailed()).toBeFalsy()
@@ -1,12 +1,8 @@
import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { Aggregate, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { SharedVaultProps } from './SharedVaultProps'
export class SharedVault extends Entity<SharedVaultProps> {
get id(): UniqueEntityId {
return this._id
}
export class SharedVault extends Aggregate<SharedVaultProps> {
private constructor(props: SharedVaultProps, id?: UniqueEntityId) {
super(props, id)
}
@@ -1,8 +1,11 @@
import { Uuid, Timestamps } from '@standardnotes/domain-core'
import { SharedVaultItem } from './Item/SharedVaultItem'
export interface SharedVaultProps {
userUuid: Uuid
fileUploadBytesUsed: number
fileUploadBytesLimit: number
timestamps: Timestamps
sharedVaultItems: SharedVaultItem[]
}
@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { SharedVaultInviteProps } from './SharedVaultInviteProps'
export class SharedVaultInvite extends Entity<SharedVaultInviteProps> {
get id(): UniqueEntityId {
return this._id
}
private constructor(props: SharedVaultInviteProps, id?: UniqueEntityId) {
super(props, id)
}
@@ -3,10 +3,6 @@ import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { SharedVaultUserProps } from './SharedVaultUserProps'
export class SharedVaultUser extends Entity<SharedVaultUserProps> {
get id(): UniqueEntityId {
return this._id
}
private constructor(props: SharedVaultUserProps, id?: UniqueEntityId) {
super(props, id)
}
@@ -32,6 +32,7 @@ export class CreateSharedVault implements UseCaseInterface<CreateSharedVaultResu
fileUploadBytesUsed: 0,
userUuid,
timestamps,
sharedVaultItems: [],
})
if (sharedVaultOrError.isFailed()) {
return Result.fail(sharedVaultOrError.getError())
@@ -24,6 +24,7 @@ describe('CreateSharedVaultFileValetToken', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
@@ -31,6 +31,7 @@ describe('DeleteSharedVault', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -115,6 +116,7 @@ describe('DeleteSharedVault', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
const useCase = createUseCase()
@@ -20,6 +20,7 @@ describe('GetSharedVaultUsers', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultUser = SharedVaultUser.create({
@@ -66,6 +67,7 @@ describe('GetSharedVaultUsers', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -29,6 +29,7 @@ describe('GetSharedVaults', () => {
timestamps: Timestamps.create(123, 123).getValue(),
fileUploadBytesLimit: 123,
fileUploadBytesUsed: 123,
sharedVaultItems: [],
}).getValue()
sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
sharedVaultRepository.findByUuids = jest.fn().mockResolvedValue([sharedVault])
@@ -21,6 +21,7 @@ describe('InviteUserToSharedVault', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -152,6 +153,7 @@ describe('InviteUserToSharedVault', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('10000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -24,6 +24,7 @@ describe('RemoveUserFromSharedVault', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -88,6 +89,7 @@ describe('RemoveUserFromSharedVault', () => {
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultItems: [],
}).getValue()
sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
@@ -0,0 +1,152 @@
import { TimerInterface } from '@standardnotes/time'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
import { GetItems } from './GetItems'
import { Item } from '../../../Item/Item'
import { ContentType, Dates, Timestamps, Uuid } from '@standardnotes/domain-core'
describe('GetItems', () => {
let itemRepository: ItemRepositoryInterface
const contentSizeTransferLimit = 100
let itemTransferCalculator: ItemTransferCalculatorInterface
let timer: TimerInterface
const maxItemsSyncLimit = 100
let item: Item
const createUseCase = () =>
new GetItems(itemRepository, contentSizeTransferLimit, itemTransferCalculator, timer, maxItemsSyncLimit)
beforeEach(() => {
item = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockResolvedValue([item])
itemRepository.countAll = jest.fn().mockResolvedValue(1)
itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockResolvedValue(['item-uuid'])
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(123)
})
it('returns items', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'user-uuid',
cursorToken: undefined,
contentType: undefined,
limit: 10,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
items: [item],
cursorToken: undefined,
})
})
it('should return cursor token if there are more items to fetch', async () => {
itemRepository.countAll = jest.fn().mockResolvedValue(101)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'user-uuid',
cursorToken: undefined,
contentType: undefined,
limit: undefined,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
items: [item],
cursorToken: 'MjowLjAwMDEyMw==',
})
})
it('should return items based on the cursort token passed', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'user-uuid',
cursorToken: 'MjowLjAwMDEyMw==',
contentType: undefined,
limit: undefined,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
items: [item],
cursorToken: undefined,
})
})
it('should return items based on a sync token containing string date', async () => {
const useCase = createUseCase()
const syncTokenData = '1:2021-01-01T00:00:00.000Z'
const syncToken = Buffer.from(syncTokenData, 'utf-8').toString('base64')
const result = await useCase.execute({
userUuid: 'user-uuid',
syncToken,
contentType: undefined,
limit: undefined,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
items: [item],
cursorToken: undefined,
})
})
it('should return error if the sync token is invalid', async () => {
const useCase = createUseCase()
const syncTokenData = 'invalid'
const syncToken = Buffer.from(syncTokenData, 'utf-8').toString('base64')
const result = await useCase.execute({
userUuid: 'user-uuid',
syncToken,
contentType: undefined,
limit: undefined,
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Sync token is missing version part')
})
it('should guard the upper bound limit of items to fetch', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'user-uuid',
cursorToken: undefined,
contentType: undefined,
limit: 200,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
items: [item],
cursorToken: undefined,
})
})
})
@@ -0,0 +1,95 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { Time, TimerInterface } from '@standardnotes/time'
import { Item } from '../../../Item/Item'
import { GetItemsResult } from './GetItemsResult'
import { ItemQuery } from '../../../Item/ItemQuery'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
import { GetItemsDTO } from './GetItemsDTO'
export class GetItems implements UseCaseInterface<GetItemsResult> {
private readonly DEFAULT_ITEMS_LIMIT = 150
private readonly SYNC_TOKEN_VERSION = 2
constructor(
private itemRepository: ItemRepositoryInterface,
private contentSizeTransferLimit: number,
private itemTransferCalculator: ItemTransferCalculatorInterface,
private timer: TimerInterface,
private maxItemsSyncLimit: number,
) {}
async execute(dto: GetItemsDTO): Promise<Result<GetItemsResult>> {
const lastSyncTimeOrError = this.getLastSyncTime(dto)
if (lastSyncTimeOrError.isFailed()) {
return Result.fail(lastSyncTimeOrError.getError())
}
const lastSyncTime = lastSyncTimeOrError.getValue()
const syncTimeComparison = dto.cursorToken ? '>=' : '>'
const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
const itemQuery: ItemQuery = {
userUuid: dto.userUuid,
lastSyncTime: lastSyncTime ?? undefined,
syncTimeComparison,
contentType: dto.contentType,
deleted: lastSyncTime ? undefined : false,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
limit: upperBoundLimit,
}
const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
itemQuery,
this.contentSizeTransferLimit,
)
let items: Array<Item> = []
if (itemUuidsToFetch.length > 0) {
items = await this.itemRepository.findAll({
uuids: itemUuidsToFetch,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
}
const totalItemsCount = await this.itemRepository.countAll(itemQuery)
let cursorToken = undefined
if (totalItemsCount > upperBoundLimit) {
const lastSyncTime = items[items.length - 1].props.timestamps.updatedAt / Time.MicrosecondsInASecond
cursorToken = Buffer.from(`${this.SYNC_TOKEN_VERSION}:${lastSyncTime}`, 'utf-8').toString('base64')
}
return Result.ok({
items,
cursorToken,
})
}
private getLastSyncTime(dto: GetItemsDTO): Result<number | null> {
let token = dto.syncToken
if (dto.cursorToken !== undefined && dto.cursorToken !== null) {
token = dto.cursorToken
}
if (!token) {
return Result.ok(null)
}
const decodedToken = Buffer.from(token, 'base64').toString('utf-8')
const tokenParts = decodedToken.split(':')
const version = tokenParts.shift()
switch (version) {
case '1':
return Result.ok(this.timer.convertStringDateToMicroseconds(tokenParts.join(':')))
case '2':
return Result.ok(+tokenParts[0] * Time.MicrosecondsInASecond)
default:
return Result.fail('Sync token is missing version part')
}
}
}
@@ -1,4 +1,4 @@
export type GetItemsDTO = {
export interface GetItemsDTO {
userUuid: string
syncToken?: string | null
cursorToken?: string | null
@@ -0,0 +1,6 @@
import { Item } from '../../../Item/Item'
export interface GetItemsResult {
items: Item[]
cursorToken?: string
}
@@ -0,0 +1,275 @@
import { TimerInterface } from '@standardnotes/time'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemSaveValidatorInterface } from '../../../Item/SaveValidator/ItemSaveValidatorInterface'
import { SaveItems } from './SaveItems'
import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
import { Logger } from 'winston'
import { ContentType, Dates, Result, Timestamps, Uuid } from '@standardnotes/domain-core'
import { ItemHash } from '../../../Item/ItemHash'
import { Item } from '../../../Item/Item'
describe('SaveItems', () => {
let itemSaveValidator: ItemSaveValidatorInterface
let itemRepository: ItemRepositoryInterface
let timer: TimerInterface
let saveNewItem: SaveNewItem
let updateExistingItem: UpdateExistingItem
let logger: Logger
let itemHash1: ItemHash
let savedItem: Item
const createUseCase = () =>
new SaveItems(itemSaveValidator, itemRepository, timer, saveNewItem, updateExistingItem, logger)
beforeEach(() => {
itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: true })
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuid = jest.fn().mockResolvedValue(null)
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
savedItem = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
saveNewItem = {} as jest.Mocked<SaveNewItem>
saveNewItem.execute = jest.fn().mockReturnValue(Result.ok(savedItem))
updateExistingItem = {} as jest.Mocked<UpdateExistingItem>
updateExistingItem.execute = jest.fn().mockResolvedValue(Result.ok(savedItem))
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
itemHash1 = ItemHash.create({
uuid: 'item-uuid',
user_uuid: 'user-uuid',
content: 'content',
content_type: ContentType.TYPES.Note,
deleted: false,
auth_hash: 'auth-hash',
enc_item_key: 'enc-item-key',
items_key_id: 'items-key-id',
key_system_identifier: null,
shared_vault_uuid: null,
created_at: '2020-01-01T00:00:00.000Z',
created_at_timestamp: 123,
updated_at: '2020-01-01T00:00:00.000Z',
updated_at_timestamp: 123,
}).getValue()
})
it('should save new items', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().syncToken).toEqual('MjowLjAwMDEyNA==')
expect(saveNewItem.execute).toHaveBeenCalledWith({
itemHash: itemHash1,
userUuid: 'user-uuid',
sessionUuid: 'session-uuid',
})
})
it('should mark items as conflicts if saving new item fails', async () => {
const useCase = createUseCase()
saveNewItem.execute = jest.fn().mockResolvedValue(Result.fail('error'))
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().conflicts).toEqual([
{
unsavedItem: itemHash1,
type: 'uuid_conflict',
},
])
})
it('should mark items as conflicts if saving new item throws an error', async () => {
const useCase = createUseCase()
saveNewItem.execute = jest.fn().mockRejectedValue(new Error('error'))
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().conflicts).toEqual([
{
unsavedItem: itemHash1,
type: 'uuid_conflict',
},
])
})
it('should not save items if in read-only mode', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: true,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(saveNewItem.execute).not.toHaveBeenCalled()
})
it('should return conflicts if the items have not passed validation', async () => {
const useCase = createUseCase()
const conflict = {
unsavedItem: itemHash1,
type: 'conflict-type',
}
itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: false, conflict })
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().conflicts).toEqual([conflict])
})
it('should mark items as saved if they are skipped on validation', async () => {
const useCase = createUseCase()
itemSaveValidator.validate = jest.fn().mockResolvedValue({ passed: false, skipped: savedItem })
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().savedItems).toEqual([savedItem])
})
it('should update existing items', async () => {
const useCase = createUseCase()
itemRepository.findByUuid = jest.fn().mockResolvedValue(savedItem)
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(updateExistingItem.execute).toHaveBeenCalledWith({
itemHash: itemHash1,
existingItem: savedItem,
sessionUuid: 'session-uuid',
})
})
it('should mark items as conflicts if updating existing item fails', async () => {
const useCase = createUseCase()
itemRepository.findByUuid = jest.fn().mockResolvedValue(savedItem)
updateExistingItem.execute = jest.fn().mockResolvedValue(Result.fail('error'))
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().conflicts).toEqual([
{
unsavedItem: itemHash1,
type: 'uuid_conflict',
},
])
})
it('should calculate the sync token based on existing and new items saved', async () => {
const useCase = createUseCase()
saveNewItem.execute = jest
.fn()
.mockResolvedValueOnce(Result.ok(savedItem))
.mockResolvedValueOnce(
Result.ok(
Item.create({
...savedItem.props,
timestamps: Timestamps.create(100, 100).getValue(),
}).getValue(),
),
)
.mockResolvedValueOnce(
Result.ok(
Item.create({
...savedItem.props,
timestamps: Timestamps.create(159, 159).getValue(),
}).getValue(),
),
)
const result = await useCase.execute({
itemHashes: [
itemHash1,
ItemHash.create({ ...itemHash1.props, uuid: 'item-uuid-2' }).getValue(),
ItemHash.create({ ...itemHash1.props, uuid: 'item-uuid-2' }).getValue(),
],
userUuid: 'user-uuid',
apiVersion: '2',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().syncToken).toEqual('MjowLjAwMDE2')
})
})
@@ -0,0 +1,145 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { SaveItemsResult } from './SaveItemsResult'
import { SaveItemsDTO } from './SaveItemsDTO'
import { Item } from '../../../Item/Item'
import { ItemConflict } from '../../../Item/ItemConflict'
import { ConflictType } from '@standardnotes/responses'
import { Time, TimerInterface } from '@standardnotes/time'
import { Logger } from 'winston'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemSaveValidatorInterface } from '../../../Item/SaveValidator/ItemSaveValidatorInterface'
import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
export class SaveItems implements UseCaseInterface<SaveItemsResult> {
private readonly SYNC_TOKEN_VERSION = 2
constructor(
private itemSaveValidator: ItemSaveValidatorInterface,
private itemRepository: ItemRepositoryInterface,
private timer: TimerInterface,
private saveNewItem: SaveNewItem,
private updateExistingItem: UpdateExistingItem,
private logger: Logger,
) {}
async execute(dto: SaveItemsDTO): Promise<Result<SaveItemsResult>> {
const savedItems: Array<Item> = []
const conflicts: Array<ItemConflict> = []
const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
for (const itemHash of dto.itemHashes) {
if (dto.readOnlyAccess) {
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.ReadOnlyError,
})
continue
}
const existingItem = await this.itemRepository.findByUuid(itemHash.props.uuid)
const processingResult = await this.itemSaveValidator.validate({
userUuid: dto.userUuid,
apiVersion: dto.apiVersion,
itemHash,
existingItem,
})
if (!processingResult.passed) {
if (processingResult.conflict) {
conflicts.push(processingResult.conflict)
}
if (processingResult.skipped) {
savedItems.push(processingResult.skipped)
}
continue
}
if (existingItem) {
const udpatedItemOrError = await this.updateExistingItem.execute({
existingItem,
itemHash,
sessionUuid: dto.sessionUuid,
})
if (udpatedItemOrError.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Updating item ${itemHash.props.uuid} failed. Error: ${udpatedItemOrError.getError()}`,
)
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
})
continue
}
const updatedItem = udpatedItemOrError.getValue()
savedItems.push(updatedItem)
} else {
try {
const newItemOrError = await this.saveNewItem.execute({
userUuid: dto.userUuid,
itemHash,
sessionUuid: dto.sessionUuid,
})
if (newItemOrError.isFailed()) {
this.logger.error(
`[${dto.userUuid}] Saving item ${itemHash.props.uuid} failed. Error: ${newItemOrError.getError()}`,
)
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
})
continue
}
const newItem = newItemOrError.getValue()
savedItems.push(newItem)
} catch (error) {
this.logger.error(
`[${dto.userUuid}] Saving item ${itemHash.props.uuid} failed. Error: ${(error as Error).message}`,
)
conflicts.push({
unsavedItem: itemHash,
type: ConflictType.UuidConflict,
})
continue
}
}
}
const syncToken = this.calculateSyncToken(lastUpdatedTimestamp, savedItems)
return Result.ok({
savedItems,
conflicts,
syncToken,
})
}
private calculateSyncToken(lastUpdatedTimestamp: number, savedItems: Array<Item>): string {
if (savedItems.length) {
const sortedItems = savedItems.sort((itemA: Item, itemB: Item) => {
return itemA.props.timestamps.updatedAt > itemB.props.timestamps.updatedAt ? 1 : -1
})
lastUpdatedTimestamp = sortedItems[sortedItems.length - 1].props.timestamps.updatedAt
}
const lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles = lastUpdatedTimestamp + 1
return Buffer.from(
`${this.SYNC_TOKEN_VERSION}:${
lastUpdatedTimestampWithMicrosecondPreventingSyncDoubles / Time.MicrosecondsInASecond
}`,
'utf-8',
).toString('base64')
}
}
@@ -1,6 +1,6 @@
import { ItemHash } from './ItemHash'
import { ItemHash } from '../../../Item/ItemHash'
export type SaveItemsDTO = {
export interface SaveItemsDTO {
itemHashes: ItemHash[]
userUuid: string
apiVersion: string
@@ -0,0 +1,8 @@
import { Item } from '../../../Item/Item'
import { ItemConflict } from '../../../Item/ItemConflict'
export interface SaveItemsResult {
savedItems: Item[]
conflicts: ItemConflict[]
syncToken: string
}
@@ -37,8 +37,11 @@ describe('SaveNewItem', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
itemHash1 = {
itemHash1 = ItemHash.create({
uuid: '1-2-3',
user_uuid: '00000000-0000-0000-0000-000000000000',
key_system_identifier: null,
shared_vault_uuid: null,
content: 'asdqwe1',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
@@ -52,7 +55,7 @@ describe('SaveNewItem', () => {
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
}).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.save = jest.fn()
@@ -91,10 +94,13 @@ describe('SaveNewItem', () => {
it('saves a new empty item', async () => {
const useCase = createUseCase()
itemHash1.content = undefined
itemHash1.content_type = null
itemHash1.enc_item_key = undefined
itemHash1.items_key_id = undefined
itemHash1 = ItemHash.create({
...itemHash1.props,
content: undefined,
content_type: null,
enc_item_key: undefined,
items_key_id: undefined,
}).getValue()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
@@ -109,8 +115,11 @@ describe('SaveNewItem', () => {
it('saves a new item with given timestamps', async () => {
const useCase = createUseCase()
itemHash1.created_at_timestamp = 123
itemHash1.updated_at_timestamp = 123
itemHash1 = ItemHash.create({
...itemHash1.props,
created_at_timestamp: 123,
updated_at_timestamp: 123,
}).getValue()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
@@ -124,7 +133,10 @@ describe('SaveNewItem', () => {
it('publishes a duplicate item synced event if the item is a duplicate', async () => {
const useCase = createUseCase()
itemHash1.duplicate_of = '00000000-0000-0000-0000-000000000003'
itemHash1 = ItemHash.create({
...itemHash1.props,
duplicate_of: '00000000-0000-0000-0000-000000000003',
}).getValue()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
@@ -140,7 +152,10 @@ describe('SaveNewItem', () => {
it('publishes a item revision creation requested event if the item is a revision', async () => {
const useCase = createUseCase()
itemHash1.updated_at = '2021-03-19T17:17:13.241Z'
itemHash1 = ItemHash.create({
...itemHash1.props,
updated_at: '2021-03-19T17:17:13.241Z',
}).getValue()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
@@ -199,7 +214,10 @@ describe('SaveNewItem', () => {
it('returns a failure if the content type is invalid', async () => {
const useCase = createUseCase()
itemHash1.content_type = 'invalid'
itemHash1 = ItemHash.create({
...itemHash1.props,
content_type: 'invalid',
}).getValue()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
@@ -213,7 +231,10 @@ describe('SaveNewItem', () => {
it('returns a failure if the duplicate uuid is invalid', async () => {
const useCase = createUseCase()
itemHash1.duplicate_of = 'invalid'
itemHash1 = ItemHash.create({
...itemHash1.props,
duplicate_of: 'invalid',
}).getValue()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
@@ -38,15 +38,15 @@ export class SaveNewItem implements UseCaseInterface<Item> {
}
const userUuid = userUuidOrError.getValue()
const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
if (contentTypeOrError.isFailed()) {
return Result.fail(contentTypeOrError.getError())
}
const contentType = contentTypeOrError.getValue()
let duplicateOf = null
if (dto.itemHash.duplicate_of) {
const duplicateOfOrError = Uuid.create(dto.itemHash.duplicate_of)
if (dto.itemHash.props.duplicate_of) {
const duplicateOfOrError = Uuid.create(dto.itemHash.props.duplicate_of)
if (duplicateOfOrError.isFailed()) {
return Result.fail(duplicateOfOrError.getError())
}
@@ -58,12 +58,12 @@ export class SaveNewItem implements UseCaseInterface<Item> {
let createdAtDate = nowDate
let createdAtTimestamp = now
if (dto.itemHash.created_at_timestamp) {
createdAtTimestamp = dto.itemHash.created_at_timestamp
if (dto.itemHash.props.created_at_timestamp) {
createdAtTimestamp = dto.itemHash.props.created_at_timestamp
createdAtDate = this.timer.convertMicrosecondsToDate(createdAtTimestamp)
} else if (dto.itemHash.created_at) {
createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at)
createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.created_at)
} else if (dto.itemHash.props.created_at) {
createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.props.created_at)
createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.props.created_at)
}
const datesOrError = Dates.create(createdAtDate, nowDate)
@@ -81,18 +81,18 @@ export class SaveNewItem implements UseCaseInterface<Item> {
const itemOrError = Item.create(
{
updatedWithSession,
content: dto.itemHash.content ?? null,
content: dto.itemHash.props.content ?? null,
userUuid,
contentType,
encItemKey: dto.itemHash.enc_item_key ?? null,
authHash: dto.itemHash.auth_hash ?? null,
itemsKeyId: dto.itemHash.items_key_id ?? null,
encItemKey: dto.itemHash.props.enc_item_key ?? null,
authHash: dto.itemHash.props.auth_hash ?? null,
itemsKeyId: dto.itemHash.props.items_key_id ?? null,
duplicateOf,
deleted: dto.itemHash.deleted ?? false,
deleted: dto.itemHash.props.deleted ?? false,
dates,
timestamps,
},
new UniqueEntityId(dto.itemHash.uuid),
new UniqueEntityId(dto.itemHash.props.uuid),
)
if (itemOrError.isFailed()) {
return Result.fail(itemOrError.getError())
@@ -3,19 +3,23 @@ import 'reflect-metadata'
import { ApiVersion } from '../../../Api/ApiVersion'
import { Item } from '../../../Item/Item'
import { ItemHash } from '../../../Item/ItemHash'
import { ItemServiceInterface } from '../../../Item/ItemServiceInterface'
import { SyncItems } from './SyncItems'
import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { GetItems } from '../GetItems/GetItems'
import { SaveItems } from '../SaveItems/SaveItems'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
describe('SyncItems', () => {
let itemService: ItemServiceInterface
let getItemsUseCase: GetItems
let saveItemsUseCase: SaveItems
let itemRepository: ItemRepositoryInterface
let item1: Item
let item2: Item
let item3: Item
let itemHash: ItemHash
const createUseCase = () => new SyncItems(itemService)
const createUseCase = () => new SyncItems(itemRepository, getItemsUseCase, saveItemsUseCase)
beforeEach(() => {
item1 = Item.create(
@@ -67,8 +71,11 @@ describe('SyncItems', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000003'),
).getValue()
itemHash = {
itemHash = ItemHash.create({
uuid: '2-3-4',
user_uuid: '1-2-3',
key_system_identifier: null,
shared_vault_uuid: null,
content: 'asdqwe',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
@@ -76,19 +83,27 @@ describe('SyncItems', () => {
items_key_id: 'asdasd',
created_at: '2021-02-19T11:35:45.655Z',
updated_at: '2021-03-25T09:37:37.944Z',
}
}).getValue()
itemService = {} as jest.Mocked<ItemServiceInterface>
itemService.getItems = jest.fn().mockReturnValue({
items: [item1],
cursorToken: 'asdzxc',
})
itemService.saveItems = jest.fn().mockReturnValue({
savedItems: [item2],
conflicts: [],
syncToken: 'qwerty',
})
itemService.frontLoadKeysItemsToTop = jest.fn().mockReturnValue([item3, item1])
getItemsUseCase = {} as jest.Mocked<GetItems>
getItemsUseCase.execute = jest.fn().mockReturnValue(
Result.ok({
items: [item1],
cursorToken: 'asdzxc',
}),
)
saveItemsUseCase = {} as jest.Mocked<SaveItems>
saveItemsUseCase.execute = jest.fn().mockReturnValue(
Result.ok({
savedItems: [item2],
conflicts: [],
syncToken: 'qwerty',
}),
)
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item3, item1])
})
it('should sync items', async () => {
@@ -113,15 +128,14 @@ describe('SyncItems', () => {
syncToken: 'qwerty',
})
expect(itemService.frontLoadKeysItemsToTop).not.toHaveBeenCalled()
expect(itemService.getItems).toHaveBeenCalledWith({
expect(getItemsUseCase.execute).toHaveBeenCalledWith({
contentType: 'Note',
cursorToken: 'bar',
limit: 10,
syncToken: 'foo',
userUuid: '1-2-3',
})
expect(itemService.saveItems).toHaveBeenCalledWith({
expect(saveItemsUseCase.execute).toHaveBeenCalledWith({
itemHashes: [itemHash],
userUuid: '1-2-3',
apiVersion: '20200115',
@@ -152,25 +166,29 @@ describe('SyncItems', () => {
})
it('should sync items and return filtered out sync conflicts for consecutive sync operations', async () => {
itemService.getItems = jest.fn().mockReturnValue({
items: [item1, item2],
cursorToken: 'asdzxc',
})
getItemsUseCase.execute = jest.fn().mockReturnValue(
Result.ok({
items: [item1, item2],
cursorToken: 'asdzxc',
}),
)
itemService.saveItems = jest.fn().mockReturnValue({
savedItems: [],
conflicts: [
{
serverItem: item2,
type: 'sync_conflict',
},
{
serverItem: undefined,
type: 'sync_conflict',
},
],
syncToken: 'qwerty',
})
saveItemsUseCase.execute = jest.fn().mockReturnValue(
Result.ok({
savedItems: [],
conflicts: [
{
serverItem: item2,
type: 'sync_conflict',
},
{
serverItem: undefined,
type: 'sync_conflict',
},
],
syncToken: 'qwerty',
}),
)
const result = await createUseCase().execute({
userUuid: '1-2-3',
@@ -203,4 +221,44 @@ describe('SyncItems', () => {
syncToken: 'qwerty',
})
})
it('should return error if get items fails', async () => {
getItemsUseCase.execute = jest.fn().mockReturnValue(Result.fail('error'))
const result = await createUseCase().execute({
userUuid: '1-2-3',
itemHashes: [itemHash],
computeIntegrityHash: false,
syncToken: 'foo',
readOnlyAccess: false,
sessionUuid: '2-3-4',
cursorToken: 'bar',
limit: 10,
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if save items fails', async () => {
saveItemsUseCase.execute = jest.fn().mockReturnValue(Result.fail('error'))
const result = await createUseCase().execute({
userUuid: '1-2-3',
itemHashes: [itemHash],
computeIntegrityHash: false,
syncToken: 'foo',
readOnlyAccess: false,
sessionUuid: '2-3-4',
cursorToken: 'bar',
limit: 10,
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
})
expect(result.isFailed()).toBeTruthy()
})
})
@@ -1,34 +1,48 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
import { Item } from '../../../Item/Item'
import { ItemConflict } from '../../../Item/ItemConflict'
import { ItemServiceInterface } from '../../../Item/ItemServiceInterface'
import { SyncItemsDTO } from './SyncItemsDTO'
import { SyncItemsResponse } from './SyncItemsResponse'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { GetItems } from '../GetItems/GetItems'
import { SaveItems } from '../SaveItems/SaveItems'
export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
constructor(private itemService: ItemServiceInterface) {}
constructor(
private itemRepository: ItemRepositoryInterface,
private getItemsUseCase: GetItems,
private saveItemsUseCase: SaveItems,
) {}
async execute(dto: SyncItemsDTO): Promise<Result<SyncItemsResponse>> {
const getItemsResult = await this.itemService.getItems({
const getItemsResultOrError = await this.getItemsUseCase.execute({
userUuid: dto.userUuid,
syncToken: dto.syncToken,
cursorToken: dto.cursorToken,
limit: dto.limit,
contentType: dto.contentType,
})
if (getItemsResultOrError.isFailed()) {
return Result.fail(getItemsResultOrError.getError())
}
const getItemsResult = getItemsResultOrError.getValue()
const saveItemsResult = await this.itemService.saveItems({
const saveItemsResultOrError = await this.saveItemsUseCase.execute({
itemHashes: dto.itemHashes,
userUuid: dto.userUuid,
apiVersion: dto.apiVersion,
readOnlyAccess: dto.readOnlyAccess,
sessionUuid: dto.sessionUuid,
})
if (saveItemsResultOrError.isFailed()) {
return Result.fail(saveItemsResultOrError.getError())
}
const saveItemsResult = saveItemsResultOrError.getValue()
let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
if (this.isFirstSync(dto)) {
retrievedItems = await this.itemService.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems)
retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems)
}
const syncResponse: SyncItemsResponse = {
@@ -59,4 +73,23 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.id.toString()) === -1)
}
private async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
const itemsKeys = await this.itemRepository.findAll({
userUuid,
contentType: ContentType.TYPES.ItemsKey,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
const retrievedItemsIds: Array<string> = retrievedItems.map((item: Item) => item.id.toString())
itemsKeys.forEach((itemKey: Item) => {
if (retrievedItemsIds.indexOf(itemKey.id.toString()) === -1) {
retrievedItems.unshift(itemKey)
}
})
return retrievedItems
}
}
@@ -37,8 +37,11 @@ describe('UpdateExistingItem', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
itemHash1 = {
itemHash1 = ItemHash.create({
uuid: '1-2-3',
user_uuid: '00000000-0000-0000-0000-000000000000',
key_system_identifier: null,
shared_vault_uuid: null,
content: 'asdqwe1',
content_type: ContentType.TYPES.Note,
duplicate_of: null,
@@ -53,7 +56,7 @@ describe('UpdateExistingItem', () => {
new Date(timeHelper.convertMicrosecondsToMilliseconds(item1.props.timestamps.updatedAt) + 1),
'YYYY-MM-DDTHH:mm:ss.SSS[Z]',
),
} as jest.Mocked<ItemHash>
}).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.save = jest.fn()
@@ -107,10 +110,10 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
content_type: 'invalid',
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -122,10 +125,10 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
deleted: true,
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -144,10 +147,10 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
duplicate_of: '00000000-0000-0000-0000-000000000001',
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -161,10 +164,10 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
duplicate_of: 'invalid-uuid',
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -176,11 +179,11 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
updated_at_timestamp: 123,
created_at_timestamp: 123,
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -193,11 +196,11 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
created_at: undefined,
created_at_timestamp: undefined,
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -214,11 +217,11 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
created_at_timestamp: 123,
updated_at_timestamp: 123,
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -237,11 +240,11 @@ describe('UpdateExistingItem', () => {
const result = await useCase.execute({
existingItem: item1,
itemHash: {
...itemHash1,
itemHash: ItemHash.create({
...itemHash1.props,
created_at_timestamp: 123,
updated_at_timestamp: 123,
},
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
})
@@ -27,12 +27,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
}
dto.existingItem.props.updatedWithSession = sessionUuid
if (dto.itemHash.content) {
dto.existingItem.props.content = dto.itemHash.content
if (dto.itemHash.props.content) {
dto.existingItem.props.content = dto.itemHash.props.content
}
if (dto.itemHash.content_type) {
const contentTypeOrError = ContentType.create(dto.itemHash.content_type)
if (dto.itemHash.props.content_type) {
const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
if (contentTypeOrError.isFailed()) {
return Result.fail(contentTypeOrError.getError())
}
@@ -40,13 +40,13 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
dto.existingItem.props.contentType = contentType
}
if (dto.itemHash.deleted !== undefined) {
dto.existingItem.props.deleted = dto.itemHash.deleted
if (dto.itemHash.props.deleted !== undefined) {
dto.existingItem.props.deleted = dto.itemHash.props.deleted
}
let wasMarkedAsDuplicate = false
if (dto.itemHash.duplicate_of) {
const duplicateOfOrError = Uuid.create(dto.itemHash.duplicate_of)
if (dto.itemHash.props.duplicate_of) {
const duplicateOfOrError = Uuid.create(dto.itemHash.props.duplicate_of)
if (duplicateOfOrError.isFailed()) {
return Result.fail(duplicateOfOrError.getError())
}
@@ -54,14 +54,14 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
dto.existingItem.props.duplicateOf = duplicateOfOrError.getValue()
}
if (dto.itemHash.auth_hash) {
dto.existingItem.props.authHash = dto.itemHash.auth_hash
if (dto.itemHash.props.auth_hash) {
dto.existingItem.props.authHash = dto.itemHash.props.auth_hash
}
if (dto.itemHash.enc_item_key) {
dto.existingItem.props.encItemKey = dto.itemHash.enc_item_key
if (dto.itemHash.props.enc_item_key) {
dto.existingItem.props.encItemKey = dto.itemHash.props.enc_item_key
}
if (dto.itemHash.items_key_id) {
dto.existingItem.props.itemsKeyId = dto.itemHash.items_key_id
if (dto.itemHash.props.items_key_id) {
dto.existingItem.props.itemsKeyId = dto.itemHash.props.items_key_id
}
const updatedAtTimestamp = this.timer.getTimestampInMicroseconds()
@@ -72,12 +72,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
let createdAtTimestamp: number
let createdAtDate: Date
if (dto.itemHash.created_at_timestamp) {
createdAtTimestamp = dto.itemHash.created_at_timestamp
if (dto.itemHash.props.created_at_timestamp) {
createdAtTimestamp = dto.itemHash.props.created_at_timestamp
createdAtDate = this.timer.convertMicrosecondsToDate(createdAtTimestamp)
} else if (dto.itemHash.created_at) {
createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at)
createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.created_at)
} else if (dto.itemHash.props.created_at) {
createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.props.created_at)
createdAtDate = this.timer.convertStringDateToDate(dto.itemHash.props.created_at)
} else {
return Result.fail('Created at timestamp is required.')
}
@@ -96,7 +96,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
dto.existingItem.props.contentSize = Buffer.byteLength(JSON.stringify(dto.existingItem))
if (dto.itemHash.deleted === true) {
if (dto.itemHash.props.deleted === true) {
dto.existingItem.props.deleted = true
dto.existingItem.props.content = null
dto.existingItem.props.contentSize = 0