diff --git a/src/utils/event.ts b/src/utils/event.ts index 3944fc1..63be785 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -5,7 +5,7 @@ import { CanonicalEvent, Event } from '../@types/event' import { EventKinds, EventTags } from '../constants/base' import { fromBuffer } from './transform' import { isGenericTagQuery } from './filter' -import { Rune } from './runes' +import { RuneLike } from './runes/rune-like' import { SubscriptionFilter } from '../@types/subscription' export const serializeEvent = (event: Partial): CanonicalEvent => [ @@ -128,7 +128,7 @@ export const isDelegatedEventValid = async (event: Event): Promise => { let result: boolean try { - [result] = Rune.from(delegation[2]).test(runifiedEvent) + [result] = RuneLike.from(delegation[2]).test(runifiedEvent) } catch (error) { result = false } diff --git a/src/utils/runes.ts b/src/utils/runes/alternative.ts similarity index 62% rename from src/utils/runes.ts rename to src/utils/runes/alternative.ts index e83a16a..c45aaa4 100644 --- a/src/utils/runes.ts +++ b/src/utils/runes/alternative.ts @@ -125,91 +125,4 @@ export class Alternative { } } -export class Restriction { - public constructor( - private readonly alternatives: Alternative[] - ) { - if (!alternatives.length) { - throw new Error('Restriction must have some alternatives') - } - } - public test(values: Record): string | undefined { - const reasons: string[] = [] - for (const alternative of this.alternatives) { - const reason = alternative.test(values) - if (typeof reason === 'undefined') { - return - } - reasons.push(reason) - } - - return reasons.join(' AND ') - } - - public encode(): string { - return this.alternatives.map((alternative) => alternative.encode()).join('|') - } - - public static decode(encodedStr: string): [Restriction, string] { - let encStr = encodedStr - let alternative: Alternative - const alternatives: Alternative[] = [] - while (encStr.length) { - if (encStr.startsWith('&')) { - encStr = encStr.slice(1) - break - } - - [alternative, encStr] = Alternative.decode(encStr) - - alternatives.push(alternative) - } - - return [new Restriction(alternatives), encStr] - } - - public static from(encodedStr: string): Restriction { - const [restriction, remainder] = Restriction.decode(encodedStr.replace(/\s+/g, '')) - - if (remainder.length) { - throw new Error(`Restriction had extra characters at end: ${remainder}`) - } - - return restriction - } -} - -export class Rune { - public constructor( - private readonly restrictions: Restriction[] = [] - ) { } - - public test(values: Record): [boolean, string] { - for (const restriction of this.restrictions) { - const reasons = restriction.test(values) - if (typeof reasons !== 'undefined') { - return [false, reasons] - } - } - - return [true, ''] - } - - public encode() { - return this.restrictions.map((restriction) => restriction.encode()).join('&') - } - - public static from(encodedStr: string): Rune { - const restrictions: Restriction[] = [] - let restriction: Restriction - let encStr = encodedStr.replace(/\s+/g, '') - - while (encStr.length) { - [restriction, encStr] = Restriction.decode(encStr) - restrictions.push(restriction) - } - - return new Rune(restrictions) - } -} diff --git a/src/utils/runes/restriction.ts b/src/utils/runes/restriction.ts new file mode 100644 index 0000000..5174cad --- /dev/null +++ b/src/utils/runes/restriction.ts @@ -0,0 +1,47 @@ +import { Alternative } from './alternative' + + +export class Restriction { + public constructor( + private readonly alternatives: Alternative[] + ) { + if (!alternatives.length) { + throw new Error('Restriction must have some alternatives') + } + } + + public test(values: Record): string | undefined { + const reasons: string[] = [] + for (const alternative of this.alternatives) { + const reason = alternative.test(values) + if (typeof reason === 'undefined') { + return + } + reasons.push(reason) + } + + return reasons.join(' AND ') + } + + public encode(): string { + return this.alternatives.map((alternative) => alternative.encode()).join('|') + } + + public static decode(encodedStr: string): [Restriction, string] { + let encStr = encodedStr + let alternative: Alternative + const alternatives: Alternative[] = [] + while (encStr.length) { + if (encStr.startsWith('&')) { + encStr = encStr.slice(1) + break + } + + [alternative, encStr] = Alternative.decode(encStr) + + alternatives.push(alternative) + } + + return [new Restriction(alternatives), encStr] + } +} diff --git a/src/utils/runes/rune-like.ts b/src/utils/runes/rune-like.ts new file mode 100644 index 0000000..0ba98ef --- /dev/null +++ b/src/utils/runes/rune-like.ts @@ -0,0 +1,36 @@ +import { Restriction } from './restriction' + + +export class RuneLike { + public constructor( + private readonly restrictions: Restriction[] = [] + ) { } + + public test(values: Record): [boolean, string] { + for (const restriction of this.restrictions) { + const reasons = restriction.test(values) + if (typeof reasons !== 'undefined') { + return [false, reasons] + } + } + + return [true, ''] + } + + public encode() { + return this.restrictions.map((restriction) => restriction.encode()).join('&') + } + + public static from(encodedStr: string): RuneLike { + const restrictions: Restriction[] = [] + let restriction: Restriction + let encStr = encodedStr.replace(/\s+/g, '') + + while (encStr.length) { + [restriction, encStr] = Restriction.decode(encStr) + restrictions.push(restriction) + } + + return new RuneLike(restrictions) + } +} diff --git a/test/unit/utils/runes.spec.ts b/test/unit/utils/runes/alternative.spec.ts similarity index 99% rename from test/unit/utils/runes.spec.ts rename to test/unit/utils/runes/alternative.spec.ts index 5f5f2a5..54c3b22 100644 --- a/test/unit/utils/runes.spec.ts +++ b/test/unit/utils/runes/alternative.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import sinon from 'sinon' -import { Alternative } from '../../../src/utils/runes' +import { Alternative } from '../../../../src/utils/runes/alternative' describe('Alternative', () => { describe('constructor', () => { diff --git a/test/unit/utils/runes/restriction.spec.ts b/test/unit/utils/runes/restriction.spec.ts new file mode 100644 index 0000000..5a6982e --- /dev/null +++ b/test/unit/utils/runes/restriction.spec.ts @@ -0,0 +1,132 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { Alternative } from '../../../../src/utils/runes/alternative' +import { Restriction } from '../../../../src/utils/runes/restriction' + +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') + }) + }) +}) diff --git a/test/unit/utils/runes/rune-like.spec.ts b/test/unit/utils/runes/rune-like.spec.ts new file mode 100644 index 0000000..2c1dc83 --- /dev/null +++ b/test/unit/utils/runes/rune-like.spec.ts @@ -0,0 +1,173 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { Alternative } from '../../../../src/utils/runes/alternative' +import { Restriction } from '../../../../src/utils/runes/restriction' +import { RuneLike } from '../../../../src/utils/runes/rune-like' + +describe('RuneLike', () => { + describe('test', () => { + it('returns true if 1 restriction is true', () => { + const values = { a: 1 } + const restrictions: Restriction[] = [ + { test: sinon.fake.returns(undefined) } as any, + ] + + expect(new RuneLike(restrictions).test(values)).to.deep.equal([true, '']) + expect(restrictions[0].test).to.have.been.calledOnceWithExactly(values) + }) + + it('returns false and reason if 1 restriction is false', () => { + const values = { a: 1 } + const restrictions: Restriction[] = [ + { test: sinon.fake.returns('reason') } as any, + ] + + expect(new RuneLike(restrictions).test(values)).to.deep.equal([false, 'reason']) + expect(restrictions[0].test).to.have.been.calledOnceWithExactly(values) + }) + + it('returns false if 1 restriction is true and 1 is false', () => { + const values = { a: 1 } + const restrictions: Restriction[] = [ + { test: sinon.fake.returns(undefined) } as any, + { test: sinon.fake.returns('reason 2') } as any, + ] + + expect(new RuneLike(restrictions).test(values)).to.deep.equal([false, 'reason 2']) + expect(restrictions[0].test).to.have.been.calledOnceWithExactly(values) + expect(restrictions[1].test).to.have.been.calledOnceWithExactly(values) + }) + + it('returns false if 1 restriction is false and 1 is true', () => { + const values = { a: 1 } + const restrictions: Restriction[] = [ + { test: sinon.fake.returns('reason 1') } as any, + { test: sinon.fake.returns(undefined) } as any, + ] + + expect(new RuneLike(restrictions).test(values)).to.deep.equal([false, 'reason 1']) + expect(restrictions[0].test).to.have.been.calledOnceWithExactly(values) + expect(restrictions[1].test).not.to.have.been.called + }) + + it('returns false if 2 restrictions are false', () => { + const values = { a: 1 } + const restrictions: Restriction[] = [ + { test: sinon.fake.returns('reason 1') } as any, + { test: sinon.fake.returns('reason 2') } as any, + ] + + expect(new RuneLike(restrictions).test(values)).to.deep.equal([false, 'reason 1']) + expect(restrictions[0].test).to.have.been.calledOnceWithExactly(values) + expect(restrictions[1].test).not.to.have.been.called + }) + }) + + describe('encode', () => { + it('encodes 1 restriction', () => { + const restrictions: Restriction[] = [ + { encode: sinon.fake.returns('a=1') }, + ] as any + + expect(new RuneLike(restrictions).encode()).to.equal('a=1') + }) + + it('encodes 2 restrictions', () => { + const restrictions: Restriction[] = [ + { encode: sinon.fake.returns('a=1') }, + { encode: sinon.fake.returns('b=2') }, + ] as any + + expect(new RuneLike(restrictions).encode()).to.equal('a=1&b=2') + }) + }) + + describe('from', () => { + let restrictionDecodeStub: sinon.SinonStub + beforeEach(() => { + restrictionDecodeStub = sinon.stub(Restriction, 'decode') + }) + + afterEach(() => { + restrictionDecodeStub.restore() + }) + + it('returns rune-like given restrictions a=1', () => { + restrictionDecodeStub.withArgs('a=1').returns([ + new Restriction([ + new Alternative('a', '=', '1'), + ]), + '', + ]) + const runeLike = RuneLike.from('a=1') + + expect(runeLike).to.be.an.instanceOf(RuneLike) + + expect(restrictionDecodeStub.firstCall).to.have.been.calledWithExactly('a=1') + expect(runeLike.encode()).to.equal('a=1') + }) + + it('returns rune-like given restrictions a=1|b=2', () => { + restrictionDecodeStub.withArgs('a=1|b=2').returns([ + new Restriction([ + new Alternative('a', '=', '1'), + new Alternative('b', '=', '2'), + ]), + '', + ]) + const runeLike = RuneLike.from('a=1|b=2') + + expect(runeLike).to.be.an.instanceOf(RuneLike) + + expect(restrictionDecodeStub.firstCall).to.have.been.calledWithExactly('a=1|b=2') + expect(runeLike.encode()).to.equal('a=1|b=2') + }) + + it('returns rune-like given restrictions a=1|b=2&c=3', () => { + restrictionDecodeStub.withArgs('a=1|b=2&c=3').returns([ + new Restriction([ + new Alternative('a', '=', '1'), + new Alternative('b', '=', '2'), + ]), + '&c=3', + ]) + restrictionDecodeStub.withArgs('&c=3').returns([ + new Restriction([ + new Alternative('c', '=', '3'), + ]), + '', + ]) + const runeLike = RuneLike.from('a=1|b=2&c=3') + + expect(runeLike).to.be.an.instanceOf(RuneLike) + + expect(restrictionDecodeStub.firstCall).to.have.been.calledWithExactly('a=1|b=2&c=3') + expect(restrictionDecodeStub.secondCall).to.have.been.calledWithExactly('&c=3') + expect(runeLike.encode()).to.equal('a=1|b=2&c=3') + }) + + it('returns rune-like given restrictions with spaces a = 1 | b = 2 & c = 3', () => { + restrictionDecodeStub.withArgs('a=1|b=2&c=3').returns([ + new Restriction([ + new Alternative('a', '=', '1'), + new Alternative('b', '=', '2'), + ]), + '&c=3', + ]) + restrictionDecodeStub.withArgs('&c=3').returns([ + new Restriction([ + new Alternative('c', '=', '3'), + ]), + '', + ]) + const runeLike = RuneLike.from('a = 1 | b = 2 & c = 3') + + expect(runeLike).to.be.an.instanceOf(RuneLike) + + expect(restrictionDecodeStub.firstCall).to.have.been.calledWithExactly('a=1|b=2&c=3') + expect(restrictionDecodeStub.secondCall).to.have.been.calledWithExactly('&c=3') + expect(runeLike.encode()).to.equal('a=1|b=2&c=3') + }) + }) +})