Compare commits

..

4 Commits

Author SHA1 Message Date
standardci
cbc024f67a chore(release): publish new version
- @standardnotes/home-server@1.11.36
 - @standardnotes/syncing-server@1.61.0
2023-07-10 11:54:54 +00:00
Karol Sójko
55ec5970da feat: message operations use cases. (#652)
Co-authored-by: Mo <mo@standardnotes.com>
2023-07-10 13:38:07 +02:00
standardci
58bdca6659 chore(release): publish new version
- @standardnotes/home-server@1.11.35
 - @standardnotes/syncing-server@1.60.0
2023-07-10 11:25:21 +00:00
Karol Sójko
ef49b0d3f8 feat: sending messages. (#651)
* feat: sending messages.

Co-authored-by: Mo <mo@standardnotes.com>

* fix: messages repository.

Co-authored-by: Mo <mo@standardnotes.com>

---------

Co-authored-by: Mo <mo@standardnotes.com>
2023-07-10 13:10:31 +02:00
22 changed files with 585 additions and 2 deletions

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.11.36](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.11.35...@standardnotes/home-server@1.11.36) (2023-07-10)
**Note:** Version bump only for package @standardnotes/home-server
## [1.11.35](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.11.34...@standardnotes/home-server@1.11.35) (2023-07-10)
**Note:** Version bump only for package @standardnotes/home-server
## [1.11.34](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.11.33...@standardnotes/home-server@1.11.34) (2023-07-10)
**Note:** Version bump only for package @standardnotes/home-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/home-server",
"version": "1.11.34",
"version": "1.11.36",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.61.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.60.0...@standardnotes/syncing-server@1.61.0) (2023-07-10)
### Features
* message operations use cases. ([#652](https://github.com/standardnotes/syncing-server-js/issues/652)) ([55ec597](https://github.com/standardnotes/syncing-server-js/commit/55ec5970daff9ef51f59e23eca17b312d392542a))
# [1.60.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.59.1...@standardnotes/syncing-server@1.60.0) (2023-07-10)
### Features
* sending messages. ([#651](https://github.com/standardnotes/syncing-server-js/issues/651)) ([ef49b0d](https://github.com/standardnotes/syncing-server-js/commit/ef49b0d3f8ab76dfa63a4c691feb9f35ad65c46f))
## [1.59.1](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.59.0...@standardnotes/syncing-server@1.59.1) (2023-07-10)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.59.1",
"version": "1.61.0",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -0,0 +1,18 @@
import { Timestamps, Uuid } from '@standardnotes/domain-core'
import { Message } from './Message'
describe('Message', () => {
it('should create an entity', () => {
const entityOrError = Message.create({
timestamps: Timestamps.create(123456789, 123456789).getValue(),
recipientUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
senderUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
encryptedMessage: 'encryptedMessage',
replaceabilityIdentifier: 'replaceabilityIdentifier',
})
expect(entityOrError.isFailed()).toBeFalsy()
expect(entityOrError.getValue().id).not.toBeNull()
})
})

View File

@@ -4,6 +4,12 @@ import { Message } from './Message'
export interface MessageRepositoryInterface {
findByUuid: (uuid: Uuid) => Promise<Message | null>
findByRecipientUuid: (uuid: Uuid) => Promise<Message[]>
findBySenderUuid: (uuid: Uuid) => Promise<Message[]>
findByRecipientUuidAndReplaceabilityIdentifier: (dto: {
recipientUuid: Uuid
replaceabilityIdentifier: string
}) => Promise<Message | null>
save(message: Message): Promise<void>
remove(message: Message): Promise<void>
}

View File

@@ -0,0 +1,60 @@
import { Result, Timestamps, Uuid } from '@standardnotes/domain-core'
import { Message } from '../../../Message/Message'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { DeleteMessage } from '../DeleteMessage/DeleteMessage'
import { DeleteAllMessagesSentToUser } from './DeleteAllMessagesSentToUser'
describe('DeleteAllMessagesSentToUser', () => {
let messageRepository: MessageRepositoryInterface
let deleteMessageUseCase: DeleteMessage
let message: Message
const createUseCase = () => new DeleteAllMessagesSentToUser(messageRepository, deleteMessageUseCase)
beforeEach(() => {
message = Message.create({
senderUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
recipientUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
encryptedMessage: 'encryptedMessage',
replaceabilityIdentifier: 'replaceabilityIdentifier',
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
messageRepository = {} as jest.Mocked<MessageRepositoryInterface>
messageRepository.findByRecipientUuid = jest.fn().mockReturnValue([message])
deleteMessageUseCase = {} as jest.Mocked<DeleteMessage>
deleteMessageUseCase.execute = jest.fn().mockReturnValue(Result.ok())
})
it('should delete all messages sent to user', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
})
it('should return error when recipient uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: 'invalid',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
})
it('should return error when delete message use case fails', async () => {
const useCase = createUseCase()
deleteMessageUseCase.execute = jest.fn().mockReturnValue(Result.fail('error'))
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('error')
})
})

View File

@@ -0,0 +1,30 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { DeleteAllMessagesSentToUserDTO } from './DeleteAllMessagesSentToUserDTO'
import { DeleteMessage } from '../DeleteMessage/DeleteMessage'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
export class DeleteAllMessagesSentToUser implements UseCaseInterface<void> {
constructor(private messageRepository: MessageRepositoryInterface, private deleteMessageUseCase: DeleteMessage) {}
async execute(dto: DeleteAllMessagesSentToUserDTO): Promise<Result<void>> {
const recipientUuidOrError = Uuid.create(dto.recipientUuid)
if (recipientUuidOrError.isFailed()) {
return Result.fail(recipientUuidOrError.getError())
}
const recipientUuid = recipientUuidOrError.getValue()
const messages = await this.messageRepository.findByRecipientUuid(recipientUuid)
for (const message of messages) {
const result = await this.deleteMessageUseCase.execute({
originatorUuid: recipientUuid.value,
messageUuid: message.id.toString(),
})
if (result.isFailed()) {
return Result.fail(result.getError())
}
}
return Result.ok()
}
}

View File

@@ -0,0 +1,3 @@
export interface DeleteAllMessagesSentToUserDTO {
recipientUuid: string
}

View File

@@ -0,0 +1,77 @@
import { Timestamps, Uuid } from '@standardnotes/domain-core'
import { Message } from '../../../Message/Message'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { DeleteMessage } from './DeleteMessage'
describe('DeleteMessage', () => {
let messageRepository: MessageRepositoryInterface
let message: Message
const createUseCase = () => new DeleteMessage(messageRepository)
beforeEach(() => {
message = Message.create({
senderUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
recipientUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
encryptedMessage: 'encryptedMessage',
replaceabilityIdentifier: 'replaceabilityIdentifier',
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
messageRepository = {} as jest.Mocked<MessageRepositoryInterface>
messageRepository.remove = jest.fn()
messageRepository.findByUuid = jest.fn().mockReturnValue(message)
})
it('should remove message', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
messageUuid: '00000000-0000-0000-0000-000000000000',
originatorUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
})
it('should return error when message is not found', async () => {
messageRepository.findByUuid = jest.fn().mockReturnValue(null)
const useCase = createUseCase()
const result = await useCase.execute({
messageUuid: '00000000-0000-0000-0000-000000000000',
originatorUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if originator is neither the sender nor the recipient', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
messageUuid: '00000000-0000-0000-0000-000000000000',
originatorUuid: '11111111-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error when message uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
messageUuid: 'invalid',
originatorUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error when originator uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
messageUuid: '00000000-0000-0000-0000-000000000000',
originatorUuid: 'invalid',
})
expect(result.isFailed()).toBeTruthy()
})
})

View File

@@ -0,0 +1,38 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { DeleteMessageDTO } from './DeleteMessageDTO'
export class DeleteMessage implements UseCaseInterface<void> {
constructor(private messageRepository: MessageRepositoryInterface) {}
async execute(dto: DeleteMessageDTO): Promise<Result<void>> {
const originatorUuidOrError = Uuid.create(dto.originatorUuid)
if (originatorUuidOrError.isFailed()) {
return Result.fail(originatorUuidOrError.getError())
}
const originatorUuid = originatorUuidOrError.getValue()
const messageUuidOrError = Uuid.create(dto.messageUuid)
if (messageUuidOrError.isFailed()) {
return Result.fail(messageUuidOrError.getError())
}
const messageUuid = messageUuidOrError.getValue()
const message = await this.messageRepository.findByUuid(messageUuid)
if (!message) {
return Result.fail('Message not found')
}
const isSentByOriginator = message.props.senderUuid.equals(originatorUuid)
const isSentToOriginator = message.props.recipientUuid.equals(originatorUuid)
if (!isSentByOriginator && !isSentToOriginator) {
return Result.fail('Not authorized to delete this message')
}
await this.messageRepository.remove(message)
return Result.ok()
}
}

View File

@@ -0,0 +1,4 @@
export interface DeleteMessageDTO {
originatorUuid: string
messageUuid: string
}

View File

@@ -0,0 +1,32 @@
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { GetMessagesSentByUser } from './GetMessagesSentByUser'
describe('GetMessagesSentByUser', () => {
let messageRepository: MessageRepositoryInterface
const createUseCase = () => new GetMessagesSentByUser(messageRepository)
beforeEach(() => {
messageRepository = {} as jest.Mocked<MessageRepositoryInterface>
messageRepository.findBySenderUuid = jest.fn().mockReturnValue([])
})
it('should return messages sent by user', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
senderUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.getValue()).toEqual([])
})
it('should return error when sender uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
senderUuid: 'invalid',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
})
})

View File

@@ -0,0 +1,21 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Message } from '../../../Message/Message'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { GetMessagesSentByUserDTO } from './GetMessagesSentByUserDTO'
export class GetMessagesSentByUser implements UseCaseInterface<Message[]> {
constructor(private messageRepository: MessageRepositoryInterface) {}
async execute(dto: GetMessagesSentByUserDTO): Promise<Result<Message[]>> {
const senderUuidOrError = Uuid.create(dto.senderUuid)
if (senderUuidOrError.isFailed()) {
return Result.fail(senderUuidOrError.getError())
}
const senderUuid = senderUuidOrError.getValue()
const messages = await this.messageRepository.findBySenderUuid(senderUuid)
return Result.ok(messages)
}
}

View File

@@ -0,0 +1,3 @@
export interface GetMessagesSentByUserDTO {
senderUuid: string
}

View File

@@ -0,0 +1,32 @@
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { GetMessagesSentToUser } from './GetMessagesSentToUser'
describe('GetMessagesSentToUser', () => {
let messageRepository: MessageRepositoryInterface
const createUseCase = () => new GetMessagesSentToUser(messageRepository)
beforeEach(() => {
messageRepository = {} as jest.Mocked<MessageRepositoryInterface>
messageRepository.findByRecipientUuid = jest.fn().mockReturnValue([])
})
it('should return messages sent to user', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.getValue()).toEqual([])
})
it('should return error when recipient uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: 'invalid',
})
expect(result.isFailed()).toBe(true)
expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
})
})

View File

@@ -0,0 +1,21 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Message } from '../../../Message/Message'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { GetMessagesSentToUserDTO } from './GetMessagesSentToUserDTO'
export class GetMessagesSentToUser implements UseCaseInterface<Message[]> {
constructor(private messageRepository: MessageRepositoryInterface) {}
async execute(dto: GetMessagesSentToUserDTO): Promise<Result<Message[]>> {
const recipientUuidOrError = Uuid.create(dto.recipientUuid)
if (recipientUuidOrError.isFailed()) {
return Result.fail(recipientUuidOrError.getError())
}
const recipientUuid = recipientUuidOrError.getValue()
const messages = await this.messageRepository.findByRecipientUuid(recipientUuid)
return Result.ok(messages)
}
}

View File

@@ -0,0 +1,3 @@
export interface GetMessagesSentToUserDTO {
recipientUuid: string
}

View File

@@ -0,0 +1,107 @@
import { TimerInterface } from '@standardnotes/time'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { SendMessageToUser } from './SendMessageToUser'
import { Message } from '../../../Message/Message'
import { Result } from '@standardnotes/domain-core'
describe('SendMessageToUser', () => {
let messageRepository: MessageRepositoryInterface
let timer: TimerInterface
let existingMessage: Message
const createUseCase = () => new SendMessageToUser(messageRepository, timer)
beforeEach(() => {
existingMessage = {} as jest.Mocked<Message>
messageRepository = {} as jest.Mocked<MessageRepositoryInterface>
messageRepository.findByRecipientUuidAndReplaceabilityIdentifier = jest.fn().mockReturnValue(null)
messageRepository.remove = jest.fn()
messageRepository.save = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
})
it('saves a new message', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
senderUuid: '00000000-0000-0000-0000-000000000000',
encryptedMessage: 'encrypted-message',
})
expect(result.isFailed()).toBeFalsy()
})
it('removes existing message with the same replaceability identifier', async () => {
messageRepository.findByRecipientUuidAndReplaceabilityIdentifier = jest.fn().mockReturnValue(existingMessage)
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
senderUuid: '00000000-0000-0000-0000-000000000000',
encryptedMessage: 'encrypted-message',
replaceabilityIdentifier: 'replaceability-identifier',
})
expect(result.isFailed()).toBeFalsy()
expect(messageRepository.remove).toHaveBeenCalledWith(existingMessage)
})
it('returns error when recipient uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: 'invalid-uuid',
senderUuid: '00000000-0000-0000-0000-000000000000',
encryptedMessage: 'encrypted-message',
})
expect(result.isFailed()).toBeTruthy()
})
it('returns error when sender uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
senderUuid: 'invalid-uuid',
encryptedMessage: 'encrypted-message',
})
expect(result.isFailed()).toBeTruthy()
})
it('returns error when message is empty', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
senderUuid: '00000000-0000-0000-0000-000000000000',
encryptedMessage: '',
})
expect(result.isFailed()).toBeTruthy()
})
it('returns error when message fails to create', async () => {
const mock = jest.spyOn(Message, 'create')
mock.mockImplementation(() => {
return Result.fail('Oops')
})
const useCase = createUseCase()
const result = await useCase.execute({
recipientUuid: '00000000-0000-0000-0000-000000000000',
senderUuid: '00000000-0000-0000-0000-000000000000',
encryptedMessage: 'encrypted-message',
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
})
})

View File

@@ -0,0 +1,59 @@
import { Result, Timestamps, UseCaseInterface, Uuid, Validator } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { SendMessageToUserDTO } from './SendMessageToUserDTO'
import { MessageRepositoryInterface } from '../../../Message/MessageRepositoryInterface'
import { Message } from '../../../Message/Message'
export class SendMessageToUser implements UseCaseInterface<Message> {
constructor(private messageRepository: MessageRepositoryInterface, private timer: TimerInterface) {}
async execute(dto: SendMessageToUserDTO): Promise<Result<Message>> {
const recipientUuidOrError = Uuid.create(dto.recipientUuid)
if (recipientUuidOrError.isFailed()) {
return Result.fail(recipientUuidOrError.getError())
}
const recipientUuid = recipientUuidOrError.getValue()
const senderUuidOrError = Uuid.create(dto.senderUuid)
if (senderUuidOrError.isFailed()) {
return Result.fail(senderUuidOrError.getError())
}
const senderUuid = senderUuidOrError.getValue()
const validateNotEmptyMessage = Validator.isNotEmpty(dto.encryptedMessage)
if (validateNotEmptyMessage.isFailed()) {
return Result.fail(validateNotEmptyMessage.getError())
}
if (dto.replaceabilityIdentifier) {
const existingMessage = await this.messageRepository.findByRecipientUuidAndReplaceabilityIdentifier({
recipientUuid,
replaceabilityIdentifier: dto.replaceabilityIdentifier,
})
if (existingMessage) {
await this.messageRepository.remove(existingMessage)
}
}
const messageOrError = Message.create({
recipientUuid,
senderUuid,
encryptedMessage: dto.encryptedMessage,
timestamps: Timestamps.create(
this.timer.getTimestampInMicroseconds(),
this.timer.getTimestampInMicroseconds(),
).getValue(),
replaceabilityIdentifier: dto.replaceabilityIdentifier ?? null,
})
if (messageOrError.isFailed()) {
return Result.fail(messageOrError.getError())
}
const message = messageOrError.getValue()
await this.messageRepository.save(message)
return Result.ok(message)
}
}

View File

@@ -0,0 +1,6 @@
export interface SendMessageToUserDTO {
recipientUuid: string
senderUuid: string
encryptedMessage: string
replaceabilityIdentifier?: string
}

View File

@@ -11,6 +11,49 @@ export class TypeORMMessageRepository implements MessageRepositoryInterface {
private mapper: MapperInterface<Message, TypeORMMessage>,
) {}
async findByRecipientUuid(uuid: Uuid): Promise<Message[]> {
const persistence = await this.ormRepository
.createQueryBuilder('message')
.where('message.recipient_uuid = :recipientUuid', {
recipientUuid: uuid.value,
})
.getMany()
return persistence.map((p) => this.mapper.toDomain(p))
}
async findBySenderUuid(uuid: Uuid): Promise<Message[]> {
const persistence = await this.ormRepository
.createQueryBuilder('message')
.where('message.sender_uuid = :senderUuid', {
senderUuid: uuid.value,
})
.getMany()
return persistence.map((p) => this.mapper.toDomain(p))
}
async findByRecipientUuidAndReplaceabilityIdentifier(dto: {
recipientUuid: Uuid
replaceabilityIdentifier: string
}): Promise<Message | null> {
const persistence = await this.ormRepository
.createQueryBuilder('message')
.where('message.recipientUuid = :recipientUuid', {
recipientUuid: dto.recipientUuid.value,
})
.andWhere('message.replaceabilityIdentifier = :replaceabilityIdentifier', {
replaceabilityIdentifier: dto.replaceabilityIdentifier,
})
.getOne()
if (persistence === null) {
return null
}
return this.mapper.toDomain(persistence)
}
async findByUuid(uuid: Uuid): Promise<Message | null> {
const persistence = await this.ormRepository
.createQueryBuilder('message')