UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

605 lines (502 loc) 17.4 kB
import type { Services } from '../../typings'; import { ObjectId } from '@getanthill/mongodb-connector'; import setup from '../../../test/setup'; import register, * as handlers from './index'; import fixtureUsers from '../../../test/fixtures/users'; describe('events', () => { let app; let services: Services; let models; beforeEach(async () => { app = await setup.build({ features: { mqtt: { isEnabled: true }, amqp: { isEnabled: true } }, }); models = await setup.initModels(app.services, [fixtureUsers]); services = { ...app.services, // @ts-ignore mqtt: { authenticate: jest.fn(), publish: jest.fn(), on: jest.fn(), subscribe: jest.fn(), emit: jest.fn(), }, amqp: { authenticate: jest.fn(), publish: jest.fn(), on: jest.fn(), subscribe: jest.fn(), emit: jest.fn(), }, }; try { const Users = models.getModel(fixtureUsers.name); await Promise.all([ Users.getStatesCollection(Users.db(services.mongodb)).deleteMany({}), Users.getEventsCollection(Users.db(services.mongodb)).deleteMany({}), Users.getSnapshotsCollection(Users.db(services.mongodb)).deleteMany({}), ]); } catch (err) { // Possibly the User model does not exist } }); afterEach(async () => { jest.restoreAllMocks(); await setup.teardownDb(app.services.mongodb); }); describe('#register', () => { it('noops on models without any event registered', async () => { // @ts-ignore services.models = { isInternalModel: jest.fn().mockReturnValue(false), MODELS: { entries: jest.fn().mockReturnValue([ [ 'model', { getModelConfig: jest.fn().mockReturnValue({ name: 'model' }), getSchema: jest.fn().mockReturnValue({ events: undefined }), }, ], ]), }, }; await register(services); expect(services.mqtt.authenticate).toHaveBeenCalledTimes(0); }); it('noops on event with undefined event version', async () => { // @ts-ignore services.models = { isInternalModel: jest.fn().mockReturnValue(false), MODELS: { entries: jest.fn().mockReturnValue([ [ 'model', { getModelConfig: jest.fn().mockReturnValue({ name: 'model' }), getSchema: jest .fn() .mockReturnValue({ events: { eventName: undefined } }), }, ], ]), }, }; await register(services); expect(services.mqtt.authenticate).toHaveBeenCalledTimes(0); }); it('registers mqtt topics', async () => { await register(services); expect(services.mqtt.authenticate).toHaveBeenCalledTimes(5); expect(services.mqtt.on.mock.calls.map((c) => c[0])).toEqual([ 'users/created', 'users/updated/{user_id}', 'users/patched/{user_id}', 'users/firstname_updated/{user_id}', 'users/email_updated/{user_id}', ]); }); it('registers amqp topics', async () => { await register(services); expect(services.amqp.authenticate).toHaveBeenCalledTimes(5); expect(services.amqp.on.mock.calls.map((c) => c[0])).toEqual([ 'users/created', 'users/updated/{user_id}', 'users/patched/{user_id}', 'users/firstname_updated/{user_id}', 'users/email_updated/{user_id}', ]); }); it('skips mqtt registration if disabled', async () => { services.config.features.mqtt.isEnabled = false; await register(services); expect(services.mqtt.authenticate).toHaveBeenCalledTimes(0); }); it('skips amqp registration if disabled', async () => { services.config.features.amqp.isEnabled = false; await register(services); expect(services.amqp.authenticate).toHaveBeenCalledTimes(0); }); }); describe('#wrapper', () => { afterEach(() => { jest.restoreAllMocks(); }); it('publishes an error message in case of schema validation error', async () => { const handler = handlers.created(services, 'users', 'users/created'); await handler({ firstname: 'Alice', email: 'alice@doe.org', invalid: 'field', }); expect(services.mqtt.publish.mock.calls[0][0]).toEqual( 'users/created/error', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ event: { firstname: 'Alice', email: 'alice@doe.org', invalid: 'field' }, details: [ { instancePath: '', keyword: 'additionalProperties', message: 'must NOT have additional properties', params: { additionalProperty: 'invalid' }, schemaPath: '#/additionalProperties', }, { event: { firstname: 'Alice', email: 'alice@doe.org', invalid: 'field', }, }, ], }); }); it('ack a message in case of processing success', async () => { const handler = handlers.created(services, 'users', 'users/created'); const opts = { ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org' }, {}, {}, opts, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/created/success', ); expect(opts.ack).toHaveBeenCalledTimes(1); expect(opts.nack).toHaveBeenCalledTimes(0); }); it('nack a message in case of processing success', async () => { const handler = handlers.created(services, 'users', 'users/created'); const opts = { ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org', invalid: 'field' }, {}, {}, opts, ); expect(services.mqtt.publish.mock.calls[0][0]).toEqual( 'users/created/error', ); expect(opts.ack).toHaveBeenCalledTimes(0); expect(opts.nack).toHaveBeenCalledTimes(1); }); it('ack a message without error publication on redelived event', async () => { const handler = handlers.created(services, 'users', 'users/created'); const opts = { delivery: 1, ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org', invalid: 'field' }, {}, {}, opts, ); expect(services.mqtt.publish).toHaveBeenCalledTimes(0); expect(opts.ack).toHaveBeenCalledTimes(1); expect(opts.nack).toHaveBeenCalledTimes(0); }); it('skips publishing error in case of internal error', async () => { jest.spyOn(services.models, 'factory').mockImplementationOnce(() => { throw new Error('Ooops'); }); const handler = handlers.created(services, 'users', 'users/created'); await handler({ firstname: 'Alice', email: 'alice@doe.org', invalid: 'field', }); expect(services.mqtt.publish).toHaveBeenCalledTimes(0); }); it('publishes the message only on available service (amqp only)', async () => { services.config.features.mqtt.isEnabled = false; const handler = handlers.created(services, 'users', 'users/created'); const opts = { delivery: 0, ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org' }, {}, {}, opts, ); expect(services.mqtt.publish).toHaveBeenCalledTimes(0); expect(services.amqp.publish).toHaveBeenCalledTimes(2); expect(opts.ack).toHaveBeenCalledTimes(1); }); it('publishes the new state of the entity', async () => { services.config.features.mqtt.isEnabled = false; const handler = handlers.created(services, 'users', 'users/created'); const opts = { delivery: 0, ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org' }, {}, {}, opts, ); const correlationId = services.amqp.publish.mock.calls[0][0].replace( /^.*\//, '', ); expect(services.amqp.publish.mock.calls[0][0]).toContain( `users/created/success/${correlationId}`, ); expect(services.amqp.publish.mock.calls[0][1]).toMatchObject({ email: 'alice@doe.org', firstname: 'Alice', is_enabled: true, user_id: correlationId, version: 0, }); }); it('publishes the events handled during the entity state update', async () => { services.config.features.mqtt.isEnabled = false; const handler = handlers.created(services, 'users', 'users/created'); const opts = { delivery: 0, ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org' }, {}, {}, opts, ); const correlationId = services.amqp.publish.mock.calls[0][0].replace( /^.*\//, '', ); expect(services.amqp.publish.mock.calls[1][0]).toContain( `users/created/events/${correlationId}`, ); expect(services.amqp.publish.mock.calls[1][1]).toMatchObject({ email: 'alice@doe.org', firstname: 'Alice', is_enabled: true, type: 'CREATED', user_id: correlationId, v: '0_0_0', version: 0, }); }); it('publishes the message only on available service (mqtt only)', async () => { services.config.features.amqp.isEnabled = false; const handler = handlers.created(services, 'users', 'users/created'); const opts = { delivery: 0, ack: jest.fn(), nack: jest.fn() }; await handler( { firstname: 'Alice', email: 'alice@doe.org' }, {}, {}, opts, ); expect(services.mqtt.publish).toHaveBeenCalledTimes(2); expect(services.amqp.publish).toHaveBeenCalledTimes(0); expect(opts.ack).toHaveBeenCalledTimes(1); }); }); describe('#created', () => { it('allows to create an entity', async () => { const handler = handlers.created(services, 'users', 'users/created'); await handler({ firstname: 'Alice', email: 'alice@doe.org' }); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/created/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Alice', email: 'alice@doe.org', version: 0, }); }); it('allows to create an entity with a fixed created_at date', async () => { const handler = handlers.created(services, 'users', 'users/created'); await handler( { firstname: 'Alice', email: 'alice@doe.org' }, {}, { 'created-at': '2021-01-01T00:00:00.000Z' }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/created/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ created_at: new Date('2021-01-01T00:00:00.000Z'), firstname: 'Alice', email: 'alice@doe.org', version: 0, }); }); }); describe('#updated', () => { it('allows to upsert an entity', async () => { const handler = handlers.updated( services, 'users', 'users/updated/{user_id}', ); const userId = new ObjectId().toString(); await handler( { firstname: 'Alice', email: 'alice@doe.org' }, { params: { correlation_id: userId } }, { upsert: 'true' }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/updated/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Alice', email: 'alice@doe.org', version: 0, }); }); it('allows to update an existing entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.updated( services, 'users', 'users/updated/{user_id}', ); const userId = user.state.user_id.toString(); await handler( { firstname: 'Bernard' }, { params: { correlation_id: userId } }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/updated/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Bernard', version: 1, }); }); it('allows to update an existing entity forcing the event creation date', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.updated( services, 'users', 'users/updated/{user_id}', ); const userId = user.state.user_id.toString(); await handler( { firstname: 'Bernard' }, { params: { correlation_id: userId } }, { 'created-at': '2021-01-01T00:00:00.000Z' }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/updated/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Bernard', version: 1, updated_at: new Date('2021-01-01T00:00:00.000Z'), }); }); it('allows to update an existing entity with an imperative version', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.updated( services, 'users', 'users/updated/{user_id}', ); const userId = user.state.user_id.toString(); await handler( { firstname: 'Bernard' }, { params: { correlation_id: userId } }, { version: '1' }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/updated/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Bernard', version: 1, }); }); }); describe('#patched', () => { it('allows to patch an entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.patched( services, 'users', 'users/patched/{user_id}', ); const userId = user.correlationId; await handler( { json_patch: [{ op: 'replace', path: '/firstname', value: 'Alice' }] }, { params: { correlation_id: userId } }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/patched/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Alice', version: 1, }); }); it('allows to patch an entity with an imperative version', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.patched( services, 'users', 'users/patched/{user_id}', ); const userId = user.correlationId; await handler( { json_patch: [{ op: 'replace', path: '/firstname', value: 'Alice' }] }, { params: { correlation_id: userId } }, { version: '1' }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/patched/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ firstname: 'Alice', version: 1, }); }); }); describe('#applied', () => { it('allows to apply an event on an entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.applied( services, 'users', 'users/applied/{user_id}/{event_name}', ); const userId = user.correlationId; await handler( { email: 'john+1@doe.org' }, { params: { correlation_id: userId, event_type: 'email_updated' } }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/email_updated/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ email: 'john+1@doe.org', version: 1, }); }); it('allows to apply an event on an entity with an imperative version', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const handler = handlers.applied( services, 'users', 'users/applied/{user_id}/{event_name}', ); const userId = user.correlationId; await handler( { email: 'john+1@doe.org' }, { params: { correlation_id: userId, event_type: 'email_updated' } }, { version: '1' }, ); expect(services.mqtt.publish.mock.calls[0][0]).toContain( 'users/email_updated/success', ); expect(services.mqtt.publish.mock.calls[0][1]).toMatchObject({ email: 'john+1@doe.org', version: 1, }); }); }); });