diff --git a/lib/core/endpoints/presence/get_state.js b/lib/core/endpoints/presence/get_state.js index a38d4a65a..359bbf085 100644 --- a/lib/core/endpoints/presence/get_state.js +++ b/lib/core/endpoints/presence/get_state.js @@ -59,7 +59,7 @@ class GetPresenceStateRequest extends request_1.AbstractRequest { } get path() { const { keySet: { subscribeKey }, uuid, channels, } = this.parameters; - return `/v2/presence/sub-key/${subscribeKey}/channel/${(0, utils_1.encodeNames)(channels !== null && channels !== void 0 ? channels : [], ',')}/uuid/${uuid}`; + return `/v2/presence/sub-key/${subscribeKey}/channel/${(0, utils_1.encodeNames)(channels !== null && channels !== void 0 ? channels : [], ',')}/uuid/${(0, utils_1.encodeString)(uuid)}`; } get queryParameters() { const { channelGroups } = this.parameters; diff --git a/lib/core/endpoints/presence/heartbeat.js b/lib/core/endpoints/presence/heartbeat.js index 604acd76e..9d8669e3e 100644 --- a/lib/core/endpoints/presence/heartbeat.js +++ b/lib/core/endpoints/presence/heartbeat.js @@ -59,7 +59,7 @@ class HeartbeatRequest extends request_1.AbstractRequest { const query = { heartbeat: `${heartbeat}` }; if (channelGroups && channelGroups.length !== 0) query['channel-group'] = channelGroups.join(','); - if (state) + if (state !== undefined) query.state = JSON.stringify(state); return query; } diff --git a/lib/core/endpoints/presence/set_state.js b/lib/core/endpoints/presence/set_state.js index ade901f85..6f5b7c143 100644 --- a/lib/core/endpoints/presence/set_state.js +++ b/lib/core/endpoints/presence/set_state.js @@ -39,7 +39,7 @@ class SetPresenceStateRequest extends request_1.AbstractRequest { const { keySet: { subscribeKey }, state, channels = [], channelGroups = [], } = this.parameters; if (!subscribeKey) return 'Missing Subscribe Key'; - if (!state) + if (state === undefined) return 'Missing State'; if ((channels === null || channels === void 0 ? void 0 : channels.length) === 0 && (channelGroups === null || channelGroups === void 0 ? void 0 : channelGroups.length) === 0) return 'Please provide a list of channels and/or channel-groups'; diff --git a/lib/core/endpoints/presence/where_now.js b/lib/core/endpoints/presence/where_now.js index 0c8ce6c30..1878967f3 100644 --- a/lib/core/endpoints/presence/where_now.js +++ b/lib/core/endpoints/presence/where_now.js @@ -44,7 +44,7 @@ class WhereNowRequest extends request_1.AbstractRequest { const serviceResponse = this.deserializeResponse(response); if (!serviceResponse.payload) return { channels: [] }; - return { channels: serviceResponse.payload.channels }; + return { channels: serviceResponse.payload.channels || [] }; }); } get path() { diff --git a/src/core/endpoints/presence/get_state.ts b/src/core/endpoints/presence/get_state.ts index ab7905ad3..b6ddc0db6 100644 --- a/src/core/endpoints/presence/get_state.ts +++ b/src/core/endpoints/presence/get_state.ts @@ -9,7 +9,7 @@ import { AbstractRequest } from '../../components/request'; import RequestOperation from '../../constants/operations'; import { KeySet, Payload, Query } from '../../types/api'; import * as Presence from '../../types/api/presence'; -import { encodeNames } from '../../utils'; +import { encodeNames, encodeString } from '../../utils'; // -------------------------------------------------------- // ------------------------ Types ------------------------- @@ -20,6 +20,11 @@ import { encodeNames } from '../../utils'; * Request configuration parameters. */ type RequestParameters = Presence.GetPresenceStateParameters & { + /** + * The subscriber uuid to get the current state. + */ + uuid: string; + /** * PubNub REST API access key set. */ @@ -103,7 +108,10 @@ export class GetPresenceStateRequest extends AbstractRequest = { heartbeat: `${heartbeat}` }; if (channelGroups && channelGroups.length !== 0) query['channel-group'] = channelGroups.join(','); - if (state) query.state = JSON.stringify(state); + if (state !== undefined) query.state = JSON.stringify(state); return query; } diff --git a/src/core/endpoints/presence/set_state.ts b/src/core/endpoints/presence/set_state.ts index 2bd2aec08..67ff4abb5 100644 --- a/src/core/endpoints/presence/set_state.ts +++ b/src/core/endpoints/presence/set_state.ts @@ -80,7 +80,7 @@ export class SetPresenceStateRequest extends AbstractRequest { diff --git a/test/integration/endpoints/channel_groups.test.ts b/test/integration/endpoints/channel_groups.test.ts index 04e3464ac..ce8a1265d 100644 --- a/test/integration/endpoints/channel_groups.test.ts +++ b/test/integration/endpoints/channel_groups.test.ts @@ -163,4 +163,440 @@ describe('channel group endpoints', () => { }); }); }); + + describe('edge cases and Promise-based execution', () => { + describe('addChannels - Promise-based API', () => { + it('should resolve with correct response', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/test-group') + .query({ + add: 'ch1,ch2', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.addChannels({ + channelGroup: 'test-group', + channels: ['ch1', 'ch2'] + }); + + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should reject on HTTP errors', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/test-group') + .query({ + add: 'ch1,ch2', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(403, '{"status": 403, "message": "Forbidden", "error": true, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + try { + await pubnub.channelGroups.addChannels({ + channelGroup: 'test-group', + channels: ['ch1', 'ch2'] + }); + assert.fail('Should have thrown error'); + } catch (error) { + assert(error); + assert.equal(scope.isDone(), true); + } + }); + + it('should handle large channel list', async () => { + const largeChannelList = Array.from({ length: 100 }, (_, i) => `channel${i + 1}`); + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/large-group') + .query({ + add: largeChannelList.join(','), + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.addChannels({ + channelGroup: 'large-group', + channels: largeChannelList + }); + + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should handle special characters in names', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group%20with%20spaces') + .query({ + add: 'channel with spaces,channel/with/slashes', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.addChannels({ + channelGroup: 'group with spaces', + channels: ['channel with spaces', 'channel/with/slashes'] + }); + + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should handle validation errors', async () => { + try { + await pubnub.channelGroups.addChannels({ + channelGroup: '', + channels: ['ch1'] + }); + assert.fail('Should have thrown validation error'); + } catch (error) { + // Just verify that an error was thrown for invalid parameters + assert(error); + assert(typeof error.message === 'string' && error.message.length > 0); + } + }); + }); + + describe('removeChannels - Promise-based API', () => { + it('should resolve with correct response', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/test-group') + .query({ + remove: 'ch1,ch2', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.removeChannels({ + channelGroup: 'test-group', + channels: ['ch1', 'ch2'] + }); + + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should handle removing from empty group', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/empty-group') + .query({ + remove: 'ch1,ch2', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.removeChannels({ + channelGroup: 'empty-group', + channels: ['ch1', 'ch2'] + }); + + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should handle removing non-existent channels', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/test-group') + .query({ + remove: 'non-existent1,non-existent2', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.removeChannels({ + channelGroup: 'test-group', + channels: ['non-existent1', 'non-existent2'] + }); + + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + }); + + describe('listChannels - Promise-based API', () => { + it('should resolve with channels array', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/test-group') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": {"channels": ["channel1", "channel2"]}, "service": "ChannelGroups"}', + { + 'content-type': 'text/javascript', + } + ); + + const result = await pubnub.channelGroups.listChannels({ channelGroup: 'test-group' }); + assert.deepEqual(result.channels, ['channel1', 'channel2']); + assert.equal(scope.isDone(), true); + }); + + it('should handle empty channel group', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/empty-group') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": {"channels": []}, "service": "ChannelGroups"}', + { + 'content-type': 'text/javascript', + } + ); + + const result = await pubnub.channelGroups.listChannels({ channelGroup: 'empty-group' }); + assert.deepEqual(result.channels, []); + assert.equal(scope.isDone(), true); + }); + + it('should handle large channel list', async () => { + const largeChannelList = Array.from({ length: 100 }, (_, i) => `channel${i + 1}`); + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/large-group') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply( + 200, + `{"status": 200, "message": "OK", "payload": {"channels": ${JSON.stringify(largeChannelList)}}, "service": "ChannelGroups"}`, + { + 'content-type': 'text/javascript', + } + ); + + const result = await pubnub.channelGroups.listChannels({ channelGroup: 'large-group' }); + assert.deepEqual(result.channels, largeChannelList); + assert.equal(scope.isDone(), true); + }); + }); + + describe('listGroups - Promise-based API', () => { + it('should resolve with groups array', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": {"groups": ["group1", "group2"]}, "service": "ChannelGroups"}', + { + 'content-type': 'text/javascript', + } + ); + + const result = await pubnub.channelGroups.listGroups(); + assert.deepEqual(result.groups, ['group1', 'group2']); + assert.equal(scope.isDone(), true); + }); + + it('should handle empty account', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": {"groups": []}, "service": "ChannelGroups"}', + { + 'content-type': 'text/javascript', + } + ); + + const result = await pubnub.channelGroups.listGroups(); + assert.deepEqual(result.groups, []); + assert.equal(scope.isDone(), true); + }); + }); + + describe('deleteGroup - Promise-based API', () => { + it('should resolve with correct response', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/test-group/remove') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.deleteGroup({ channelGroup: 'test-group' }); + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should handle deleting group with channels', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group-with-channels/remove') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const result = await pubnub.channelGroups.deleteGroup({ channelGroup: 'group-with-channels' }); + assert.deepEqual(result, {}); + assert.equal(scope.isDone(), true); + }); + + it('should handle deleting non-existent group', async () => { + const scope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/non-existent-group/remove') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(404, '{"status": 404, "message": "Not Found", "error": true, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + try { + await pubnub.channelGroups.deleteGroup({ channelGroup: 'non-existent-group' }); + assert.fail('Should have thrown error'); + } catch (error) { + assert(error); + assert.equal(scope.isDone(), true); + } + }); + }); + + describe('Concurrent operations', () => { + it('should handle multiple addChannels calls independently', async () => { + const scope1 = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group1') + .query({ + add: 'ch1,ch2', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const scope2 = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group2') + .query({ + add: 'ch3,ch4', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const [result1, result2] = await Promise.all([ + pubnub.channelGroups.addChannels({ channelGroup: 'group1', channels: ['ch1', 'ch2'] }), + pubnub.channelGroups.addChannels({ channelGroup: 'group2', channels: ['ch3', 'ch4'] }) + ]); + + assert.deepEqual(result1, {}); + assert.deepEqual(result2, {}); + assert.equal(scope1.isDone(), true); + assert.equal(scope2.isDone(), true); + }); + + it('should handle mixed operations concurrently', async () => { + const addScope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group1') + .query({ + add: 'ch1', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const listScope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group2') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": {"channels": ["ch2", "ch3"]}, "service": "ChannelGroups"}', + { + 'content-type': 'text/javascript', + } + ); + + const deleteScope = utils + .createNock() + .get('/v1/channel-registration/sub-key/mySubKey/channel-group/group3/remove') + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + }) + .reply(200, '{"status": 200, "message": "OK", "payload": {}, "service": "ChannelGroups"}', { + 'content-type': 'text/javascript', + }); + + const [addResult, listResult, deleteResult] = await Promise.all([ + pubnub.channelGroups.addChannels({ channelGroup: 'group1', channels: ['ch1'] }), + pubnub.channelGroups.listChannels({ channelGroup: 'group2' }), + pubnub.channelGroups.deleteGroup({ channelGroup: 'group3' }) + ]); + + assert.deepEqual(addResult, {}); + assert.deepEqual(listResult.channels, ['ch2', 'ch3']); + assert.deepEqual(deleteResult, {}); + assert.equal(addScope.isDone(), true); + assert.equal(listScope.isDone(), true); + assert.equal(deleteScope.isDone(), true); + }); + }); + }); }); diff --git a/test/integration/endpoints/fetch_messages.test.ts b/test/integration/endpoints/fetch_messages.test.ts index e3abb68db..42bc70f71 100644 --- a/test/integration/endpoints/fetch_messages.test.ts +++ b/test/integration/endpoints/fetch_messages.test.ts @@ -733,4 +733,1014 @@ describe('fetch messages endpoints', () => { } }); }); + + it('throws error when MESSAGE_PERSISTENCE_MODULE disabled', async () => { + const originalEnv = process.env.MESSAGE_PERSISTENCE_MODULE; + process.env.MESSAGE_PERSISTENCE_MODULE = 'disabled'; + + try { + let errorCaught = false; + try { + await pubnub.fetchMessages({ channels: ['ch1'] }); + } catch (error) { + assert(error instanceof Error); + assert(error.message.includes('message persistence module disabled')); + errorCaught = true; + } + assert(errorCaught, 'Expected error was not thrown'); + } finally { + if (originalEnv !== undefined) { + process.env.MESSAGE_PERSISTENCE_MODULE = originalEnv; + } else { + delete process.env.MESSAGE_PERSISTENCE_MODULE; + } + } + }); + + it('handles empty response gracefully', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": {} }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert.deepEqual(response, { channels: {} }); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles null message types correctly', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "message_type": null, "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal(message.messageType, -1); // PubNubMessageType.Message + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('processes file messages with URL generation', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": "{\\"message\\": \\"Hello\\", \\"file\\": {\\"id\\": \\"file-id\\", \\"name\\": \\"file-name\\", \\"mime-type\\": \\"image/png\\", \\"size\\": 1024}}", "timetoken": "16048329933709932", "message_type": 4, "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.setCipherKey('cipherKey'); + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal(message.messageType, 4); // PubNubMessageType.Files + // Just check that the message is processed, URL generation is a complex feature that depends on PubNub configuration + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles various encryption failure scenarios', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": "invalid-encrypted-data", "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.setCipherKey('wrongkey'); + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal(message.message, 'invalid-encrypted-data'); // Should return original payload + assert(message.error); // Should have error field + assert(message.error.includes('Error while decrypting message content')); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles binary encryption results with ArrayBuffer', (done) => { + nock.disableNetConnect(); + + // Create a mock crypto module that returns ArrayBuffer + const mockCrypto = { + logger: { + log: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + trace: () => {} + } as any, + decrypt: () => new TextEncoder().encode('{"text": "hello"}').buffer, + encrypt: (data: string | ArrayBuffer) => data, + encryptFile: (data: ArrayBuffer) => data, + decryptFile: (data: ArrayBuffer) => data, + } as any; + + const pubnubWithMockCrypto = new PubNub({ + subscribeKey, + publishKey, + uuid: 'myUUID', + // @ts-expect-error Force override default value. + useRequestId: false, + useRandomIVs: false, + cryptoModule: mockCrypto, + }); + + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnubWithMockCrypto.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": "encrypted-data", "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnubWithMockCrypto.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.deepEqual(message.message, { text: 'hello' }); + assert.equal(scope.isDone(), true); + pubnubWithMockCrypto.destroy(true); + done(); + } catch (error) { + pubnubWithMockCrypto.destroy(true); + done(error); + } + }); + }); + + it('supports both actions and data fields for backward compatibility', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history-with-actions/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '25', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid", "actions": {"reaction": {"like": [{"uuid": "user1", "actionTimetoken": "16048329933709933"}]}}}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeMessageActions: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + // TypeScript requires type assertion + assert('actions' in message); + assert('data' in message); + assert.deepEqual((message as any).actions, (message as any).data); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('validates includeMessageActions single channel constraint', async () => { + let errorCaught = false; + try { + await pubnub.fetchMessages({ channels: ['ch1', 'ch2'], includeMessageActions: true }); + } catch (error) { + assert(error instanceof PubNubError); + assert.equal( + error.status!.message, + 'History can return actions data for a single channel only. Either pass a single channel or disable the includeMessageActions flag.' + ); + errorCaught = true; + } + assert(errorCaught); + }); + + it('handles server error responses gracefully', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 403, + '{"status": 403, "error": true, "error_message": "Forbidden"}', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.category, 'PNAccessDeniedCategory'); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('processes pagination more field correctly', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history-with-actions/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '25', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{"channels": {"ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}]}, "more": {"url": "/v3/history-with-actions/sub-key/sub-key/channel/ch1?start=16048329933709932&max=25", "start": "16048329933709932", "max": 25}}', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeMessageActions: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + assert('more' in response); + assert.equal(response.more.url, '/v3/history-with-actions/sub-key/sub-key/channel/ch1?start=16048329933709932&max=25'); + assert.equal(response.more.start, '16048329933709932'); + assert.equal(response.more.max, 25); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles stringified timetokens option', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + string_message_token: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], stringifiedTimeToken: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('includes meta data when requested', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + include_meta: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid", "meta": {"custom": "data"}}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeMeta: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.deepEqual(message.meta, { custom: 'data' }); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('excludes meta data when not requested', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeMeta: false }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + // When includeMeta is false, the query should not include include_meta parameter + // and the server response should not include meta field + assert.equal(message.meta, undefined); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles custom message types correctly', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + include_custom_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid", "custom_message_type": "my-custom-type"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeCustomMessageType: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal(message.customMessageType, 'my-custom-type'); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('processes UUID field when included', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "publisher-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeUUID: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal(message.uuid, 'publisher-uuid'); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('omits UUID field when not requested', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "publisher-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeUUID: false }, (status, response) => { + try { + assert.equal(status.error, false); + // The nock interceptor correctly matches the query without include_uuid parameter + // which verifies that includeUUID: false works as expected + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles start and end timetoken parameters', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + start: '15610547826970000', + end: '15610547826970100', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "15610547826970050", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ + channels: ['ch1'], + start: '15610547826970000', + end: '15610547826970100' + }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal(message.timetoken, '15610547826970050'); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('supports both callback and promise patterns', async () => { + nock.disableNetConnect(); + + // Test Promise pattern + const promiseScope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + try { + const response = await pubnub.fetchMessages({ channels: ['ch1'] }); + assert(response !== null); + assert(response.channels.ch1); + assert.equal(promiseScope.isDone(), true); + } catch (error) { + throw error; + } + + // Test Callback pattern + const callbackScope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch2`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch2": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + return new Promise((resolve, reject) => { + pubnub.fetchMessages({ channels: ['ch2'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + assert(response.channels.ch2); + assert.equal(callbackScope.isDone(), true); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + + it('logs requests and responses when logVerbosity enabled', (done) => { + nock.disableNetConnect(); + + // Create PubNub instance with logVerbosity enabled + const pubnubWithLogging = new PubNub({ + subscribeKey, + publishKey, + uuid: 'myUUID', + // @ts-expect-error Force override default value. + useRequestId: false, + logVerbosity: true, + }); + + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnubWithLogging.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + // Mock console.log to capture log calls + const originalLog = console.log; + let logCalled = false; + console.log = (...args) => { + if (args.some(arg => typeof arg === 'string' && arg.includes('decryption'))) { + logCalled = true; + } + originalLog.apply(console, args); + }; + + pubnubWithLogging.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(scope.isDone(), true); + console.log = originalLog; // Restore console.log + pubnubWithLogging.destroy(true); + done(); + } catch (error) { + console.log = originalLog; // Restore console.log + pubnubWithLogging.destroy(true); + done(error); + } + }); + }); + + it('handles concurrent fetchMessages calls safely', async () => { + nock.disableNetConnect(); + + const scopes = [1, 2, 3].map(i => + utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch${i}`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + `{ "channels": { "ch${i}": [{"message": {"text": "hello${i}"}, "timetoken": "1604832993370993${i}", "uuid": "test-uuid"}] } }`, + { 'content-type': 'text/javascript' }, + ) + ); + + const promises = [1, 2, 3].map(i => + pubnub.fetchMessages({ channels: [`ch${i}`] }) + ); + + const responses = await Promise.all(promises); + + responses.forEach((response, index) => { + assert(response !== null); + assert(response.channels[`ch${index + 1}`]); + assert.equal(scopes[index].isDone(), true); + }); + }); + + it('supports large channel lists within limits', (done) => { + nock.disableNetConnect(); + + const channels = Array.from({ length: 10 }, (_, i) => `channel${i}`); + const encodedChannels = channels.join(','); + + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/${encodedChannels}`) + .query({ + max: '25', // Should default to 25 for multiple channels + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": {} }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels }, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles malformed service responses gracefully', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + 'invalid json response', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('adds signature when secretKey configured', (done) => { + nock.disableNetConnect(); + + const pubnubWithSecret = new PubNub({ + subscribeKey, + publishKey, + secretKey: 'my-secret-key', + uuid: 'myUUID', + // @ts-expect-error Force override default value. + useRequestId: false, + }); + + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query((queryObject) => { + // Ensure the return value is always boolean to satisfy type requirements + return !!(queryObject.signature && queryObject.signature.toString().startsWith('v2.')); + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "hello"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnubWithSecret.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(scope.isDone(), true); + pubnubWithSecret.destroy(true); + done(); + } catch (error) { + pubnubWithSecret.destroy(true); + done(error); + } + }); + }); + + it('handles very large message payloads', (done) => { + nock.disableNetConnect(); + + const largeMessage = 'x'.repeat(10000); // Large message payload + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + `{ "channels": { "ch1": [{"message": {"text": "${largeMessage}"}, "timetoken": "16048329933709932", "uuid": "test-uuid"}] } }`, + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const message = response.channels.ch1[0]; + assert.equal((message.message as any).text, largeMessage); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('processes mixed file and regular messages', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [{"message": {"text": "regular message"}, "timetoken": "16048329933709932", "message_type": null, "uuid": "test-uuid"}, {"message": "{\\"message\\": \\"file message\\", \\"file\\": {\\"id\\": \\"file-id\\", \\"name\\": \\"file.txt\\", \\"mime-type\\": \\"text/plain\\", \\"size\\": 100}}", "timetoken": "16048329933709933", "message_type": 4, "uuid": "test-uuid"}] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.setCipherKey('cipherKey'); + pubnub.fetchMessages({ channels: ['ch1'] }, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + const messages = response.channels.ch1; + + // Regular message + assert.equal(messages[0].messageType, -1); + assert.deepEqual(messages[0].message, { text: 'regular message' }); + + // File message - just check that message type is correct + assert.equal(messages[1].messageType, 4); + + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('handles includeCustomMessageType flag variations', (done) => { + nock.disableNetConnect(); + + // Test with includeCustomMessageType: true + const trueScope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + include_custom_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], includeCustomMessageType: true }, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(trueScope.isDone(), true); + + // Test with includeCustomMessageType: false + const falseScope = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch2`) + .query({ + max: '100', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + include_custom_message_type: 'false', + }) + .reply( + 200, + '{ "channels": { "ch2": [] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch2'], includeCustomMessageType: false }, (status2, response2) => { + try { + assert.equal(status2.error, false); + assert.equal(falseScope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + } catch (error) { + done(error); + } + }); + }); + + it('validates operation type correctly', () => { + const request = new (require('../../../src/core/endpoints/fetch_messages').FetchMessagesRequest)({ + keySet: { subscribeKey: 'test-key', publishKey: 'pub-key' }, + channels: ['ch1'], + getFileUrl: () => 'https://example.com/file', + }); + + const operation = request.operation(); + const RequestOperation = require('../../../src/core/constants/operations').default; + assert.equal(operation, RequestOperation.PNFetchMessagesOperation); + }); + + it('handles edge case count values', (done) => { + nock.disableNetConnect(); + + // Test count=0 should use defaults + const scope1 = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch1`) + .query({ + max: '100', // Should default to 100 for single channel + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch1": [] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch1'], count: 0 }, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(scope1.isDone(), true); + + // Test count=1 should work as specified + const scope2 = utils + .createNock() + .get(`/v3/history/sub-key/${subscribeKey}/channel/ch2`) + .query({ + max: '1', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + include_uuid: 'true', + include_message_type: 'true', + }) + .reply( + 200, + '{ "channels": { "ch2": [] } }', + { 'content-type': 'text/javascript' }, + ); + + pubnub.fetchMessages({ channels: ['ch2'], count: 1 }, (status2, response2) => { + try { + assert.equal(status2.error, false); + assert.equal(scope2.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + } catch (error) { + done(error); + } + }); + }); }); diff --git a/test/integration/endpoints/message_actions.test.ts b/test/integration/endpoints/message_actions.test.ts index d93a749fd..af649a47c 100644 --- a/test/integration/endpoints/message_actions.test.ts +++ b/test/integration/endpoints/message_actions.test.ts @@ -751,4 +751,723 @@ describe('message actions endpoints', () => { }); }).timeout(60000); }); + + describe('edge cases and error handling', () => { + it('should handle network connection errors gracefully', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .replyWithError('Network connection failed'); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: 'reaction', value: 'test' } }, + (status) => { + try { + assert.equal(status.error, true); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should handle 403 forbidden error', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(403, { + error: { + message: 'Forbidden', + source: 'actions', + }, + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: 'reaction', value: 'test' } }, + (status) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 403); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should handle 404 channel not found error', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/nonexistent-channel`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(404, { + error: { + message: 'Channel not found', + source: 'actions', + }, + }); + + pubnub.getMessageActions({ channel: 'nonexistent-channel' }, (status) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 404); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle 500 internal server error', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .delete(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890/action/15610547826970050`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(500, { + error: { + message: 'Internal Server Error', + source: 'actions', + }, + }); + + pubnub.removeMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', actionTimetoken: '15610547826970050' }, + (status) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 500); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should handle malformed response', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/test-channel`) + .reply(200, 'invalid json response'); + + pubnub.getMessageActions({ channel: 'test-channel' }, (status) => { + try { + assert.equal(status.error, true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('Unicode and special character handling', () => { + it('should handle Unicode characters in action type and value', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'emoji', + value: '😀🎉', + uuid: 'myUUID', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: 'emoji', value: '😀🎉' } }, + (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.type, 'emoji'); + assert.equal(response.data.value, '😀🎉'); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should handle Unicode channel names', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/caf%C3%A9`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: [], + }); + + pubnub.getMessageActions({ channel: 'café' }, (status) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle special characters in channel names', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test%20channel%2Bspecial%26chars/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'reaction', + value: 'test', + uuid: 'myUUID', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + }); + + pubnub.addMessageAction( + { + channel: 'test channel+special&chars', + messageTimetoken: '1234567890', + action: { type: 'reaction', value: 'test' } + }, + (status) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + }); + + describe('pagination and response limits', () => { + it('should handle empty message actions response', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/empty-channel`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: [], + }); + + pubnub.getMessageActions({ channel: 'empty-channel' }, (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.length, 0); + assert.equal(response.start, null); + assert.equal(response.end, null); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle pagination with start parameter', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/test-channel`) + .query({ + start: '15610547826970050', + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: [ + { + type: 'reaction', + value: 'test', + uuid: 'user1', + actionTimetoken: '15610547826970040', + messageTimetoken: '1234567890', + }, + ], + }); + + pubnub.getMessageActions({ channel: 'test-channel', start: '15610547826970050' }, (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.length, 1); + assert.equal(response.start, '15610547826970040'); + assert.equal(response.end, '15610547826970040'); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle pagination with limit parameter', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/test-channel`) + .query({ + limit: 5, + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: Array.from({ length: 5 }, (_, i) => ({ + type: 'reaction', + value: `value${i}`, + uuid: `user${i}`, + actionTimetoken: `1561054782697005${i}`, + messageTimetoken: '1234567890', + })), + }); + + pubnub.getMessageActions({ channel: 'test-channel', limit: 5 }, (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.length, 5); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle response with more field for pagination', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/test-channel`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: [ + { + type: 'reaction', + value: 'test', + uuid: 'user1', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + ], + more: { + url: `/v1/message-actions/${subscribeKey}/channel/test-channel?start=15610547826970049`, + start: '15610547826970049', + end: '15610547826970000', + limit: 100, + }, + }); + + pubnub.getMessageActions({ channel: 'test-channel' }, (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.length, 1); + assert(response.more); + assert.equal(response.more?.start, '15610547826970049'); + assert.equal(response.more?.limit, 100); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('boundary conditions', () => { + it('should handle action type at maximum length (15 characters)', (done) => { + nock.disableNetConnect(); + const maxLengthType = '123456789012345'; // exactly 15 characters + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: maxLengthType, + value: 'test', + uuid: 'myUUID', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: maxLengthType, value: 'test' } }, + (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.type, maxLengthType); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should handle very long action values', (done) => { + nock.disableNetConnect(); + const longValue = 'a'.repeat(1000); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'reaction', + value: longValue, + uuid: 'myUUID', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: 'reaction', value: longValue } }, + (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.value, longValue); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should handle very large timetoken values', (done) => { + nock.disableNetConnect(); + const largeTimetoken = '99999999999999999999'; + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/${largeTimetoken}`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'reaction', + value: 'test', + uuid: 'myUUID', + actionTimetoken: '15610547826970050', + messageTimetoken: largeTimetoken, + }, + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: largeTimetoken, action: { type: 'reaction', value: 'test' } }, + (status, response) => { + try { + assert.equal(scope.isDone(), true); + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.data.messageTimetoken, largeTimetoken); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + }); + + describe('promise API support', () => { + it('should support promise-based addMessageAction', async () => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'reaction', + value: 'test', + uuid: 'myUUID', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + }); + + try { + const response = await pubnub.addMessageAction({ + channel: 'test-channel', + messageTimetoken: '1234567890', + action: { type: 'reaction', value: 'test' }, + }); + + assert.equal(scope.isDone(), true); + assert.equal(response.data.type, 'reaction'); + assert.equal(response.data.value, 'test'); + } catch (error) { + throw error; + } + }); + + it('should support promise-based getMessageActions', async () => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/test-channel`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: [ + { + type: 'reaction', + value: 'test', + uuid: 'user1', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + ], + }); + + try { + const response = await pubnub.getMessageActions({ channel: 'test-channel' }); + + assert.equal(scope.isDone(), true); + assert.equal(response.data.length, 1); + assert.equal(response.data[0].type, 'reaction'); + } catch (error) { + throw error; + } + }); + + it('should support promise-based removeMessageAction', async () => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .delete(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890/action/15610547826970050`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: {}, + }); + + try { + const response = await pubnub.removeMessageAction({ + channel: 'test-channel', + messageTimetoken: '1234567890', + actionTimetoken: '15610547826970050', + }); + + assert.equal(scope.isDone(), true); + assert(response.data); + } catch (error) { + throw error; + } + }); + }); + + describe('HTTP compliance verification', () => { + it('should use correct HTTP method for add action', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'reaction', + value: 'test', + uuid: 'test', + actionTimetoken: '123', + messageTimetoken: '1234567890' + } + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: 'reaction', value: 'test' } }, + (status) => { + try { + assert.equal(status.error, false); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should use correct HTTP method for get actions', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .get(`/v1/message-actions/${subscribeKey}/channel/test-channel`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { status: 200, data: [] }); + + pubnub.getMessageActions({ channel: 'test-channel' }, (status) => { + try { + assert.equal(status.error, false); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should use correct HTTP method for remove action', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .delete(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890/action/15610547826970050`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { status: 200, data: {} }); + + pubnub.removeMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', actionTimetoken: '15610547826970050' }, + (status) => { + try { + assert.equal(status.error, false); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + + it('should include correct Content-Type header for POST requests', (done) => { + nock.disableNetConnect(); + const scope = utils + .createNock() + .post(`/v1/message-actions/${subscribeKey}/channel/test-channel/message/1234567890`) + .query({ + pnsdk: `PubNub-JS-Nodejs/${pubnub.getVersion()}`, + uuid: 'myUUID', + auth: 'myAuthKey', + }) + .reply(200, { + status: 200, + data: { + type: 'reaction', + value: 'test', + uuid: 'test', + actionTimetoken: '123', + messageTimetoken: '1234567890' + } + }); + + pubnub.addMessageAction( + { channel: 'test-channel', messageTimetoken: '1234567890', action: { type: 'reaction', value: 'test' } }, + (status) => { + try { + assert.equal(status.error, false); + done(); + } catch (error) { + done(error); + } + }, + ); + }); + }); }); diff --git a/test/integration/endpoints/objects/channel.test.ts b/test/integration/endpoints/objects/channel.test.ts index 69c39491c..3c229339c 100644 --- a/test/integration/endpoints/objects/channel.test.ts +++ b/test/integration/endpoints/objects/channel.test.ts @@ -255,4 +255,476 @@ describe('objects channel', () => { await expect(resultP).to.be.rejected; }); }); + + describe('error handling', () => { + it('should handle 400 Bad Request errors', async () => { + const channelName = 'test-channel'; + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(400, { + status: 400, + error: { + message: 'Bad Request', + source: 'objects', + details: [ + { + message: 'Invalid channel name', + location: 'channel', + locationType: 'path', + } + ] + } + }); + + const resultP = pubnub.objects.getChannelMetadata({ channel: channelName }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.be.rejected; + }); + + it('should handle 403 Forbidden errors', async () => { + const channelName = 'test-channel'; + const scope = utils + .createNock() + .patch(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(403, { + status: 403, + error: { + message: 'Forbidden', + source: 'objects', + } + }); + + const resultP = pubnub.objects.setChannelMetadata({ + channel: channelName, + data: { name: 'Test Channel' } + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.be.rejected; + }); + + it('should handle 404 Not Found errors', async () => { + const channelName = 'non-existent-channel'; + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(404, { + status: 404, + error: { + message: 'Not Found', + source: 'objects', + } + }); + + const resultP = pubnub.objects.getChannelMetadata({ channel: channelName }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.be.rejected; + }); + + it('should handle network timeout errors', async () => { + const channelName = 'test-channel-timeout'; + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(200, { + status: 200, + data: asResponse(channel1), + }); + + const resultP = pubnub.objects.getChannelMetadata({ channel: channelName }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + }); + + describe('data validation', () => { + it('should handle large payload data', async () => { + const channelName = 'test-channel'; + const largeCustomData = { + description: 'A'.repeat(1000), // Large description + custom: Array.from({ length: 50 }, (_, i) => [ + [`property_${i}`, `value_${'x'.repeat(50)}_${i}`] + ]).reduce((acc, curr) => ({ ...acc, [curr[0][0]]: curr[0][1] }), {}), + }; + + const scope = utils + .createNock() + .patch(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(200, { + status: 200, + data: asResponse({ ...channel1, data: largeCustomData }), + }); + + const resultP = pubnub.objects.setChannelMetadata({ + channel: channelName, + data: largeCustomData + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + + it('should handle special characters in channel name', async () => { + const channelName = 'test-channel-with-special-chars'; + const encodedChannelName = 'test-channel-with-special-chars'; + + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels/${encodedChannelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(200, { + status: 200, + data: asResponse({ ...channel1, id: channelName }), + }); + + const resultP = pubnub.objects.getChannelMetadata({ channel: channelName }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + + it('should handle unicode characters in metadata', async () => { + const channelName = 'test-channel'; + const unicodeData = { + name: 'Test Channel 测试频道', + description: 'A test channel with unicode: 🚀 💫 ⭐ 🌟', + custom: { + emoji: '😀😃😄😁😆😅😂🤣', + chinese: '你好世界', + japanese: 'こんにちは世界', + arabic: 'مرحبا بالعالم', + }, + }; + + const scope = utils + .createNock() + .patch(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .reply(200, { + status: 200, + data: asResponse({ ...channel1, data: unicodeData }), + }); + + const resultP = pubnub.objects.setChannelMetadata({ + channel: channelName, + data: unicodeData + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + }); + + describe('advanced features', () => { + it('should support conditional updates with ETag', async () => { + const channelName = 'test-channel'; + const etag = 'AaG95A8Y1bq_8g'; + + const scope = utils + .createNock() + .patch(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .matchHeader('If-Match', etag) + .reply(200, { + status: 200, + data: asResponse(channel1), + }); + + const resultP = pubnub.objects.setChannelMetadata({ + channel: channelName, + data: channel1.data, + ifMatchesEtag: etag, + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + + it('should reject updates with mismatched ETag', async () => { + const channelName = 'test-channel'; + const wrongEtag = 'WrongETag123'; + + const scope = utils + .createNock() + .patch(`/v2/objects/${SUBSCRIBE_KEY}/channels/${channelName}`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + }) + .matchHeader('If-Match', wrongEtag) + .reply(412, { + status: 412, + error: { + message: 'Precondition Failed', + source: 'objects', + details: [ + { + message: 'ETag mismatch', + location: 'If-Match', + locationType: 'header', + } + ] + } + }); + + const resultP = pubnub.objects.setChannelMetadata({ + channel: channelName, + data: channel1.data, + ifMatchesEtag: wrongEtag, + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.be.rejected; + }); + + it('should handle complex sorting combinations', async () => { + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + count: false, + sort: ['name:asc', 'updated:desc', 'status'], + limit: 100, + }) + .reply(200, { + status: 200, + data: allChannels.map(asResponse), + }); + + const resultP = pubnub.objects.getAllChannelMetadata({ + sort: { + name: 'asc', + updated: 'desc', + status: null, + }, + include: { + customFields: true, + totalCount: false, + }, + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + + it('should handle complex filter expressions', async () => { + const complexFilter = 'name LIKE "test*" AND (status = "active" OR priority > 5)'; + + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + count: false, + filter: complexFilter, + limit: 100, + }) + .reply(200, { + status: 200, + data: allChannels.slice(0, 2).map(asResponse), // Return filtered subset + }); + + const resultP = pubnub.objects.getAllChannelMetadata({ + filter: complexFilter, + include: { + customFields: true, + totalCount: false, + }, + }); + + await expect(scope).to.have.been.requested; + await expect(resultP).to.eventually.have.property('status', 200); + }); + }); + + describe('boundary testing', () => { + it('should handle empty results for getAllChannelMetadata', async () => { + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + count: false, + limit: 100, + }) + .reply(200, { + status: 200, + data: [], + totalCount: 0, + next: null, + prev: null, + }); + + const resultP = pubnub.objects.getAllChannelMetadata({ + include: { + customFields: true, + totalCount: false, + }, + }); + + await expect(scope).to.have.been.requested; + const result = await resultP; + expect(result.data).to.be.an('array').that.is.empty; + }); + + it('should handle maximum limit for getAllChannelMetadata', async () => { + const maxLimit = 100; + const scope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + count: false, + limit: maxLimit, + }) + .reply(200, { + status: 200, + data: Array.from({ length: maxLimit }, (_, i) => + asResponse({ ...channel1, id: `channel_${i}` }) + ), + }); + + const resultP = pubnub.objects.getAllChannelMetadata({ + limit: maxLimit, + include: { + customFields: true, + totalCount: false, + }, + }); + + await expect(scope).to.have.been.requested; + const result = await resultP; + expect(result.data).to.have.length(maxLimit); + }); + + it('should handle pagination edge cases', async () => { + // Test first page + const firstPageScope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + count: false, + limit: 10, + }) + .reply(200, { + status: 200, + data: allChannels.slice(0, 10).map(asResponse), + next: 'page2_cursor', + prev: null, + }); + + // Test last page + const lastPageScope = utils + .createNock() + .get(`/v2/objects/${SUBSCRIBE_KEY}/channels`) + .query({ + auth: AUTH_KEY, + uuid: UUID, + pnsdk: PNSDK, + include: 'status,type,custom', + count: false, + limit: 10, + start: 'last_page_cursor', + }) + .reply(200, { + status: 200, + data: allChannels.slice(-5).map(asResponse), // Less than full page + next: null, + prev: 'prev_page_cursor', + }); + + // First page request + const firstPageResult = pubnub.objects.getAllChannelMetadata({ + limit: 10, + include: { + customFields: true, + totalCount: false, + }, + }); + + await expect(firstPageScope).to.have.been.requested; + + // Last page request + const lastPageResult = pubnub.objects.getAllChannelMetadata({ + limit: 10, + page: { next: 'last_page_cursor' }, + include: { + customFields: true, + totalCount: false, + }, + }); + + await expect(lastPageScope).to.have.been.requested; + const lastResult = await lastPageResult; + expect(lastResult.data).to.have.length.lessThan(10); + }); + }); }); diff --git a/test/integration/endpoints/presence_errors.test.ts b/test/integration/endpoints/presence_errors.test.ts new file mode 100644 index 000000000..b0817b4b7 --- /dev/null +++ b/test/integration/endpoints/presence_errors.test.ts @@ -0,0 +1,507 @@ +/* global describe, beforeEach, it, before, afterEach */ + +import assert from 'assert'; +import nock from 'nock'; + +import PubNub from '../../../src/node/index'; +import utils from '../../utils'; + +describe('presence error handling', () => { + let pubnub: PubNub; + + before(() => { + nock.disableNetConnect(); + }); + + beforeEach(() => { + nock.cleanAll(); + pubnub = new PubNub({ + subscribeKey: 'mySubscribeKey', + publishKey: 'myPublishKey', + uuid: 'myUUID', + // @ts-expect-error Force override default value. + useRequestId: false, + }); + }); + + afterEach(() => { + pubnub.destroy(true); + }); + + describe('network connectivity errors', () => { + it('should handle network unreachable for all presence endpoints', (done) => { + // Test just one endpoint to verify network error handling + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .replyWithError('ENETUNREACH'); + + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle DNS resolution failure', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .replyWithError('ENOTFOUND'); + + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle connection refused', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .replyWithError('ECONNREFUSED'); + + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle connection reset', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/testChannel') + .query(true) + .replyWithError('ECONNRESET'); + + pubnub.hereNow({ channels: ['testChannel'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('HTTP status code errors', () => { + it('should handle 401 unauthorized for all endpoints', (done) => { + // Test one endpoint at a time to avoid race conditions + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .reply(401, { + status: 401, + error: true, + message: 'Unauthorized', + service: 'Presence' + }, { 'content-type': 'application/json' }); + + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 401); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle 429 rate limit exceeded', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .reply(429, { + status: 429, + error: true, + message: 'Too Many Requests', + service: 'Presence' + }, { + 'Retry-After': '60', + 'content-type': 'application/json' + }); + + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 429); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle 502 bad gateway', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test') + .query(true) + .reply(502, { + status: 502, + error: true, + message: 'Bad Gateway', + service: 'Presence' + }, { 'content-type': 'application/json' }); + + pubnub.hereNow({ channels: ['test'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 502); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle 503 service unavailable with retry-after', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test/uuid/myUUID') + .query(true) + .reply(503, { + status: 503, + error: true, + message: 'Service Unavailable', + service: 'Presence' + }, { + 'Retry-After': '30', + 'content-type': 'application/json' + }); + + pubnub.getState({ channels: ['test'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 503); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('malformed response handling', () => { + it('should handle empty response body', (done) => { + const endpoints = [ + { method: 'whereNow', params: {}, path: '/v2/presence/sub-key/mySubscribeKey/uuid/myUUID' }, + { method: 'hereNow', params: { channels: ['test'] }, path: '/v2/presence/sub-key/mySubscribeKey/channel/test' }, + ]; + + let completedTests = 0; + const expectedTests = endpoints.length; + + endpoints.forEach((config) => { + const scope = utils + .createNock() + .get(config.path) + .query(true) + .reply(200, '', { 'content-type': 'application/json' }); + + (pubnub as any)[config.method](config.params, (status: any) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + + completedTests++; + if (completedTests === expectedTests) { + done(); + } + } catch (error) { + done(error); + } + }); + }); + }); + + it('should handle invalid JSON in response', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test/uuid/myUUID/data') + .query(true) + .reply(200, '{"status": 200, "incomplete": json', { 'content-type': 'application/json' }); + + pubnub.setState({ channels: ['test'], state: { key: 'value' } }, (status, response) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle HTML response instead of JSON', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test/heartbeat') + .query(true) + .reply(200, 'Error Page', { 'content-type': 'text/html' }); + + (pubnub as any).heartbeat({ channels: ['test'], heartbeat: 300 }, (status: any, response: any) => { + try { + assert.equal(status.error, true); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle missing required fields in JSON response', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .reply(200, '{"message": "OK", "service": "Presence"}', { 'content-type': 'application/json' }); // Missing status field + + pubnub.whereNow({}, (status, response) => { + try { + // The response should still be handled, but may have unexpected structure + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('false success responses', () => { + it('should handle 200 OK with error status in body', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test') + .query(true) + .reply(200, '{"status": 403, "error": 1, "message": "Access Denied", "service": "Presence"}', { + 'content-type': 'application/json', + }); + + pubnub.hereNow({ channels: ['test'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle 200 OK with inconsistent data', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test/uuid/myUUID') + .query(true) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": "this should be an object", "service": "Presence"}', + { 'content-type': 'application/json' }, + ); + + pubnub.getState({ channels: ['test'] }, (status, response) => { + try { + // Should handle gracefully even with wrong payload type + assert.equal(status.error, false); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('resource limits and edge cases', () => { + it('should handle extremely large response payload', (done) => { + const largeChannels: Record = {}; + // Create a large response (but not too large to cause memory issues in tests) + for (let i = 0; i < 1000; i++) { + largeChannels[`channel-${i}`] = { + uuids: Array.from({ length: 10 }, (_, j) => `user-${i}-${j}`), + occupancy: 10, + }; + } + + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey') + .query(true) + .reply( + 200, + JSON.stringify({ + status: 200, + message: 'OK', + payload: { + channels: largeChannels, + total_channels: 1000, + total_occupancy: 10000, + }, + service: 'Presence', + }), + { 'content-type': 'application/json' }, + ); + + pubnub.hereNow({}, (status, response) => { + try { + assert.equal(status.error, false); + assert(response !== null); + assert.equal(response.totalChannels, 1000); + assert.equal(response.totalOccupancy, 10000); + assert.equal(Object.keys(response.channels).length, 1000); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + + it('should handle response with null or undefined values', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test/uuid/myUUID/data') + .query(true) + .reply( + 200, + '{"status": 200, "message": "OK", "payload": null, "service": "Presence"}', + { 'content-type': 'application/json' }, + ); + + pubnub.setState({ channels: ['test'], state: { key: 'value' } }, (status, response) => { + try { + assert.equal(status.error, false); + // The response should handle null payload gracefully + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe('concurrent error scenarios', () => { + it('should handle mixed success and error responses concurrently', (done) => { + // Simplified test with just two scenarios - one success and one error + const successScope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .reply(200, { + status: 200, + message: 'OK', + payload: { channels: [] }, + service: 'Presence' + }, { 'content-type': 'application/json' }); + + const errorScope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/channel/test1') + .query(true) + .reply(403, { + status: 403, + error: true, + message: 'Forbidden', + service: 'Presence' + }, { 'content-type': 'application/json' }); + + let completedTests = 0; + const expectedTests = 2; + + // First call - should succeed + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, false); + assert.equal(successScope.isDone(), true); + + completedTests++; + if (completedTests === expectedTests) { + done(); + } + } catch (error) { + done(error); + } + }); + + // Second call - should error + pubnub.hereNow({ channels: ['test1'] }, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 403); + assert.equal(errorScope.isDone(), true); + + completedTests++; + if (completedTests === expectedTests) { + done(); + } + } catch (error) { + done(error); + } + }); + }); + + it('should handle timeout with retry attempts', (done) => { + const scope = utils + .createNock() + .get('/v2/presence/sub-key/mySubscribeKey/uuid/myUUID') + .query(true) + .reply(408, { + status: 408, + error: true, + message: 'Request Timeout', + service: 'Presence' + }, { 'content-type': 'application/json' }); + + pubnub.whereNow({}, (status, response) => { + try { + assert.equal(status.error, true); + assert.equal(status.statusCode, 408); + assert(status.errorData); + assert.equal(scope.isDone(), true); + done(); + } catch (error) { + done(error); + } + }); + }); + }); +}); diff --git a/test/unit/access_manager/access_manager_grant_token.test.ts b/test/unit/access_manager/access_manager_grant_token.test.ts new file mode 100644 index 000000000..cd2976c97 --- /dev/null +++ b/test/unit/access_manager/access_manager_grant_token.test.ts @@ -0,0 +1,790 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { GrantTokenRequest } from '../../../src/core/endpoints/access_manager/grant_token'; +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as PAM from '../../../src/core/types/api/access-manager'; + +// Helper function to parse body as string +function parseBodyAsString(body: string | ArrayBuffer | any): any { + if (typeof body === 'string') { + return JSON.parse(body); + } + throw new Error('Expected body to be string'); +} + +describe('GrantTokenRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: PAM.GrantTokenParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + secretKey: 'test_secret_key', + }; + + defaultParameters = { + keySet: defaultKeySet, + ttl: 60, + resources: { + channels: { + test_channel: { + read: true, + write: true, + }, + }, + }, + }; + }); + + describe('validation', () => { + it('should validate required subscribe key', () => { + const requestWithoutSubscribeKey = new GrantTokenRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(requestWithoutSubscribeKey.validate(), "Missing Subscribe Key"); + }); + + it('should validate required publish key', () => { + const requestWithoutPublishKey = new GrantTokenRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, publishKey: '' }, + }); + assert.equal(requestWithoutPublishKey.validate(), "Missing Publish Key"); + }); + + it('should validate required secret key', () => { + const requestWithoutSecretKey = new GrantTokenRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, secretKey: '' }, + }); + assert.equal(requestWithoutSecretKey.validate(), "Missing Secret Key"); + }); + + it('should require either resources or patterns', () => { + const requestWithoutResourcesOrPatterns = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + // Both resources and patterns are undefined + } as any); + assert.equal(requestWithoutResourcesOrPatterns.validate(), "Missing values for either Resources or Patterns"); + }); + + it('should require non-empty resources or patterns', () => { + const requestWithEmptyResources = new GrantTokenRequest({ + ...defaultParameters, + resources: {}, + patterns: {}, + }); + assert.equal(requestWithEmptyResources.validate(), "Missing values for either Resources or Patterns"); + }); + + it('should require non-empty resource objects', () => { + const requestWithEmptyResourceObjects = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: {}, + groups: {}, + uuids: {}, + }, + }); + assert.equal(requestWithEmptyResourceObjects.validate(), "Missing values for either Resources or Patterns"); + }); + + it('should pass validation with valid channel resources', () => { + const validRequest = new GrantTokenRequest(defaultParameters); + assert.equal(validRequest.validate(), undefined); + }); + + it('should pass validation with patterns', () => { + const validPatternRequest = new GrantTokenRequest({ + ...defaultParameters, + resources: undefined, + patterns: { + channels: { + '.*': { + read: true, + }, + }, + }, + }); + assert.equal(validPatternRequest.validate(), undefined); + }); + + it('should pass validation with both resources and patterns', () => { + const validBothRequest = new GrantTokenRequest({ + ...defaultParameters, + patterns: { + channels: { + 'pattern.*': { + read: true, + }, + }, + }, + }); + assert.equal(validBothRequest.validate(), undefined); + }); + }); + + describe('VSP legacy permissions validation', () => { + it('should validate VSP authorizedUserId without new permission fields', () => { + const vspRequest = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + authorizedUserId: 'user123', + resources: { + users: { + user1: { + get: true, // VSP legacy - using valid UuidTokenPermissions property + }, + }, + }, + } as any); // Type assertion for VSP legacy + assert.equal(vspRequest.validate(), undefined); + }); + + it('should reject mixing VSP and new permissions in resources', () => { + const mixedRequest = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + authorizedUserId: 'user123', + resources: { + users: { + user1: { + get: true, + }, + }, + channels: { + channel1: { + read: true, + }, + }, + }, + } as any); // Type assertion for VSP legacy + assert.equal( + mixedRequest.validate(), + "Cannot mix `users`, `spaces` and `authorizedUserId` with `uuids`, `channels`, `groups` and `authorized_uuid`" + ); + }); + + it('should reject mixing VSP and new permissions in patterns', () => { + const mixedRequest = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + authorizedUserId: 'user123', + resources: { + users: { + user1: { + get: true, + }, + }, + }, + patterns: { + channels: { + '.*': { + read: true, + }, + }, + }, + } as any); // Type assertion for VSP legacy + assert.equal( + mixedRequest.validate(), + "Cannot mix `users`, `spaces` and `authorizedUserId` with `uuids`, `channels`, `groups` and `authorized_uuid`" + ); + }); + }); + + describe('operation', () => { + it('should return PNAccessManagerGrantToken operation', () => { + const request = new GrantTokenRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNAccessManagerGrantToken); + }); + }); + + describe('request method', () => { + it('should use POST method', () => { + const request = new GrantTokenRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.POST); + }); + }); + + describe('URL construction', () => { + it('should construct correct URL path', () => { + const request = new GrantTokenRequest(defaultParameters); + const transportRequest = request.request(); + + const expectedPath = `/v3/pam/${defaultKeySet.subscribeKey}/grant`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct URL with different subscribe key', () => { + const customKeySet = { ...defaultKeySet, subscribeKey: 'custom_sub_key' }; + const request = new GrantTokenRequest({ + ...defaultParameters, + keySet: customKeySet, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.path, `/v3/pam/custom_sub_key/grant`); + }); + }); + + describe('headers', () => { + it('should set Content-Type header to application/json', () => { + const request = new GrantTokenRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + }); + + describe('JSON body construction', () => { + it('should include TTL in body when specified', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + ttl: 1440, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.ttl, 1440); + }); + + it('should include TTL when TTL is 0', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + ttl: 0, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.ttl, 0); + }); + + it('should not include TTL when not specified', () => { + const params = { ...defaultParameters }; + delete (params as any).ttl; + const request = new GrantTokenRequest(params); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.ttl, undefined); + }); + + it('should include authorized_uuid when specified', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + authorized_uuid: 'test_uuid', + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.uuid, 'test_uuid'); + }); + + it('should include authorizedUserId for VSP permissions', () => { + const request = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + authorizedUserId: 'vsp_user', + resources: { + users: { + user1: { + get: true, + }, + }, + }, + } as any); // Type assertion for VSP legacy + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.uuid, 'vsp_user'); + }); + + it('should include meta when specified', () => { + const metaData = { key1: 'value1', key2: 'value2' }; + const request = new GrantTokenRequest({ + ...defaultParameters, + meta: metaData, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.deepEqual(body.permissions.meta, metaData); + }); + + it('should include empty meta object when not specified', () => { + const request = new GrantTokenRequest(defaultParameters); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.deepEqual(body.permissions.meta, {}); + }); + }); + + describe('permission bit calculation', () => { + it('should calculate read permission bit (1)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + read: true, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 1); + }); + + it('should calculate write permission bit (2)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + write: true, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 2); + }); + + it('should calculate manage permission bit (4)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + manage: true, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 4); + }); + + it('should calculate delete permission bit (8)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + delete: true, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 8); + }); + + it('should calculate get permission bit (32)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + uuids: { + test_uuid: { + get: true, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.uuids.test_uuid, 32); + }); + + it('should calculate update permission bit (64)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + uuids: { + test_uuid: { + update: true, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.uuids.test_uuid, 64); + }); + + it('should calculate join permission bit (128)', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + join: true, // join is valid for channels + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 128); + }); + + it('should combine multiple permission bits', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + read: true, // 1 + write: true, // 2 + manage: true, // 4 + // Total: 1 + 2 + 4 = 7 + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 7); + }); + + it('should combine all permission bits', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + read: true, // 1 + write: true, // 2 + manage: true, // 4 + delete: true, // 8 + get: true, // 32 + update: true, // 64 + join: true, // 128 + // Total: 1 + 2 + 4 + 8 + 32 + 64 + 128 = 239 + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 239); + }); + + it('should handle false permissions as 0', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test_channel: { + read: false, + write: false, + manage: false, + delete: false, + }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + assert.equal(body.permissions.resources.channels.test_channel, 0); + }); + }); + + describe('resources and patterns structure', () => { + it('should structure channel resources correctly', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + channel1: { read: true }, + channel2: { write: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.channels.channel1, 1); + assert.equal(body.permissions.resources.channels.channel2, 2); + assert.deepEqual(body.permissions.resources.groups, {}); + assert.deepEqual(body.permissions.resources.uuids, {}); + }); + + it('should structure channel group resources correctly', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + groups: { + group1: { read: true, manage: true }, + group2: { read: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.groups.group1, 5); // 1 + 4 + assert.equal(body.permissions.resources.groups.group2, 1); // read only + assert.deepEqual(body.permissions.resources.channels, {}); + assert.deepEqual(body.permissions.resources.uuids, {}); + }); + + it('should structure uuid resources correctly', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + uuids: { + uuid1: { get: true, update: true }, + uuid2: { delete: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.uuids.uuid1, 96); // 32 + 64 + assert.equal(body.permissions.resources.uuids.uuid2, 8); // delete + assert.deepEqual(body.permissions.resources.channels, {}); + assert.deepEqual(body.permissions.resources.groups, {}); + }); + + it('should structure patterns correctly', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: undefined, + patterns: { + channels: { + 'channel.*': { read: true }, + 'private.*': { read: true, write: true }, + }, + groups: { + 'group.*': { manage: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.patterns.channels['channel.*'], 1); + assert.equal(body.permissions.patterns.channels['private.*'], 3); // 1 + 2 + assert.equal(body.permissions.patterns.groups['group.*'], 4); + assert.deepEqual(body.permissions.patterns.uuids, {}); + }); + + it('should handle both resources and patterns', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + specific_channel: { read: true }, + }, + }, + patterns: { + channels: { + 'dynamic.*': { write: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.channels.specific_channel, 1); + assert.equal(body.permissions.patterns.channels['dynamic.*'], 2); + }); + }); + + describe('VSP legacy permissions handling', () => { + it('should handle VSP users as uuids', () => { + const request = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + resources: { + users: { + user1: { get: true }, // VSP legacy - mapped to uuids + user2: { get: true, update: true }, + }, + }, + } as any); // Type assertion for VSP legacy + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.uuids.user1, 32); // get + assert.equal(body.permissions.resources.uuids.user2, 96); // 32 + 64 + }); + + it('should handle VSP spaces as channels', () => { + const request = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + resources: { + spaces: { + space1: { read: true, write: true }, + space2: { manage: true }, + }, + }, + } as any); // Type assertion for VSP legacy + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.channels.space1, 3); // 1 + 2 + assert.equal(body.permissions.resources.channels.space2, 4); + }); + + it('should handle VSP patterns correctly', () => { + const request = new GrantTokenRequest({ + keySet: defaultKeySet, + ttl: 60, + patterns: { + users: { + 'user.*': { get: true }, + }, + spaces: { + 'space.*': { read: true, write: true }, + }, + }, + } as any); // Type assertion for VSP legacy + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.patterns.uuids['user.*'], 32); + assert.equal(body.permissions.patterns.channels['space.*'], 3); // 1 + 2 + }); + }); + + describe('response parsing', () => { + it('should parse successful grant token response', async () => { + const request = new GrantTokenRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: { + message: 'Success', + token: 'p0AkFl043rhDdHRsple3KgQ3NwY6BDcENnctokenVzcqBDczaWdYIGOAeTyWGJI', + }, + service: 'Access Manager', + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse, 'p0AkFl043rhDdHRsple3KgQ3NwY6BDcENnctokenVzcqBDczaWdYIGOAeTyWGJI'); + }); + }); + + describe('edge cases', () => { + it('should handle empty resource objects with default structure', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + test: { read: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + // Should have all resource types even if not specified + assert.equal(typeof body.permissions.resources.channels, 'object'); + assert.equal(typeof body.permissions.resources.groups, 'object'); + assert.equal(typeof body.permissions.resources.uuids, 'object'); + + // Should have all pattern types even if not specified + assert.equal(typeof body.permissions.patterns.channels, 'object'); + assert.equal(typeof body.permissions.patterns.groups, 'object'); + assert.equal(typeof body.permissions.patterns.uuids, 'object'); + }); + + it('should handle special characters in resource names', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { + channels: { + 'channel-with-dashes': { read: true }, + 'channel_with_underscores': { write: true }, + 'channel.with.dots': { manage: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.resources.channels['channel-with-dashes'], 1); + assert.equal(body.permissions.resources.channels['channel_with_underscores'], 2); + assert.equal(body.permissions.resources.channels['channel.with.dots'], 4); + }); + + it('should handle regex patterns in pattern names', () => { + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: undefined, + patterns: { + channels: { + '^channel-[A-Za-z0-9]+$': { read: true }, + '.*private.*': { write: true }, + }, + }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(body.permissions.patterns.channels['^channel-[A-Za-z0-9]+$'], 1); + assert.equal(body.permissions.patterns.channels['.*private.*'], 2); + }); + + it('should handle large numbers of resources', () => { + const channels: Record = {}; + for (let i = 0; i < 100; i++) { + channels[`channel_${i}`] = { read: true }; + } + + const request = new GrantTokenRequest({ + ...defaultParameters, + resources: { channels }, + }); + + const transportRequest = request.request(); + const body = parseBodyAsString(transportRequest.body!); + + assert.equal(Object.keys(body.permissions.resources.channels).length, 100); + assert.equal(body.permissions.resources.channels.channel_0, 1); + assert.equal(body.permissions.resources.channels.channel_99, 1); + }); + }); +}); diff --git a/test/unit/access_manager/access_manager_revoke_token.test.ts b/test/unit/access_manager/access_manager_revoke_token.test.ts new file mode 100644 index 000000000..f6bcbb10c --- /dev/null +++ b/test/unit/access_manager/access_manager_revoke_token.test.ts @@ -0,0 +1,406 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { RevokeTokenRequest } from '../../../src/core/endpoints/access_manager/revoke_token'; +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; + +describe('RevokeTokenRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: { token: string; keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + secretKey: 'test_secret_key', + }; + + defaultParameters = { + token: 'p0AkFl043rhDdHRsple3KgQ3NwY6BDcENnctokenVzcqBDczaWdYIGOAeTyWGJI', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required secret key', () => { + const requestWithoutSecretKey = new RevokeTokenRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, secretKey: '' }, + }); + assert.equal(requestWithoutSecretKey.validate(), "Missing Secret Key"); + }); + + it('should validate required token', () => { + const requestWithoutToken = new RevokeTokenRequest({ + ...defaultParameters, + token: '', + }); + assert.equal(requestWithoutToken.validate(), "token can't be empty"); + }); + + it('should pass validation with valid parameters', () => { + const validRequest = new RevokeTokenRequest(defaultParameters); + assert.equal(validRequest.validate(), undefined); + }); + + it('should pass validation with only secret key and token', () => { + const minimalRequest = new RevokeTokenRequest({ + token: 'test_token', + keySet: { subscribeKey: 'test_sub_key', secretKey: 'test_secret_key' }, + }); + assert.equal(minimalRequest.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNAccessManagerRevokeToken operation', () => { + const request = new RevokeTokenRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNAccessManagerRevokeToken); + }); + }); + + describe('request method', () => { + it('should use DELETE method', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.DELETE); + }); + }); + + describe('URL construction', () => { + it('should construct correct URL path with encoded token', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + + const expectedPath = `/v3/pam/${defaultKeySet.subscribeKey}/grant/${encodeURIComponent(defaultParameters.token)}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct URL with different subscribe key', () => { + const customKeySet = { ...defaultKeySet, subscribeKey: 'custom_sub_key' }; + const request = new RevokeTokenRequest({ + ...defaultParameters, + keySet: customKeySet, + }); + + const transportRequest = request.request(); + const expectedPath = `/v3/pam/custom_sub_key/grant/${encodeURIComponent(defaultParameters.token)}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle different token values', () => { + const customToken = 'qEF2AkF0GmEI03xDdHRsGDxDcmVzpURjaGFuoWljaGFubmVsLTEY70NncnChb2NoYW5uZWxfZ3JvdXAtMQVDdXNyoENzcGOgRHV1aWShZnV1aWQtMRhoQ3BhdKVEY2hhbqFtXmNoYW5uZWwtXFMqJBjvQ2dycKF0XjpjaGFubmVsX2dyb3VwLVxTKiQFQ3VzcqBDc3BjoER1dWlkoWpedXVpZC1cUyokGGhEbWV0YaBEdXVpZHR0ZXN0LWF1dGhvcml6ZWQtdXVpZENzaWdYIPpU-vCe9rkpYs87YUrFNWkyNq8CVvmKwEjVinnDrJJc'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: customToken, + }); + + const transportRequest = request.request(); + const expectedPath = `/v3/pam/${defaultKeySet.subscribeKey}/grant/${encodeURIComponent(customToken)}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle tokens with special characters that need encoding', () => { + const tokenWithSpecialChars = 'token+with/special=chars&more?data'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: tokenWithSpecialChars, + }); + + const transportRequest = request.request(); + const expectedPath = `/v3/pam/${defaultKeySet.subscribeKey}/grant/${encodeURIComponent(tokenWithSpecialChars)}`; + assert.equal(transportRequest.path, expectedPath); + + // Verify that special characters are actually encoded using encodeString + // encodeString does additional encoding beyond encodeURIComponent + const encoded = transportRequest.path; + assert(encoded.includes(encodeURIComponent(tokenWithSpecialChars))); + }); + + it('should handle tokens with spaces', () => { + const tokenWithSpaces = 'token with spaces'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: tokenWithSpaces, + }); + + const transportRequest = request.request(); + const expectedPath = `/v3/pam/${defaultKeySet.subscribeKey}/grant/${encodeURIComponent(tokenWithSpaces)}`; + assert.equal(transportRequest.path, expectedPath); + + // Verify that spaces are encoded as %20 + assert(transportRequest.path.includes('token%20with%20spaces')); + }); + + it('should handle empty subscribe key', () => { + const emptySubKeyRequest = new RevokeTokenRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + + const transportRequest = emptySubKeyRequest.request(); + const expectedPath = `/v3/pam//grant/${encodeURIComponent(defaultParameters.token)}`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('token encoding edge cases', () => { + it('should handle very long tokens', () => { + const longToken = 'a'.repeat(1000); + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: longToken, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(longToken))); + }); + + it('should handle tokens with Unicode characters', () => { + const unicodeToken = 'token_with_unicode_🚀_characters_ñ_ü'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: unicodeToken, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(unicodeToken))); + }); + + it('should handle tokens with Base64-like characters', () => { + const base64Token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: base64Token, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(base64Token))); + }); + + it('should handle tokens with URL-unsafe characters', () => { + const unsafeToken = 'token#with%unsafe&characters?and=more'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: unsafeToken, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(unsafeToken))); + }); + }); + + describe('response parsing', () => { + it('should parse successful revoke token response', async () => { + const request = new RevokeTokenRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: {}, + service: 'Access Manager', + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.deepEqual(parsedResponse, {}); + }); + + it('should parse successful revoke response with additional data', async () => { + const request = new RevokeTokenRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: { + message: 'Token successfully revoked', + timestamp: 1234567890, + }, + service: 'Access Manager', + })), + }; + + const parsedResponse = await request.parse(mockResponse); + // Should always return empty object regardless of response content + assert.deepEqual(parsedResponse, {}); + }); + + it('should handle empty response body', async () => { + const request = new RevokeTokenRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(''), + }; + + try { + await request.parse(mockResponse); + // Should not reach here if parsing fails + assert.fail('Expected parsing to fail with empty body'); + } catch (error) { + // Expected to throw due to invalid JSON + assert(error instanceof Error); + } + }); + + it('should handle non-JSON response', async () => { + const request = new RevokeTokenRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'text/plain' }, + body: new TextEncoder().encode('Success'), + }; + + try { + await request.parse(mockResponse); + // Should not reach here if parsing fails + assert.fail('Expected parsing to fail with non-JSON body'); + } catch (error) { + // Expected to throw due to invalid JSON + assert(error instanceof Error); + } + }); + }); + + describe('minimal configurations', () => { + it('should work with only required parameters', () => { + const minimalRequest = new RevokeTokenRequest({ + token: 'test_token', + keySet: { subscribeKey: 'test_sub_key', secretKey: 'test_secret' }, + }); + + assert.equal(minimalRequest.validate(), undefined); + assert.equal(minimalRequest.operation(), RequestOperation.PNAccessManagerRevokeToken); + + const transportRequest = minimalRequest.request(); + assert.equal(transportRequest.method, TransportMethod.DELETE); + assert(transportRequest.path.includes('test_token')); + }); + + it('should not require publish key for validation', () => { + const requestWithoutPubKey = new RevokeTokenRequest({ + token: 'test_token', + keySet: { subscribeKey: 'test_sub_key', secretKey: 'test_secret' }, + }); + + assert.equal(requestWithoutPubKey.validate(), undefined); + }); + }); + + describe('edge cases', () => { + it('should handle undefined keySet properties gracefully', () => { + const request = new RevokeTokenRequest({ + token: 'test_token', + keySet: { + secretKey: 'test_secret', + subscribeKey: undefined as any, + publishKey: undefined as any, + }, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.path, `/v3/pam/undefined/grant/${encodeURIComponent('test_token')}`); + }); + + it('should handle very short tokens', () => { + const shortToken = 'a'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: shortToken, + }); + + assert.equal(request.validate(), undefined); + const transportRequest = request.request(); + assert(transportRequest.path.includes(shortToken)); + }); + + it('should handle numeric-like tokens', () => { + const numericToken = '1234567890'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: numericToken, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(numericToken)); + }); + + it('should handle tokens with only special characters', () => { + const specialCharsToken = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + const request = new RevokeTokenRequest({ + ...defaultParameters, + token: specialCharsToken, + }); + + const transportRequest = request.request(); + // Verify the token is in the path (it will be encoded) + assert(transportRequest.path.includes('/grant/')); + }); + }); + + describe('request properties', () => { + it('should have empty query parameters object', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + + // DELETE requests have empty query parameters object (not undefined) + assert.deepEqual(transportRequest.queryParameters, {}); + }); + + it('should not have request body', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + + // DELETE requests typically don't have bodies + assert.equal(transportRequest.body, undefined); + }); + + it('should have default headers from AbstractRequest', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + + // Should have default headers from AbstractRequest + assert.deepEqual(transportRequest.headers, { + 'Accept-Encoding': 'gzip, deflate' + }); + }); + + it('should be configured as non-compressible', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + + // DELETE requests are typically not compressible + assert.equal(transportRequest.compressible, false); + }); + }); + + describe('consistency with other access manager endpoints', () => { + it('should follow same URL pattern as other v3 PAM endpoints', () => { + const request = new RevokeTokenRequest(defaultParameters); + const transportRequest = request.request(); + + // Should follow /v3/pam/{subscribeKey}/* pattern + assert(transportRequest.path.startsWith('/v3/pam/')); + assert(transportRequest.path.includes(defaultKeySet.subscribeKey!)); + }); + + it('should require secret key like other PAM operations', () => { + const requestWithoutSecret = new RevokeTokenRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, secretKey: '' }, + }); + + // All PAM operations should require secret key + assert.equal(requestWithoutSecret.validate(), "Missing Secret Key"); + }); + }); +}); diff --git a/test/unit/app_context/channel_objects_get.test.ts b/test/unit/app_context/channel_objects_get.test.ts new file mode 100644 index 000000000..dff7e2235 --- /dev/null +++ b/test/unit/app_context/channel_objects_get.test.ts @@ -0,0 +1,116 @@ +import assert from 'assert'; + +import { GetChannelMetadataRequest } from '../../../src/core/endpoints/objects/channel/get'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('GetChannelMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.GetChannelMetadataParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + channel: 'test_channel', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it('should return "Channel cannot be empty" when channel is empty string', () => { + const request = new GetChannelMetadataRequest({ + ...defaultParameters, + channel: '', + }); + assert.equal(request.validate(), 'Channel cannot be empty'); + }); + + it('should return "Channel cannot be empty" when channel is null', () => { + const request = new GetChannelMetadataRequest({ + ...defaultParameters, + channel: null as any, + }); + assert.equal(request.validate(), 'Channel cannot be empty'); + }); + + it('should return "Channel cannot be empty" when channel is undefined', () => { + const request = new GetChannelMetadataRequest({ + ...defaultParameters, + channel: undefined as any, + }); + assert.equal(request.validate(), 'Channel cannot be empty'); + }); + + it('should return undefined when channel is provided', () => { + const request = new GetChannelMetadataRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new GetChannelMetadataRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetChannelMetadataOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with subscribeKey and channel', () => { + const request = new GetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/channels/${defaultParameters.channel}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel name', () => { + const request = new GetChannelMetadataRequest({ + ...defaultParameters, + channel: 'test-channel#1@2', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/channels/test-channel%231%402`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include custom fields by default', () => { + const request = new GetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('custom')); + }); + + it('should exclude custom fields when include.customFields is false', () => { + const request = new GetChannelMetadataRequest({ + ...defaultParameters, + include: { customFields: false }, + }); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(!includeParam?.includes('custom')); + }); + + it('should include status and type fields', () => { + const request = new GetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('status')); + assert(includeParam?.includes('type')); + }); + }); + + describe('HTTP method', () => { + it('should use GET method', () => { + const request = new GetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); +}); diff --git a/test/unit/app_context/channel_objects_get_all.test.ts b/test/unit/app_context/channel_objects_get_all.test.ts new file mode 100644 index 000000000..5a5fbe027 --- /dev/null +++ b/test/unit/app_context/channel_objects_get_all.test.ts @@ -0,0 +1,122 @@ +import assert from 'assert'; + +import { GetAllChannelsMetadataRequest } from '../../../src/core/endpoints/objects/channel/get_all'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('GetAllChannelsMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.GetAllMetadataParameters> & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + keySet: defaultKeySet, + }; + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new GetAllChannelsMetadataRequest>(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetAllChannelMetadataOperation); + }); + }); + + describe('default parameters', () => { + it('should set default limit to 100', () => { + const request = new GetAllChannelsMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.limit, 100); + }); + + it('should set includeCustomFields to false by default', () => { + const request = new GetAllChannelsMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(!includeParam?.includes('custom')); + }); + + it('should set includeTotalCount to false by default', () => { + const request = new GetAllChannelsMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.count, 'false'); + }); + }); + + describe('query parameters', () => { + it('should handle custom limit parameter', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + limit: 50, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.limit, 50); + }); + + it('should handle filter parameter', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + filter: 'name LIKE "test*"', + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.filter, 'name LIKE "test*"'); + }); + + it('should handle pagination start cursor', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + page: { next: 'test-next-cursor' }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.start, 'test-next-cursor'); + }); + + it('should handle pagination end cursor', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + page: { prev: 'test-prev-cursor' }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.end, 'test-prev-cursor'); + }); + }); + + describe('sorting', () => { + it('should handle string sort parameter', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + sort: 'name', + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.sort, 'name'); + }); + + it('should handle object sort with direction', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + sort: { name: 'desc', updated: 'asc' }, + }); + const transportRequest = request.request(); + const sortParams = transportRequest.queryParameters?.sort as string[]; + assert(sortParams.includes('name:desc')); + assert(sortParams.includes('updated:asc')); + }); + + it('should handle object sort with null direction', () => { + const request = new GetAllChannelsMetadataRequest>({ + ...defaultParameters, + sort: { name: null, updated: 'asc' }, + }); + const transportRequest = request.request(); + const sortParams = transportRequest.queryParameters?.sort as string[]; + assert(sortParams.includes('name')); + assert(sortParams.includes('updated:asc')); + }); + }); +}); diff --git a/test/unit/app_context/channel_objects_remove.test.ts b/test/unit/app_context/channel_objects_remove.test.ts new file mode 100644 index 000000000..ad893b89f --- /dev/null +++ b/test/unit/app_context/channel_objects_remove.test.ts @@ -0,0 +1,65 @@ +import assert from 'assert'; + +import { RemoveChannelMetadataRequest } from '../../../src/core/endpoints/objects/channel/remove'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('RemoveChannelMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.RemoveChannelMetadataParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + channel: 'test_channel', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it('should return "Channel cannot be empty" when channel is empty', () => { + const request = new RemoveChannelMetadataRequest({ + ...defaultParameters, + channel: '', + }); + assert.equal(request.validate(), 'Channel cannot be empty'); + }); + + it('should return undefined when channel provided', () => { + const request = new RemoveChannelMetadataRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('HTTP method', () => { + it('should use DELETE method', () => { + const request = new RemoveChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.DELETE); + }); + }); + + describe('URL construction', () => { + it('should construct correct path', () => { + const request = new RemoveChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/channels/${defaultParameters.channel}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode channel name in path', () => { + const request = new RemoveChannelMetadataRequest({ + ...defaultParameters, + channel: 'test-channel#1@2', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/channels/test-channel%231%402`; + assert.equal(transportRequest.path, expectedPath); + }); + }); +}); diff --git a/test/unit/app_context/channel_objects_set.test.ts b/test/unit/app_context/channel_objects_set.test.ts new file mode 100644 index 000000000..47b038221 --- /dev/null +++ b/test/unit/app_context/channel_objects_set.test.ts @@ -0,0 +1,129 @@ +import assert from 'assert'; + +import { SetChannelMetadataRequest } from '../../../src/core/endpoints/objects/channel/set'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('SetChannelMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.SetChannelMetadataParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + channel: 'test_channel', + data: { + name: 'Test Channel', + description: 'A test channel', + custom: { + category: 'test', + priority: 1, + }, + }, + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it('should return "Channel cannot be empty" when channel is empty', () => { + const request = new SetChannelMetadataRequest({ + ...defaultParameters, + channel: '', + }); + assert.equal(request.validate(), 'Channel cannot be empty'); + }); + + it('should return "Data cannot be empty" when data is null', () => { + const request = new SetChannelMetadataRequest({ + ...defaultParameters, + data: null as any, + }); + assert.equal(request.validate(), 'Data cannot be empty'); + }); + + it('should return "Data cannot be empty" when data is undefined', () => { + const request = new SetChannelMetadataRequest({ + ...defaultParameters, + data: undefined as any, + }); + assert.equal(request.validate(), 'Data cannot be empty'); + }); + + it('should return undefined when all required parameters provided', () => { + const request = new SetChannelMetadataRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('HTTP method and headers', () => { + it('should use PATCH method', () => { + const request = new SetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.PATCH); + }); + + it('should set Content-Type header', () => { + const request = new SetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + + it('should set If-Match header when ifMatchesEtag provided', () => { + const request = new SetChannelMetadataRequest({ + ...defaultParameters, + ifMatchesEtag: 'test-etag-123', + }); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['If-Match'], 'test-etag-123'); + }); + + it('should not set If-Match header when ifMatchesEtag not provided', () => { + const request = new SetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['If-Match'], undefined); + }); + }); + + describe('body serialization', () => { + it('should serialize data as JSON string', () => { + const request = new SetChannelMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.body, JSON.stringify(defaultParameters.data)); + }); + + it('should handle complex nested data objects', () => { + const complexData = { + name: 'Complex Channel', + description: 'A complex test channel', + custom: { + metadata: { + tags: ['test', 'complex'], + settings: { + notifications: true, + theme: 'dark', + }, + }, + permissions: { + read: ['user1', 'user2'], + write: ['admin'], + }, + }, + }; + + const request = new SetChannelMetadataRequest({ + ...defaultParameters, + data: complexData as any, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.body, JSON.stringify(complexData)); + const parsedBody = JSON.parse(transportRequest.body as string); + assert.deepEqual(parsedBody, complexData); + }); + }); +}); diff --git a/test/unit/app_context/membership_objects_get.test.ts b/test/unit/app_context/membership_objects_get.test.ts new file mode 100644 index 000000000..818a20822 --- /dev/null +++ b/test/unit/app_context/membership_objects_get.test.ts @@ -0,0 +1,309 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { GetUUIDMembershipsRequest } from '../../../src/core/endpoints/objects/membership/get'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('GetUUIDMembershipsRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.GetMembershipsParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + uuid: 'test_user_uuid', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it("should return \"'uuid' cannot be empty\" when uuid is empty string", () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: '', + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it("should return \"'uuid' cannot be empty\" when uuid is null", () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: null as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it("should return \"'uuid' cannot be empty\" when uuid is undefined", () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: undefined, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return undefined when uuid is provided', () => { + const request = new GetUUIDMembershipsRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new GetUUIDMembershipsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetMembershipsOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with subscribeKey and uuid', () => { + const request = new GetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/${defaultParameters.uuid}/channels`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in uuid', () => { + const specialUuid = 'test-uuid#1@2'; + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: specialUuid, + }); + const transportRequest = request.request(); + const expectedEncodedUuid = 'test-uuid%231%402'; + assert(transportRequest.path.includes(expectedEncodedUuid)); + }); + }); + + describe('HTTP method', () => { + it('should use GET method', () => { + const request = new GetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('query parameters', () => { + it('should construct query parameters with all include options', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + include: { + customFields: true, + totalCount: true, + statusField: true, + typeField: true, + channelFields: true, + customChannelFields: true, + channelStatusField: true, + channelTypeField: true, + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.include, 'status,type,custom,channel,channel.status,channel.type,channel.custom'); + }); + + it('should handle pagination parameters', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + page: { + next: 'nextToken', + prev: 'prevToken', + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + // Both start and end can be present if both next and prev are provided + assert.equal(queryParams?.start, 'nextToken'); + assert.equal(queryParams?.end, 'prevToken'); + }); + + it('should handle filter parameter', () => { + const filterString = 'name == "test"'; + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + filter: filterString, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.filter, filterString); + }); + + it('should handle sort parameter as string', () => { + const sortString = 'updated:desc'; + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + sort: 'updated' as any, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.sort, 'updated'); + }); + + it('should handle sort parameter as object', () => { + const sortObject = { updated: 'desc', status: 'asc' }; + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + sort: sortObject as any, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + // Should contain both sort fields as array + assert(Array.isArray(queryParams?.sort)); + const sortArray = queryParams?.sort as string[]; + assert(sortArray.includes('updated:desc')); + assert(sortArray.includes('status:asc')); + }); + + it('should handle limit parameter', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + limit: 50, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.limit, 50); + }); + }); + + describe('default values', () => { + it('should apply default values for include options', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + // No include specified + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + // By default, all include flags should be false, so include should not be present + assert.equal(queryParams?.include, undefined); + }); + + it('should apply default limit', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + // No limit specified + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.limit, 100); // Default limit from the source code + }); + }); + + describe('backward compatibility', () => { + it('should map userId to uuid parameter', () => { + const request = new GetUUIDMembershipsRequest({ + keySet: defaultKeySet, + userId: 'test-user-id', // Using userId instead of uuid + // uuid is not provided + } as any); + + // The request should be valid as userId gets mapped to uuid internally + assert.equal(request.validate(), undefined); + + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/test-user-id/channels`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameter combinations', () => { + it('should handle multiple query parameters together', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + include: { + customFields: true, + statusField: true, + channelFields: true, + }, + limit: 25, + filter: 'channel.name like "*test*"', + sort: 'channel.name', + page: { + next: 'token123', + }, + }); + + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + + assert.equal(queryParams?.include, 'status,custom,channel'); + assert.equal(queryParams?.limit, 25); + assert.equal(queryParams?.filter, 'channel.name like "*test*"'); + assert.equal(queryParams?.sort, 'channel.name'); + assert.equal(queryParams?.start, 'token123'); + assert.equal(queryParams?.count, 'false'); // totalCount default + }); + + it('should handle page prev without next', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + page: { + prev: 'prevToken', + // no next token + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.end, 'prevToken'); + assert.equal(queryParams?.start, undefined); + }); + + it('should handle totalCount flag', () => { + const requestWithCount = new GetUUIDMembershipsRequest({ + ...defaultParameters, + include: { + totalCount: true, + }, + }); + const transportRequestWithCount = requestWithCount.request(); + const queryParamsWithCount = transportRequestWithCount.queryParameters; + assert.equal(queryParamsWithCount?.count, 'true'); + + const requestWithoutCount = new GetUUIDMembershipsRequest(defaultParameters); + const transportRequestWithoutCount = requestWithoutCount.request(); + const queryParamsWithoutCount = transportRequestWithoutCount.queryParameters; + assert.equal(queryParamsWithoutCount?.count, 'false'); + }); + }); + + describe('edge cases', () => { + it('should handle empty sort object', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + sort: {} as any, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + // Empty sort object results in no sort parameter in query (undefined) + assert.equal(queryParams?.sort, undefined); + }); + + it('should handle null sort values in object', () => { + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + sort: { updated: null, created: 'asc' } as any, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + const sortArray = queryParams?.sort as string[]; + assert(sortArray.includes('updated')); // null becomes just the field name + assert(sortArray.includes('created:asc')); + }); + + it('should handle very long uuid', () => { + const longUuid = 'a'.repeat(100); + const request = new GetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: longUuid, + }); + const transportRequest = request.request(); + assert(transportRequest.path.includes(longUuid)); + }); + }); +}); diff --git a/test/unit/app_context/membership_objects_remove.test.ts b/test/unit/app_context/membership_objects_remove.test.ts new file mode 100644 index 000000000..8db74892b --- /dev/null +++ b/test/unit/app_context/membership_objects_remove.test.ts @@ -0,0 +1,418 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { SetUUIDMembershipsRequest } from '../../../src/core/endpoints/objects/membership/set'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('SetUUIDMembershipsRequest (Remove Operation)', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.SetMembershipsParameters & { keySet: KeySet; type: 'delete' }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + uuid: 'test_user_uuid', + channels: ['channel1'], + type: 'delete', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it("should return \"'uuid' cannot be empty\" when uuid is empty", () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: '', + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "Channels cannot be empty" when channels is empty array', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [], + }); + assert.equal(request.validate(), 'Channels cannot be empty'); + }); + + it('should return "Channels cannot be empty" when channels is null', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: null as any, + }); + assert.equal(request.validate(), 'Channels cannot be empty'); + }); + + it('should return undefined when required parameters provided', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: 'test', + channels: ['channel1'], + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNSetMembershipsOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path for remove operation', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/${defaultParameters.uuid}/channels`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('HTTP method', () => { + it('should use PATCH method', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.PATCH); + }); + }); + + describe('headers', () => { + it('should set correct Content-Type header', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + }); + + describe('request body', () => { + it('should create proper body for remove operation', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + type: 'delete', + }); + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + const expectedBody = { + delete: [ + { channel: { id: 'channel1' } }, + { channel: { id: 'channel2' } } + ] + }; + + assert.deepEqual(body, expectedBody); + }); + + it('should create proper body for object channels in remove operation', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [{ + id: 'channel1', + custom: { role: 'admin' }, + status: 'active', + type: 'member' + }], + type: 'delete', + }); + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + const expectedBody = { + delete: [{ + channel: { id: 'channel1' }, + status: 'active', + type: 'member', + custom: { role: 'admin' } + }] + }; + + assert.deepEqual(body, expectedBody); + }); + + it('should handle mixed string and object channels for delete', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [ + 'channel1', + { id: 'channel2', status: 'active' } + ], + type: 'delete', + }); + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + const expectedBody = { + delete: [ + { channel: { id: 'channel1' } }, + { + channel: { id: 'channel2' }, + status: 'active' + // undefined values are omitted by JSON.stringify + } + ] + }; + + assert.deepEqual(body, expectedBody); + }); + }); + + describe('backward compatibility', () => { + it('should map userId to uuid parameter', () => { + const request = new SetUUIDMembershipsRequest({ + keySet: defaultKeySet, + userId: 'test-user-id', // Using userId instead of uuid + channels: ['channel1'], + type: 'delete', + // uuid is not provided + } as any); + + // The request should be valid as userId gets mapped to uuid internally + assert.equal(request.validate(), undefined); + + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/test-user-id/channels`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('remove operation specifics', () => { + it('should use delete key in request body', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2', 'channel3'], + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert(body.hasOwnProperty('delete')); + assert(!body.hasOwnProperty('set')); + assert.equal(body.delete.length, 3); + }); + + it('should handle single channel removal', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: ['single-channel'], + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.equal(body.delete.length, 1); + assert.equal(body.delete[0].channel.id, 'single-channel'); + }); + + it('should handle bulk channel removal', () => { + const channels = Array.from({ length: 50 }, (_, i) => `channel_${i}`); + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels, + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.equal(body.delete.length, 50); + channels.forEach((channel, index) => { + assert.equal(body.delete[index].channel.id, channel); + }); + }); + }); + + describe('query parameters for delete operation', () => { + it('should include default include flags', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + + // Default include flags as per source code + const includeParam = queryParams?.include as string; + assert(includeParam?.includes('channel.status')); + assert(includeParam?.includes('channel.type')); + assert(includeParam?.includes('status')); + }); + + it('should handle pagination parameters with delete', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + page: { + next: 'nextToken', + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.start, 'nextToken'); + }); + + it('should handle include options with delete', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + include: { + customFields: true, + channelFields: true, + totalCount: true, + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + + const includeString = queryParams?.include as string; + assert(includeString.includes('custom')); + assert(includeString.includes('channel')); + assert.equal(queryParams?.count, 'true'); + }); + }); + + describe('channel removal edge cases', () => { + it('should handle channels with special characters', () => { + const specialChannels = [ + 'channel#1@domain.com', + 'channel with spaces', + 'channel/with/slashes', + ]; + + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: specialChannels, + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + specialChannels.forEach((channelId, index) => { + assert.equal(body.delete[index].channel.id, channelId); + }); + }); + + it('should handle empty custom objects in remove', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [{ + id: 'channel1', + custom: {}, + status: 'inactive' + }], + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.deepEqual(body.delete[0].custom, {}); + assert.equal(body.delete[0].status, 'inactive'); + }); + + it('should handle null values in channel objects for delete', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [{ + id: 'channel1', + custom: null as any, + status: null as any + }], + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.equal(body.delete[0].custom, null); + assert.equal(body.delete[0].status, null); + }); + }); + + describe('comprehensive validation tests', () => { + it('should handle uuid with special characters for delete', () => { + const specialUuid = 'user@domain.com#123'; + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: specialUuid, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(specialUuid))); + }); + + it('should maintain request isolation between set and delete', () => { + const setRequest = new SetUUIDMembershipsRequest({ + ...defaultParameters, + type: 'set', + }); + + const deleteRequest = new SetUUIDMembershipsRequest({ + ...defaultParameters, + type: 'delete', + }); + + const setBody = JSON.parse(setRequest.request().body as string); + const deleteBody = JSON.parse(deleteRequest.request().body as string); + + assert(setBody.hasOwnProperty('set')); + assert(!setBody.hasOwnProperty('delete')); + assert(deleteBody.hasOwnProperty('delete')); + assert(!deleteBody.hasOwnProperty('set')); + }); + }); + + describe('large dataset handling', () => { + it('should handle removal of many channels', () => { + const manyChannels = Array.from({ length: 100 }, (_, i) => ({ + id: `channel_${i}`, + status: i % 2 === 0 ? 'active' : 'inactive', + custom: { index: i } + })); + + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: manyChannels, + type: 'delete', + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.equal(body.delete.length, 100); + assert.equal(body.delete[0].channel.id, 'channel_0'); + assert.equal(body.delete[99].channel.id, 'channel_99'); + assert.deepEqual(body.delete[50].custom, { index: 50 }); + }); + }); + + describe('filter and sort parameters for delete', () => { + it('should handle filter parameter with delete operation', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + filter: 'channel.status == "active"', + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.filter, 'channel.status == "active"'); + }); + + it('should handle sort parameter with delete operation', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + sort: { 'channel.updated': 'desc' }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + const sortArray = queryParams?.sort as string[]; + assert(Array.isArray(sortArray)); + assert(sortArray.includes('channel.updated:desc')); + }); + }); +}); diff --git a/test/unit/app_context/membership_objects_set.test.ts b/test/unit/app_context/membership_objects_set.test.ts new file mode 100644 index 000000000..34bc5de2e --- /dev/null +++ b/test/unit/app_context/membership_objects_set.test.ts @@ -0,0 +1,401 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { SetUUIDMembershipsRequest } from '../../../src/core/endpoints/objects/membership/set'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('SetUUIDMembershipsRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.SetMembershipsParameters & { keySet: KeySet; type: 'set' }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + uuid: 'test_user_uuid', + channels: ['channel1'], + type: 'set', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it("should return \"'uuid' cannot be empty\" when uuid is empty", () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: '', + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "Channels cannot be empty" when channels is empty array', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [], + }); + assert.equal(request.validate(), 'Channels cannot be empty'); + }); + + it('should return "Channels cannot be empty" when channels is null', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: null as any, + }); + assert.equal(request.validate(), 'Channels cannot be empty'); + }); + + it('should return undefined when required parameters provided', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: 'test', + channels: ['channel1'], + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNSetMembershipsOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path for set operation', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/${defaultParameters.uuid}/channels`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('HTTP method', () => { + it('should use PATCH method', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.PATCH); + }); + }); + + describe('headers', () => { + it('should set correct Content-Type header', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + }); + + describe('request body', () => { + it('should create proper body for string channels', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + type: 'set', + }); + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + const expectedBody = { + set: [ + { channel: { id: 'channel1' } }, + { channel: { id: 'channel2' } } + ] + }; + + assert.deepEqual(body, expectedBody); + }); + + it('should create proper body for object channels', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [{ + id: 'channel1', + custom: { role: 'admin' }, + status: 'active', + type: 'member' + }], + type: 'set', + }); + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + const expectedBody = { + set: [{ + channel: { id: 'channel1' }, + status: 'active', + type: 'member', + custom: { role: 'admin' } + }] + }; + + assert.deepEqual(body, expectedBody); + }); + + it('should handle mixed string and object channels', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [ + 'channel1', + { id: 'channel2', status: 'active' } + ], + type: 'set', + }); + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + const expectedBody = { + set: [ + { channel: { id: 'channel1' } }, + { + channel: { id: 'channel2' }, + status: 'active' + // undefined values are omitted by JSON.stringify + } + ] + }; + + assert.deepEqual(body, expectedBody); + }); + }); + + describe('query parameters', () => { + it('should include default include flags in query', () => { + const request = new SetUUIDMembershipsRequest(defaultParameters); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + + // Default include flags as per source code + const includeParam = queryParams?.include as string; + assert(includeParam?.includes('channel.status')); + assert(includeParam?.includes('channel.type')); + assert(includeParam?.includes('status')); + }); + + it('should handle all include options', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + include: { + customFields: true, + totalCount: true, + statusField: true, + typeField: true, + channelFields: true, + customChannelFields: true, + channelStatusField: true, + channelTypeField: true, + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + + // Should contain all include flags + const includeString = queryParams?.include as string; + assert(includeString.includes('status')); + assert(includeString.includes('type')); + assert(includeString.includes('custom')); + assert(includeString.includes('channel')); + assert(includeString.includes('channel.status')); + assert(includeString.includes('channel.type')); + assert(includeString.includes('channel.custom')); + }); + }); + + describe('backward compatibility', () => { + it('should map userId to uuid parameter', () => { + const request = new SetUUIDMembershipsRequest({ + keySet: defaultKeySet, + userId: 'test-user-id', // Using userId instead of uuid + channels: ['channel1'], + type: 'set', + // uuid is not provided + } as any); + + // The request should be valid as userId gets mapped to uuid internally + assert.equal(request.validate(), undefined); + + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/test-user-id/channels`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('channel object structure', () => { + it('should preserve custom data in channel objects', () => { + const customData = { + role: 'moderator', + level: 5, + isActive: true + }; + + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [{ + id: 'channel1', + custom: customData, + status: 'active', + type: 'premium' + }], + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.deepEqual(body.set[0].custom, customData); + assert.equal(body.set[0].status, 'active'); + assert.equal(body.set[0].type, 'premium'); + }); + + it('should handle partial channel objects', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [ + { id: 'channel1' }, // minimal object + { id: 'channel2', status: 'inactive' }, // with status only + { id: 'channel3', custom: { note: 'test' } } // with custom only + ], + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + // First channel - minimal (undefined values omitted by JSON.stringify) + assert.deepEqual(body.set[0], { + channel: { id: 'channel1' } + }); + + // Second channel - with status + assert.deepEqual(body.set[1], { + channel: { id: 'channel2' }, + status: 'inactive' + }); + + // Third channel - with custom + assert.deepEqual(body.set[2], { + channel: { id: 'channel3' }, + custom: { note: 'test' } + }); + }); + }); + + describe('large data handling', () => { + it('should handle large channel lists', () => { + const channels = Array.from({ length: 100 }, (_, i) => `channel_${i}`); + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels, + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.equal(body.set.length, 100); + assert.equal(body.set[0].channel.id, 'channel_0'); + assert.equal(body.set[99].channel.id, 'channel_99'); + }); + + it('should handle large custom data objects', () => { + const largeCustomData = { + description: 'x'.repeat(1000), + category: 'premium', + priority: 10, + isEnabled: true + }; + + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: [{ + id: 'channel1', + custom: largeCustomData + }], + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + assert.deepEqual(body.set[0].custom, largeCustomData); + }); + }); + + describe('query parameter combinations', () => { + it('should handle pagination parameters', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + page: { + next: 'nextToken', + prev: 'prevToken', + }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + // Both start and end can be present if both next and prev are provided + assert.equal(queryParams?.start, 'nextToken'); + assert.equal(queryParams?.end, 'prevToken'); + }); + + it('should handle filter and sort parameters', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + filter: 'channel.name like "*test*"', + sort: { 'channel.updated': 'desc' }, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.filter, 'channel.name like "*test*"'); + const sortArray = queryParams?.sort as string[]; + assert(Array.isArray(sortArray)); + assert(sortArray.includes('channel.updated:desc')); + }); + + it('should handle limit parameter', () => { + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + limit: 25, + }); + const transportRequest = request.request(); + const queryParams = transportRequest.queryParameters; + assert.equal(queryParams?.limit, 25); + }); + }); + + describe('edge cases', () => { + it('should handle channels with special characters in id', () => { + const specialChannels = [ + 'channel#1@domain.com', + 'channel with spaces', + 'channel/with/slashes', + 'channel?with=query¶ms' + ]; + + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + channels: specialChannels, + }); + + const transportRequest = request.request(); + const body = JSON.parse(transportRequest.body as string); + + specialChannels.forEach((channelId, index) => { + assert.equal(body.set[index].channel.id, channelId); + }); + }); + + it('should handle uuid with special characters', () => { + const specialUuid = 'user#1@domain.com'; + const request = new SetUUIDMembershipsRequest({ + ...defaultParameters, + uuid: specialUuid, + }); + + const transportRequest = request.request(); + // Should URL encode the uuid in path + assert(transportRequest.path.includes(encodeURIComponent(specialUuid))); + }); + }); +}); diff --git a/test/unit/chanel_groups/channel_groups_delete_group.test.ts b/test/unit/chanel_groups/channel_groups_delete_group.test.ts new file mode 100644 index 000000000..4874f812a --- /dev/null +++ b/test/unit/chanel_groups/channel_groups_delete_group.test.ts @@ -0,0 +1,270 @@ +/* global describe, beforeEach, it */ + +import assert from 'assert'; +import { DeleteChannelGroupRequest } from '../../../src/core/endpoints/channel_groups/delete_group'; +import RequestOperation from '../../../src/core/constants/operations'; + +describe('DeleteChannelGroupRequest', () => { + let request: DeleteChannelGroupRequest; + const keySet = { + subscribeKey: 'mySubKey', + publishKey: 'myPublishKey', + secretKey: 'mySecretKey' + }; + + describe('Parameter validation', () => { + it('should return "Missing Subscribe Key" when subscribeKey is missing', () => { + const requestWithoutSubKey = new DeleteChannelGroupRequest({ + keySet: { subscribeKey: '', publishKey: 'myPublishKey', secretKey: 'mySecretKey' }, + channelGroup: 'test-group' + }); + + const result = requestWithoutSubKey.validate(); + assert.strictEqual(result, 'Missing Subscribe Key'); + }); + + it('should return "Missing Channel Group" when channelGroup is missing', () => { + const requestWithoutChannelGroup = new DeleteChannelGroupRequest({ + keySet, + channelGroup: '' + }); + + const result = requestWithoutChannelGroup.validate(); + assert.strictEqual(result, 'Missing Channel Group'); + }); + + it('should return undefined when all required parameters are provided', () => { + request = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group' + }); + + const result = request.validate(); + assert.strictEqual(result, undefined); + }); + }); + + describe('Operation type', () => { + beforeEach(() => { + request = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should return correct operation type', () => { + const operation = request.operation(); + assert.strictEqual(operation, RequestOperation.PNRemoveGroupOperation); + }); + }); + + describe('URL path construction', () => { + beforeEach(() => { + request = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should construct correct REST endpoint path with /remove suffix', () => { + const path = (request as any).path; + const expectedPathComponents = [ + '/v1/channel-registration', + 'sub-key', + keySet.subscribeKey, + 'channel-group', + 'test-group', + 'remove' + ]; + + // Split path and verify components + const pathComponents = path.split('/').filter((component: string) => component !== ''); + // Expected path: /v1/channel-registration/sub-key/mySubKey/channel-group/test-group/remove + // Components: ['v1', 'channel-registration', 'sub-key', 'mySubKey', 'channel-group', 'test-group', 'remove'] + assert.strictEqual(pathComponents.length, 7); + assert.strictEqual(pathComponents[0], 'v1'); + assert.strictEqual(pathComponents[1], 'channel-registration'); + assert.strictEqual(pathComponents[2], 'sub-key'); + assert.strictEqual(pathComponents[3], keySet.subscribeKey); + assert.strictEqual(pathComponents[4], 'channel-group'); + assert.strictEqual(pathComponents[5], 'test-group'); + assert.strictEqual(pathComponents[6], 'remove'); + + // Verify the exact path structure + assert.strictEqual(path, `/v1/channel-registration/sub-key/${keySet.subscribeKey}/channel-group/test-group/remove`); + }); + }); + + describe('Response parsing', () => { + beforeEach(() => { + request = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should parse empty service response correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, {}); + }); + }); + + describe('Channel group encoding', () => { + it('should handle channel group names with special characters', () => { + const specialGroupRequest = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test group with spaces' + }); + + const path = (specialGroupRequest as any).path; + + // Split path and verify encoded channel group name + const pathComponents = path.split('/').filter((component: string) => component !== ''); + assert.strictEqual(pathComponents[5], 'test%20group%20with%20spaces'); + assert.strictEqual(pathComponents[6], 'remove'); + }); + + it('should handle channel group names with unicode characters', () => { + const unicodeGroupRequest = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group-éñ中文🚀' + }); + + const path = (unicodeGroupRequest as any).path; + + // Verify the unicode characters are properly encoded in the path + assert(path.includes('test-group-%C3%A9%C3%B1%E4%B8%AD%E6%96%87%F0%9F%9A%80')); + assert(path.endsWith('/remove')); + }); + + it('should handle channel group names with symbols', () => { + const symbolGroupRequest = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group!@#$%^&*()' + }); + + const path = (symbolGroupRequest as any).path; + + // Verify the symbols are properly URL encoded in the path + assert(path.includes('test-group%21%40%23%24%25%5E%26%2A%28%29')); + assert(path.endsWith('/remove')); + }); + }); + + describe('Non-existent group', () => { + it('should handle deleting non-existent group gracefully', () => { + const nonExistentGroupRequest = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'non-existent-group' + }); + + // Validation should succeed even for non-existent groups + const result = nonExistentGroupRequest.validate(); + assert.strictEqual(result, undefined); + + // Path should be constructed correctly + const path = (nonExistentGroupRequest as any).path; + assert.strictEqual(path, `/v1/channel-registration/sub-key/${keySet.subscribeKey}/channel-group/non-existent-group/remove`); + }); + }); + + describe('Path components validation', () => { + beforeEach(() => { + request = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should have exactly the required path components in correct order', () => { + const path = (request as any).path; + const pathSegments = path.split('/').filter((segment: string) => segment !== ''); + + assert.strictEqual(pathSegments.length, 7); + assert.strictEqual(pathSegments[0], 'v1'); + assert.strictEqual(pathSegments[1], 'channel-registration'); + assert.strictEqual(pathSegments[2], 'sub-key'); + assert.strictEqual(pathSegments[3], keySet.subscribeKey); + assert.strictEqual(pathSegments[4], 'channel-group'); + assert.strictEqual(pathSegments[5], 'test-group'); + assert.strictEqual(pathSegments[6], 'remove'); + }); + + it('should construct path correctly with different subscribeKey and channelGroup values', () => { + const differentKeySet = { + subscribeKey: 'different-sub-key-123', + publishKey: 'myPublishKey', + secretKey: 'mySecretKey' + }; + + const requestWithDifferentValues = new DeleteChannelGroupRequest({ + keySet: differentKeySet, + channelGroup: 'different-channel-group' + }); + + const path = (requestWithDifferentValues as any).path; + assert.strictEqual(path, `/v1/channel-registration/sub-key/${differentKeySet.subscribeKey}/channel-group/different-channel-group/remove`); + }); + }); + + describe('Query parameters', () => { + beforeEach(() => { + request = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should not have any query parameters for delete group request', () => { + const queryParams = (request as any).queryParameters; + + // DeleteChannelGroupRequest doesn't override queryParameters, so it should be undefined or empty + assert(queryParams === undefined || Object.keys(queryParams).length === 0); + }); + }); + + describe('Edge cases', () => { + it('should handle very long channel group names', () => { + const longGroupName = 'a'.repeat(100); + const longGroupRequest = new DeleteChannelGroupRequest({ + keySet, + channelGroup: longGroupName + }); + + const result = longGroupRequest.validate(); + assert.strictEqual(result, undefined); + + const path = (longGroupRequest as any).path; + assert(path.includes(longGroupName)); + assert(path.endsWith('/remove')); + }); + + it('should handle channel group names with forward slashes', () => { + const slashGroupRequest = new DeleteChannelGroupRequest({ + keySet, + channelGroup: 'group/with/slashes' + }); + + const path = (slashGroupRequest as any).path; + + // Forward slashes should be encoded in URL + assert(path.includes('group%2Fwith%2Fslashes')); + assert(path.endsWith('/remove')); + }); + }); +}); diff --git a/test/unit/chanel_groups/channel_groups_list_channels.test.ts b/test/unit/chanel_groups/channel_groups_list_channels.test.ts new file mode 100644 index 000000000..320728246 --- /dev/null +++ b/test/unit/chanel_groups/channel_groups_list_channels.test.ts @@ -0,0 +1,284 @@ +/* global describe, beforeEach, it */ + +import assert from 'assert'; +import { ListChannelGroupChannels } from '../../../src/core/endpoints/channel_groups/list_channels'; +import RequestOperation from '../../../src/core/constants/operations'; + +describe('ListChannelGroupChannels', () => { + let request: ListChannelGroupChannels; + const keySet = { + subscribeKey: 'mySubKey', + publishKey: 'myPublishKey', + secretKey: 'mySecretKey' + }; + + describe('Parameter validation', () => { + it('should return "Missing Subscribe Key" when subscribeKey is missing', () => { + const requestWithoutSubKey = new ListChannelGroupChannels({ + keySet: { subscribeKey: '', publishKey: 'myPublishKey', secretKey: 'mySecretKey' }, + channelGroup: 'test-group' + }); + + const result = requestWithoutSubKey.validate(); + assert.strictEqual(result, 'Missing Subscribe Key'); + }); + + it('should return "Missing Channel Group" when channelGroup is missing', () => { + const requestWithoutChannelGroup = new ListChannelGroupChannels({ + keySet, + channelGroup: '' + }); + + const result = requestWithoutChannelGroup.validate(); + assert.strictEqual(result, 'Missing Channel Group'); + }); + + it('should return undefined when all required parameters are provided', () => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + + const result = request.validate(); + assert.strictEqual(result, undefined); + }); + }); + + describe('Operation type', () => { + beforeEach(() => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should return correct operation type', () => { + const operation = request.operation(); + assert.strictEqual(operation, RequestOperation.PNChannelsForGroupOperation); + }); + }); + + describe('URL path construction', () => { + beforeEach(() => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should construct correct REST endpoint path', () => { + const path = (request as any).path; + const expectedPathComponents = [ + '/v1/channel-registration', + 'sub-key', + keySet.subscribeKey, + 'channel-group', + 'test-group' + ]; + + // Split path and verify components + const pathComponents = path.split('/').filter((component: string) => component !== ''); + // Expected path: /v1/channel-registration/sub-key/mySubKey/channel-group/test-group + // Components: ['v1', 'channel-registration', 'sub-key', 'mySubKey', 'channel-group', 'test-group'] + assert.strictEqual(pathComponents.length, 6); + assert.strictEqual(pathComponents[0], 'v1'); + assert.strictEqual(pathComponents[1], 'channel-registration'); + assert.strictEqual(pathComponents[2], 'sub-key'); + assert.strictEqual(pathComponents[3], keySet.subscribeKey); + assert.strictEqual(pathComponents[4], 'channel-group'); + assert.strictEqual(pathComponents[5], 'test-group'); + }); + }); + + describe('Response parsing with channels', () => { + beforeEach(() => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should parse service response with channels correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + channels: ['channel1', 'channel2'] + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { channels: ['channel1', 'channel2'] }); + }); + }); + + describe('Response parsing empty channels', () => { + beforeEach(() => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should parse service response with empty channels array correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + channels: [] + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { channels: [] }); + }); + }); + + describe('Channel group encoding', () => { + it('should handle channel group names with special characters', () => { + const specialGroupRequest = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test group with spaces' + }); + + const path = (specialGroupRequest as any).path; + + // Split path and verify encoded channel group name + const pathComponents = path.split('/').filter((component: string) => component !== ''); + assert.strictEqual(pathComponents[pathComponents.length - 1], 'test%20group%20with%20spaces'); + }); + + it('should handle channel group names with unicode characters', () => { + const unicodeGroupRequest = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group-éñ中文🚀' + }); + + const path = (unicodeGroupRequest as any).path; + + // Verify the unicode characters are properly encoded in the path + assert(path.includes('test-group-éñ中文🚀') || path.includes('test-group-%C3%A9%C3%B1%E4%B8%AD%E6%96%87%F0%9F%9A%80')); + }); + + it('should handle channel group names with symbols', () => { + const symbolGroupRequest = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group!@#$%^&*()' + }); + + const path = (symbolGroupRequest as any).path; + + // Verify the symbols are properly URL encoded in the path + assert(path.includes('test-group%21%40%23%24%25%5E%26%2A%28%29')); + }); + }); + + describe('Large channel list parsing', () => { + beforeEach(() => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should parse response with many channels correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Create a large array of channels + const largeChannelList = Array.from({ length: 100 }, (_, i) => `channel${i + 1}`); + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + channels: largeChannelList + } + }); + + const result = await request.parse(mockResponse); + + // Verify all channels in payload.channels array are returned + assert.deepStrictEqual(result, { channels: largeChannelList }); + assert.strictEqual(result.channels.length, 100); + assert.strictEqual(result.channels[0], 'channel1'); + assert.strictEqual(result.channels[99], 'channel100'); + }); + + it('should handle response with mixed channel name types', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + const mixedChannels = [ + 'simple-channel', + 'channel with spaces', + 'channel/with/slashes', + 'channel-éñ', + 'channel-中文', + 'channel-🚀', + 'channel!@#$%^&*()' + ]; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + channels: mixedChannels + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { channels: mixedChannels }); + }); + }); + + describe('Query parameters', () => { + beforeEach(() => { + request = new ListChannelGroupChannels({ + keySet, + channelGroup: 'test-group' + }); + }); + + it('should not have any query parameters for list channels request', () => { + const queryParams = (request as any).queryParameters; + + // ListChannelGroupChannels doesn't override queryParameters, so it should be undefined or empty + assert(queryParams === undefined || Object.keys(queryParams).length === 0); + }); + }); +}); diff --git a/test/unit/chanel_groups/channel_groups_list_groups.test.ts b/test/unit/chanel_groups/channel_groups_list_groups.test.ts new file mode 100644 index 000000000..10208ea29 --- /dev/null +++ b/test/unit/chanel_groups/channel_groups_list_groups.test.ts @@ -0,0 +1,292 @@ +/* global describe, beforeEach, it */ + +import assert from 'assert'; +import { ListChannelGroupsRequest } from '../../../src/core/endpoints/channel_groups/list_groups'; +import RequestOperation from '../../../src/core/constants/operations'; + +describe('ListChannelGroupsRequest', () => { + let request: ListChannelGroupsRequest; + const keySet = { + subscribeKey: 'mySubKey', + publishKey: 'myPublishKey', + secretKey: 'mySecretKey' + }; + + describe('Parameter validation', () => { + it('should return "Missing Subscribe Key" when subscribeKey is missing', () => { + const requestWithoutSubKey = new ListChannelGroupsRequest({ + keySet: { subscribeKey: '', publishKey: 'myPublishKey', secretKey: 'mySecretKey' } + }); + + const result = requestWithoutSubKey.validate(); + assert.strictEqual(result, 'Missing Subscribe Key'); + }); + + it('should return undefined when valid parameters are provided', () => { + request = new ListChannelGroupsRequest({ + keySet + }); + + const result = request.validate(); + assert.strictEqual(result, undefined); + }); + }); + + describe('Operation type', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should return correct operation type', () => { + const operation = request.operation(); + assert.strictEqual(operation, RequestOperation.PNChannelGroupsOperation); + }); + }); + + describe('URL path construction', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should construct correct REST endpoint path', () => { + const path = (request as any).path; + const expectedPathComponents = [ + '/v1/channel-registration', + 'sub-key', + keySet.subscribeKey, + 'channel-group' + ]; + + // Split path and verify components + const pathComponents = path.split('/').filter((component: string) => component !== ''); + // Expected path: /v1/channel-registration/sub-key/mySubKey/channel-group + // Components: ['v1', 'channel-registration', 'sub-key', 'mySubKey', 'channel-group'] + assert.strictEqual(pathComponents.length, 5); + assert.strictEqual(pathComponents[0], 'v1'); + assert.strictEqual(pathComponents[1], 'channel-registration'); + assert.strictEqual(pathComponents[2], 'sub-key'); + assert.strictEqual(pathComponents[3], keySet.subscribeKey); + assert.strictEqual(pathComponents[4], 'channel-group'); + + // Verify the exact path structure + assert.strictEqual(path, `/v1/channel-registration/sub-key/${keySet.subscribeKey}/channel-group`); + }); + }); + + describe('Response parsing with groups', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should parse service response with groups correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + groups: ['group1', 'group2'] + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { groups: ['group1', 'group2'] }); + }); + }); + + describe('Response parsing empty groups', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should parse service response with empty groups array correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + groups: [] + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { groups: [] }); + }); + }); + + describe('Large groups list parsing', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should parse response with many groups correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Create a large array of groups + const largeGroupList = Array.from({ length: 50 }, (_, i) => `group${i + 1}`); + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + groups: largeGroupList + } + }); + + const result = await request.parse(mockResponse); + + // Verify all groups in payload.groups array are returned + assert.deepStrictEqual(result, { groups: largeGroupList }); + assert.strictEqual(result.groups.length, 50); + assert.strictEqual(result.groups[0], 'group1'); + assert.strictEqual(result.groups[49], 'group50'); + }); + + it('should handle response with mixed group name types', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + const mixedGroups = [ + 'simple-group', + 'group with spaces', + 'group/with/slashes', + 'group-éñ', + 'group-中文', + 'group-🚀', + 'group!@#$%^&*()' + ]; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + groups: mixedGroups + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { groups: mixedGroups }); + }); + }); + + describe('Query parameters', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should not have any query parameters for list groups request', () => { + const queryParams = (request as any).queryParameters; + + // ListChannelGroupsRequest doesn't override queryParameters, so it should be undefined or empty + assert(queryParams === undefined || Object.keys(queryParams).length === 0); + }); + }); + + describe('Path components validation', () => { + beforeEach(() => { + request = new ListChannelGroupsRequest({ + keySet + }); + }); + + it('should have exactly the required path components in correct order', () => { + const path = (request as any).path; + const pathSegments = path.split('/').filter((segment: string) => segment !== ''); + + assert.strictEqual(pathSegments.length, 5); + assert.strictEqual(pathSegments[0], 'v1'); + assert.strictEqual(pathSegments[1], 'channel-registration'); + assert.strictEqual(pathSegments[2], 'sub-key'); + assert.strictEqual(pathSegments[3], keySet.subscribeKey); + assert.strictEqual(pathSegments[4], 'channel-group'); + }); + + it('should construct path correctly with different subscribeKey values', () => { + const differentKeySet = { + subscribeKey: 'different-sub-key-123', + publishKey: 'myPublishKey', + secretKey: 'mySecretKey' + }; + + const requestWithDifferentKey = new ListChannelGroupsRequest({ + keySet: differentKeySet + }); + + const path = (requestWithDifferentKey as any).path; + assert.strictEqual(path, `/v1/channel-registration/sub-key/${differentKeySet.subscribeKey}/channel-group`); + }); + }); + + describe('Edge cases', () => { + it('should handle null or undefined in service response', async () => { + const request = new ListChannelGroupsRequest({ + keySet + }); + + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return null groups + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false, + payload: { + groups: null + } + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, { groups: null }); + }); + }); +}); diff --git a/test/unit/chanel_groups/channel_groups_remove_channels.test.ts b/test/unit/chanel_groups/channel_groups_remove_channels.test.ts new file mode 100644 index 000000000..ad49df5e9 --- /dev/null +++ b/test/unit/chanel_groups/channel_groups_remove_channels.test.ts @@ -0,0 +1,226 @@ +/* global describe, beforeEach, it */ + +import assert from 'assert'; +import { RemoveChannelGroupChannelsRequest } from '../../../src/core/endpoints/channel_groups/remove_channels'; +import RequestOperation from '../../../src/core/constants/operations'; + +describe('RemoveChannelGroupChannelsRequest', () => { + let request: RemoveChannelGroupChannelsRequest; + const keySet = { + subscribeKey: 'mySubKey', + publishKey: 'myPublishKey', + secretKey: 'mySecretKey' + }; + + describe('Parameter validation', () => { + it('should return "Missing Subscribe Key" when subscribeKey is missing', () => { + const requestWithoutSubKey = new RemoveChannelGroupChannelsRequest({ + keySet: { subscribeKey: '', publishKey: 'myPublishKey', secretKey: 'mySecretKey' }, + channelGroup: 'test-group', + channels: ['channel1', 'channel2'] + }); + + const result = requestWithoutSubKey.validate(); + assert.strictEqual(result, 'Missing Subscribe Key'); + }); + + it('should return "Missing Channel Group" when channelGroup is missing', () => { + const requestWithoutChannelGroup = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: '', + channels: ['channel1', 'channel2'] + }); + + const result = requestWithoutChannelGroup.validate(); + assert.strictEqual(result, 'Missing Channel Group'); + }); + + it('should return "Missing channels" when channels array is missing', () => { + const requestWithoutChannels = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + // @ts-expect-error Testing missing channels + channels: undefined + }); + + const result = requestWithoutChannels.validate(); + assert.strictEqual(result, 'Missing channels'); + }); + + it('should return undefined when all required parameters are provided', () => { + request = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['channel1', 'channel2'] + }); + + const result = request.validate(); + assert.strictEqual(result, undefined); + }); + }); + + describe('Operation type', () => { + beforeEach(() => { + request = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['channel1', 'channel2'] + }); + }); + + it('should return correct operation type', () => { + const operation = request.operation(); + assert.strictEqual(operation, RequestOperation.PNRemoveChannelsFromGroupOperation); + }); + }); + + describe('URL path construction', () => { + beforeEach(() => { + request = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['channel1', 'channel2'] + }); + }); + + it('should construct correct REST endpoint path', () => { + const path = (request as any).path; + const expectedPathComponents = [ + '/v1/channel-registration', + 'sub-key', + keySet.subscribeKey, + 'channel-group', + 'test-group' + ]; + + // Split path and verify components + const pathComponents = path.split('/').filter((component: string) => component !== ''); + // Expected path: /v1/channel-registration/sub-key/mySubKey/channel-group/test-group + // Components: ['v1', 'channel-registration', 'sub-key', 'mySubKey', 'channel-group', 'test-group'] + assert.strictEqual(pathComponents.length, 6); + assert.strictEqual(pathComponents[0], 'v1'); + assert.strictEqual(pathComponents[1], 'channel-registration'); + assert.strictEqual(pathComponents[2], 'sub-key'); + assert.strictEqual(pathComponents[3], keySet.subscribeKey); + assert.strictEqual(pathComponents[4], 'channel-group'); + assert.strictEqual(pathComponents[5], 'test-group'); + }); + }); + + describe('Query parameters', () => { + beforeEach(() => { + request = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['channel1', 'channel2'] + }); + }); + + it('should format channels correctly in remove query string', () => { + const queryParams = (request as any).queryParameters; + assert.deepStrictEqual(queryParams, { remove: 'channel1,channel2' }); + }); + + it('should handle single channel correctly', () => { + const singleChannelRequest = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['single-channel'] + }); + + const queryParams = (singleChannelRequest as any).queryParameters; + assert.deepStrictEqual(queryParams, { remove: 'single-channel' }); + }); + }); + + describe('Channel encoding', () => { + it('should handle channels with special characters', () => { + const specialChannelsRequest = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test group with spaces', + channels: ['channel with spaces', 'channel/with/slashes', 'channel?with=query¶ms'] + }); + + const path = (specialChannelsRequest as any).path; + const queryParams = (specialChannelsRequest as any).queryParameters; + + // Verify the channel group is URL encoded in path + assert(path.includes('test%20group%20with%20spaces')); + + // Verify channels are properly formatted in query parameters + assert.strictEqual(queryParams.remove, 'channel with spaces,channel/with/slashes,channel?with=query¶ms'); + }); + + it('should handle channels with unicode characters', () => { + const unicodeChannelsRequest = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['channel-éñ', 'channel-中文', 'channel-🚀'] + }); + + const queryParams = (unicodeChannelsRequest as any).queryParameters; + assert.strictEqual(queryParams.remove, 'channel-éñ,channel-中文,channel-🚀'); + }); + }); + + describe('Response parsing', () => { + beforeEach(() => { + request = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['channel1', 'channel2'] + }); + }); + + it('should parse empty service response correctly', async () => { + const mockResponse = { + status: 200, + url: '', + headers: {}, + body: new ArrayBuffer(0) + }; + + // Mock the deserializeResponse method to return expected service response + (request as any).deserializeResponse = () => ({ + status: 200, + message: 'OK', + service: 'ChannelGroups', + error: false + }); + + const result = await request.parse(mockResponse); + assert.deepStrictEqual(result, {}); + }); + }); + + describe('Multiple channels', () => { + it('should format array of multiple channels correctly', () => { + const multiChannelRequest = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['ch1', 'ch2', 'ch3', 'ch4', 'ch5'] + }); + + const queryParams = (multiChannelRequest as any).queryParameters; + assert.strictEqual(queryParams.remove, 'ch1,ch2,ch3,ch4,ch5'); + }); + }); + + describe('Non-existent channels', () => { + it('should handle removing non-existent channels gracefully', () => { + const nonExistentChannelsRequest = new RemoveChannelGroupChannelsRequest({ + keySet, + channelGroup: 'test-group', + channels: ['non-existent-channel1', 'non-existent-channel2'] + }); + + // Validation should succeed even for non-existent channels + const result = nonExistentChannelsRequest.validate(); + assert.strictEqual(result, undefined); + + // Query parameters should be formatted correctly + const queryParams = (nonExistentChannelsRequest as any).queryParameters; + assert.strictEqual(queryParams.remove, 'non-existent-channel1,non-existent-channel2'); + }); + }); +}); diff --git a/test/unit/chanel_groups/uuid_objects_get.test.ts b/test/unit/chanel_groups/uuid_objects_get.test.ts new file mode 100644 index 000000000..4d666f68a --- /dev/null +++ b/test/unit/chanel_groups/uuid_objects_get.test.ts @@ -0,0 +1,133 @@ +import assert from 'assert'; + +import { GetUUIDMetadataRequest } from '../../../src/core/endpoints/objects/uuid/get'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('GetUUIDMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.GetUUIDMetadataParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + uuid: 'test_uuid', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it('should return "\'uuid\' cannot be empty" when uuid is empty string', () => { + const request = new GetUUIDMetadataRequest({ + ...defaultParameters, + uuid: '', + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "\'uuid\' cannot be empty" when uuid is null', () => { + const request = new GetUUIDMetadataRequest({ + ...defaultParameters, + uuid: null as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "\'uuid\' cannot be empty" when uuid is undefined', () => { + const request = new GetUUIDMetadataRequest({ + ...defaultParameters, + uuid: undefined as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return undefined when uuid is provided', () => { + const request = new GetUUIDMetadataRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new GetUUIDMetadataRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetUUIDMetadataOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with subscribeKey and uuid', () => { + const request = new GetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/${defaultParameters.uuid}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in uuid', () => { + const request = new GetUUIDMetadataRequest({ + ...defaultParameters, + uuid: 'test-uuid#1@2', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/test-uuid%231%402`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include custom fields by default', () => { + const request = new GetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('custom')); + }); + + it('should exclude custom fields when include.customFields is false', () => { + const request = new GetUUIDMetadataRequest({ + ...defaultParameters, + include: { customFields: false }, + }); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(!includeParam?.includes('custom')); + }); + + it('should include status and type fields', () => { + const request = new GetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('status')); + assert(includeParam?.includes('type')); + }); + }); + + describe('HTTP method', () => { + it('should use GET method', () => { + const request = new GetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('backward compatibility', () => { + it('should map userId parameter to uuid', () => { + const request = new GetUUIDMetadataRequest({ + ...defaultParameters, + uuid: undefined as any, + userId: 'legacy_user_id', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/legacy_user_id`; + assert.equal(transportRequest.path, expectedPath); + }); + }); +}); + + + + diff --git a/test/unit/chanel_groups/uuid_objects_get_all.test.ts b/test/unit/chanel_groups/uuid_objects_get_all.test.ts new file mode 100644 index 000000000..db93beab3 --- /dev/null +++ b/test/unit/chanel_groups/uuid_objects_get_all.test.ts @@ -0,0 +1,187 @@ +import assert from 'assert'; + +import { GetAllUUIDMetadataRequest } from '../../../src/core/endpoints/objects/uuid/get_all'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('GetAllUUIDMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.GetAllMetadataParameters> & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + keySet: defaultKeySet, + }; + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new GetAllUUIDMetadataRequest>(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetAllUUIDMetadataOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with subscribeKey', () => { + const request = new GetAllUUIDMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('default parameters', () => { + it('should set default limit to 100', () => { + const request = new GetAllUUIDMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.limit, 100); + }); + + it('should set includeCustomFields to false by default', () => { + const request = new GetAllUUIDMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(!includeParam?.includes('custom')); + }); + + it('should include status and type fields by default', () => { + const request = new GetAllUUIDMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('status')); + assert(includeParam?.includes('type')); + }); + }); + + describe('query parameters', () => { + it('should handle custom limit parameter', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + limit: 50, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.limit, 50); + }); + + it('should handle filter parameter', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + filter: 'name == "test"', + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.filter, 'name == "test"'); + }); + + it('should handle pagination start cursor', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + page: { next: 'next-cursor-token' }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.start, 'next-cursor-token'); + }); + + it('should handle pagination end cursor', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + page: { prev: 'prev-cursor-token' }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.end, 'prev-cursor-token'); + }); + + it('should include custom fields when include.customFields is true', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + include: { customFields: true }, + }); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('custom')); + }); + + it('should handle totalCount include option', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + include: { totalCount: true }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.count, 'true'); + }); + + it('should not include count when totalCount is false', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + include: { totalCount: false }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.count, 'false'); + }); + }); + + describe('sorting', () => { + it('should handle string sort parameter', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + sort: 'name', + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.sort, 'name'); + }); + + it('should handle object sort with direction', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + sort: { name: 'asc', updated: 'desc' }, + }); + const transportRequest = request.request(); + const sortArray = transportRequest.queryParameters?.sort as string[]; + assert(Array.isArray(sortArray)); + assert(sortArray.includes('name:asc')); + assert(sortArray.includes('updated:desc')); + }); + + it('should handle object sort with null direction', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + sort: { name: null }, + }); + const transportRequest = request.request(); + const sortArray = transportRequest.queryParameters?.sort as string[]; + assert(Array.isArray(sortArray)); + assert(sortArray.includes('name')); + }); + + it('should handle mixed sort directions', () => { + const request = new GetAllUUIDMetadataRequest>({ + ...defaultParameters, + sort: { name: 'asc', updated: null, status: 'desc' }, + }); + const transportRequest = request.request(); + const sortArray = transportRequest.queryParameters?.sort as string[]; + assert(Array.isArray(sortArray)); + assert(sortArray.includes('name:asc')); + assert(sortArray.includes('updated')); + assert(sortArray.includes('status:desc')); + }); + }); + + describe('HTTP method', () => { + it('should use GET method', () => { + const request = new GetAllUUIDMetadataRequest>(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); +}); + + + + diff --git a/test/unit/chanel_groups/uuid_objects_remove.test.ts b/test/unit/chanel_groups/uuid_objects_remove.test.ts new file mode 100644 index 000000000..09b789638 --- /dev/null +++ b/test/unit/chanel_groups/uuid_objects_remove.test.ts @@ -0,0 +1,146 @@ +import assert from 'assert'; + +import { RemoveUUIDMetadataRequest } from '../../../src/core/endpoints/objects/uuid/remove'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +describe('RemoveUUIDMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.RemoveUUIDMetadataParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + uuid: 'test_uuid', + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it('should return "\'uuid\' cannot be empty" when uuid is empty', () => { + const request = new RemoveUUIDMetadataRequest({ + ...defaultParameters, + uuid: '', + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "\'uuid\' cannot be empty" when uuid is null', () => { + const request = new RemoveUUIDMetadataRequest({ + ...defaultParameters, + uuid: null as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "\'uuid\' cannot be empty" when uuid is undefined', () => { + const request = new RemoveUUIDMetadataRequest({ + ...defaultParameters, + uuid: undefined as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return undefined when uuid provided', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNRemoveUUIDMetadataOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/${defaultParameters.uuid}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode uuid name in path', () => { + const request = new RemoveUUIDMetadataRequest({ + ...defaultParameters, + uuid: 'test-uuid#1@2', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/test-uuid%231%402`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('HTTP method', () => { + it('should use DELETE method', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.DELETE); + }); + }); + + describe('query parameters', () => { + it('should not include any query parameters', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + + // RemoveUUIDMetadataRequest doesn't define any query parameters + // The request should either have no queryParameters or empty queryParameters + const queryParams = transportRequest.queryParameters; + if (queryParams) { + // If queryParameters exist, they should be empty or contain no meaningful parameters + assert.equal(Object.keys(queryParams).length, 0); + } + }); + }); + + describe('headers', () => { + it('should not set any custom headers', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + + // RemoveUUIDMetadataRequest doesn't define custom headers + // Check that no application-specific headers are set + const headers = transportRequest.headers; + if (headers) { + assert.equal(headers['Content-Type'], undefined); + assert.equal(headers['If-Match'], undefined); + } + }); + }); + + describe('body', () => { + it('should not have a request body', () => { + const request = new RemoveUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + + // DELETE requests typically don't have a body + assert.equal(transportRequest.body, undefined); + }); + }); + + describe('backward compatibility', () => { + it('should map userId parameter to uuid', () => { + const request = new RemoveUUIDMetadataRequest({ + ...defaultParameters, + uuid: undefined as any, + userId: 'legacy_user_id', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/legacy_user_id`; + assert.equal(transportRequest.path, expectedPath); + }); + }); +}); + + + + diff --git a/test/unit/chanel_groups/uuid_objects_set.test.ts b/test/unit/chanel_groups/uuid_objects_set.test.ts new file mode 100644 index 000000000..dce590244 --- /dev/null +++ b/test/unit/chanel_groups/uuid_objects_set.test.ts @@ -0,0 +1,232 @@ +import assert from 'assert'; + +import { SetUUIDMetadataRequest } from '../../../src/core/endpoints/objects/uuid/set'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as AppContext from '../../../src/core/types/api/app-context'; + +// Local type alias for UUIDMetadata since it's not exported +type UUIDMetadata = { + name?: string; + email?: string; + externalId?: string; + profileUrl?: string; + type?: string; + status?: string; + custom?: Custom; +}; + +describe('SetUUIDMetadataRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: AppContext.SetUUIDMetadataParameters & { keySet: KeySet }; + let testData: UUIDMetadata; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + testData = { + name: 'Test User', + email: 'test@example.com', + custom: { + age: 25, + location: 'San Francisco', + }, + }; + + defaultParameters = { + uuid: 'test_uuid', + data: testData, + keySet: defaultKeySet, + }; + }); + + describe('parameter validation', () => { + it('should return "\'uuid\' cannot be empty" when uuid is empty', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + uuid: '', + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "\'uuid\' cannot be empty" when uuid is null', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + uuid: null as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "\'uuid\' cannot be empty" when uuid is undefined', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + uuid: undefined as any, + }); + assert.equal(request.validate(), "'uuid' cannot be empty"); + }); + + it('should return "Data cannot be empty" when data is null', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + data: null as any, + }); + assert.equal(request.validate(), 'Data cannot be empty'); + }); + + it('should return "Data cannot be empty" when data is undefined', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + data: undefined as any, + }); + assert.equal(request.validate(), 'Data cannot be empty'); + }); + + it('should return undefined when all required parameters provided', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNSetUUIDMetadataOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with subscribeKey and uuid', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/${defaultParameters.uuid}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in uuid', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + uuid: 'test-uuid#1@2', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/test-uuid%231%402`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('HTTP method and headers', () => { + it('should use PATCH method', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.PATCH); + }); + + it('should set Content-Type header', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + + it('should set If-Match header when ifMatchesEtag provided', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + ifMatchesEtag: 'test-etag-value', + }); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['If-Match'], 'test-etag-value'); + }); + + it('should not set If-Match header when ifMatchesEtag not provided', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['If-Match'], undefined); + }); + }); + + describe('query parameters', () => { + it('should include custom fields by default', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('custom')); + }); + + it('should exclude custom fields when include.customFields is false', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + include: { customFields: false }, + }); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(!includeParam?.includes('custom')); + }); + + it('should include status and type fields', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + const includeParam = transportRequest.queryParameters?.include as string; + assert(includeParam?.includes('status')); + assert(includeParam?.includes('type')); + }); + }); + + describe('body serialization', () => { + it('should serialize data as JSON string', () => { + const request = new SetUUIDMetadataRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.body, JSON.stringify(testData)); + }); + + it('should handle complex nested data objects', () => { + const complexData = { + name: 'Complex User', + email: 'complex@example.com', + custom: { + profile: { + settings: { + theme: 'dark', + notifications: true, + }, + preferences: ['option1', 'option2'], + }, + metadata: { + tags: ['tag1', 'tag2', 'tag3'], + scores: { math: 95, science: 87 }, + }, + }, + }; + + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + data: complexData as any, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.body, JSON.stringify(complexData)); + + // Verify that the serialized data can be parsed back correctly + const parsedBody = JSON.parse(transportRequest.body as string); + assert.deepEqual(parsedBody, complexData); + }); + }); + + describe('backward compatibility', () => { + it('should map userId parameter to uuid', () => { + const request = new SetUUIDMetadataRequest({ + ...defaultParameters, + uuid: undefined as any, + userId: 'legacy_user_id', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/objects/${defaultKeySet.subscribeKey}/uuids/legacy_user_id`; + assert.equal(transportRequest.path, expectedPath); + }); + }); +}); + + + + diff --git a/test/unit/fetch_messages/fetch_messages.test.ts b/test/unit/fetch_messages/fetch_messages.test.ts new file mode 100644 index 000000000..c72e72101 --- /dev/null +++ b/test/unit/fetch_messages/fetch_messages.test.ts @@ -0,0 +1,330 @@ +/* global describe, beforeEach, it */ + +import assert from 'assert'; + +import { FetchMessagesRequest } from '../../../src/core/endpoints/fetch_messages'; +import RequestOperation from '../../../src/core/constants/operations'; +import { PubNubMessageType } from '../../../src/core/types/api/history'; +import { KeySet } from '../../../src/core/types/api'; + +describe('FetchMessagesRequest', () => { + let keySet: KeySet; + let getFileUrl: (params: any) => string; + + beforeEach(() => { + keySet = { + subscribeKey: 'sub-key', + publishKey: 'pub-key', + }; + getFileUrl = (params: any) => `https://example.com/files/${params.id}/${params.name}`; + }); + + describe('validates required parameters', () => { + it('should return error for missing subscribeKey', () => { + const request = new FetchMessagesRequest({ + keySet: { subscribeKey: '', publishKey: 'pub-key' }, + channels: ['channel1'], + getFileUrl, + }); + + const error = request.validate(); + assert.equal(error, 'Missing Subscribe Key'); + }); + + it('should return error for missing channels', () => { + // We can't test undefined/null channels because constructor accesses .length + // But we can test the validate() method directly with a mock request + const mockRequest = { + parameters: { + keySet: { subscribeKey: 'test-key' }, + channels: null, + getFileUrl: () => '', + }, + validate: FetchMessagesRequest.prototype.validate, + }; + + const error = mockRequest.validate(); + assert.equal(error, 'Missing channels'); + }); + + it('should return error for includeMessageActions with multiple channels', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1', 'channel2'], + includeMessageActions: true, + getFileUrl, + }); + + const error = request.validate(); + assert.equal( + error, + 'History can return actions data for a single channel only. Either pass a single channel or disable the includeMessageActions flag.' + ); + }); + + it('should return undefined for valid parameters', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + getFileUrl, + }); + + const error = request.validate(); + assert.equal(error, undefined); + }); + }); + + describe('applies correct default values', () => { + it('should default count to 100 for single channel', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + getFileUrl, + }); + + // Access private parameters property via type assertion + const parameters = (request as any).parameters; + assert.equal(parameters.count, 100); + assert.equal(parameters.includeUUID, true); + assert.equal(parameters.includeMessageType, true); + assert.equal(parameters.stringifiedTimeToken, false); + }); + + it('should default count to 25 for multiple channels', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1', 'channel2'], + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.count, 25); + }); + + it('should default count to 25 when includeMessageActions is true', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + includeMessageActions: true, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.count, 25); + }); + }); + + describe('constructs correct path for regular history', () => { + it('should build path without includeMessageActions', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + getFileUrl, + }); + + const path = (request as any).path; + assert.equal(path, '/v3/history/sub-key/sub-key/channel/channel1'); + }); + + it('should encode channel names with special characters', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel#1', 'channel/2', 'channel 3'], + getFileUrl, + }); + + const path = (request as any).path; + assert.equal(path, '/v3/history/sub-key/sub-key/channel/channel%231,channel%2F2,channel%203'); + }); + }); + + describe('constructs correct path for history-with-actions', () => { + it('should build path with includeMessageActions=true', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + includeMessageActions: true, + getFileUrl, + }); + + const path = (request as any).path; + assert.equal(path, '/v3/history-with-actions/sub-key/sub-key/channel/channel1'); + }); + }); + + describe('builds query parameters correctly', () => { + it('should include all optional parameters in query', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + count: 50, + start: '12345', + end: '67890', + includeUUID: true, + includeMessageType: true, + includeMeta: true, + includeCustomMessageType: true, + stringifiedTimeToken: true, + getFileUrl, + }); + + const queryParams = (request as any).queryParameters; + assert.equal(queryParams.max, 50); + assert.equal(queryParams.start, '12345'); + assert.equal(queryParams.end, '67890'); + assert.equal(queryParams.include_uuid, 'true'); + assert.equal(queryParams.include_message_type, 'true'); + assert.equal(queryParams.include_meta, 'true'); + assert.equal(queryParams.include_custom_message_type, 'true'); + assert.equal(queryParams.string_message_token, 'true'); + }); + + it('should omit optional parameters when not provided', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + getFileUrl, + }); + + const queryParams = (request as any).queryParameters; + assert.equal(queryParams.start, undefined); + assert.equal(queryParams.end, undefined); + assert.equal(queryParams.include_meta, undefined); + assert.equal(queryParams.string_message_token, undefined); + }); + + it('should handle includeCustomMessageType false value', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + includeCustomMessageType: false, + getFileUrl, + }); + + const queryParams = (request as any).queryParameters; + assert.equal(queryParams.include_custom_message_type, 'false'); + }); + }); + + describe('handles channel name encoding', () => { + it('should properly encode special characters', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel#test', 'channel/test', 'channel test', 'channel@test', 'channel+test'], + getFileUrl, + }); + + const path = (request as any).path; + assert(path.includes('channel%23test')); // # encoded + assert(path.includes('channel%2Ftest')); // / encoded + assert(path.includes('channel%20test')); // space encoded + assert(path.includes('channel%40test')); // @ encoded + assert(path.includes('channel%2Btest')); // + encoded + }); + + it('should handle unicode characters', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['café', '测试'], + getFileUrl, + }); + + const path = (request as any).path; + // Unicode characters should be properly encoded + assert(path.includes('caf%C3%A9')); + assert(path.includes('%E6%B5%8B%E8%AF%95')); + }); + }); + + describe('enforces count limits correctly', () => { + it('should clamp count to 100 for single channel', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + count: 150, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.count, 100); + }); + + it('should clamp count to 25 for multiple channels', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1', 'channel2'], + count: 50, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.count, 25); + }); + + it('should clamp count to 25 when includeMessageActions is true', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + count: 50, + includeMessageActions: true, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.count, 25); + }); + }); + + describe('handles backward compatibility for includeUuid', () => { + it('should map includeUuid to includeUUID when truthy', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + includeUuid: true, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.includeUUID, true); + assert.equal(parameters.includeUuid, true); + }); + + it('should use default includeUUID when includeUuid is falsy', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + includeUuid: false, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.includeUUID, true); // Should default to true + assert.equal(parameters.includeUuid, false); // Original value preserved + }); + + it('should prefer includeUUID over includeUuid when both provided', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + includeUuid: false, + includeUUID: true, + getFileUrl, + }); + + const parameters = (request as any).parameters; + assert.equal(parameters.includeUUID, true); + }); + }); + + describe('operation type', () => { + it('should return correct operation type', () => { + const request = new FetchMessagesRequest({ + keySet, + channels: ['channel1'], + getFileUrl, + }); + + assert.equal(request.operation(), RequestOperation.PNFetchMessagesOperation); + }); + }); +}); diff --git a/test/unit/messahe_actions/message_actions.test.ts b/test/unit/messahe_actions/message_actions.test.ts new file mode 100644 index 000000000..e0154533a --- /dev/null +++ b/test/unit/messahe_actions/message_actions.test.ts @@ -0,0 +1,664 @@ +/* global describe, beforeEach, it */ + +import assert from 'assert'; + +import { AddMessageActionRequest } from '../../../src/core/endpoints/actions/add_message_action'; +import { GetMessageActionsRequest } from '../../../src/core/endpoints/actions/get_message_actions'; +import { RemoveMessageAction } from '../../../src/core/endpoints/actions/remove_message_action'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { TransportResponse } from '../../../src/core/types/transport-response'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import * as MessageAction from '../../../src/core/types/api/message-action'; + +describe('Message Actions Request Classes', () => { + let defaultKeySet: KeySet; + + beforeEach(() => { + defaultKeySet = { + subscribeKey: 'test_subscribe_key', + publishKey: 'test_publish_key', + }; + }); + + describe('AddMessageActionRequest', () => { + let defaultParameters: MessageAction.AddMessageActionParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultParameters = { + channel: 'test_channel', + messageTimetoken: '1234567890', + action: { + type: 'reaction', + value: 'smiley_face', + }, + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required channel', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + channel: '', + }); + assert.equal(request.validate(), 'Missing message channel'); + }); + + it('should validate required messageTimetoken', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + messageTimetoken: '', + }); + assert.equal(request.validate(), 'Missing message timetoken'); + }); + + it('should validate required action', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: undefined as any, + }); + assert.equal(request.validate(), 'Missing Action'); + }); + + it('should validate required action.type', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { value: 'test' } as any, + }); + assert.equal(request.validate(), 'Missing Action.type'); + }); + + it('should validate required action.value', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { type: 'reaction' } as any, + }); + assert.equal(request.validate(), 'Missing Action.value'); + }); + + it('should validate action.type length limit', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { + type: '1234567890123456', // 16 characters (over limit) + value: 'test', + }, + }); + assert.equal(request.validate(), 'Action.type value exceed maximum length of 15'); + }); + + it('should allow action.type at maximum length', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { + type: '123456789012345', // 15 characters (exactly at limit) + value: 'test', + }, + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with valid parameters', () => { + const request = new AddMessageActionRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNAddMessageActionOperation', () => { + const request = new AddMessageActionRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNAddMessageActionOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path', () => { + const request = new AddMessageActionRequest(defaultParameters); + const transportRequest = request.request(); + + const expectedPath = `/v1/message-actions/${defaultKeySet.subscribeKey}/channel/${defaultParameters.channel}/message/${defaultParameters.messageTimetoken}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel name', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + channel: 'test channel#1/2', + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes('test%20channel%231%2F2')); + }); + + it('should encode Unicode characters in channel name', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + channel: 'café测试', + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes('caf%C3%A9%E6%B5%8B%E8%AF%95')); + }); + }); + + describe('HTTP method and headers', () => { + it('should use POST method', () => { + const request = new AddMessageActionRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.method, TransportMethod.POST); + }); + + it('should set correct Content-Type header', () => { + const request = new AddMessageActionRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + }); + + describe('request body', () => { + it('should serialize action to JSON in body', () => { + const request = new AddMessageActionRequest(defaultParameters); + const transportRequest = request.request(); + + const expectedBody = JSON.stringify(defaultParameters.action); + assert.equal(transportRequest.body, expectedBody); + }); + + it('should handle action with Unicode characters', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { + type: 'emoji', + value: '😀🎉', + }, + }); + const transportRequest = request.request(); + + const expectedBody = JSON.stringify({ type: 'emoji', value: '😀🎉' }); + assert.equal(transportRequest.body, expectedBody); + }); + }); + + describe('response parsing', () => { + it('should parse successful response', async () => { + const request = new AddMessageActionRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: { + type: 'reaction', + value: 'smiley_face', + uuid: 'test_user', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse.data.type, 'reaction'); + assert.equal(parsedResponse.data.value, 'smiley_face'); + assert.equal(parsedResponse.data.uuid, 'test_user'); + assert.equal(parsedResponse.data.actionTimetoken, '15610547826970050'); + assert.equal(parsedResponse.data.messageTimetoken, '1234567890'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string action.type', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { type: '', value: 'test' }, + }); + assert.equal(request.validate(), 'Missing Action.type'); + }); + + it('should handle empty string action.value', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: { type: 'reaction', value: '' }, + }); + assert.equal(request.validate(), 'Missing Action.value'); + }); + + it('should handle null action', () => { + const request = new AddMessageActionRequest({ + ...defaultParameters, + action: null as any, + }); + assert.equal(request.validate(), 'Missing Action'); + }); + }); + }); + + describe('GetMessageActionsRequest', () => { + let defaultParameters: MessageAction.GetMessageActionsParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultParameters = { + channel: 'test_channel', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required channel', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + channel: '', + }); + assert.equal(request.validate(), 'Missing message channel'); + }); + + it('should pass validation with valid parameters', () => { + const request = new GetMessageActionsRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNGetMessageActionsOperation', () => { + const request = new GetMessageActionsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetMessageActionsOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path', () => { + const request = new GetMessageActionsRequest(defaultParameters); + const transportRequest = request.request(); + + const expectedPath = `/v1/message-actions/${defaultKeySet.subscribeKey}/channel/${defaultParameters.channel}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel name', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + channel: 'test channel#1/2', + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes('test%20channel%231%2F2')); + }); + + it('should use GET method by default', () => { + const request = new GetMessageActionsRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('query parameters', () => { + it('should include start parameter when provided', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + start: '1234567890', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, '1234567890'); + }); + + it('should include end parameter when provided', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + end: '9876543210', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.end, '9876543210'); + }); + + it('should include limit parameter when provided', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + limit: 50, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.limit, 50); + }); + + it('should include all parameters when provided', () => { + const request = new GetMessageActionsRequest({ + ...defaultParameters, + start: '1234567890', + end: '9876543210', + limit: 25, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, '1234567890'); + assert.equal(transportRequest.queryParameters?.end, '9876543210'); + assert.equal(transportRequest.queryParameters?.limit, 25); + }); + + it('should not include optional parameters when not provided', () => { + const request = new GetMessageActionsRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, undefined); + assert.equal(transportRequest.queryParameters?.end, undefined); + assert.equal(transportRequest.queryParameters?.limit, undefined); + }); + }); + + describe('response parsing', () => { + it('should parse response with data', async () => { + const request = new GetMessageActionsRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: [ + { + type: 'reaction', + value: 'smiley_face', + uuid: 'user1', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + { + type: 'reaction', + value: 'thumbs_up', + uuid: 'user2', + actionTimetoken: '15610547826970051', + messageTimetoken: '1234567890', + }, + ], + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse.data.length, 2); + assert.equal(parsedResponse.start, '15610547826970050'); + assert.equal(parsedResponse.end, '15610547826970051'); + assert.equal(parsedResponse.data[0].type, 'reaction'); + assert.equal(parsedResponse.data[1].value, 'thumbs_up'); + }); + + it('should handle empty response', async () => { + const request = new GetMessageActionsRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: [], + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse.data.length, 0); + assert.equal(parsedResponse.start, null); + assert.equal(parsedResponse.end, null); + }); + + it('should handle response with more data', async () => { + const request = new GetMessageActionsRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: [ + { + type: 'reaction', + value: 'smiley_face', + uuid: 'user1', + actionTimetoken: '15610547826970050', + messageTimetoken: '1234567890', + }, + ], + more: { + url: '/v1/message-actions/test_subscribe_key/channel/test_channel?start=15610547826970049', + start: '15610547826970049', + end: '15610547826970000', + limit: 100, + }, + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse.data.length, 1); + assert.equal(parsedResponse.start, '15610547826970050'); + assert.equal(parsedResponse.end, '15610547826970050'); + assert(parsedResponse.more); + assert.equal(parsedResponse.more?.start, '15610547826970049'); + assert.equal(parsedResponse.more?.limit, 100); + }); + }); + }); + + describe('RemoveMessageAction', () => { + let defaultParameters: MessageAction.RemoveMessageActionParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultParameters = { + channel: 'test_channel', + messageTimetoken: '1234567890', + actionTimetoken: '15610547826970050', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required channel', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + channel: '', + }); + assert.equal(request.validate(), 'Missing message action channel'); + }); + + it('should validate required messageTimetoken', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + messageTimetoken: '', + }); + assert.equal(request.validate(), 'Missing message timetoken'); + }); + + it('should validate required actionTimetoken', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + actionTimetoken: '', + }); + assert.equal(request.validate(), 'Missing action timetoken'); + }); + + it('should pass validation with valid parameters', () => { + const request = new RemoveMessageAction(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNRemoveMessageActionOperation', () => { + const request = new RemoveMessageAction(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNRemoveMessageActionOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path', () => { + const request = new RemoveMessageAction(defaultParameters); + const transportRequest = request.request(); + + const expectedPath = `/v1/message-actions/${defaultKeySet.subscribeKey}/channel/${defaultParameters.channel}/message/${defaultParameters.messageTimetoken}/action/${defaultParameters.actionTimetoken}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel name', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + channel: 'test channel#1/2', + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes('test%20channel%231%2F2')); + }); + + it('should use DELETE method', () => { + const request = new RemoveMessageAction(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.method, TransportMethod.DELETE); + }); + }); + + describe('response parsing', () => { + it('should parse successful response', async () => { + const request = new RemoveMessageAction(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ + status: 200, + data: {}, + })), + }; + + const parsedResponse = await request.parse(mockResponse); + assert(parsedResponse.data); + assert.equal(typeof parsedResponse.data, 'object'); + }); + }); + + describe('edge cases', () => { + it('should handle undefined channel', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + channel: undefined as any, + }); + assert.equal(request.validate(), 'Missing message action channel'); + }); + + it('should handle null messageTimetoken', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + messageTimetoken: null as any, + }); + assert.equal(request.validate(), 'Missing message timetoken'); + }); + + it('should handle null actionTimetoken', () => { + const request = new RemoveMessageAction({ + ...defaultParameters, + actionTimetoken: null as any, + }); + assert.equal(request.validate(), 'Missing action timetoken'); + }); + }); + }); + + describe('boundary value testing', () => { + it('should handle minimum valid action.type length', () => { + const request = new AddMessageActionRequest({ + channel: 'test', + messageTimetoken: '1234567890', + action: { type: 'a', value: 'test' }, + keySet: defaultKeySet, + }); + assert.equal(request.validate(), undefined); + }); + + it('should handle special characters in action values', () => { + const request = new AddMessageActionRequest({ + channel: 'test', + messageTimetoken: '1234567890', + action: { type: 'reaction', value: '!@#$%^&*()_+-={}[]|\\:";\'<>?,./~`' }, + keySet: defaultKeySet, + }); + assert.equal(request.validate(), undefined); + }); + + it('should handle very long action values', () => { + const longValue = 'a'.repeat(1000); + const request = new AddMessageActionRequest({ + channel: 'test', + messageTimetoken: '1234567890', + action: { type: 'reaction', value: longValue }, + keySet: defaultKeySet, + }); + assert.equal(request.validate(), undefined); + }); + + it('should handle numeric strings as timetoken', () => { + const request = new AddMessageActionRequest({ + channel: 'test', + messageTimetoken: '999999999999999999', + action: { type: 'reaction', value: 'test' }, + keySet: defaultKeySet, + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('concurrent request isolation', () => { + it('should maintain isolated configurations across multiple requests', () => { + const request1 = new AddMessageActionRequest({ + channel: 'channel1', + messageTimetoken: '1111111111', + action: { type: 'reaction', value: 'value1' }, + keySet: defaultKeySet, + }); + + const request2 = new AddMessageActionRequest({ + channel: 'channel2', + messageTimetoken: '2222222222', + action: { type: 'custom', value: 'value2' }, + keySet: { subscribeKey: 'different_key', publishKey: 'pub' }, + }); + + const transport1 = request1.request(); + const transport2 = request2.request(); + + assert(transport1.path.includes('channel1')); + assert(transport1.path.includes('1111111111')); + assert.equal(transport1.body, JSON.stringify({ type: 'reaction', value: 'value1' })); + + assert(transport2.path.includes('channel2')); + assert(transport2.path.includes('2222222222')); + assert(transport2.path.includes('different_key')); + assert.equal(transport2.body, JSON.stringify({ type: 'custom', value: 'value2' })); + }); + }); +}); diff --git a/test/unit/presence/presence_get_state.test.ts b/test/unit/presence/presence_get_state.test.ts new file mode 100644 index 000000000..b73f02c63 --- /dev/null +++ b/test/unit/presence/presence_get_state.test.ts @@ -0,0 +1,365 @@ +import assert from 'assert'; + +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { GetPresenceStateRequest } from '../../../src/core/endpoints/presence/get_state'; +import { KeySet, Payload } from '../../../src/core/types/api'; +import * as Presence from '../../../src/core/types/api/presence'; +import RequestOperation from '../../../src/core/constants/operations'; +import { createMockResponse } from '../test-utils'; + +describe('GetPresenceStateRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: Presence.GetPresenceStateParameters & { keySet: KeySet; uuid: string }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + defaultParameters = { + uuid: 'test_uuid', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should pass validation with minimal parameters', () => { + const request = new GetPresenceStateRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channels', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channel groups', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with both channels and groups', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return correct operation type', () => { + const request = new GetPresenceStateRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGetStateOperation); + }); + }); + + describe('default parameter handling', () => { + it('should handle default empty arrays', () => { + const request = new GetPresenceStateRequest(defaultParameters); + // Access private field for testing + const params = (request as any).parameters; + assert.deepEqual(params.channels, []); + assert.deepEqual(params.channelGroups, []); + }); + + it('should preserve provided channels', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['ch1', 'ch2'], + }); + const params = (request as any).parameters; + assert.deepEqual(params.channels, ['ch1', 'ch2']); + }); + + it('should preserve provided channel groups', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channelGroups: ['cg1', 'cg2'], + }); + const params = (request as any).parameters; + assert.deepEqual(params.channelGroups, ['cg1', 'cg2']); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with UUID and channels', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/uuid/test_uuid`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for multiple channels', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1,channel2/uuid/test_uuid`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel names', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel#1', 'channel@2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel%231,channel%402/uuid/test_uuid`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in UUID', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + uuid: 'test#uuid@123', + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/uuid/test%23uuid%40123`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle empty channels array', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: [], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/uuid/test_uuid`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle undefined UUID', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + uuid: undefined as any, // Explicit type assertion for test case + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/uuid/undefined`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should not include channel-group when no channel groups provided', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + assert.deepEqual(transportRequest.queryParameters, {}); + }); + + it('should include channel-group when provided', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channelGroups: ['group1', 'group2'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'group1,group2'); + }); + + it('should handle empty channel groups array', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channelGroups: [], + }); + const transportRequest = request.request(); + assert.deepEqual(transportRequest.queryParameters, {}); + }); + + it('should handle single channel group', () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channelGroups: ['single-group'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'single-group'); + }); + }); + + describe('response parsing', () => { + it('should parse single channel response', async () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: [], + }); + const mockState = { status: 'online', age: 25 }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: mockState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, { + channel1: mockState, + }); + }); + + it('should parse multiple channels response', async () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['ch1', 'ch2'], + }); + const mockStates = { + ch1: { status: 'online' }, + ch2: { status: 'away', mood: 'happy' }, + }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: mockStates, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, mockStates); + }); + + it('should handle empty state response', async () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: [], + }); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: {}, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, { + channel1: {}, + }); + }); + + it('should handle null state response', async () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: [], + }); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: null, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, { + channel1: null, + }); + }); + + it('should handle complex state objects', async () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: [], + }); + const complexState = { + user: { + name: 'John', + preferences: { + theme: 'dark', + notifications: true, + }, + }, + location: { + country: 'US', + city: 'New York', + }, + activity: ['typing', 'online'], + metadata: null, + }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: complexState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, { + channel1: complexState, + }); + }); + + it('should determine single vs multiple channels correctly', async () => { + // Test with exactly 1 channel and 0 channel groups + const singleChannelRequest = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['only-channel'], + channelGroups: [], + }); + + const mockState = { single: true }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: mockState, + service: 'Presence', + }); + const result = await singleChannelRequest.parse(mockResponse); + + assert.deepEqual(result.channels, { + 'only-channel': mockState, + }); + }); + + it('should handle multiple channels with groups', async () => { + const request = new GetPresenceStateRequest({ + ...defaultParameters, + channels: ['ch1'], + channelGroups: ['group1'], + }); + const mockStates = { + ch1: { status: 'online' }, + 'group1-ch1': { status: 'away' }, + }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuid: 'test_uuid', + payload: mockStates, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, mockStates); + }); + }); +}); diff --git a/test/unit/presence/presence_heartbeat.test.ts b/test/unit/presence/presence_heartbeat.test.ts new file mode 100644 index 000000000..7856c4926 --- /dev/null +++ b/test/unit/presence/presence_heartbeat.test.ts @@ -0,0 +1,384 @@ +import assert from 'assert'; + +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { HeartbeatRequest } from '../../../src/core/endpoints/presence/heartbeat'; +import { KeySet } from '../../../src/core/types/api'; +import * as Presence from '../../../src/core/types/api/presence'; +import RequestOperation from '../../../src/core/constants/operations'; +import { createMockResponse } from '../test-utils'; + +describe('HeartbeatRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: Presence.PresenceHeartbeatParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + defaultParameters = { + heartbeat: 300, + channels: ['channel1'], + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate channels or channelGroups required', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: [], + channelGroups: [], + }); + assert.equal(request.validate(), 'Please provide a list of channels and/or channel-groups'); + }); + + it('should validate channels or channelGroups required when undefined', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: undefined, + }); + assert.equal(request.validate(), 'Please provide a list of channels and/or channel-groups'); + }); + + it('should pass validation with channels only', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: undefined, + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channel groups only', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with both channels and groups', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with empty channels but non-empty groups', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: [], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with non-empty channels but empty groups', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: [], + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return correct operation type', () => { + const request = new HeartbeatRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNHeartbeatOperation); + }); + }); + + describe('constructor options', () => { + it('should set cancellable option', () => { + const request = new HeartbeatRequest(defaultParameters); + // Check that the request was created with cancellable: true + // This is validated by checking the generated transport request + const transportRequest = request.request(); + assert.equal(transportRequest.cancellable, true); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with single channel', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/heartbeat`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for multiple channels', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1,channel2/heartbeat`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel names', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: ['channel#1', 'channel@2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel%231,channel%402/heartbeat`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle empty channels array', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: [], + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/heartbeat`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle undefined channels', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/heartbeat`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include heartbeat parameter', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + heartbeat: 300, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.heartbeat, '300'); + }); + + it('should include heartbeat parameter with different values', () => { + const testValues = [60, 120, 300, 600, 1800]; + + testValues.forEach(heartbeatValue => { + const request = new HeartbeatRequest({ + ...defaultParameters, + heartbeat: heartbeatValue, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.heartbeat, heartbeatValue.toString()); + }); + }); + + it('should include state when provided', () => { + const state = { status: 'online' }; + const request = new HeartbeatRequest({ + ...defaultParameters, + state, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(state)); + }); + + it('should not include state when not provided', () => { + const request = new HeartbeatRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, undefined); + }); + + it('should include channel groups when provided', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channelGroups: ['group1', 'group2'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'group1,group2'); + }); + + it('should not include channel-group when empty', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channelGroups: [], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], undefined); + }); + + it('should handle single channel group', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + channelGroups: ['single-group'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'single-group'); + }); + + it('should serialize complex state objects', () => { + const complexState = { + user: { + name: 'Alice', + activity: 'typing', + }, + preferences: { + notifications: true, + }, + location: 'US', + timestamp: 1234567890, + }; + const request = new HeartbeatRequest({ + ...defaultParameters, + state: complexState, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(complexState)); + }); + + it('should serialize null state', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + state: null as any, // Cast to bypass TypeScript restriction while testing runtime behavior + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, 'null'); + }); + + it('should serialize empty state object', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + state: {}, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, '{}'); + }); + + it('should combine all query parameters', () => { + const state = { active: true }; + const request = new HeartbeatRequest({ + ...defaultParameters, + heartbeat: 450, + channelGroups: ['cg1', 'cg2'], + state, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.heartbeat, '450'); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'cg1,cg2'); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(state)); + }); + + it('should handle zero heartbeat value', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + heartbeat: 0, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.heartbeat, '0'); + }); + + it('should handle large heartbeat value', () => { + const request = new HeartbeatRequest({ + ...defaultParameters, + heartbeat: 86400, // 24 hours in seconds + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.heartbeat, '86400'); + }); + }); + + describe('response parsing', () => { + it('should parse successful response to empty object', async () => { + const request = new HeartbeatRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result, {}); + }); + + it('should parse successful response with payload to empty object', async () => { + const request = new HeartbeatRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { some: 'data' }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + // Heartbeat response should always return empty object regardless of payload + assert.deepEqual(result, {}); + }); + + it('should handle empty response body', async () => { + const request = new HeartbeatRequest(defaultParameters); + const encoder = new TextEncoder(); + const mockResponse: TransportResponse = { + url: 'https://ps.pndsn.com/v2/presence/sub-key/test/channel/test/heartbeat', + status: 200, + headers: { 'content-type': 'text/javascript' }, + body: encoder.encode(''), + }; + + // Should throw error for empty body + await assert.rejects(async () => { + await request.parse(mockResponse); + }); + }); + }); + + describe('edge cases', () => { + it('should handle very long channel names', () => { + const longChannelName = 'a'.repeat(1000); + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: [longChannelName], + }); + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(longChannelName))); + }); + + it('should handle many channels', () => { + const manyChannels = Array.from({ length: 100 }, (_, i) => `channel-${i}`); + const request = new HeartbeatRequest({ + ...defaultParameters, + channels: manyChannels, + }); + const transportRequest = request.request(); + const expectedChannelsPart = manyChannels.join(','); + assert(transportRequest.path.includes(expectedChannelsPart)); + }); + + it('should handle many channel groups', () => { + const manyGroups = Array.from({ length: 50 }, (_, i) => `group-${i}`); + const request = new HeartbeatRequest({ + ...defaultParameters, + channelGroups: manyGroups, + }); + const transportRequest = request.request(); + const expectedGroupsPart = manyGroups.join(','); + assert.equal(transportRequest.queryParameters?.['channel-group'], expectedGroupsPart); + }); + }); +}); diff --git a/test/unit/presence/presence_here_now.test.ts b/test/unit/presence/presence_here_now.test.ts new file mode 100644 index 000000000..1ffea149d --- /dev/null +++ b/test/unit/presence/presence_here_now.test.ts @@ -0,0 +1,401 @@ +import assert from 'assert'; + +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { HereNowRequest } from '../../../src/core/endpoints/presence/here_now'; +import { KeySet, Payload } from '../../../src/core/types/api'; +import * as Presence from '../../../src/core/types/api/presence'; +import RequestOperation from '../../../src/core/constants/operations'; +import { createMockResponse } from '../test-utils'; + +describe('HereNowRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: Presence.HereNowParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + defaultParameters = { + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new HereNowRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should pass validation with minimal parameters', () => { + const request = new HereNowRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channels', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channel groups', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channelGroups: ['group1', 'group2'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with both channels and groups', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNGlobalHereNowOperation for empty channels/groups', () => { + const request = new HereNowRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNGlobalHereNowOperation); + }); + + it('should return PNGlobalHereNowOperation for empty arrays', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: [], + channelGroups: [], + }); + assert.equal(request.operation(), RequestOperation.PNGlobalHereNowOperation); + }); + + it('should return PNHereNowOperation for specific channels', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + assert.equal(request.operation(), RequestOperation.PNHereNowOperation); + }); + + it('should return PNHereNowOperation for specific channel groups', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channelGroups: ['group1'], + }); + assert.equal(request.operation(), RequestOperation.PNHereNowOperation); + }); + + it('should return PNHereNowOperation for both channels and groups', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['group1'], + }); + assert.equal(request.operation(), RequestOperation.PNHereNowOperation); + }); + }); + + describe('URL construction', () => { + it('should construct global here now path', () => { + const request = new HereNowRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for single channel', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for multiple channels', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1,channel2`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel names', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel#1', 'channel@2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel%231,channel%402`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for channel groups only', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include includeUUIDs=true by default', () => { + const request = new HereNowRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.disable_uuids, undefined); + }); + + it('should set disable_uuids when includeUUIDs is false', () => { + const request = new HereNowRequest({ + ...defaultParameters, + includeUUIDs: false, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.disable_uuids, '1'); + }); + + it('should include state when includeState is true', () => { + const request = new HereNowRequest({ + ...defaultParameters, + includeState: true, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, '1'); + }); + + it('should include channel groups in query', () => { + const request = new HereNowRequest({ + ...defaultParameters, + channelGroups: ['group1', 'group2'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'group1,group2'); + }); + + it('should include custom query parameters', () => { + const request = new HereNowRequest({ + ...defaultParameters, + queryParameters: { custom: 'value', test: 'param' }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.custom, 'value'); + assert.equal(transportRequest.queryParameters?.test, 'param'); + }); + + it('should combine all query parameters', () => { + const request = new HereNowRequest({ + ...defaultParameters, + includeUUIDs: false, + includeState: true, + channelGroups: ['group1'], + queryParameters: { custom: 'value' }, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.disable_uuids, '1'); + assert.equal(transportRequest.queryParameters?.state, '1'); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'group1'); + assert.equal(transportRequest.queryParameters?.custom, 'value'); + }); + }); + + describe('response parsing', () => { + it('should parse single channel response', async () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuids: ['uuid1', 'uuid2'], + occupancy: 2, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.equal(result.totalChannels, 1); + assert.equal(result.totalOccupancy, 2); + assert.deepEqual(result.channels, { + channel1: { + name: 'channel1', + occupancy: 2, + occupants: [ + { uuid: 'uuid1', state: null }, + { uuid: 'uuid2', state: null }, + ], + }, + }); + }); + + it('should parse single channel response without uuids', async () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + includeUUIDs: false, + }); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + occupancy: 2, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.equal(result.totalChannels, 1); + assert.equal(result.totalOccupancy, 2); + assert.deepEqual(result.channels, { + channel1: { + name: 'channel1', + occupancy: 2, + occupants: [], + }, + }); + }); + + it('should parse multiple channels response', async () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['ch1', 'ch2'], + }); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { + total_channels: 2, + total_occupancy: 3, + channels: { + ch1: { uuids: ['uuid1'], occupancy: 1 }, + ch2: { uuids: ['uuid2', 'uuid3'], occupancy: 2 }, + }, + }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.equal(result.totalChannels, 2); + assert.equal(result.totalOccupancy, 3); + assert.deepEqual(result.channels, { + ch1: { + name: 'ch1', + occupancy: 1, + occupants: [{ uuid: 'uuid1', state: null }], + }, + ch2: { + name: 'ch2', + occupancy: 2, + occupants: [ + { uuid: 'uuid2', state: null }, + { uuid: 'uuid3', state: null }, + ], + }, + }); + }); + + it('should parse response with state data', async () => { + const request = new HereNowRequest({ + ...defaultParameters, + channels: ['channel1'], + includeState: true, + }); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + uuids: [ + 'uuid1', + { uuid: 'uuid2', state: { status: 'online' } }, + ], + occupancy: 2, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.channels, { + channel1: { + name: 'channel1', + occupancy: 2, + occupants: [ + { uuid: 'uuid1', state: null }, + { uuid: 'uuid2', state: { status: 'online' } }, + ], + }, + }); + }); + + it('should handle empty channels response', async () => { + const request = new HereNowRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { + total_channels: 0, + total_occupancy: 0, + channels: {}, + }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.equal(result.totalChannels, 0); + assert.equal(result.totalOccupancy, 0); + assert.deepEqual(result.channels, {}); + }); + + it('should handle response without payload channels', async () => { + const request = new HereNowRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { + total_channels: 0, + total_occupancy: 0, + }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.equal(result.totalChannels, 0); + assert.equal(result.totalOccupancy, 0); + assert.deepEqual(result.channels, {}); + }); + }); + + describe('defaults handling', () => { + it('should apply default values for includeUUIDs and includeState', () => { + const request = new HereNowRequest(defaultParameters); + // Access private field for testing - this validates the defaults are applied + const params = (request as any).parameters; + assert.equal(params.includeUUIDs, true); + assert.equal(params.includeState, false); + }); + + it('should preserve custom values over defaults', () => { + const request = new HereNowRequest({ + ...defaultParameters, + includeUUIDs: false, + includeState: true, + }); + const params = (request as any).parameters; + assert.equal(params.includeUUIDs, false); + assert.equal(params.includeState, true); + }); + + it('should initialize empty queryParameters if not provided', () => { + const request = new HereNowRequest(defaultParameters); + const params = (request as any).parameters; + assert.deepEqual(params.queryParameters, {}); + }); + }); +}); diff --git a/test/unit/presence/presence_leave.test.ts b/test/unit/presence/presence_leave.test.ts new file mode 100644 index 000000000..423c22890 --- /dev/null +++ b/test/unit/presence/presence_leave.test.ts @@ -0,0 +1,420 @@ +import assert from 'assert'; + +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { PresenceLeaveRequest } from '../../../src/core/endpoints/presence/leave'; +import { KeySet } from '../../../src/core/types/api'; +import * as Presence from '../../../src/core/types/api/presence'; +import RequestOperation from '../../../src/core/constants/operations'; +import { createMockResponse } from '../test-utils'; +import { encodeString } from '../../../src/core/utils'; + +describe('PresenceLeaveRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: Presence.PresenceLeaveParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + defaultParameters = { + channels: ['channel1'], + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate channels or channelGroups required', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: [], + channelGroups: [], + }); + assert.equal(request.validate(), 'At least one `channel` or `channel group` should be provided.'); + }); + + it('should validate channels or channelGroups required when undefined', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: undefined, + }); + assert.equal(request.validate(), 'At least one `channel` or `channel group` should be provided.'); + }); + + it('should pass validation with channels only', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: undefined, + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channel groups only', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with both channels and groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with empty channels but non-empty groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: [], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with non-empty channels but empty groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: [], + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return correct operation type', () => { + const request = new PresenceLeaveRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNUnsubscribeOperation); + }); + }); + + describe('channel handling', () => { + it('should deduplicate channels', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1', 'channel1', 'channel2'], + channelGroups: undefined, + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1,channel2/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should deduplicate channel groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1', 'group1', 'group2'], + }); + // Access private field for testing + const params = (request as any).parameters; + assert.deepEqual(params.channelGroups, ['group1', 'group2']); + }); + + it('should sort channels in path', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channelZ', 'channelA', 'channelM'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channelA,channelM,channelZ/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should sort channel groups in query', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['groupZ', 'groupA', 'groupM'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'groupA,groupM,groupZ'); + }); + + it('should handle complex deduplication and sorting', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['c', 'a', 'b', 'a', 'c'], + channelGroups: ['g3', 'g1', 'g2', 'g1'], + }); + const transportRequest = request.request(); + + // Channels should be deduplicated and sorted + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/a,b,c/leave`; + assert.equal(transportRequest.path, expectedPath); + + // Channel groups should be deduplicated and sorted + assert.equal(transportRequest.queryParameters?.['channel-group'], 'g1,g2,g3'); + }); + + it('should preserve original arrays without mutation', () => { + const originalChannels = ['channel1', 'channel1', 'channel2']; + const originalGroups = ['group1', 'group1', 'group2']; + + new PresenceLeaveRequest({ + ...defaultParameters, + channels: originalChannels, + channelGroups: originalGroups, + }); + + // Original arrays should not be modified + assert.deepEqual(originalChannels, ['channel1', 'channel1', 'channel2']); + assert.deepEqual(originalGroups, ['group1', 'group1', 'group2']); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with single channel', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for multiple channels', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1,channel2/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel names', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel#1', 'channel@2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel%231,channel%402/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle empty channels array with groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: [], + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle undefined channels with groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle null channels with groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: null as any, + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should not include channel-group when no channel groups provided', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + assert.deepEqual(transportRequest.queryParameters, {}); + }); + + it('should include channel-group when provided', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channelGroups: ['group1', 'group2'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'group1,group2'); + }); + + it('should handle empty channel groups array', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channelGroups: [], + }); + const transportRequest = request.request(); + assert.deepEqual(transportRequest.queryParameters, {}); + }); + + it('should handle single channel group', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channelGroups: ['single-group'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'single-group'); + }); + + it('should handle undefined channel groups', () => { + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channelGroups: undefined, + }); + const transportRequest = request.request(); + assert.deepEqual(transportRequest.queryParameters, {}); + }); + }); + + describe('response parsing', () => { + it('should parse successful response to empty object', async () => { + const request = new PresenceLeaveRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + action: 'leave', + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result, {}); + }); + + it('should parse successful response with payload to empty object', async () => { + const request = new PresenceLeaveRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + action: 'leave', + payload: { some: 'data' }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + // Leave response should always return empty object regardless of payload + assert.deepEqual(result, {}); + }); + + it('should handle malformed response', async () => { + const request = new PresenceLeaveRequest(defaultParameters); + const mockResponse: TransportResponse = { + url: 'test-url', + status: 200, + headers: {}, + body: new TextEncoder().encode('invalid json').buffer, + }; + + // Should throw error for invalid JSON + await assert.rejects(async () => { + await request.parse(mockResponse); + }); + }); + + it('should handle empty response body', async () => { + const request = new PresenceLeaveRequest(defaultParameters); + const mockResponse: TransportResponse = { + url: 'test-url', + status: 200, + headers: {}, + body: new ArrayBuffer(0), + }; + + // Should throw error for empty body + await assert.rejects(async () => { + await request.parse(mockResponse); + }); + }); + }); + + describe('edge cases', () => { + it('should handle very long channel names', () => { + const longChannelName = 'a'.repeat(1000); + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: [longChannelName], + }); + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(longChannelName))); + }); + + it('should handle many channels', () => { + const manyChannels = Array.from({ length: 100 }, (_, i) => `channel-${i}`); + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: manyChannels, + }); + const transportRequest = request.request(); + + // Should be sorted + const sortedChannels = [...manyChannels].sort(); + const expectedChannelsPart = sortedChannels.join(','); + assert(transportRequest.path.includes(expectedChannelsPart)); + }); + + it('should handle many channel groups', () => { + const manyGroups = Array.from({ length: 50 }, (_, i) => `group-${i}`); + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channelGroups: manyGroups, + }); + const transportRequest = request.request(); + + // Should be sorted + const sortedGroups = [...manyGroups].sort(); + const expectedGroupsPart = sortedGroups.join(','); + assert.equal(transportRequest.queryParameters?.['channel-group'], expectedGroupsPart); + }); + + it('should handle channels with special characters requiring sorting', () => { + const specialChannels = ['channel-!', 'channel-@', 'channel-#', 'channel-$']; + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: specialChannels, + }); + const transportRequest = request.request(); + + // Should be sorted lexicographically + const sortedChannels = [...specialChannels].sort(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/${sortedChannels.map(c => encodeString(c)).join(',')}/leave`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle mixed case channel names', () => { + const mixedCaseChannels = ['Channel-A', 'channel-b', 'CHANNEL-C', 'channel-D']; + const request = new PresenceLeaveRequest({ + ...defaultParameters, + channels: mixedCaseChannels, + }); + const transportRequest = request.request(); + + // Should maintain case but sort properly + const sortedChannels = [...mixedCaseChannels].sort(); + const expectedChannelsPart = sortedChannels.join(','); + assert(transportRequest.path.includes(expectedChannelsPart)); + }); + }); +}); diff --git a/test/unit/presence/presence_set_state.test.ts b/test/unit/presence/presence_set_state.test.ts new file mode 100644 index 000000000..9d80e1175 --- /dev/null +++ b/test/unit/presence/presence_set_state.test.ts @@ -0,0 +1,417 @@ +import assert from 'assert'; + +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { SetPresenceStateRequest } from '../../../src/core/endpoints/presence/set_state'; +import { KeySet, Payload } from '../../../src/core/types/api'; +import * as Presence from '../../../src/core/types/api/presence'; +import RequestOperation from '../../../src/core/constants/operations'; +import { createMockResponse } from '../test-utils'; + +describe('SetPresenceStateRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: Presence.SetPresenceStateParameters & { uuid: string; keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + defaultParameters = { + uuid: 'test_uuid', + state: { status: 'online' }, + channels: ['channel1'], + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required state', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: undefined as any, + }); + assert.equal(request.validate(), 'Missing State'); + }); + + it('should validate channels or channelGroups required', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: [], + channelGroups: [], + }); + assert.equal(request.validate(), 'Please provide a list of channels and/or channel-groups'); + }); + + it('should validate channels or channelGroups required when undefined', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: undefined, + }); + assert.equal(request.validate(), 'Please provide a list of channels and/or channel-groups'); + }); + + it('should pass validation with channels only', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: undefined, + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with channel groups only', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with both channels and groups', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + channelGroups: ['group1'], + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with empty state object', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: {}, + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with null state', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: null as any, + }); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation with complex state', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: { + user: { name: 'John', age: 30 }, + preferences: { theme: 'dark' }, + activities: ['typing', 'online'], + }, + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return correct operation type', () => { + const request = new SetPresenceStateRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNSetStateOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with single channel', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/uuid/test_uuid/data`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for multiple channels', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel1', 'channel2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1,channel2/uuid/test_uuid/data`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in channel names', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: ['channel#1', 'channel@2'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel%231,channel%402/uuid/test_uuid/data`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in UUID', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + uuid: 'test#uuid@123', + channels: ['channel1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/channel1/uuid/test%23uuid%40123/data`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle empty channels array', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: [], + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/uuid/test_uuid/data`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle undefined channels', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channels: undefined, + channelGroups: ['group1'], + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/channel/,/uuid/test_uuid/data`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include serialized state', () => { + const state = { status: 'online', mood: 'happy' }; + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(state)); + }); + + it('should include channel groups when provided', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channelGroups: ['group1', 'group2'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'group1,group2'); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(defaultParameters.state)); + }); + + it('should not include channel-group when empty', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channelGroups: [], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], undefined); + }); + + it('should handle single channel group', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + channelGroups: ['single-group'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'single-group'); + }); + + it('should serialize complex state objects', () => { + const complexState = { + user: { + name: 'Alice', + details: { + age: 25, + location: 'NYC', + }, + }, + preferences: { + notifications: true, + theme: 'dark', + }, + activities: ['typing', 'online'], + metadata: null, + timestamp: 1234567890, + }; + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: complexState, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(complexState)); + }); + + it('should serialize null state', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: null as any, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, 'null'); + }); + + it('should serialize empty state object', () => { + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state: {}, + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, '{}'); + }); + + it('should combine state and channel groups', () => { + const state = { active: true }; + const request = new SetPresenceStateRequest({ + ...defaultParameters, + state, + channelGroups: ['cg1', 'cg2'], + }); + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.state, JSON.stringify(state)); + assert.equal(transportRequest.queryParameters?.['channel-group'], 'cg1,cg2'); + }); + }); + + describe('response parsing', () => { + it('should parse successful response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const returnedState = { status: 'online', updated: true }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: returnedState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, returnedState); + }); + + it('should handle empty payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: {}, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, {}); + }); + + it('should handle null payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: null, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, null); + }); + + it('should handle complex payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const complexState = { + user: { + name: 'Bob', + profile: { + avatar: 'url', + status: 'premium', + }, + }, + settings: { + privacy: 'public', + notifications: { + email: true, + push: false, + }, + }, + lastActivity: { + action: 'message_sent', + timestamp: 1234567890, + channel: 'general', + }, + metadata: ['tag1', 'tag2'], + }; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: complexState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, complexState); + }); + + it('should handle array payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const arrayState = ['active', 'typing', 'away']; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: arrayState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, arrayState); + }); + + it('should handle string payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const stringState = 'online'; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: stringState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, stringState); + }); + + it('should handle number payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const numberState = 42; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: numberState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, numberState); + }); + + it('should handle boolean payload response', async () => { + const request = new SetPresenceStateRequest(defaultParameters); + const booleanState = true; + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: booleanState, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + + assert.deepEqual(result.state, booleanState); + }); + }); +}); diff --git a/test/unit/presence/presence_where_now.test.ts b/test/unit/presence/presence_where_now.test.ts new file mode 100644 index 000000000..43d777ea3 --- /dev/null +++ b/test/unit/presence/presence_where_now.test.ts @@ -0,0 +1,158 @@ +import assert from 'assert'; + +import { TransportResponse } from '../../../src/core/types/transport-response'; +import { WhereNowRequest } from '../../../src/core/endpoints/presence/where_now'; +import { KeySet } from '../../../src/core/types/api'; +import * as Presence from '../../../src/core/types/api/presence'; +import RequestOperation from '../../../src/core/constants/operations'; + +import { createMockResponse } from '../test-utils'; + +describe('WhereNowRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: Required & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + defaultParameters = { + uuid: 'test_uuid', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new WhereNowRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should pass validation with valid parameters', () => { + const request = new WhereNowRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return correct operation type', () => { + const request = new WhereNowRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNWhereNowOperation); + }); + }); + + describe('URL construction', () => { + it('should construct correct path with UUID', () => { + const request = new WhereNowRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/uuid/test_uuid`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special characters in UUID', () => { + const request = new WhereNowRequest({ + ...defaultParameters, + uuid: 'test#uuid@123', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/uuid/test%23uuid%40123`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle empty UUID', () => { + const request = new WhereNowRequest({ + ...defaultParameters, + uuid: '', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/uuid/`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should handle UUID with spaces', () => { + const request = new WhereNowRequest({ + ...defaultParameters, + uuid: 'test uuid with spaces', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/presence/sub-key/${defaultKeySet.subscribeKey}/uuid/test%20uuid%20with%20spaces`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('response parsing', () => { + it('should parse response with channels', async () => { + const request = new WhereNowRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { channels: ['channel1', 'channel2'] }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + assert.deepEqual(result.channels, ['channel1', 'channel2']); + }); + + it('should handle empty payload', async () => { + const request = new WhereNowRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + service: 'Presence', + }); + const result = await request.parse(mockResponse); + assert.deepEqual(result.channels, []); + }); + + it('should handle payload without channels', async () => { + const request = new WhereNowRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: {}, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + assert.deepEqual(result.channels, []); + }); + + it('should handle single channel', async () => { + const request = new WhereNowRequest(defaultParameters); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { channels: ['single-channel'] }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + assert.deepEqual(result.channels, ['single-channel']); + assert.equal(result.channels.length, 1); + }); + + it('should handle many channels', async () => { + const request = new WhereNowRequest(defaultParameters); + const manyChannels = Array.from({ length: 100 }, (_, i) => `channel-${i}`); + const mockResponse = createMockResponse({ + status: 200, + message: 'OK', + payload: { channels: manyChannels }, + service: 'Presence', + }); + const result = await request.parse(mockResponse); + assert.deepEqual(result.channels, manyChannels); + assert.equal(result.channels.length, 100); + }); + }); + + describe('query parameters', () => { + it('should not include any query parameters by default', () => { + const request = new WhereNowRequest(defaultParameters); + const transportRequest = request.request(); + assert.deepEqual(transportRequest.queryParameters, {}); + }); + }); +}); diff --git a/test/unit/publish/publish.test.ts b/test/unit/publish/publish.test.ts new file mode 100644 index 000000000..2655f3585 --- /dev/null +++ b/test/unit/publish/publish.test.ts @@ -0,0 +1,508 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { PublishRequest, PublishParameters, PublishResponse } from '../../../src/core/endpoints/publish'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { TransportResponse } from '../../../src/core/types/transport-response'; +import RequestOperation from '../../../src/core/constants/operations'; +import { ICryptoModule } from '../../../src/core/interfaces/crypto-module'; +import { KeySet } from '../../../src/core/types/api'; +import { encode } from '../../../src/core/components/base64_codec'; + +describe('PublishRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: PublishParameters & { keySet: KeySet }; + + beforeEach(() => { + defaultKeySet = { + publishKey: 'test_publish_key', + subscribeKey: 'test_subscribe_key', + }; + + defaultParameters = { + channel: 'test_channel', + message: { test: 'message' }, + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required parameters', () => { + // Test missing channel + const requestWithoutChannel = new PublishRequest({ + ...defaultParameters, + channel: '', + }); + assert.equal(requestWithoutChannel.validate(), "Missing 'channel'"); + + // Test missing message + const requestWithoutMessage = new PublishRequest({ + ...defaultParameters, + message: undefined as any, + }); + assert.equal(requestWithoutMessage.validate(), "Missing 'message'"); + + // Test missing publishKey + const requestWithoutPublishKey = new PublishRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, publishKey: '' }, + }); + assert.equal(requestWithoutPublishKey.validate(), "Missing 'publishKey'"); + + // Test valid parameters + const validRequest = new PublishRequest(defaultParameters); + assert.equal(validRequest.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNPublishOperation', () => { + const request = new PublishRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNPublishOperation); + }); + }); + + describe('URL construction', () => { + it('should construct GET URL with encoded payload and channel', () => { + const request = new PublishRequest({ + ...defaultParameters, + channel: 'test channel', + message: { test: 'message' }, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + + const expectedPath = `/publish/${defaultKeySet.publishKey}/${defaultKeySet.subscribeKey}/0/test%20channel/0/${encodeURIComponent(JSON.stringify({ test: 'message' }))}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct POST URL without payload in path', () => { + const request = new PublishRequest({ + ...defaultParameters, + sendByPost: true, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.POST); + + const expectedPath = `/publish/${defaultKeySet.publishKey}/${defaultKeySet.subscribeKey}/0/${defaultParameters.channel}/0`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should encode special character channels', () => { + const specialChannel = 'a b/#'; + const request = new PublishRequest({ + ...defaultParameters, + channel: specialChannel, + sendByPost: false, + }); + + const transportRequest = request.request(); + const encodedChannel = encodeURIComponent(specialChannel).replace(/[!~*'()]/g, (x) => `%${x.charCodeAt(0).toString(16).toUpperCase()}`); + assert(transportRequest.path.includes(encodedChannel)); + }); + }); + + describe('POST method handling', () => { + it('should set correct headers for POST requests', () => { + const request = new PublishRequest({ + ...defaultParameters, + sendByPost: true, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.headers?.['Content-Type'], 'application/json'); + assert.equal(transportRequest.headers?.['Accept-Encoding'], 'gzip, deflate'); + }); + + it('should include message in body for POST requests', () => { + const testMessage = { test: 'data' }; + const request = new PublishRequest({ + ...defaultParameters, + message: testMessage, + sendByPost: true, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.body, JSON.stringify(testMessage)); + }); + + it('should not include headers for GET requests', () => { + const request = new PublishRequest({ + ...defaultParameters, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert.notEqual(transportRequest.headers?.['Content-Type'], 'application/json'); + }); + }); + + describe('query parameters mapping', () => { + it('should map storeInHistory parameter', () => { + // Test storeInHistory: true + const requestStoreTrue = new PublishRequest({ + ...defaultParameters, + storeInHistory: true, + }); + const queryParams1 = requestStoreTrue.request().queryParameters; + assert.equal(queryParams1?.store, '1'); + + // Test storeInHistory: false + const requestStoreFalse = new PublishRequest({ + ...defaultParameters, + storeInHistory: false, + }); + const queryParams2 = requestStoreFalse.request().queryParameters; + assert.equal(queryParams2?.store, '0'); + + // Test storeInHistory: undefined (should not be present) + const requestStoreUndefined = new PublishRequest(defaultParameters); + const queryParams3 = requestStoreUndefined.request().queryParameters; + assert.equal(queryParams3?.store, undefined); + }); + + it('should map ttl parameter', () => { + const request = new PublishRequest({ + ...defaultParameters, + ttl: 24, + }); + const queryParams4 = request.request().queryParameters; + assert.equal(queryParams4?.ttl, 24); + }); + + it('should map replicate parameter when false', () => { + const request = new PublishRequest({ + ...defaultParameters, + replicate: false, + }); + const queryParams5 = request.request().queryParameters; + assert.equal(queryParams5?.norep, 'true'); + + // Should not be present when true + const requestReplicateTrue = new PublishRequest({ + ...defaultParameters, + replicate: true, + }); + const queryParams6 = requestReplicateTrue.request().queryParameters; + assert.equal(queryParams6?.norep, undefined); + }); + + it('should map meta parameter when object', () => { + const metaData = { userId: '123', type: 'chat' }; + const request = new PublishRequest({ + ...defaultParameters, + meta: metaData, + }); + const queryParams7 = request.request().queryParameters; + assert.equal(queryParams7?.meta, JSON.stringify(metaData)); + + // Should not be present when not an object + const requestNonObjectMeta = new PublishRequest({ + ...defaultParameters, + meta: 'string_meta' as any, + }); + const queryParams8 = requestNonObjectMeta.request().queryParameters; + assert.equal(queryParams8?.meta, undefined); + }); + + it('should map custom_message_type parameter', () => { + const customType = 'test-message-type'; + const request = new PublishRequest({ + ...defaultParameters, + customMessageType: customType, + }); + const queryParams9 = request.request().queryParameters; + assert.equal(queryParams9?.custom_message_type, customType); + }); + + it('should combine multiple query parameters', () => { + const request = new PublishRequest({ + ...defaultParameters, + storeInHistory: false, + ttl: 12, + replicate: false, + meta: { test: 'meta' }, + customMessageType: 'test-type', + }); + + const queryParams = request.request().queryParameters; + assert.equal(queryParams?.store, '0'); + assert.equal(queryParams?.ttl, 12); + assert.equal(queryParams?.norep, 'true'); + assert.equal(queryParams?.meta, JSON.stringify({ test: 'meta' })); + assert.equal(queryParams?.custom_message_type, 'test-type'); + }); + }); + + describe('encryption handling', () => { + const mockCryptoModule: ICryptoModule = { + set logger(_logger: any) {}, + encrypt: (data: string) => `encrypted_${data}`, + decrypt: (data: string | ArrayBuffer) => data.toString().replace('encrypted_', ''), + encryptFile: undefined as any, + decryptFile: undefined as any, + }; + + it('should encrypt message with cryptoModule for GET', () => { + const testMessage = { secret: 'data' }; + const request = new PublishRequest({ + ...defaultParameters, + message: testMessage, + crypto: mockCryptoModule, + sendByPost: false, + }); + + const transportRequest = request.request(); + const expectedEncryptedMessage = JSON.stringify(`encrypted_${JSON.stringify(testMessage)}`); + assert(transportRequest.path.includes(encodeURIComponent(expectedEncryptedMessage))); + }); + + it('should encrypt message with cryptoModule for POST', () => { + const testMessage = { secret: 'data' }; + const request = new PublishRequest({ + ...defaultParameters, + message: testMessage, + crypto: mockCryptoModule, + sendByPost: true, + }); + + const transportRequest = request.request(); + const expectedEncryptedMessage = JSON.stringify(`encrypted_${JSON.stringify(testMessage)}`); + assert.equal(transportRequest.body, expectedEncryptedMessage); + }); + + it('should handle ArrayBuffer encryption result', () => { + const arrayBufferCrypto: ICryptoModule = { + set logger(_logger: any) {}, + encrypt: (data: string) => { + const buffer = new ArrayBuffer(8); + const view = new Uint8Array(buffer); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + return buffer; + }, + decrypt: undefined as any, + encryptFile: undefined as any, + decryptFile: undefined as any, + }; + + const request = new PublishRequest({ + ...defaultParameters, + crypto: arrayBufferCrypto, + sendByPost: true, + }); + + const transportRequest = request.request(); + const expectedEncodedBuffer = JSON.stringify(encode(new ArrayBuffer(8))); + // We can't easily compare ArrayBuffer content, so just verify it's a string + assert.equal(typeof transportRequest.body, 'string'); + }); + + it('should handle encryption failure', () => { + const failingCrypto: ICryptoModule = { + set logger(_logger: any) {}, + encrypt: () => { + throw new Error('Encryption failed'); + }, + decrypt: undefined as any, + encryptFile: undefined as any, + decryptFile: undefined as any, + }; + + assert.throws(() => { + new PublishRequest({ + ...defaultParameters, + crypto: failingCrypto, + }).request(); + }, /Encryption failed/); + }); + }); + + describe('payload types support', () => { + it('should handle string payloads', () => { + const request = new PublishRequest({ + ...defaultParameters, + message: 'test string', + sendByPost: false, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(JSON.stringify('test string')))); + }); + + it('should handle number payloads', () => { + const request = new PublishRequest({ + ...defaultParameters, + message: 42, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(JSON.stringify(42)))); + }); + + it('should handle boolean payloads', () => { + const request = new PublishRequest({ + ...defaultParameters, + message: true, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(JSON.stringify(true)))); + }); + + it('should handle object payloads', () => { + const objectMessage = { key: 'value', nested: { prop: 123 } }; + const request = new PublishRequest({ + ...defaultParameters, + message: objectMessage, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(JSON.stringify(objectMessage)))); + }); + + it('should handle array payloads', () => { + const arrayMessage = [1, 2, 'three', { four: 4 }]; + const request = new PublishRequest({ + ...defaultParameters, + message: arrayMessage, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert(transportRequest.path.includes(encodeURIComponent(JSON.stringify(arrayMessage)))); + }); + }); + + describe('response parsing', () => { + it('should parse timetoken from service response tuple', async () => { + const request = new PublishRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode('[1, "Sent", "14647523059145592"]'), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse.timetoken, '14647523059145592'); + }); + + it('should handle different service response formats', async () => { + const request = new PublishRequest(defaultParameters); + const mockResponse: TransportResponse = { + status: 200, + url: 'https://test.pubnub.com', + headers: { 'content-type': 'application/json' }, + body: new TextEncoder().encode('[0, "Failed", "123456789"]'), + }; + + const parsedResponse = await request.parse(mockResponse); + assert.equal(parsedResponse.timetoken, '123456789'); + }); + }); + + describe('request configuration', () => { + it('should default to GET method when sendByPost is false', () => { + const request = new PublishRequest({ + ...defaultParameters, + sendByPost: false, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + + it('should use POST method when sendByPost is true', () => { + const request = new PublishRequest({ + ...defaultParameters, + sendByPost: true, + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.POST); + }); + + it('should default sendByPost to false', () => { + const request = new PublishRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + + it('should set compressible flag based on sendByPost', () => { + const getRequest = new PublishRequest({ + ...defaultParameters, + sendByPost: false, + }); + assert.equal(getRequest.request().compressible, false); + + const postRequest = new PublishRequest({ + ...defaultParameters, + sendByPost: true, + }); + assert.equal(postRequest.request().compressible, true); + }); + }); + + describe('concurrent operations simulation', () => { + it('should handle multiple publish requests with different methods', () => { + const getRequest = new PublishRequest({ + ...defaultParameters, + channel: 'channel1', + message: 'GET message', + sendByPost: false, + }); + + const postRequest = new PublishRequest({ + ...defaultParameters, + channel: 'channel2', + message: 'POST message', + sendByPost: true, + }); + + const getTransportRequest = getRequest.request(); + const postTransportRequest = postRequest.request(); + + // Verify they maintain their individual configurations + assert.equal(getTransportRequest.method, TransportMethod.GET); + assert.equal(postTransportRequest.method, TransportMethod.POST); + + assert(getTransportRequest.path.includes('channel1')); + assert(postTransportRequest.path.includes('channel2')); + + assert(getTransportRequest.path.includes(encodeURIComponent(JSON.stringify('GET message')))); + assert.equal(postTransportRequest.body, JSON.stringify('POST message')); + }); + + it('should maintain request isolation', () => { + const requests = Array.from({ length: 5 }, (_, i) => + new PublishRequest({ + ...defaultParameters, + channel: `channel_${i}`, + message: `message_${i}`, + sendByPost: i % 2 !== 0, // Alternate between GET and POST + }) + ); + + requests.forEach((request, index) => { + const transportRequest = request.request(); + assert(transportRequest.path.includes(`channel_${index}`)); + + if (index % 2 === 0) { + // GET request + assert.equal(transportRequest.method, TransportMethod.GET); + assert(transportRequest.path.includes(encodeURIComponent(JSON.stringify(`message_${index}`)))); + } else { + // POST request + assert.equal(transportRequest.method, TransportMethod.POST); + assert.equal(transportRequest.body, JSON.stringify(`message_${index}`)); + } + }); + }); + }); +}); diff --git a/test/unit/push_notification/push_add_channels.test.ts b/test/unit/push_notification/push_add_channels.test.ts new file mode 100644 index 000000000..1df6159fe --- /dev/null +++ b/test/unit/push_notification/push_add_channels.test.ts @@ -0,0 +1,272 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { AddDevicePushNotificationChannelsRequest } from '../../../src/core/endpoints/push/add_push_channels'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { createMockResponse } from '../test-utils'; + +describe('AddDevicePushNotificationChannelsRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: any; + + beforeEach(() => { + defaultKeySet = { + subscribeKey: 'test_subscribe_key', + publishKey: 'test_publish_key', + }; + + defaultParameters = { + device: 'test_device_id', + pushGateway: 'gcm', + channels: ['channel1', 'channel2'], + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required device', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: '', + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should validate required channels', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: [], + }); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should validate required pushGateway', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: undefined, + }); + assert.equal(request.validate(), 'Missing GW Type (pushGateway: gcm or apns2)'); + }); + + it('should validate APNS2 topic when using apns2', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: undefined, + }); + assert.equal(request.validate(), 'Missing APNS2 topic'); + }); + + it('should pass validation with valid parameters', () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation for APNS2 with topic', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNAddPushNotificationEnabledChannelsOperation', () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNAddPushNotificationEnabledChannelsOperation); + }); + }); + + describe('path construction', () => { + it('should construct path for GCM', () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for APNS2', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/push/sub-key/${defaultKeySet.subscribeKey}/devices-apns2/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include required query parameters for GCM', () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'gcm'); + assert.equal(transportRequest.queryParameters?.add, 'channel1,channel2'); + assert.equal(transportRequest.queryParameters?.environment, undefined); + assert.equal(transportRequest.queryParameters?.topic, undefined); + }); + + it('should include environment and topic for APNS2', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + assert.equal(transportRequest.queryParameters?.add, 'channel1,channel2'); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + }); + + it('should default APNS2 environment to development', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + + it('should include start and count parameters when provided', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + start: 'start_token', + count: 50, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, 'start_token'); + assert.equal(transportRequest.queryParameters?.count, 50); + }); + + it('should limit count to maximum value', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + count: 2000, // Exceeds MAX_COUNT of 1000 + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + }); + + describe('request method', () => { + it('should use GET method', () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('response parsing', () => { + it('should parse successful response', async () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + const mockResponse = createMockResponse([1, 'Modified Channels']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, {}); + }); + + it('should parse error response', async () => { + const request = new AddDevicePushNotificationChannelsRequest(defaultParameters); + const mockResponse = createMockResponse([0, 'Error Message']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, {}); + }); + }); + + describe('channel handling', () => { + it('should handle single channel', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['single_channel'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, 'single_channel'); + }); + + it('should handle multiple channels', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['ch1', 'ch2', 'ch3'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, 'ch1,ch2,ch3'); + }); + + it('should handle channels with special characters', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['channel-1', 'channel_2', 'channel.3'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, 'channel-1,channel_2,channel.3'); + }); + }); + + describe('device handling', () => { + it('should handle device ID with special characters', () => { + const deviceId = 'device-123_abc.def'; + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: deviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(deviceId)); + }); + }); + + describe('environment defaults', () => { + it('should not set environment for GCM', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'gcm', + environment: 'production', // Should be ignored for GCM + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, undefined); + }); + }); + + describe('error conditions', () => { + it('should handle missing channels array', () => { + const { channels, ...parametersWithoutChannels } = defaultParameters; + const request = new AddDevicePushNotificationChannelsRequest(parametersWithoutChannels); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should handle invalid pushGateway', () => { + const request = new AddDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'invalid_gateway' as any, + }); + // Should still validate since base validation only checks for presence + assert.equal(request.validate(), undefined); + }); + }); +}); diff --git a/test/unit/push_notification/push_base.test.ts b/test/unit/push_notification/push_base.test.ts new file mode 100644 index 000000000..9c0e84fbf --- /dev/null +++ b/test/unit/push_notification/push_base.test.ts @@ -0,0 +1,522 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { BasePushNotificationChannelsRequest } from '../../../src/core/endpoints/push/push'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { createMockResponse } from '../test-utils'; + +// Concrete implementation for testing the abstract base class +class TestPushNotificationRequest extends BasePushNotificationChannelsRequest { + constructor(parameters: any) { + super(parameters); + } + + operation(): RequestOperation { + return RequestOperation.PNAddPushNotificationEnabledChannelsOperation; + } + + async parse(response: any): Promise { + return this.deserializeResponse(response); + } +} + +describe('BasePushNotificationChannelsRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: any; + + beforeEach(() => { + defaultKeySet = { + subscribeKey: 'test_subscribe_key', + publishKey: 'test_publish_key', + }; + + defaultParameters = { + device: 'test_device_id', + pushGateway: 'gcm', + channels: ['channel1', 'channel2'], + keySet: defaultKeySet, + action: 'add', + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate missing subscribeKey', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: undefined }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required device', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + device: '', + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should validate missing device', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + device: undefined, + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should validate required channels for add action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'add', + channels: [], + }); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should validate required channels for remove action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'remove', + channels: [], + }); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should not validate channels for list action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'list', + channels: undefined, + }); + assert.equal(request.validate(), undefined); + }); + + it('should not validate channels for remove-device action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'remove-device', + channels: undefined, + }); + assert.equal(request.validate(), undefined); + }); + + it('should validate required pushGateway', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: undefined, + }); + assert.equal(request.validate(), 'Missing GW Type (pushGateway: gcm or apns2)'); + }); + + it('should validate missing pushGateway', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: '', + }); + assert.equal(request.validate(), 'Missing GW Type (pushGateway: gcm or apns2)'); + }); + + it('should validate APNS2 topic when using apns2', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: undefined, + }); + assert.equal(request.validate(), 'Missing APNS2 topic'); + }); + + it('should validate missing APNS2 topic', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: '', + }); + assert.equal(request.validate(), 'Missing APNS2 topic'); + }); + + it('should pass validation with valid GCM parameters', () => { + const request = new TestPushNotificationRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation for APNS2 with topic', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('path construction', () => { + it('should construct base path for GCM', () => { + const request = new TestPushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct base path for APNS2', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/push/sub-key/${defaultKeySet.subscribeKey}/devices-apns2/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should append /remove for remove-device action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'remove-device', + }); + const transportRequest = request.request(); + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}/remove`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should append /remove for remove-device action with APNS2', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + action: 'remove-device', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/push/sub-key/${defaultKeySet.subscribeKey}/devices-apns2/${defaultParameters.device}/remove`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include type parameter for GCM', () => { + const request = new TestPushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'gcm'); + }); + + it('should include type parameter for APNS2', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + }); + + it('should include channels for add action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'add', + channels: ['ch1', 'ch2'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, 'ch1,ch2'); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + + it('should include channels for remove action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'remove', + channels: ['ch1', 'ch2'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.remove, 'ch1,ch2'); + assert.equal(transportRequest.queryParameters?.add, undefined); + }); + + it('should not include channels for list action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'list', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, undefined); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + + it('should not include channels for remove-device action', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'remove-device', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, undefined); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + + it('should include APNS2 environment and topic', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + }); + + it('should default APNS2 environment to development', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + + it('should not include environment for GCM', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'gcm', + environment: 'production', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, undefined); + }); + + it('should include start parameter when provided', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + start: 'start_token', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, 'start_token'); + }); + + it('should not include start parameter when empty', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + start: '', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, undefined); + }); + + it('should include count parameter when provided and positive', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + count: 50, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 50); + }); + + it('should not include count parameter when zero', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + count: 0, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, undefined); + }); + + it('should not include count parameter when negative', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + count: -5, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, undefined); + }); + }); + + describe('count limits', () => { + it('should limit count to maximum value', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + count: 2000, // Exceeds MAX_COUNT of 1000 + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + + it('should preserve count when within limits', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + count: 500, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 500); + }); + + it('should set count to maximum when exactly at limit', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + count: 1000, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + }); + + describe('environment handling', () => { + it('should apply development environment default for APNS2', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + // No environment specified + }); + + // Check that environment is set in parameters after construction + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + + it('should preserve explicit environment for APNS2', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + }); + + it('should not apply environment default for GCM', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'gcm', + // No environment specified + }); + + const transportRequest = request.request(); + assert.equal(transportRequest.queryParameters?.environment, undefined); + }); + }); + + describe('request method', () => { + it('should use GET method', () => { + const request = new TestPushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('channel formatting', () => { + it('should format single channel', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'add', + channels: ['single_channel'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, 'single_channel'); + }); + + it('should format multiple channels with commas', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'add', + channels: ['ch1', 'ch2', 'ch3'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, 'ch1,ch2,ch3'); + }); + + it('should handle empty channel names in array', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + action: 'add', + channels: ['', 'valid_channel', ''], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, ',valid_channel,'); + }); + }); + + describe('abstract method requirements', () => { + it('should throw error when operation() is not overridden in base class', () => { + class IncompleteRequest extends BasePushNotificationChannelsRequest { + constructor(parameters: any) { + super(parameters); + } + // Missing operation() implementation + } + + const request = new IncompleteRequest(defaultParameters); + assert.throws(() => request.operation(), /Should be implemented in subclass/); + }); + }); + + describe('response deserialization', () => { + it('should deserialize valid response', async () => { + const request = new TestPushNotificationRequest(defaultParameters); + const mockResponse = createMockResponse([1, 'Success', 'Extra']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, [1, 'Success', 'Extra']); + }); + + it('should handle error response', async () => { + const request = new TestPushNotificationRequest(defaultParameters); + const mockResponse = createMockResponse([0, 'Error Message']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, [0, 'Error Message']); + }); + }); + + describe('parameter combinations', () => { + it('should handle all parameters together', () => { + const request = new TestPushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + action: 'add', + channels: ['ch1', 'ch2'], + start: 'start_token', + count: 100, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.add, 'ch1,ch2'); + assert.equal(transportRequest.queryParameters?.start, 'start_token'); + assert.equal(transportRequest.queryParameters?.count, 100); + }); + }); +}); diff --git a/test/unit/push_notification/push_list_channels.test.ts b/test/unit/push_notification/push_list_channels.test.ts new file mode 100644 index 000000000..8addce7e7 --- /dev/null +++ b/test/unit/push_notification/push_list_channels.test.ts @@ -0,0 +1,313 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { ListDevicePushNotificationChannelsRequest } from '../../../src/core/endpoints/push/list_push_channels'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { createMockResponse } from '../test-utils'; + +describe('ListDevicePushNotificationChannelsRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: any; + + beforeEach(() => { + defaultKeySet = { + subscribeKey: 'test_subscribe_key', + publishKey: 'test_publish_key', + }; + + defaultParameters = { + device: 'test_device_id', + pushGateway: 'gcm', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required device', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: '', + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should validate required pushGateway', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: undefined, + }); + assert.equal(request.validate(), 'Missing GW Type (pushGateway: gcm or apns2)'); + }); + + it('should validate APNS2 topic when using apns2', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: undefined, + }); + assert.equal(request.validate(), 'Missing APNS2 topic'); + }); + + it('should pass validation with valid GCM parameters', () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation for APNS2 with topic', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + assert.equal(request.validate(), undefined); + }); + + it('should not require channels for list operation', () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + // Channels are not required for list operation + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNPushNotificationEnabledChannelsOperation', () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNPushNotificationEnabledChannelsOperation); + }); + }); + + describe('path construction', () => { + it('should construct path for GCM', () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for APNS2', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/push/sub-key/${defaultKeySet.subscribeKey}/devices-apns2/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include required query parameters for GCM', () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'gcm'); + assert.equal(transportRequest.queryParameters?.environment, undefined); + assert.equal(transportRequest.queryParameters?.topic, undefined); + assert.equal(transportRequest.queryParameters?.add, undefined); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + + it('should include environment and topic for APNS2', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + }); + + it('should default APNS2 environment to development', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + + it('should include start and count parameters when provided', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + start: 'start_token', + count: 50, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, 'start_token'); + assert.equal(transportRequest.queryParameters?.count, 50); + }); + + it('should limit count to maximum value', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + count: 2000, // Exceeds MAX_COUNT of 1000 + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + + it('should not include count when zero', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + count: 0, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, undefined); + }); + + it('should not include start when empty', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + start: '', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, undefined); + }); + }); + + describe('request method', () => { + it('should use GET method', () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('response parsing', () => { + it('should parse successful response with channels', async () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const mockChannels = ['channel1', 'channel2', 'channel3']; + const mockResponse = createMockResponse(mockChannels); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, { channels: mockChannels }); + }); + + it('should parse successful response with empty channels', async () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const mockChannels: string[] = []; + const mockResponse = createMockResponse(mockChannels); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, { channels: mockChannels }); + }); + + it('should parse response with single channel', async () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const mockChannels = ['single_channel']; + const mockResponse = createMockResponse(mockChannels); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, { channels: mockChannels }); + }); + + it('should parse response with channels containing special characters', async () => { + const request = new ListDevicePushNotificationChannelsRequest(defaultParameters); + const mockChannels = ['channel-1', 'channel_2', 'channel.3']; + const mockResponse = createMockResponse(mockChannels); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, { channels: mockChannels }); + }); + }); + + describe('device handling', () => { + it('should handle device ID with special characters', () => { + const deviceId = 'device-123_abc.def'; + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: deviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(deviceId)); + }); + + it('should handle long device IDs', () => { + const longDeviceId = 'a'.repeat(200); + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: longDeviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(longDeviceId)); + }); + }); + + describe('pagination', () => { + it('should handle pagination parameters', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + start: 'pagination_start_token', + count: 100, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, 'pagination_start_token'); + assert.equal(transportRequest.queryParameters?.count, 100); + }); + + it('should handle maximum count limit', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + count: 1500, // Above 1000 limit + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + }); + + describe('environment defaults', () => { + it('should not set environment for GCM', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'gcm', + environment: 'production', // Should be ignored for GCM + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, undefined); + }); + }); + + describe('error conditions', () => { + it('should handle missing device gracefully', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: undefined, + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should handle null device gracefully', () => { + const request = new ListDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: null as any, + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + }); +}); diff --git a/test/unit/push_notification/push_remove_channels.test.ts b/test/unit/push_notification/push_remove_channels.test.ts new file mode 100644 index 000000000..ad74508f2 --- /dev/null +++ b/test/unit/push_notification/push_remove_channels.test.ts @@ -0,0 +1,315 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { RemoveDevicePushNotificationChannelsRequest } from '../../../src/core/endpoints/push/remove_push_channels'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { createMockResponse } from '../test-utils'; + +describe('RemoveDevicePushNotificationChannelsRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: any; + + beforeEach(() => { + defaultKeySet = { + subscribeKey: 'test_subscribe_key', + publishKey: 'test_publish_key', + }; + + defaultParameters = { + device: 'test_device_id', + pushGateway: 'gcm', + channels: ['channel1', 'channel2'], + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required device', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: '', + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should validate required channels', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: [], + }); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should validate required pushGateway', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: undefined, + }); + assert.equal(request.validate(), 'Missing GW Type (pushGateway: gcm or apns2)'); + }); + + it('should validate APNS2 topic when using apns2', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: undefined, + }); + assert.equal(request.validate(), 'Missing APNS2 topic'); + }); + + it('should pass validation with valid GCM parameters', () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation for APNS2 with topic', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNRemovePushNotificationEnabledChannelsOperation', () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNRemovePushNotificationEnabledChannelsOperation); + }); + }); + + describe('path construction', () => { + it('should construct path for GCM', () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for APNS2', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/push/sub-key/${defaultKeySet.subscribeKey}/devices-apns2/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + }); + + describe('query parameters', () => { + it('should include required query parameters for GCM', () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'gcm'); + assert.equal(transportRequest.queryParameters?.remove, 'channel1,channel2'); + assert.equal(transportRequest.queryParameters?.environment, undefined); + assert.equal(transportRequest.queryParameters?.topic, undefined); + }); + + it('should include environment and topic for APNS2', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + assert.equal(transportRequest.queryParameters?.remove, 'channel1,channel2'); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + }); + + it('should default APNS2 environment to development', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + + it('should include start and count parameters when provided', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + start: 'start_token', + count: 50, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, 'start_token'); + assert.equal(transportRequest.queryParameters?.count, 50); + }); + + it('should limit count to maximum value', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + count: 2000, // Exceeds MAX_COUNT of 1000 + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + }); + + describe('request method', () => { + it('should use GET method', () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('response parsing', () => { + it('should parse successful response', async () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + const mockResponse = createMockResponse([1, 'Modified Channels']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, {}); + }); + + it('should parse error response', async () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + const mockResponse = createMockResponse([0, 'Error Message']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, {}); + }); + }); + + describe('channel handling', () => { + it('should handle single channel', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['single_channel'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.remove, 'single_channel'); + }); + + it('should handle multiple channels', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['ch1', 'ch2', 'ch3'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.remove, 'ch1,ch2,ch3'); + }); + + it('should handle channels with special characters', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['channel-1', 'channel_2', 'channel.3'], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.remove, 'channel-1,channel_2,channel.3'); + }); + + it('should handle empty channel names', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: ['', 'valid_channel', ''], + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.remove, ',valid_channel,'); + }); + }); + + describe('device handling', () => { + it('should handle device ID with special characters', () => { + const deviceId = 'device-123_abc.def'; + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + device: deviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(deviceId)); + }); + }); + + describe('environment defaults', () => { + it('should not set environment for GCM', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'gcm', + environment: 'production', // Should be ignored for GCM + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, undefined); + }); + }); + + describe('error conditions', () => { + it('should handle missing channels array', () => { + const { channels, ...parametersWithoutChannels } = defaultParameters; + const request = new RemoveDevicePushNotificationChannelsRequest(parametersWithoutChannels); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should handle null channels', () => { + const { channels, ...parametersWithoutChannels } = defaultParameters; + const request = new RemoveDevicePushNotificationChannelsRequest(parametersWithoutChannels); + assert.equal(request.validate(), 'Missing Channels'); + }); + + it('should handle empty channel array', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + channels: [], + }); + assert.equal(request.validate(), 'Missing Channels'); + }); + }); + + describe('consistency with add channels operation', () => { + it('should use same path structure as add operation', () => { + const request = new RemoveDevicePushNotificationChannelsRequest(defaultParameters); + const transportRequest = request.request(); + + // Path should be identical to add operation + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should use same query parameter structure as add operation for APNS2', () => { + const request = new RemoveDevicePushNotificationChannelsRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + const transportRequest = request.request(); + + // Same structure as add, but with 'remove' instead of 'add' + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + assert.equal(transportRequest.queryParameters?.remove, 'channel1,channel2'); + assert.equal(transportRequest.queryParameters?.add, undefined); + }); + }); +}); diff --git a/test/unit/push_notification/push_remove_device.test.ts b/test/unit/push_notification/push_remove_device.test.ts new file mode 100644 index 000000000..f5970996c --- /dev/null +++ b/test/unit/push_notification/push_remove_device.test.ts @@ -0,0 +1,369 @@ +/* global describe, it, beforeEach */ + +import assert from 'assert'; +import { RemoveDevicePushNotificationRequest } from '../../../src/core/endpoints/push/remove_device'; +import RequestOperation from '../../../src/core/constants/operations'; +import { KeySet } from '../../../src/core/types/api'; +import { TransportMethod } from '../../../src/core/types/transport-request'; +import { createMockResponse } from '../test-utils'; + +describe('RemoveDevicePushNotificationRequest', () => { + let defaultKeySet: KeySet; + let defaultParameters: any; + + beforeEach(() => { + defaultKeySet = { + subscribeKey: 'test_subscribe_key', + publishKey: 'test_publish_key', + }; + + defaultParameters = { + device: 'test_device_id', + pushGateway: 'gcm', + keySet: defaultKeySet, + }; + }); + + describe('validation', () => { + it('should validate required subscribeKey', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + keySet: { ...defaultKeySet, subscribeKey: '' }, + }); + assert.equal(request.validate(), 'Missing Subscribe Key'); + }); + + it('should validate required device', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: '', + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should validate required pushGateway', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: undefined, + }); + assert.equal(request.validate(), 'Missing GW Type (pushGateway: gcm or apns2)'); + }); + + it('should validate APNS2 topic when using apns2', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: undefined, + }); + assert.equal(request.validate(), 'Missing APNS2 topic'); + }); + + it('should pass validation with valid GCM parameters', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + assert.equal(request.validate(), undefined); + }); + + it('should pass validation for APNS2 with topic', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + assert.equal(request.validate(), undefined); + }); + + it('should not require channels for remove device operation', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + // Channels are not required for remove device operation + assert.equal(request.validate(), undefined); + }); + }); + + describe('operation', () => { + it('should return PNRemoveAllPushNotificationsOperation', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + assert.equal(request.operation(), RequestOperation.PNRemoveAllPushNotificationsOperation); + }); + }); + + describe('path construction', () => { + it('should construct path for GCM with /remove suffix', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + const expectedPath = `/v1/push/sub-key/${defaultKeySet.subscribeKey}/devices/${defaultParameters.device}/remove`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should construct path for APNS2 with /remove suffix', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + const expectedPath = `/v2/push/sub-key/${defaultKeySet.subscribeKey}/devices-apns2/${defaultParameters.device}/remove`; + assert.equal(transportRequest.path, expectedPath); + }); + + it('should always include /remove suffix regardless of gateway', () => { + const gcmRequest = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'gcm', + }); + const gcmTransportRequest = gcmRequest.request(); + assert(gcmTransportRequest.path.endsWith('/remove')); + + const apnsRequest = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const apnsTransportRequest = apnsRequest.request(); + assert(apnsTransportRequest.path.endsWith('/remove')); + }); + }); + + describe('query parameters', () => { + it('should include required query parameters for GCM', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'gcm'); + assert.equal(transportRequest.queryParameters?.environment, undefined); + assert.equal(transportRequest.queryParameters?.topic, undefined); + assert.equal(transportRequest.queryParameters?.add, undefined); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + + it('should include environment and topic for APNS2', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + environment: 'production', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.type, 'apns2'); + assert.equal(transportRequest.queryParameters?.environment, 'production'); + assert.equal(transportRequest.queryParameters?.topic, 'com.test.app'); + }); + + it('should default APNS2 environment to development', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + + it('should include start and count parameters when provided', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + start: 'start_token', + count: 50, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.start, 'start_token'); + assert.equal(transportRequest.queryParameters?.count, 50); + }); + + it('should limit count to maximum value', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + count: 2000, // Exceeds MAX_COUNT of 1000 + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.count, 1000); + }); + + it('should not include channel-related parameters', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + + // Remove device should not have add/remove parameters + assert.equal(transportRequest.queryParameters?.add, undefined); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + }); + + describe('request method', () => { + it('should use GET method', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + assert.equal(transportRequest.method, TransportMethod.GET); + }); + }); + + describe('response parsing', () => { + it('should parse successful response', async () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const mockResponse = createMockResponse([1, 'Modified Channels']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, {}); + }); + + it('should parse error response', async () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const mockResponse = createMockResponse([0, 'Error Message']); + + const result = await request.parse(mockResponse); + assert.deepEqual(result, {}); + }); + + it('should parse response with different status codes', async () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + + // Success case + const successResponse = createMockResponse([1, 'Success']); + const successResult = await request.parse(successResponse); + assert.deepEqual(successResult, {}); + + // Failure case + const failureResponse = createMockResponse([0, 'Device not found']); + const failureResult = await request.parse(failureResponse); + assert.deepEqual(failureResult, {}); + }); + }); + + describe('device handling', () => { + it('should handle device ID with special characters', () => { + const deviceId = 'device-123_abc.def'; + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: deviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(deviceId)); + assert(transportRequest.path.endsWith('/remove')); + }); + + it('should handle long device IDs', () => { + const longDeviceId = 'a'.repeat(200); + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: longDeviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(longDeviceId)); + }); + + it('should handle device ID with URL-encoded characters', () => { + const deviceId = 'device%20with%20spaces'; + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: deviceId, + }); + const transportRequest = request.request(); + + assert(transportRequest.path.includes(deviceId)); + }); + }); + + describe('environment defaults', () => { + it('should not set environment for GCM', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'gcm', + environment: 'production', // Should be ignored for GCM + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, undefined); + }); + + it('should set development as default for APNS2', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + // No environment specified + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.environment, 'development'); + }); + }); + + describe('error conditions', () => { + it('should handle missing device gracefully', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: undefined, + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should handle null device gracefully', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: null as any, + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + + it('should handle empty string device', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + device: '', + }); + assert.equal(request.validate(), 'Missing Device ID (device)'); + }); + }); + + describe('difference from channel operations', () => { + it('should use different path suffix than channel operations', () => { + const request = new RemoveDevicePushNotificationRequest(defaultParameters); + const transportRequest = request.request(); + + // Should end with /remove, unlike channel operations + assert(transportRequest.path.endsWith('/remove')); + assert(!transportRequest.path.endsWith('/devices/test_device_id')); + }); + + it('should not include channel parameters', () => { + const request = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + // These should be ignored since this is device removal + channels: ['channel1', 'channel2'] as any, + }); + const transportRequest = request.request(); + + assert.equal(transportRequest.queryParameters?.add, undefined); + assert.equal(transportRequest.queryParameters?.remove, undefined); + }); + }); + + describe('consistency across gateways', () => { + it('should use consistent structure for both GCM and APNS2', () => { + const gcmRequest = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'gcm', + }); + const gcmTransport = gcmRequest.request(); + + const apnsRequest = new RemoveDevicePushNotificationRequest({ + ...defaultParameters, + pushGateway: 'apns2', + topic: 'com.test.app', + }); + const apnsTransport = apnsRequest.request(); + + // Both should end with /remove + assert(gcmTransport.path.endsWith('/remove')); + assert(apnsTransport.path.endsWith('/remove')); + + // Both should have type parameter + assert.equal(gcmTransport.queryParameters?.type, 'gcm'); + assert.equal(apnsTransport.queryParameters?.type, 'apns2'); + }); + }); +}); diff --git a/test/unit/test-utils.ts b/test/unit/test-utils.ts new file mode 100644 index 000000000..0442bd041 --- /dev/null +++ b/test/unit/test-utils.ts @@ -0,0 +1,17 @@ +import { TransportResponse } from '../../src/core/types/transport-response'; + +/** + * Helper function to create proper TransportResponse with encoded body + * for unit tests. This ensures the body is properly encoded as ArrayBuffer + * instead of a raw string, which is required by the TextDecoder. + */ +export function createMockResponse(data: any, status: number = 200): TransportResponse { + const jsonString = JSON.stringify(data); + const encoder = new TextEncoder(); + return { + url: 'test-url', + status, + body: encoder.encode(jsonString), + headers: { 'content-type': 'text/javascript' }, + }; +}