Skip to content

Commit

Permalink
test: raise coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
adrigzr committed Mar 21, 2023
1 parent 8187518 commit 09db582
Show file tree
Hide file tree
Showing 18 changed files with 303 additions and 23 deletions.
3 changes: 2 additions & 1 deletion packages/adapter-tcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"inputs": [
"{workspaceRoot}/.mocharc.yml",
"{workspaceRoot}/nyc.config.js",
"{projectRoot}/src/**/*"
"{projectRoot}/src/**/*",
"{projectRoot}/test/**/*"
],
"outputs": [
"{projectRoot}/coverage"
Expand Down
32 changes: 32 additions & 0 deletions packages/adapter-tcp/src/tcp.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { imock, instance } from '@johanblumenberg/ts-mockito';
import { TCPServer } from './tcp.server';
import type { Commands, DeviceRepository } from '@agnoc/domain';
import type { EventHandlerRegistry, TaskHandlerRegistry } from '@agnoc/toolkit';

describe('TCPServer', function () {
let domainEventHandlerRegistry: EventHandlerRegistry;
let commandHandlerRegistry: TaskHandlerRegistry<Commands>;
let deviceRepository: DeviceRepository;
let tcpAdapter: TCPServer;

beforeEach(function () {
domainEventHandlerRegistry = imock();
commandHandlerRegistry = imock();
deviceRepository = imock();
tcpAdapter = new TCPServer(
instance(deviceRepository),
instance(domainEventHandlerRegistry),
instance(commandHandlerRegistry),
);
});

it('should listen and close servers', async function () {
await tcpAdapter.listen();
await tcpAdapter.close();
});

it('should listen and close servers with custom ports', async function () {
await tcpAdapter.listen({ ports: { cmd: 0, map: 0, ntp: 0 } });
await tcpAdapter.close();
});
});
88 changes: 88 additions & 0 deletions packages/core/src/agnoc.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { DeviceRepository, LocateDeviceCommand } from '@agnoc/domain';
import { EventHandlerRegistry, ID, TaskHandlerRegistry } from '@agnoc/toolkit';
import { capture, fnmock, imock, instance, verify, when } from '@johanblumenberg/ts-mockito';
import { expect } from 'chai';
import { AgnocServer } from './agnoc.server';
import type { SubscribeHandler } from './agnoc.server';
import type { Device, DomainEventBus, DeviceLockedDomainEvent } from '@agnoc/domain';
import type { Server, TaskHandler } from '@agnoc/toolkit';

describe('AgnocServer', function () {
let server: Server;
let agnocServer: AgnocServer;

beforeEach(function () {
server = imock();
agnocServer = new AgnocServer();
});

it('should provide a container to build an adapter', function () {
agnocServer.buildAdapter((container) => {
expect(container.deviceRepository).to.be.instanceOf(DeviceRepository);
expect(container.domainEventHandlerRegistry).to.be.instanceOf(EventHandlerRegistry);
expect(container.commandHandlerRegistry).to.be.instanceOf(TaskHandlerRegistry);

return instance(server);
});
});

it('should listen adapters', async function () {
agnocServer.buildAdapter(() => {
return instance(server);
});

await agnocServer.listen();

verify(server.listen()).once();
});

it('should close adapters', async function () {
agnocServer.buildAdapter(() => {
return instance(server);
});

await agnocServer.close();

verify(server.close()).once();
});

it('should subscribe to domain events', async function () {
const device: Device = imock();
const event: DeviceLockedDomainEvent = imock();
const handler: SubscribeHandler<'DeviceLockedDomainEvent'> = fnmock();

agnocServer.subscribe('DeviceLockedDomainEvent', instance(handler));

agnocServer.buildAdapter(({ deviceRepository }) => {
void deviceRepository.saveOne(instance(device));

return instance(server);
});

// Extract the eventBus from the `device.publishEvents` method.
// This is just a way to obtain the eventBus to manually publish events.
const args = capture(device.publishEvents).first();
const eventBus = args[0] as DomainEventBus;

await eventBus.emit('DeviceLockedDomainEvent', instance(event));

verify(handler(instance(event))).once();
});

it('should trigger commands', async function () {
const taskHandler: TaskHandler = imock();
const command = new LocateDeviceCommand({ deviceId: new ID(1) });

when(taskHandler.forName).thenReturn('LocateDeviceCommand');

agnocServer.buildAdapter(({ commandHandlerRegistry }) => {
commandHandlerRegistry.register(instance(taskHandler));

return instance(server);
});

await agnocServer.trigger(command);

verify(taskHandler.handle(command)).once();
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { AggregateRoot, ArgumentInvalidException, ArgumentNotProvidedException } from '@agnoc/toolkit';
import { expect } from 'chai';
import { DeviceConnectedDomainEvent } from '../domain-events/device-connected.domain-event';
import { DeviceLockedDomainEvent } from '../domain-events/device-locked.domain-event';
import { DeviceBattery } from '../domain-primitives/device-battery.domain-primitive';
import { DeviceError, DeviceErrorValue } from '../domain-primitives/device-error.domain-primitive';
import { DeviceFanSpeed, DeviceFanSpeedValue } from '../domain-primitives/device-fan-speed.domain-primitive';
import { DeviceMode, DeviceModeValue } from '../domain-primitives/device-mode.domain-primitive';
import { DeviceState, DeviceStateValue } from '../domain-primitives/device-state.domain-primitive';
import { DeviceWaterLevel, DeviceWaterLevelValue } from '../domain-primitives/device-water-level.domain-primitive';
import { DeviceMap } from '../entities/device-map.entity';
import { DeviceOrder } from '../entities/device-order.entity';
import {
givenSomeDeviceCleanWorkProps,
givenSomeDeviceConsumableProps,
Expand All @@ -23,9 +27,7 @@ import { DeviceSettings } from '../value-objects/device-settings.value-object';
import { DeviceSystem } from '../value-objects/device-system.value-object';
import { DeviceVersion } from '../value-objects/device-version.value-object';
import { DeviceWlan } from '../value-objects/device-wlan.value-object';
import { DeviceMap } from './device-map.entity';
import { DeviceOrder } from './device-order.entity';
import { Device } from './device.entity';
import { Device } from './device.aggregate-root';

describe('Device', function () {
it('should be created', function () {
Expand All @@ -41,6 +43,8 @@ describe('Device', function () {
expect(device.userId).to.be.equal(deviceProps.userId);
expect(device.system).to.be.equal(deviceProps.system);
expect(device.version).to.be.equal(deviceProps.version);
expect(device.isConnected).to.be.false;
expect(device.isLocked).to.be.false;
});

it("should throw an error when 'userId' is not provided", function () {
Expand Down Expand Up @@ -91,6 +95,22 @@ describe('Device', function () {
);
});

it("should throw an error when 'isConnected' is not a boolean", function () {
// @ts-expect-error - invalid property
expect(() => new Device({ ...givenSomeDeviceProps(), isConnected: 'foo' })).to.throw(
ArgumentInvalidException,
`Value 'foo' for property 'isConnected' of Device is not a boolean`,
);
});

it("should throw an error when 'isLocked' is not a boolean", function () {
// @ts-expect-error - invalid property
expect(() => new Device({ ...givenSomeDeviceProps(), isLocked: 'foo' })).to.throw(
ArgumentInvalidException,
`Value 'foo' for property 'isLocked' of Device is not a boolean`,
);
});

it("should throw an error when 'config' is not a DeviceSettings", function () {
// @ts-expect-error - invalid property
expect(() => new Device({ ...givenSomeDeviceProps(), config: 'foo' })).to.throw(
Expand Down Expand Up @@ -219,6 +239,28 @@ describe('Device', function () {
);
});

describe('#setAsConnected()', function () {
it('should update the device system', function () {
const device = new Device({ ...givenSomeDeviceProps(), isConnected: false });

device.setAsConnected();

expect(device.isConnected).to.be.true;
expect(device.domainEvents).to.deep.contain(new DeviceConnectedDomainEvent({ aggregateId: device.id }));
});
});

describe('#setAsLocked()', function () {
it('should update the device system', function () {
const device = new Device({ ...givenSomeDeviceProps(), isLocked: false });

device.setAsLocked();

expect(device.isLocked).to.be.true;
expect(device.domainEvents).to.deep.contain(new DeviceLockedDomainEvent({ aggregateId: device.id }));
});
});

describe('#updateSystem()', function () {
it('should update the device system', function () {
const device = new Device(givenSomeDeviceProps());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import { DeviceFanSpeed } from '../domain-primitives/device-fan-speed.domain-pri
import { DeviceMode } from '../domain-primitives/device-mode.domain-primitive';
import { DeviceState } from '../domain-primitives/device-state.domain-primitive';
import { DeviceWaterLevel } from '../domain-primitives/device-water-level.domain-primitive';
import { DeviceMap } from '../entities/device-map.entity';
import { DeviceOrder } from '../entities/device-order.entity';
import { DeviceCleanWork } from '../value-objects/device-clean-work.value-object';
import { DeviceConsumable } from '../value-objects/device-consumable.value-object';
import { DeviceSettings } from '../value-objects/device-settings.value-object';
import { DeviceSystem } from '../value-objects/device-system.value-object';
import { DeviceVersion } from '../value-objects/device-version.value-object';
import { DeviceWlan } from '../value-objects/device-wlan.value-object';
import { DeviceMap } from './device-map.entity';
import { DeviceOrder } from './device-order.entity';
import type { EntityProps } from '@agnoc/toolkit';

/** Describes the properties of a device. */
Expand Down Expand Up @@ -276,6 +276,8 @@ export class Device extends AggregateRoot<DeviceProps> {
this.validateInstanceProp(props, 'userId', ID);
this.validateInstanceProp(props, 'system', DeviceSystem);
this.validateInstanceProp(props, 'version', DeviceVersion);
this.validateTypeProp(props, 'isConnected', 'boolean');
this.validateTypeProp(props, 'isLocked', 'boolean');
this.validateInstanceProp(props, 'config', DeviceSettings);
this.validateInstanceProp(props, 'currentClean', DeviceCleanWork);
this.validateArrayProp(props, 'orders', DeviceOrder);
Expand Down
34 changes: 34 additions & 0 deletions packages/domain/src/commands/locate-device.command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ArgumentInvalidException, ArgumentNotProvidedException, Command, ID } from '@agnoc/toolkit';
import { expect } from 'chai';
import { LocateDeviceCommand } from './locate-device.command';
import type { LocateDeviceCommandInput } from './locate-device.command';

describe('LocateDeviceCommand', function () {
it('should be created', function () {
const input = givenALocateDeviceCommandInput();
const command = new LocateDeviceCommand(input);

expect(command).to.be.instanceOf(Command);
expect(command.deviceId).to.be.equal(input.deviceId);
});

it("should throw an error when 'deviceId' is not provided", function () {
// @ts-expect-error - missing property
expect(() => new LocateDeviceCommand({ ...givenALocateDeviceCommandInput(), deviceId: undefined })).to.throw(
ArgumentNotProvidedException,
`Property 'deviceId' for LocateDeviceCommand not provided`,
);
});

it("should throw an error when 'deviceId' is not an ID", function () {
// @ts-expect-error - invalid property
expect(() => new LocateDeviceCommand({ ...givenALocateDeviceCommandInput(), deviceId: 'foo' })).to.throw(
ArgumentInvalidException,
`Value 'foo' for property 'deviceId' of LocateDeviceCommand is not an instance of ID`,
);
});
});

function givenALocateDeviceCommandInput(): LocateDeviceCommandInput {
return { deviceId: ID.generate() };
}
8 changes: 4 additions & 4 deletions packages/domain/src/commands/locate-device.command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Command } from '@agnoc/toolkit';
import type { ID } from '@agnoc/toolkit';
import { Command, ID } from '@agnoc/toolkit';

export interface LocateDeviceCommandInput {
deviceId: ID;
Expand All @@ -10,7 +9,8 @@ export class LocateDeviceCommand extends Command<LocateDeviceCommandInput, void>
return this.props.deviceId;
}

protected validate(): void {
// TODO: validate input
protected validate(props: LocateDeviceCommandInput): void {
this.validateDefinedProp(props, 'deviceId');
this.validateInstanceProp(props, 'deviceId', ID);
}
}
15 changes: 15 additions & 0 deletions packages/domain/src/event-buses/command.event-bus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { TaskBus } from '@agnoc/toolkit';
import { expect } from 'chai';
import { CommandBus } from './command.event-bus';

describe('CommandBus', function () {
let commandBus: CommandBus;

beforeEach(function () {
commandBus = new CommandBus();
});

it('should be created', function () {
expect(commandBus).to.be.instanceOf(TaskBus);
});
});
19 changes: 17 additions & 2 deletions packages/domain/src/event-buses/domain.event-bus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { EventBus } from '@agnoc/toolkit';
import { EventBus, debug } from '@agnoc/toolkit';
import type { DomainEventNames, DomainEvents } from '../domain-events/domain-events';

export type DomainEventBusEvents = { [Name in DomainEventNames]: DomainEvents[Name] };
export class DomainEventBus extends EventBus<DomainEventBusEvents> {}
export class DomainEventBus extends EventBus<DomainEventBusEvents> {
constructor() {
/* istanbul ignore next */
super({
debug: {
enabled: true,
name: DomainEventBus.name,
logger: (type, _, eventName, eventData) => {
debug(__filename).extend(type)(
`event '${eventName?.toString() ?? 'undefined'}' with data: ${JSON.stringify(eventData)}`,
);
},
},
});
}
}
2 changes: 1 addition & 1 deletion packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export * from './domain-primitives/device-water-level.domain-primitive';
export * from './domain-primitives/week-day.domain-primitive';
export * from './entities/device-map.entity';
export * from './entities/device-order.entity';
export * from './entities/device.entity';
export * from './aggregate-roots/device.aggregate-root';
export * from './entities/room.entity';
export * from './entities/zone.entity';
export * from './event-buses/command.event-bus';
Expand Down
2 changes: 1 addition & 1 deletion packages/domain/src/repositories/device.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Repository } from '@agnoc/toolkit';
import type { Device } from '../entities/device.entity';
import type { Device } from '../aggregate-roots/device.aggregate-root';

export class DeviceRepository extends Repository<Device> {}
2 changes: 1 addition & 1 deletion packages/domain/src/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { MapCoordinate } from './value-objects/map-coordinate.value-object';
import { MapPixel } from './value-objects/map-pixel.value-object';
import { QuietHoursSetting } from './value-objects/quiet-hours-setting.value-object';
import { VoiceSetting } from './value-objects/voice-setting.value-object';
import type { DeviceProps } from './aggregate-roots/device.aggregate-root';
import type { DeviceMapProps } from './entities/device-map.entity';
import type { DeviceOrderProps } from './entities/device-order.entity';
import type { DeviceProps } from './entities/device.entity';
import type { RoomProps } from './entities/room.entity';
import type { ZoneProps } from './entities/zone.entity';
import type { DeviceCleanWorkProps } from './value-objects/device-clean-work.value-object';
Expand Down
5 changes: 3 additions & 2 deletions packages/toolkit/src/base-classes/aggregate-root.base.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { debug } from '../utils/debug.util';
import { toDashCase } from '../utils/to-dash-case.util';
import { Entity } from './entity.base';
import type { DomainEvent } from './domain-event.base';
import type { EntityProps } from './entity.base';
import type { EventBus } from './event-bus.base';

export abstract class AggregateRoot<T extends EntityProps = EntityProps> extends Entity<T> {
private readonly debug = debug(__filename).extend(`${this.constructor.name.toLowerCase()}:${this.id.value}`);
private readonly debug = debug(__filename).extend(toDashCase(this.constructor.name)).extend(this.id.toString());
readonly #domainEvents = new Set<DomainEvent>();

get domainEvents(): DomainEvent[] {
Expand All @@ -19,7 +20,7 @@ export abstract class AggregateRoot<T extends EntityProps = EntityProps> extends
async publishEvents(eventBus: EventBus): Promise<void> {
await Promise.all(
this.domainEvents.map(async (event) => {
this.debug(`publishing domain event '${event.constructor.name}'`);
this.debug(`publishing domain event '${event.constructor.name}' with data: ${JSON.stringify(event)}`);
return eventBus.emit(event.constructor.name, event);
}),
);
Expand Down
2 changes: 2 additions & 0 deletions packages/toolkit/src/base-classes/domain-event.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface DomainEventProps {
}

export abstract class DomainEvent<T extends DomainEventProps = DomainEventProps> extends Validatable<T> {
readonly eventName = this.constructor.name;

constructor(protected readonly props: T) {
super(props);
}
Expand Down
Loading

0 comments on commit 09db582

Please sign in to comment.