Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/dsc 93 event stream #1

Merged
merged 2 commits into from
Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 21 additions & 57 deletions __mocks__/ably/promises/index.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,33 @@
import { Types } from 'ably/promises';

const MOCK_CLIENT_ID = 'MOCK_CLIENT_ID';
const mockPromiseErrorNotImplemented = <T>(name: string): Promise<T> => new Promise((_, reject) => reject(new Error(`mock '${name}' not implemented`)));
const mockNotImplemented = <T>(name: string): T => { throw new Error(`mock ${name} not implemented`) };

const mockPromisify = <T>(expectedReturnValue): Promise<T> => new Promise((resolve) => resolve(expectedReturnValue));
const methodReturningVoidPromise = () => mockPromisify<void>((() => {})());
type MockChannel = Partial<Types.RealtimeChannelPromise>;

const mockPresence = {
get: () => mockPromisify<Types.PresenceMessage[]>([]),
update: () => mockPromisify<void>(undefined),
enter: methodReturningVoidPromise,
leave: methodReturningVoidPromise,
subscriptions: {
once: async (_, fn) => {
return await fn();
},
},
subscribe: () => {},
};
const mockChannel: MockChannel = {
on: () => mockNotImplemented<void>('on'),
attach: () => mockPromiseErrorNotImplemented<void>('attach'),
detach: () => mockPromiseErrorNotImplemented<void>('detach'),
subscribe: () => mockPromiseErrorNotImplemented<void>('subscribe'),
}

const mockHistory = {
items: [],
first: () => mockPromisify(mockHistory),
next: () => mockPromisify(mockHistory),
current: () => mockPromisify(mockHistory),
hasNext: () => false,
isLast: () => true,
};
type MockChannels = Partial<Types.Channels<MockChannel>>;

const mockEmitter = {
any: [],
events: {},
anyOnce: [],
eventsOnce: {},
};
const mockChannels: MockChannels = {
get: () => mockChannel,
release: () => mockNotImplemented<void>('release'),
}

const mockChannel = {
presence: mockPresence,
history: () => mockHistory,
subscribe: () => {},
publish: () => {},
subscriptions: mockEmitter,
};
type MockConnection = Partial<Types.ConnectionPromise>;

class MockRealtime {
public channels: {
get: () => typeof mockChannel;
};
public auth: {
clientId: string;
};
public connection: {
id?: string;
};
const mockConnection: MockConnection = {
whenState: () => mockPromiseErrorNotImplemented<Types.ConnectionStateChange>('whenState')
}

constructor() {
this.channels = {
get: () => mockChannel,
};
this.auth = {
clientId: MOCK_CLIENT_ID,
};
this.connection = {
id: '1',
};
}
class MockRealtime {
public channels = mockChannels;
public connection = mockConnection;
}

export { MockRealtime as Realtime };
69 changes: 50 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"vitest": "^0.29.8"
},
"dependencies": {
"ably": "^1.2.39"
"ably": "^1.2.39",
"rxjs": "^7.8.1"
}
}
50 changes: 50 additions & 0 deletions src/Model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { it, describe, expect, expectTypeOf, beforeEach, afterEach } from 'vitest';
import { Realtime, Types } from 'ably/promises';
import { WebSocket } from 'mock-socket';

import Model from './Model';
import Stream from './Stream';

import Server from './utilities/test/mock-server.js';
import defaultClientConfig from './utilities/test/default-client-config.js';

interface ModelTestContext {
client: Types.RealtimePromise;
server: Server;
}

describe('Model', () => {
beforeEach<ModelTestContext>((context) => {
(Realtime as any).Platform.Config.WebSocket = WebSocket;
context.server = new Server('wss://realtime.ably.io/');
context.client = new Realtime(defaultClientConfig);
});

afterEach<ModelTestContext>((context) => {
context.server.stop();
});

it<ModelTestContext>('connects successfully with the Ably Client', async ({ client, server }) => {
server.start();
const connectSuccess = await client.connection.whenState('connected');
expect(connectSuccess.current).toBe('connected');
});

it<ModelTestContext>('expects the injected client to be of the type RealtimePromise', ({ client }) => {
const model = new Model('test', client);
expect(model.name).toEqual('test');
expectTypeOf(model.client).toMatchTypeOf<Types.RealtimePromise>();
});

it<ModelTestContext>('expects model to be instantiated with the provided event streams', ({ client }) => {
const model = new Model('test', client, {
streams: [new Stream('s1', client, { channel: 's1' }), new Stream('s2', client, { channel: 's2' })],
});
expect(model.name).toEqual('test');
expect(model.stream('s1')).toBeTruthy();
expect(model.stream('s1').name).toEqual('s1');
expect(model.stream('s2')).toBeTruthy();
expect(model.stream('s2').name).toEqual('s2');
expect(() => model.stream('s3')).toThrowError("stream with name 's3' not registered on model 'test'");
});
});
34 changes: 27 additions & 7 deletions src/Model.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { Types } from 'ably';

import Stream from './Stream';
import ModelOptions from './options/ModelOptions';
import EventEmitter from './utilities/EventEmitter';

const MODEL_OPTIONS_DEFAULTS = {};
enum ModelState {
/**
* The model has been initialized but has not yet been synchronised.
*/
INITIALIZED = 'initialized',
/**
* An indefinite failure condition. This state is entered if a channel error has been received from the Ably service, such as an attempt to attach without the necessary access rights.
*/
FAILED = 'failed',
}

class Model extends EventEmitter<any> {
private options: ModelOptions;
private connectionId?: string;
class Model<T> extends EventEmitter<any> {
private state: ModelState = ModelState.INITIALIZED;
private streams: Record<string, Stream<any>> = {};
private data: T;

constructor(readonly name: string, readonly client: Types.RealtimePromise, options?: ModelOptions) {
super();
this.options = { ...MODEL_OPTIONS_DEFAULTS, ...options };
this.connectionId = this.client.connection.id;
if (options) {
for (let stream of options.streams) {
this.streams[stream.name] = stream;
}
}
}

stream(name: string): Stream<any> {
if (!this.streams[name]) {
throw new Error(`stream with name '${name}' not registered on model '${this.name}'`);
}
return this.streams[name];
}
}

Expand Down
50 changes: 45 additions & 5 deletions src/Models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ describe('Models', () => {
context.server.stop();
});

it<ModelsTestContext>('expects the injected client to be of the type RealtimePromise', ({ client }) => {
const models = new Models(client);
expectTypeOf(models.ably).toMatchTypeOf<Types.RealtimePromise>();
});

it<ModelsTestContext>('connects successfully with the Ably Client', async ({ client, server }) => {
server.start();
const connectSuccess = await client.connection.whenState('connected');
expect(connectSuccess.current).toBe('connected');
});

it<ModelsTestContext>('expects the injected client to be of the type RealtimePromise', ({ client }) => {
const models = new Models(client);
expectTypeOf(models.ably).toMatchTypeOf<Types.RealtimePromise>();
});

it<ModelsTestContext>('creates a client with default options when a key is passed in', () => {
const models = new Models(defaultClientConfig.key);
expect(models.ably['options'].key).toEqual(defaultClientConfig.key);
Expand Down Expand Up @@ -65,4 +65,44 @@ describe('Models', () => {
'model-default-client',
]);
});

it<ModelsTestContext>('creates a model that inherits the root class ably client', () => {
const models = new Models({ ...defaultClientConfig });
const model = models.Model('test');
expect(model.name).toEqual('test');
expect(model.client['options']).toContain(defaultClientConfig);
});

it<ModelsTestContext>('getting a model with the same name returns the same instance', () => {
const models = new Models({ ...defaultClientConfig });
const model1 = models.Model('test');
expect(model1.name).toEqual('test');
const model2 = models.Model('test');
expect(model2.name).toEqual('test');
expect(model1).toEqual(model2);
});

it<ModelsTestContext>('creates a stream that inherits the root class ably client', () => {
const models = new Models({ ...defaultClientConfig });
const stream = models.Stream('test', { channel: 'foobar' });
expect(stream.name).toEqual('test');
expect(stream.ably['options']).toContain(defaultClientConfig);
});

it<ModelsTestContext>('getting a stream without options throws', () => {
const models = new Models({ ...defaultClientConfig });
expect(() => models.Stream('test')).toThrow('Stream cannot be instantiated without options');
});

it<ModelsTestContext>('getting an event stream with the same name returns the same instance', () => {
const models = new Models({ ...defaultClientConfig });
const stream1 = models.Stream('test', { channel: 'foobar' }); // first call requires options to instantiate
expect(stream1.name).toEqual('test');
const stream2 = models.Stream('test'); // subsequent calls do not require options
expect(stream2.name).toEqual('test');
expect(stream1).toEqual(stream2);
const stream3 = models.Stream('test', { channel: 'barbaz' }); // providing options to subsequent calls is allowed but ignored
expect(stream3.name).toEqual('test');
expect(stream1).toEqual(stream3);
});
});
Loading