diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 8d29f52..7d62f0f 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -13,7 +13,7 @@ import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceab export const eventStrategyFactory = ( eventRepository: IEventRepository, -): Factory>, [Event, IWebSocketAdapter]> => +): Factory>, [Event, IWebSocketAdapter]> => ([event, adapter]: [Event, IWebSocketAdapter]) => { if (isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) @@ -22,7 +22,7 @@ export const eventStrategyFactory = ( } else if (isNullEvent(event)) { return new NullEventStrategy() } else if (isDeleteEvent(event)) { - return new DeleteEventStrategy(eventRepository) + return new DeleteEventStrategy(adapter, eventRepository) } return new DefaultEventStrategy(adapter, eventRepository) diff --git a/src/handlers/event-strategies/delete-event-strategy.ts b/src/handlers/event-strategies/delete-event-strategy.ts index 1909207..943ca42 100644 --- a/src/handlers/event-strategies/delete-event-strategy.ts +++ b/src/handlers/event-strategies/delete-event-strategy.ts @@ -13,22 +13,17 @@ export class DeleteEventStrategy implements IEventStrategy> ) { } public async execute(event: Event): Promise { + await this.eventRepository.create(event) + const eTags = event.tags.filter((tag) => tag[0] === EventTags.Event) - if (!eTags.length) { - return + if (eTags.length) { + await this.eventRepository.deleteByPubkeyAndIds( + event.pubkey, + eTags.map((tag) => tag[1]) + ) } - const count = await this.eventRepository.create(event) - if (!count) { - return - } - - await this.eventRepository.deleteByPubkeyAndIds( - event.pubkey, - eTags.map((tag) => tag[1]) - ) this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) } - } diff --git a/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts b/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts new file mode 100644 index 0000000..4cb3133 --- /dev/null +++ b/test/unit/handlers/event-strategies/delete-event-strategy.spec.ts @@ -0,0 +1,105 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +import { DatabaseClient } from '../../../../src/@types/base' +import { DeleteEventStrategy } from '../../../../src/handlers/event-strategies/delete-event-strategy' +import { Event } from '../../../../src/@types/event' +import { EventRepository } from '../../../../src/repositories/event-repository' +import { EventTags } from '../../../../src/constants/base' +import { IEventRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +const { expect } = chai + +describe('DeleteEventStrategy', () => { + const event: Event = { + pubkey: 'pubkey', + tags: [ + [EventTags.Event, 'event id 1'], + [EventTags.Event, 'event id 2'], + ], + } as any + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let eventRepositoryDeleteByPubkeyAndIdsStub: Sinon.SinonStub + + let strategy: IEventStrategy> + + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + eventRepositoryCreateStub = sandbox.stub(EventRepository.prototype, 'create') + eventRepositoryDeleteByPubkeyAndIdsStub = sandbox.stub(EventRepository.prototype, 'deleteByPubkeyAndIds') + + webSocketEmitStub = sandbox.stub() + webSocket = { + emit: webSocketEmitStub, + } as any + const client: DatabaseClient = {} as any + eventRepository = new EventRepository(client) + + strategy = new DeleteEventStrategy(webSocket, eventRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + it('creates event', async () => { + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + }) + + it('deletes events if it has e tags', async () => { + await strategy.execute(event) + + expect(eventRepositoryDeleteByPubkeyAndIdsStub).to.have.been.calledOnceWithExactly( + event.pubkey, + ['event id 1', 'event id 2'], + ) + }) + + it('does not delete events if there are no e tags', async () => { + event.tags = [] + + await strategy.execute(event) + + expect(eventRepositoryDeleteByPubkeyAndIdsStub).not.to.have.been.called + }) + + it('broadcast event', async () => { + eventRepositoryCreateStub.resolves() + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Broadcast, + event + ) + }) + + it('rejects if unable to create event', async () => { + const error = new Error() + eventRepositoryCreateStub.rejects(error) + + await expect(strategy.execute(event)).to.eventually.be.rejectedWith(error) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + expect(eventRepositoryDeleteByPubkeyAndIdsStub).not.to.have.been.called + expect(webSocketEmitStub).not.to.have.been.called + }) + }) +}) diff --git a/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts b/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts new file mode 100644 index 0000000..5bf1f27 --- /dev/null +++ b/test/unit/handlers/event-strategies/ephemeral-event-strategy.spec.ts @@ -0,0 +1,48 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +import { EphemeralEventStrategy } from '../../../../src/handlers/event-strategies/ephemeral-event-strategy' +import { Event } from '../../../../src/@types/event' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +const { expect } = chai + +describe('EphemeralEventStrategy', () => { + const event: Event = {} as any + let webSocket: IWebSocketAdapter + let strategy: IEventStrategy> + let sandbox: Sinon.SinonSandbox + + let webSocketEmitStub: Sinon.SinonStub + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + webSocketEmitStub = sandbox.stub() + webSocket = { + emit: webSocketEmitStub, + } as any + + strategy = new EphemeralEventStrategy(webSocket) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + it('broadcasts event', async () => { + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Broadcast, + event + ) + }) + }) +}) diff --git a/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts b/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts new file mode 100644 index 0000000..af1ef27 --- /dev/null +++ b/test/unit/handlers/event-strategies/replaceable-event-strategy.spec.ts @@ -0,0 +1,78 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +import { DatabaseClient } from '../../../../src/@types/base' +import { Event } from '../../../../src/@types/event' +import { EventRepository } from '../../../../src/repositories/event-repository' +import { IEventRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { ReplaceableEventStrategy } from '../../../../src/handlers/event-strategies/replaceable-event-strategy' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +const { expect } = chai + +describe('ReplaceableEventStrategy', () => { + const event: Event = {} as any + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryUpsertStub: Sinon.SinonStub + + let strategy: IEventStrategy> + + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + eventRepositoryUpsertStub = sandbox.stub(EventRepository.prototype, 'upsert') + + webSocketEmitStub = sandbox.stub() + webSocket = { + emit: webSocketEmitStub, + } as any + const client: DatabaseClient = {} as any + eventRepository = new EventRepository(client) + + strategy = new ReplaceableEventStrategy(webSocket, eventRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + it('upserts event', async () => { + await strategy.execute(event) + + expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event) + }) + + it('broadcast event if event is created', async () => { + eventRepositoryUpsertStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Broadcast, + event + ) + }) + + it('rejects if unable to upsert event', async () => { + const error = new Error() + eventRepositoryUpsertStub.rejects(error) + + await expect(strategy.execute(event)).to.eventually.be.rejectedWith(error) + + expect(eventRepositoryUpsertStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).not.to.have.been.called + }) + }) +}) diff --git a/test/unit/utils/runes.spec.ts b/test/unit/utils/runes.spec.ts new file mode 100644 index 0000000..f3fbafb --- /dev/null +++ b/test/unit/utils/runes.spec.ts @@ -0,0 +1,347 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { Alternative } from '../../../src/utils/runes/alternative' +import { Restriction } from '../../../src/utils/runes/restriction' + +describe('Alternative', () => { + describe('constructor', () => { + it('throw error if field has punctuations', () => { + expect(() => new Alternative('%', '=', 'value')).to.throw(Error, 'Field is not valid') + }) + + it('throw error if cond is not valid', () => { + expect(() => new Alternative('field', '@', 'value')).to.throw(Error, 'Cond is not valid') + }) + + it('returns an Alternative if given valid fields', () => { + expect(new Alternative('field', '=', 'value')).to.be.an.instanceOf(Alternative) + }) + }) + + describe('test', () => { + it('returns undefined always if rule is #', () => { + expect(new Alternative('', '#', '').test({})).to.be.undefined + }) + + it('returns reason if field not present with rule field=value', () => { + expect(new Alternative('field', '=', 'value').test({})).to.equal('field: is missing') + }) + + it('returns undefined if field is not present with rule field!', () => { + expect(new Alternative('field', '!', '').test({})).to.be.undefined + }) + + it('returns reason if field is present with rule field!', () => { + expect(new Alternative('field', '!', '').test({ field: 'value' })).to.equal('field: is present') + }) + + it('calls function if field is a function', () => { + const spy = sinon.fake.returns('reason') + const alternative = new Alternative('field', '=', 'value') + + const result = alternative.test({ field: spy }) + + expect(spy).to.have.been.calledOnceWithExactly(alternative) + expect(result).to.equal('reason') + }) + + it('returns reason if field not equals value with rule field=value', () => { + expect(new Alternative('field', '=', 'value').test({ field: 'not value' })).to.equal('field: != value') + }) + + it('returns undefined if field equals value with rule field=value', () => { + expect(new Alternative('field', '=', 'value').test({ field: 'value' })).to.be.undefined + }) + + it('returns undefined if field not equals value with rule field/value', () => { + expect(new Alternative('field', '/', 'value').test({ field: 'not value' })).to.be.undefined + }) + + it('returns reason if field equals value with rule field/value', () => { + expect(new Alternative('field', '/', 'value').test({ field: 'value' })).to.equal('field: = value') + }) + + it('returns undefined if field starts with value with rule field^value', () => { + expect(new Alternative('field', '^', 'value').test({ field: 'value <- here' })).to.be.undefined + }) + + it('returns reason if field does not start value with rule field^value', () => { + expect(new Alternative('field', '^', 'value').test({ field: 'nope' })).to.equal('field: does not start with value') + }) + + it('returns undefined if field ends with value with rule field$value', () => { + expect(new Alternative('field', '$', 'value').test({ field: 'ends -> value' })).to.be.undefined + }) + + it('returns reason if field does not end value with rule field$value', () => { + expect(new Alternative('field', '$', 'value').test({ field: 'nope' })).to.equal('field: does not end with value') + }) + + it('returns undefined if field contains value with in string rule field~value', () => { + expect(new Alternative('field', '~', 'value').test({ field: '-> value <-' })).to.be.undefined + }) + + it('returns reason if field does not contain value in string with rule field~value', () => { + expect(new Alternative('field', '~', 'value').test({ field: 'nope' })).to.equal('field: does not contain value') + }) + + it('returns undefined if field contains value in array with rule field~value', () => { + expect(new Alternative('field', '~', 'value').test({ field: ['value'] })).to.be.undefined + }) + + it('returns reason if field does not contain value in array with rule field~value', () => { + expect(new Alternative('field', '~', 'value').test({ field: [] })).to.equal('field: does not contain value') + }) + + + + + it('returns undefined if field is less than value with rule field { + expect(new Alternative('field', '<', '0').test({ field: -1 })).to.be.undefined + }) + + it('returns reason if field does not less than value with rule field { + expect(new Alternative('field', '<', '0').test({ field: 0 })).to.equal('field: >= 0') + }) + + it('returns undefined if field is greater than value with rule field>value', () => { + expect(new Alternative('field', '>', '0').test({ field: 1 })).to.be.undefined + }) + + it('returns reason if field does not greater than value with rule field>value', () => { + expect(new Alternative('field', '>', '0').test({ field: 0 })).to.equal('field: <= 0') + }) + + it('returns reason if field is not an integer with rule field { + expect(new Alternative('field', '<', '0').test({ field: 'not an integer' })).to.equal('field: not an integer field') + }) + + it('returns reason if field is not an integer with rule field>value', () => { + expect(new Alternative('field', '>', '0').test({ field: 'not an integer' })).to.equal('field: not an integer field') + }) + + it('returns reason if field is not an integer with rule field { + expect(new Alternative('field', '<', 'not an integer').test({ field: 1 })).to.equal('field: not a valid integer') + }) + + it('returns reason if field is not an integer with rule field>value', () => { + expect(new Alternative('field', '>', 'not an integer').test({ field: 1 })).to.equal('field: not a valid integer') + }) + + + + it('returns undefined if field is same as value with rule field{value', () => { + expect(new Alternative('field', '{', 'b').test({ field: 'b' })).to.equal('field: is the same or ordered after b') + }) + + it('returns undefined if field is ordered before value with rule field{value', () => { + expect(new Alternative('field', '{', 'b').test({ field: 'a' })).to.be.undefined + }) + + it('returns reason if field is ordered after value with rule field{value', () => { + expect(new Alternative('field', '{', 'b').test({ field: 'c' })).to.equal('field: is the same or ordered after b') + }) + + it('returns undefined if field is same as value with rule field}value', () => { + expect(new Alternative('field', '}', 'b').test({ field: 'b' })).to.equal('field: is the same or ordered before b') + }) + + it('returns undefined if field is ordered after value with rule field}value', () => { + expect(new Alternative('field', '}', 'b').test({ field: 'c' })).to.be.undefined + }) + + it('returns reason if field is ordered before value with rule field}value', () => { + expect(new Alternative('field', '}', 'b').test({ field: 'a' })).to.equal('field: is the same or ordered before b') + }) + }) + + describe('encode', () => { + it('returns encoded alternative field = value', () => { + expect(new Alternative('field', '=', 'value').encode()).to.equal('field=value') + }) + + it('returns encoded alternative #', () => { + expect(new Alternative('', '#', '').encode()).to.equal('#') + }) + + it('returns encoded alternative field!', () => { + expect(new Alternative('field', '!', '').encode()).to.equal('field!') + }) + + it('returns encoded alternative field=\\|&', () => { + expect(new Alternative('field', '=', '\\|&').encode()).to.equal('field=\\\\\\|\\&') + }) + }) + + describe('decode', () => { + it('decodes #', () => { + const [alternative] = Alternative.decode('#') + expect(alternative.encode()).to.equal('#') + }) + + it('decodes field!', () => { + const [alternative] = Alternative.decode('field!') + expect(alternative.encode()).to.equal('field!') + }) + + it('decodes field=value', () => { + const [alternative] = Alternative.decode('field=value') + expect(alternative.encode()).to.equal('field=value') + }) + + it('decodes field=\\\\\\|\\&', () => { + const [alternative] = Alternative.decode('field=\\\\\\|\\&') + expect(alternative.encode()).to.equal('field=\\\\\\|\\&') + }) + + it('throw error decoding value', () => { + expect(() => Alternative.decode('value')).to.throw(Error, 'value does not contain any operator') + }) + + it('decodes until first | and consumes it', () => { + const [alternative, remainder] = Alternative.decode('a=1|b=2') + expect(alternative.encode()).to.equal('a=1') + expect(remainder).to.equal('b=2') + }) + + it('decodes until first &', () => { + const [alternative, remainder] = Alternative.decode('a=1&b=2') + expect(alternative.encode()).to.equal('a=1') + expect(remainder).to.equal('&b=2') + }) + }) + + describe('from', () => { + it('creates alternative rule with spaces "field = value"', () => { + expect(Alternative.from('field = value').encode()).to.equal('field=value') + }) + }) +}) + +describe('Restriction', () => { + describe('constructor', () => { + it('throws if given alternatives list is empty', () => [ + expect(() => new Restriction([])).to.throw(Error, 'Restriction must have some alternatives'), + ]) + }) + + describe('test', () => { + it('returns undefined given 1 true alternative', () => { + const values = { a: 1 } + const alternatives: Alternative[] = [ + { test: sinon.fake.returns(undefined) }, + ] as any + + expect(new Restriction(alternatives).test(values)).to.be.undefined + + expect(alternatives[0].test).to.have.been.calledOnceWithExactly(values) + }) + + it('returns undefined given 2 true alternative', () => { + const values = { a: 1 } + const alternatives: Alternative[] = [ + { test: sinon.fake.returns(undefined) }, + { test: sinon.fake.returns(undefined) }, + ] as any + + expect(new Restriction(alternatives).test(values)).to.be.undefined + + expect(alternatives[0].test).to.have.been.calledOnceWithExactly(values) + expect(alternatives[1].test).not.to.have.been.called + }) + + it('returns undefined given 1 true and 1 false alternative', () => { + const values = { a: 1 } + const alternatives: Alternative[] = [ + { test: sinon.fake.returns(undefined) }, + { test: sinon.fake.returns('reason') }, + ] as any + + expect(new Restriction(alternatives).test(values)).to.be.undefined + + expect(alternatives[0].test).to.have.been.calledOnceWithExactly(values) + expect(alternatives[1].test).not.to.have.been.called + }) + + it('returns reason given 1 false alternative', () => { + const values = { a: 1 } + const alternatives: Alternative[] = [ + { test: sinon.fake.returns('reason') }, + ] as any + + expect(new Restriction(alternatives).test(values)).to.equal('reason') + + expect(alternatives[0].test).to.have.been.calledOnceWithExactly(values) + }) + + it('returns undefined given 1 false and 1 true alternative', () => { + const values = { a: 1 } + const alternatives: Alternative[] = [ + { test: sinon.fake.returns('reason') }, + { test: sinon.fake.returns(undefined) }, + ] as any + + expect(new Restriction(alternatives).test(values)).to.be.undefined + + expect(alternatives[0].test).to.have.been.calledOnceWithExactly(values) + expect(alternatives[1].test).to.have.been.calledOnceWithExactly(values) + }) + + it('returns reasons given 2 false alternatives', () => { + const values = { a: 1 } + const alternatives: Alternative[] = [ + { test: sinon.fake.returns('reason 1') }, + { test: sinon.fake.returns('reason 2') }, + ] as any + + expect(new Restriction(alternatives).test(values)).to.equal('reason 1 AND reason 2') + + expect(alternatives[0].test).to.have.been.calledOnceWithExactly(values) + expect(alternatives[1].test).to.have.been.calledOnceWithExactly(values) + }) + }) + + describe('encode', () => { + it('returns encoded restriction with 1 alternative', () => { + const alternatives: Alternative[] = [ + { encode: sinon.fake.returns('a=1') }, + ] as any + + expect(new Restriction(alternatives).encode()).to.equal('a=1') + }) + + + it('returns encoded restrictions with 2 alternatives', () => { + const alternatives: Alternative[] = [ + { encode: sinon.fake.returns('a=1') }, + { encode: sinon.fake.returns('b=2') }, + ] as any + + expect(new Restriction(alternatives).encode()).to.equal('a=1|b=2') + }) + }) + + describe('decode', () => { + it('returns encoded restriction given 1 alternative', () => { + const [restriction, remainder] = Restriction.decode('a=1') + + expect(restriction.encode()).to.equal('a=1') + expect(remainder).to.be.empty + }) + + it('returns encoded restriction given 2 alternatives', () => { + const [restriction, remainder] = Restriction.decode('a=1|b=2') + + expect(restriction.encode()).to.equal('a=1|b=2') + expect(remainder).to.be.empty + }) + + it('returns encoded restriction given 2 alternatives and another restriction', () => { + const [restriction, remainder] = Restriction.decode('a=1|b=2&c=1') + + expect(restriction.encode()).to.equal('a=1|b=2') + expect(remainder).to.equal('c=1') + }) + }) +})