@getanthill/datastore
Version:
Event-Sourced Datastore
605 lines (502 loc) • 17.4 kB
text/typescript
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,
});
});
});
});