@getanthill/datastore
Version:
Event-Sourced Datastore
736 lines (633 loc) • 18.5 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).toBeCalledTimes(1);
expect(opts.nack).toBeCalledTimes(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).toBeCalledTimes(0);
expect(opts.nack).toBeCalledTimes(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).toBeCalledTimes(0);
expect(opts.ack).toBeCalledTimes(1);
expect(opts.nack).toBeCalledTimes(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).toBeCalledTimes(0);
expect(services.amqp.publish).toBeCalledTimes(2);
expect(opts.ack).toBeCalledTimes(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).toBeCalledTimes(2);
expect(services.amqp.publish).toBeCalledTimes(0);
expect(opts.ack).toBeCalledTimes(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,
});
});
});
});