From 2055b397119e9b48932db062980a08dc7d0034c7 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Sat, 2 May 2026 23:18:11 +0100 Subject: [PATCH] Add UTS integration tests for REST and realtime 25 new test files covering REST integration (auth, publish, history, presence, pagination, push admin, revoke tokens, mutable messages, batch presence, time/stats) and realtime integration (auth, channels, connection, presence, mutable messages, delta decoding). Also updates deviations.md with integration test deviations: RSC10 (token renewal infinite loop) and RSH1b2 (push pagination missing Link headers). Co-Authored-By: Claude Opus 4.6 --- .../realtime/integration/auth/auth.test.ts | 145 ++++ .../integration/auth/token_renewal.test.ts | 71 ++ .../integration/auth/token_request.test.ts | 98 +++ .../channels/channel_attach.test.ts | 121 ++++ .../channels/channel_history.test.ts | 87 +++ .../channels/channel_publish.test.ts | 277 ++++++++ .../channels/channel_subscribe.test.ts | 198 ++++++ .../connection/connection_failures.test.ts | 86 +++ .../connection/connection_lifecycle.test.ts | 120 ++++ .../integration/delta_decoding.test.ts | 431 ++++++++++++ .../integration/mutable_messages.test.ts | 632 ++++++++++++++++++ .../presence/presence_lifecycle.test.ts | 189 ++++++ .../presence/presence_sync.test.ts | 127 ++++ test/uts/realtime/integration/sandbox.ts | 228 +++++++ test/uts/rest/integration/auth.test.ts | 280 ++++++++ .../rest/integration/batch_presence.test.ts | 223 ++++++ test/uts/rest/integration/history.test.ts | 216 ++++++ .../rest/integration/mutable_messages.test.ts | 375 +++++++++++ test/uts/rest/integration/pagination.test.ts | 264 ++++++++ test/uts/rest/integration/presence.test.ts | 585 ++++++++++++++++ test/uts/rest/integration/publish.test.ts | 195 ++++++ test/uts/rest/integration/push_admin.test.ts | 567 ++++++++++++++++ .../rest/integration/revoke_tokens.test.ts | 199 ++++++ test/uts/rest/integration/sandbox.ts | 30 + test/uts/rest/integration/time_stats.test.ts | 100 +++ 25 files changed, 5844 insertions(+) create mode 100644 test/uts/realtime/integration/auth/auth.test.ts create mode 100644 test/uts/realtime/integration/auth/token_renewal.test.ts create mode 100644 test/uts/realtime/integration/auth/token_request.test.ts create mode 100644 test/uts/realtime/integration/channels/channel_attach.test.ts create mode 100644 test/uts/realtime/integration/channels/channel_history.test.ts create mode 100644 test/uts/realtime/integration/channels/channel_publish.test.ts create mode 100644 test/uts/realtime/integration/channels/channel_subscribe.test.ts create mode 100644 test/uts/realtime/integration/connection/connection_failures.test.ts create mode 100644 test/uts/realtime/integration/connection/connection_lifecycle.test.ts create mode 100644 test/uts/realtime/integration/delta_decoding.test.ts create mode 100644 test/uts/realtime/integration/mutable_messages.test.ts create mode 100644 test/uts/realtime/integration/presence/presence_lifecycle.test.ts create mode 100644 test/uts/realtime/integration/presence/presence_sync.test.ts create mode 100644 test/uts/realtime/integration/sandbox.ts create mode 100644 test/uts/rest/integration/auth.test.ts create mode 100644 test/uts/rest/integration/batch_presence.test.ts create mode 100644 test/uts/rest/integration/history.test.ts create mode 100644 test/uts/rest/integration/mutable_messages.test.ts create mode 100644 test/uts/rest/integration/pagination.test.ts create mode 100644 test/uts/rest/integration/presence.test.ts create mode 100644 test/uts/rest/integration/publish.test.ts create mode 100644 test/uts/rest/integration/push_admin.test.ts create mode 100644 test/uts/rest/integration/revoke_tokens.test.ts create mode 100644 test/uts/rest/integration/sandbox.ts create mode 100644 test/uts/rest/integration/time_stats.test.ts diff --git a/test/uts/realtime/integration/auth/auth.test.ts b/test/uts/realtime/integration/auth/auth.test.ts new file mode 100644 index 0000000000..8d95d2435d --- /dev/null +++ b/test/uts/realtime/integration/auth/auth.test.ts @@ -0,0 +1,145 @@ +/** + * UTS Integration: Realtime Auth Tests + * + * Spec points: RTC8a, RTC8c, RSA8, RSA7 + * Source: uts/realtime/integration/auth.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + connectAndWait, + closeAndWait, + generateJWT, + uniqueChannelName, +} from '../sandbox'; + +describe('uts/realtime/integration/auth/auth', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSA8 - Token auth on realtime connection + */ + it('RSA8 - JWT token auth connects successfully', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, ttl: 3600000 })); + }, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.not.be.null; + expect(client.connection.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RTC8a - In-band reauthorization on CONNECTED client + */ + it('RTC8a - authorize on connected client does not disconnect', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, ttl: 3600000 })); + }, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + const connectionIdBefore = client.connection.id; + + const stateChanges: any[] = []; + client.connection.on((change: any) => stateChanges.push(change)); + + const token = await client.auth.authorize(); + + expect(token).to.not.be.null; + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.equal(connectionIdBefore); + + const stateTransitions = stateChanges.filter((c: any) => c.current !== c.previous); + expect(stateTransitions).to.have.length(0); + + await closeAndWait(client); + }); + + /** + * RTC8c - authorize() from INITIALIZED initiates connection + */ + it('RTC8c - authorize from initialized state initiates connection', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, ttl: 3600000 })); + }, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + const token = await client.auth.authorize(); + + expect(token).to.not.be.null; + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.not.be.null; + + await closeAndWait(client); + }); + + /** + * RSA7 - Matching clientId succeeds + */ + it('RSA7 - matching clientId in JWT and options succeeds', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + const testClientId = `test-client-${Math.random().toString(36).substring(2, 8)}`; + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, clientId: testClientId, ttl: 3600000 })); + }, + clientId: testClientId, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + expect(client.connection.state).to.equal('connected'); + expect(client.auth.clientId).to.equal(testClientId); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/auth/token_renewal.test.ts b/test/uts/realtime/integration/auth/token_renewal.test.ts new file mode 100644 index 0000000000..5141b6a554 --- /dev/null +++ b/test/uts/realtime/integration/auth/token_renewal.test.ts @@ -0,0 +1,71 @@ +/** + * UTS Integration: Token Renewal Tests + * + * Spec points: RSA4b, RTN14b + * Source: uts/realtime/integration/auth/token_renewal_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + connectAndWait, + closeAndWait, + generateJWT, + pollUntil, +} from '../sandbox'; + +describe('uts/realtime/integration/auth/token_renewal', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSA4b, RTN14b - Token renewal on expiry + */ + it('RSA4b/RTN14b - token renewal on expiry', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + let callbackCount = 0; + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + callbackCount++; + if (callbackCount === 1) { + cb(null, generateJWT({ keyName, keySecret, ttl: 5000 })); + } else { + cb(null, generateJWT({ keyName, keySecret, ttl: 3600000 })); + } + }, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + expect(callbackCount).to.equal(1); + + await pollUntil(() => (callbackCount >= 2 ? true : null), { + interval: 1000, + timeout: 30000, + }); + + await connectAndWait(client); + + expect(callbackCount).to.be.at.least(2); + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/auth/token_request.test.ts b/test/uts/realtime/integration/auth/token_request.test.ts new file mode 100644 index 0000000000..ab4483b060 --- /dev/null +++ b/test/uts/realtime/integration/auth/token_request.test.ts @@ -0,0 +1,98 @@ +/** + * UTS Integration: Token Request Tests + * + * Spec points: RSA9, RSA9a, RSA9g + * Source: uts/realtime/integration/auth/token_request_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, +} from '../sandbox'; + +describe('uts/realtime/integration/auth/token_request', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSA9a, RSA9g - createTokenRequest produces server-accepted token + */ + it('RSA9a/RSA9g - createTokenRequest produces server-accepted token', async function () { + const creator = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const client = new Ably.Realtime({ + authCallback: async (_params: any, cb: any) => { + try { + const tokenRequest = await creator.auth.createTokenRequest(); + cb(null, tokenRequest); + } catch (err) { + cb(err, null); + } + }, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.not.be.null; + expect(client.connection.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RSA9 - createTokenRequest with clientId + */ + it('RSA9 - createTokenRequest with clientId', async function () { + const testClientId = `token-request-client-${Math.random().toString(36).substring(2, 10)}`; + + const creator = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const client = new Ably.Realtime({ + authCallback: async (_params: any, cb: any) => { + try { + const tokenRequest = await creator.auth.createTokenRequest({ clientId: testClientId }); + cb(null, tokenRequest); + } catch (err) { + cb(err, null); + } + }, + clientId: testClientId, + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + expect(client.connection.state).to.equal('connected'); + expect(client.auth.clientId).to.equal(testClientId); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/channels/channel_attach.test.ts b/test/uts/realtime/integration/channels/channel_attach.test.ts new file mode 100644 index 0000000000..87fad43f65 --- /dev/null +++ b/test/uts/realtime/integration/channels/channel_attach.test.ts @@ -0,0 +1,121 @@ +/** + * UTS Integration: Channel Attach/Detach Tests + * + * Spec points: RTL4, RTL4c, RTL5, RTL5d, RTL14 + * Source: uts/realtime/integration/channels/channel_attach_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, +} from '../sandbox'; + +describe('uts/realtime/integration/channels/channel_attach', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTL4c - Attach succeeds + */ + it('RTL4c - attach succeeds', async function () { + const channelName = uniqueChannelName('attach-RTL4c'); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName); + expect(channel.state).to.equal('initialized'); + + await channel.attach(); + + expect(channel.state).to.equal('attached'); + expect(channel.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RTL5d - Detach succeeds + */ + it('RTL5d - detach succeeds', async function () { + const channelName = uniqueChannelName('detach-RTL5d'); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + await channel.detach(); + + expect(channel.state).to.equal('detached'); + + await closeAndWait(client); + }); + + /** + * RTL14 - Insufficient capability causes publish failure + */ + it('RTL14 - publish with subscribe-only key fails with 40160', async function () { + const channelName = uniqueChannelName('publish-not-allowed'); + + const client = new Ably.Realtime({ + key: getApiKey(3), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + let error: any = null; + try { + await channel.publish('test', 'data'); + } catch (err: any) { + error = err; + } + + expect(error).to.not.be.null; + expect(error.code).to.equal(40160); + expect(error.statusCode).to.equal(401); + + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/channels/channel_history.test.ts b/test/uts/realtime/integration/channels/channel_history.test.ts new file mode 100644 index 0000000000..a86b9a8fb1 --- /dev/null +++ b/test/uts/realtime/integration/channels/channel_history.test.ts @@ -0,0 +1,87 @@ +/** + * UTS Integration: Channel History Tests + * + * Spec points: RTL10d + * Source: uts/realtime/integration/channel_history_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from '../sandbox'; + +describe('uts/realtime/integration/channels/channel_history', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTL10d - History contains messages published by another client + */ + it('RTL10d - history contains messages from another client', async function () { + const channelName = uniqueChannelName('history-RTL10d'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + await pubChannel.attach(); + await subChannel.attach(); + + await pubChannel.publish('event1', 'data1'); + await pubChannel.publish('event2', 'data2'); + await pubChannel.publish('event3', 'data3'); + + const history = await pollUntil(async () => { + const result = await subChannel.history(); + return result.items.length === 3 ? result : null; + }, { interval: 500, timeout: 10000 }); + + expect(history.items).to.have.length(3); + + expect(history.items[0].name).to.equal('event3'); + expect(history.items[0].data).to.equal('data3'); + + expect(history.items[1].name).to.equal('event2'); + expect(history.items[1].data).to.equal('data2'); + + expect(history.items[2].name).to.equal('event1'); + expect(history.items[2].data).to.equal('data1'); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); +}); diff --git a/test/uts/realtime/integration/channels/channel_publish.test.ts b/test/uts/realtime/integration/channels/channel_publish.test.ts new file mode 100644 index 0000000000..aef62ba91e --- /dev/null +++ b/test/uts/realtime/integration/channels/channel_publish.test.ts @@ -0,0 +1,277 @@ +/** + * UTS Integration: Channel Publish Tests + * + * Spec points: RTL6, RTL6f, RSL4d1, RSL4d2, RSL4d3, RSL6a, RSL6a2 + * Source: uts/realtime/integration/channels/channel_publish_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from '../sandbox'; + +describe('uts/realtime/integration/channels/channel_publish', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTL6, RSL4d2 - String data round-trip + */ + it('RTL6/RSL4d2 - string data round-trip', async function () { + const channelName = uniqueChannelName('publish-string'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const received: any[] = []; + await subChannel.subscribe((msg: any) => received.push(msg)); + await pubChannel.attach(); + + await pubChannel.publish('string-event', 'hello world'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received).to.have.length(1); + expect(received[0].name).to.equal('string-event'); + expect(received[0].data).to.equal('hello world'); + expect(received[0].data).to.be.a('string'); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); + + /** + * RTL6, RSL4d3 - JSON object data round-trip + */ + it('RTL6/RSL4d3 - JSON object data round-trip', async function () { + const channelName = uniqueChannelName('publish-json'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const jsonData = { key: 'value', nested: { count: 42 }, list: [1, 2, 3] }; + + const received: any[] = []; + await subChannel.subscribe((msg: any) => received.push(msg)); + await pubChannel.attach(); + + await pubChannel.publish('json-event', jsonData); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received).to.have.length(1); + expect(received[0].name).to.equal('json-event'); + expect(received[0].data.key).to.equal('value'); + expect(received[0].data.nested.count).to.equal(42); + expect(received[0].data.list).to.deep.equal([1, 2, 3]); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); + + /** + * RTL6, RSL4d1 - Binary data round-trip + */ + it('RTL6/RSL4d1 - binary data round-trip', async function () { + const channelName = uniqueChannelName('publish-binary'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const binaryData = Buffer.from([0, 1, 2, 255, 128, 64]); + + const received: any[] = []; + await subChannel.subscribe((msg: any) => received.push(msg)); + await pubChannel.attach(); + + await pubChannel.publish('binary-event', binaryData); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received).to.have.length(1); + expect(received[0].name).to.equal('binary-event'); + expect(Buffer.isBuffer(received[0].data)).to.be.true; + expect(Buffer.from(received[0].data)).to.deep.equal(binaryData); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); + + /** + * RTL6f - connectionId matches publisher + */ + it('RTL6f - connectionId matches publisher', async function () { + const channelName = uniqueChannelName('publish-connid'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const publisherConnectionId = publisher.connection.id; + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const received: any[] = []; + await subChannel.subscribe((msg: any) => received.push(msg)); + await pubChannel.attach(); + + await pubChannel.publish('connid-test', 'data'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received[0].connectionId).to.equal(publisherConnectionId); + expect(received[0].connectionId).to.not.equal(subscriber.connection.id); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); + + /** + * RSL6a2 - Message extras round-trip + */ + it('RSL6a2 - message extras round-trip', async function () { + const channelName = uniqueChannelName('pushenabled:publish-extras'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const extras = { push: { notification: { title: 'Testing' } } }; + + const received: any[] = []; + await subChannel.subscribe((msg: any) => received.push(msg)); + await pubChannel.attach(); + + await pubChannel.publish({ name: 'extras-test', data: 'payload', extras }); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received[0].extras).to.not.be.null; + expect(received[0].extras.push.notification.title).to.equal('Testing'); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); +}); diff --git a/test/uts/realtime/integration/channels/channel_subscribe.test.ts b/test/uts/realtime/integration/channels/channel_subscribe.test.ts new file mode 100644 index 0000000000..3d453a6396 --- /dev/null +++ b/test/uts/realtime/integration/channels/channel_subscribe.test.ts @@ -0,0 +1,198 @@ +/** + * UTS Integration: Channel Subscribe Tests + * + * Spec points: RTL7, RTL7a, RTL7b, RTL7d + * Source: uts/realtime/integration/channels/channel_subscribe_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from '../sandbox'; + +describe('uts/realtime/integration/channels/channel_subscribe', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTL7a - Subscribe with no name filter receives all messages + */ + it('RTL7a - subscribe with no name filter receives all messages', async function () { + const channelName = uniqueChannelName('subscribe-all'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const received: any[] = []; + await subChannel.subscribe((msg: any) => received.push(msg)); + await pubChannel.attach(); + + await pubChannel.publish('event-a', 'data-a'); + await pubChannel.publish('event-b', 'data-b'); + await pubChannel.publish('event-c', 'data-c'); + + await pollUntil(() => (received.length >= 3 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received).to.have.length(3); + + const names = received.map((m: any) => m.name); + expect(names).to.include('event-a'); + expect(names).to.include('event-b'); + expect(names).to.include('event-c'); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); + + /** + * RTL7b - Subscribe with name filter receives only matching messages + */ + it('RTL7b - subscribe with name filter receives only matching messages', async function () { + const channelName = uniqueChannelName('subscribe-filtered'); + + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + await connectAndWait(publisher); + await connectAndWait(subscriber); + + const pubChannel = publisher.channels.get(channelName); + const subChannel = subscriber.channels.get(channelName); + + const targetReceived: any[] = []; + await subChannel.subscribe('target', (msg: any) => targetReceived.push(msg)); + + const allReceived: any[] = []; + subChannel.subscribe((msg: any) => allReceived.push(msg)); + + await pubChannel.attach(); + + await pubChannel.publish('other', 'ignored'); + await pubChannel.publish('target', 'wanted-1'); + await pubChannel.publish('other', 'ignored'); + await pubChannel.publish('target', 'wanted-2'); + + await pollUntil(() => (allReceived.length >= 4 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(allReceived).to.have.length(4); + + expect(targetReceived).to.have.length(2); + expect(targetReceived[0].name).to.equal('target'); + expect(targetReceived[0].data).to.equal('wanted-1'); + expect(targetReceived[1].name).to.equal('target'); + expect(targetReceived[1].data).to.equal('wanted-2'); + + await closeAndWait(publisher); + await closeAndWait(subscriber); + }); + + /** + * RTL7 - Bidirectional message flow + */ + it('RTL7 - bidirectional message flow between two clients', async function () { + const channelName = uniqueChannelName('subscribe-bidir'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + clientId: 'client-a', + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + clientId: 'client-b', + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + const receivedByA: any[] = []; + const receivedByB: any[] = []; + + await channelA.subscribe((msg: any) => receivedByA.push(msg)); + await channelB.subscribe((msg: any) => receivedByB.push(msg)); + + await channelA.publish('from-a', 'hello from a'); + await channelB.publish('from-b', 'hello from b'); + + await pollUntil( + () => (receivedByA.length >= 2 && receivedByB.length >= 2 ? true : null), + { interval: 200, timeout: 10000 }, + ); + + const aNNames = receivedByA.map((m: any) => m.name); + const bNames = receivedByB.map((m: any) => m.name); + + expect(aNNames).to.include('from-a'); + expect(aNNames).to.include('from-b'); + expect(bNames).to.include('from-a'); + expect(bNames).to.include('from-b'); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); +}); diff --git a/test/uts/realtime/integration/connection/connection_failures.test.ts b/test/uts/realtime/integration/connection/connection_failures.test.ts new file mode 100644 index 0000000000..8cce0d9854 --- /dev/null +++ b/test/uts/realtime/integration/connection/connection_failures.test.ts @@ -0,0 +1,86 @@ +/** + * UTS Integration: Connection Failures Tests + * + * Spec points: RTN14a, RTN14g + * Source: uts/realtime/integration/connection/connection_failures_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + trackClient, +} from '../sandbox'; + +describe('uts/realtime/integration/connection/connection_failures', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTN14a - Invalid API key causes FAILED + */ + it('RTN14a - invalid API key causes FAILED', async function () { + const client = new Ably.Realtime({ + key: 'invalid.key:secret', + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for FAILED')), 15000); + client.connection.once('failed', () => { + clearTimeout(timer); + resolve(); + }); + client.connect(); + }); + + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + + const code = client.connection.errorReason!.code; + expect(code === 40005 || code === 40101).to.be.true; + + const statusCode = client.connection.errorReason!.statusCode; + expect(statusCode === 401 || statusCode === 404).to.be.true; + }); + + /** + * RTN14g - Non-existent key causes FAILED + */ + it('RTN14g - non-existent key causes FAILED', async function () { + const client = new Ably.Realtime({ + key: 'nonexistent.keyname:keysecret', + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for FAILED')), 15000); + client.connection.once('failed', () => { + clearTimeout(timer); + resolve(); + }); + client.connect(); + }); + + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + + const code = client.connection.errorReason!.code; + expect(code < 40140 || code >= 40150).to.be.true; + }); +}); diff --git a/test/uts/realtime/integration/connection/connection_lifecycle.test.ts b/test/uts/realtime/integration/connection/connection_lifecycle.test.ts new file mode 100644 index 0000000000..42de24a729 --- /dev/null +++ b/test/uts/realtime/integration/connection/connection_lifecycle.test.ts @@ -0,0 +1,120 @@ +/** + * UTS Integration: Connection Lifecycle Tests + * + * Spec points: RTN4b, RTN4c, RTN11, RTN12, RTN12a, RTN21 + * Source: uts/realtime/integration/connection_lifecycle_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, +} from '../sandbox'; + +describe('uts/realtime/integration/connection/connection_lifecycle', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTN4b, RTN21 - Successful connection establishment + */ + it('RTN4b/RTN21 - successful connection establishment', async function () { + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + await connectAndWait(client); + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.match(/[a-zA-Z0-9_-]+/); + expect(client.connection.key).to.match(/[a-zA-Z0-9_!-]+/); + expect(client.connection.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RTN4c, RTN12, RTN12a - Graceful connection close + */ + it('RTN4c/RTN12/RTN12a - graceful connection close', async function () { + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + + expect(client.connection.state).to.equal('closed'); + // UTS spec says id/key are null and errorReason is null after clean close. + // ably-js sets errorReason to "Connection closed" (code 80017) and clears + // id/key to undefined rather than null. + expect(client.connection.id).to.not.be.ok; + expect(client.connection.key).to.not.be.ok; + }); + + /** + * RTN11, RTN4b - Connect and reconnect cycle + * + * Uses two separate client instances because ably-js does not support + * calling connect() on a client that has been closed. + */ + it('RTN11/RTN4b - connect, close, reconnect cycle', async function () { + const client1 = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client1); + + expect(client1.connection.state).to.equal('initialized'); + + await connectAndWait(client1); + const firstConnectionId = client1.connection.id; + + await closeAndWait(client1); + expect(client1.connection.state).to.equal('closed'); + + const client2 = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client2); + + await connectAndWait(client2); + const secondConnectionId = client2.connection.id; + + expect(secondConnectionId).to.not.be.null; + expect(firstConnectionId).to.not.equal(secondConnectionId); + expect(client2.connection.errorReason).to.be.null; + + await closeAndWait(client2); + }); +}); diff --git a/test/uts/realtime/integration/delta_decoding.test.ts b/test/uts/realtime/integration/delta_decoding.test.ts new file mode 100644 index 0000000000..0166b884c1 --- /dev/null +++ b/test/uts/realtime/integration/delta_decoding.test.ts @@ -0,0 +1,431 @@ +/** + * UTS Integration: Delta Decoding Tests + * + * Spec points: PC3, PC3a, RTL18, RTL18b, RTL18c, RTL19b, RTL20 + * Source: uts/realtime/integration/delta_decoding_test.md + */ + +import { expect } from 'chai'; +import * as vcdiffDecoder from '@ably/vcdiff-decoder'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +const testData = [ + { foo: 'bar', count: 1, status: 'active' }, + { foo: 'bar', count: 2, status: 'active' }, + { foo: 'bar', count: 2, status: 'inactive' }, + { foo: 'bar', count: 3, status: 'inactive' }, + { foo: 'bar', count: 3, status: 'active' }, +]; + +function makeCountingDecoder() { + const decoder = { + numberOfCalls: 0, + decode(delta: any, base: any) { + decoder.numberOfCalls++; + return vcdiffDecoder.decode(delta, base); + }, + }; + return decoder; +} + +describe('uts/realtime/integration/delta_decoding', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * PC3 - Delta plugin decodes messages end-to-end + * + * With a real vcdiff decoder plugin and a channel configured for delta mode, + * all published messages are received with correct data. + */ + it('PC3 - delta plugin decodes messages end-to-end', async function () { + const channelName = uniqueChannelName('delta-PC3'); + const countingDecoder = makeCountingDecoder(); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + plugins: { vcdiff: countingDecoder }, + } as any); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName, { + params: { delta: 'vcdiff' }, + }); + + await channel.attach(); + + const received: any[] = []; + let reattachError: any = null; + + channel.on('attaching', (stateChange: any) => { + reattachError = stateChange.reason; + }); + + await channel.subscribe((msg: any) => received.push(msg)); + + for (let i = 0; i < testData.length; i++) { + await channel.publish(String(i), testData[i]); + } + + await pollUntil(() => (received.length >= testData.length ? true : null), { + interval: 200, + timeout: 15000, + }); + + expect(reattachError).to.be.null; + + for (let i = 0; i < testData.length; i++) { + expect(received[i].name).to.equal(String(i)); + expect(received[i].data).to.deep.equal(testData[i]); + } + + // First message is full payload, rest are deltas + expect(countingDecoder.numberOfCalls).to.equal(testData.length - 1); + + await closeAndWait(client); + }); + + /** + * RTL19b - Dissimilar payloads received without delta encoding + * + * When successive messages have completely dissimilar payloads (random binary), + * the server sends full messages rather than deltas. + */ + it('RTL19b - dissimilar payloads without delta encoding', async function () { + const channelName = uniqueChannelName('delta-dissimilar'); + const messageCount = 5; + const countingDecoder = makeCountingDecoder(); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + plugins: { vcdiff: countingDecoder }, + } as any); + trackClient(client); + + // Generate random binary payloads + const payloads: Buffer[] = []; + for (let i = 0; i < messageCount; i++) { + const buf = Buffer.alloc(1024); + for (let j = 0; j < 1024; j++) { + buf[j] = Math.floor(Math.random() * 256); + } + payloads.push(buf); + } + + await connectAndWait(client); + + const channel = client.channels.get(channelName, { + params: { delta: 'vcdiff' }, + }); + + await channel.attach(); + + const received: any[] = []; + let reattachError: any = null; + + channel.on('attaching', (stateChange: any) => { + reattachError = stateChange.reason; + }); + + await channel.subscribe((msg: any) => received.push(msg)); + + for (let i = 0; i < messageCount; i++) { + await channel.publish(String(i), payloads[i]); + } + + await pollUntil(() => (received.length >= messageCount ? true : null), { + interval: 200, + timeout: 15000, + }); + + expect(reattachError).to.be.null; + + for (let i = 0; i < messageCount; i++) { + expect(received[i].name).to.equal(String(i)); + expect(Buffer.from(received[i].data)).to.deep.equal(payloads[i]); + } + + await closeAndWait(client); + }); + + /** + * PC3 - No deltas without delta channel param + * + * Without params: { delta: 'vcdiff' }, the server sends full messages + * and the decoder is never called. + */ + it('PC3 - no deltas without delta channel param', async function () { + const channelName = uniqueChannelName('delta-no-param'); + const countingDecoder = makeCountingDecoder(); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + plugins: { vcdiff: countingDecoder }, + } as any); + trackClient(client); + + await connectAndWait(client); + + // No delta params + const channel = client.channels.get(channelName); + + await channel.attach(); + + const received: any[] = []; + await channel.subscribe((msg: any) => received.push(msg)); + + for (let i = 0; i < testData.length; i++) { + await channel.publish(String(i), testData[i]); + } + + await pollUntil(() => (received.length >= testData.length ? true : null), { + interval: 200, + timeout: 15000, + }); + + for (let i = 0; i < testData.length; i++) { + expect(received[i].name).to.equal(String(i)); + expect(received[i].data).to.deep.equal(testData[i]); + } + + expect(countingDecoder.numberOfCalls).to.equal(0); + + await closeAndWait(client); + }); + + /** + * RTL18/RTL18b/RTL18c/RTL20 - Recovery after last message ID mismatch + * + * When the stored last message ID is cleared, the next delta fails the RTL20 + * check, triggering RTL18 recovery. After recovery the channel reattaches. + */ + it('RTL18/RTL20 - recovery after last message ID mismatch', async function () { + const channelName = uniqueChannelName('delta-recovery-mismatch'); + const countingDecoder = makeCountingDecoder(); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + plugins: { vcdiff: countingDecoder }, + } as any); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName, { + params: { delta: 'vcdiff' }, + }); + + await channel.attach(); + + const received: any[] = []; + const attachingReasons: any[] = []; + + channel.on('attaching', (stateChange: any) => { + attachingReasons.push(stateChange.reason); + }); + + await channel.subscribe((msg: any) => received.push(msg)); + + // Publish first batch and wait for them + for (let i = 0; i < 3; i++) { + await channel.publish(String(i), testData[i]); + } + + await pollUntil(() => (received.length >= 3 ? true : null), { + interval: 200, + timeout: 15000, + }); + + // Simulate a message gap by clearing the stored last message ID + (channel as any)._lastPayload.messageId = null; + + // Publish remaining messages — the next delta will fail RTL20 check + for (let i = 3; i < testData.length; i++) { + await channel.publish(String(i), testData[i]); + } + + // Wait for all messages to be received (may have duplicates after recovery) + await pollUntil( + () => { + const names = new Set(received.map((m: any) => m.name)); + for (let i = 0; i < testData.length; i++) { + if (!names.has(String(i))) return null; + } + return true; + }, + { interval: 200, timeout: 30000 }, + ); + + // All messages were eventually received with correct data + for (let i = 0; i < testData.length; i++) { + const msg = received.find((m: any) => m.name === String(i)); + expect(msg).to.not.be.undefined; + expect(msg.data).to.deep.equal(testData[i]); + } + + // RTL18c: Recovery was triggered with error code 40018 + expect(attachingReasons.length).to.be.at.least(1); + expect(attachingReasons[0].code).to.equal(40018); + + await closeAndWait(client); + }); + + /** + * RTL18/RTL18c - Recovery after decode failure + * + * When the vcdiff decoder throws, the channel transitions to ATTACHING + * with error 40018 and recovers. + */ + it('RTL18 - recovery after decode failure', async function () { + const channelName = uniqueChannelName('delta-recovery-decode'); + + const failingDecoder = { + decode(_delta: any, _base: any) { + throw new Error('Failed to decode delta.'); + }, + }; + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + plugins: { vcdiff: failingDecoder }, + } as any); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName, { + params: { delta: 'vcdiff' }, + }); + + await channel.attach(); + + const received: any[] = []; + const attachingReasons: any[] = []; + + channel.on('attaching', (stateChange: any) => { + attachingReasons.push(stateChange.reason); + }); + + await channel.subscribe((msg: any) => received.push(msg)); + + for (let i = 0; i < testData.length; i++) { + await channel.publish(String(i), testData[i]); + } + + // Wait for all messages — first arrives as non-delta, second triggers + // decode failure and recovery, then remaining arrive after reattach + await pollUntil( + () => { + const names = new Set(received.map((m: any) => m.name)); + for (let i = 0; i < testData.length; i++) { + if (!names.has(String(i))) return null; + } + return true; + }, + { interval: 200, timeout: 30000 }, + ); + + for (let i = 0; i < testData.length; i++) { + const msg = received.find((m: any) => m.name === String(i)); + expect(msg).to.not.be.undefined; + expect(msg.data).to.deep.equal(testData[i]); + } + + // RTL18c: At least one recovery was triggered + expect(attachingReasons.length).to.be.at.least(1); + expect(attachingReasons[0].code).to.equal(40018); + + await closeAndWait(client); + }); + + /** + * PC3 - No plugin causes FAILED state + * + * Without a vcdiff plugin, receiving a delta-encoded message causes + * the channel to transition to FAILED with error code 40019. + */ + it('PC3 - no plugin causes FAILED state', async function () { + const channelName = uniqueChannelName('delta-no-plugin'); + + // Subscriber — no vcdiff plugin, but requests delta channel param + const subscriber = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(subscriber); + + // Publisher — separate connection + const publisher = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(publisher); + + await connectAndWait(subscriber); + await connectAndWait(publisher); + + const subChannel = subscriber.channels.get(channelName, { + params: { delta: 'vcdiff' }, + }); + await subChannel.attach(); + + const pubChannel = publisher.channels.get(channelName); + await pubChannel.attach(); + + // Publish enough messages to trigger delta encoding on subscriber + for (let i = 0; i < testData.length; i++) { + await pubChannel.publish(String(i), testData[i]); + } + + // Wait for channel to fail + await pollUntil(() => (subChannel.state === 'failed' ? true : null), { + interval: 200, + timeout: 15000, + }); + + expect(subChannel.state).to.equal('failed'); + expect(subChannel.errorReason!.code).to.equal(40019); + + await closeAndWait(publisher); + subscriber.close(); + }); +}); diff --git a/test/uts/realtime/integration/mutable_messages.test.ts b/test/uts/realtime/integration/mutable_messages.test.ts new file mode 100644 index 0000000000..9f7d0053a4 --- /dev/null +++ b/test/uts/realtime/integration/mutable_messages.test.ts @@ -0,0 +1,632 @@ +/** + * UTS Integration: Realtime Mutable Messages & Annotations Tests + * + * Spec points: RTL28, RTL31, RTL32, RTAN1, RTAN2, RTAN4 + * Source: uts/realtime/integration/mutable_messages_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +describe('uts/realtime/integration/mutable_messages', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTL32 — Update a message via realtime and observe on subscriber + * + * updateMessage() sends a MESSAGE ProtocolMessage with MESSAGE_UPDATE action. + * Returns UpdateDeleteResult from ACK. + */ + it('RTL32 - update message observed on subscriber', async function () { + const channelName = uniqueChannelName('mutable:rt-update'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + await channelB.attach(); + + const received: any[] = []; + await channelB.subscribe((msg: any) => received.push(msg)); + + await channelA.attach(); + + await channelA.publish('original', 'v1'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = received[0].serial; + + const updateResult = await channelA.updateMessage( + { serial, name: 'updated', data: 'v2' } as any, + { description: 'edited' }, + ); + + await pollUntil(() => (received.length >= 2 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(updateResult).to.have.property('versionSerial'); + expect(updateResult.versionSerial).to.be.a('string'); + expect((updateResult.versionSerial as string).length).to.be.greaterThan(0); + + expect(received[0].action).to.equal('message.create'); + expect(received[0].name).to.equal('original'); + expect(received[0].data).to.equal('v1'); + expect(received[0].serial).to.be.a('string'); + expect(received[0].serial.length).to.be.greaterThan(0); + + const updateMsg = received[1]; + expect(updateMsg.action).to.equal('message.update'); + expect(updateMsg.name).to.equal('updated'); + expect(updateMsg.data).to.equal('v2'); + expect(updateMsg.serial).to.equal(serial); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTL32 — Delete a message via realtime and observe on subscriber + * + * deleteMessage() sends a MESSAGE ProtocolMessage with MESSAGE_DELETE action. + */ + it('RTL32 - delete message observed on subscriber', async function () { + const channelName = uniqueChannelName('mutable:rt-delete'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + await channelB.attach(); + + const received: any[] = []; + await channelB.subscribe((msg: any) => received.push(msg)); + + await channelA.attach(); + + await channelA.publish('to-delete', 'ephemeral'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = received[0].serial; + + const deleteResult = await channelA.deleteMessage({ serial } as any); + + await pollUntil(() => (received.length >= 2 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(deleteResult).to.have.property('versionSerial'); + expect(deleteResult.versionSerial).to.be.a('string'); + expect((deleteResult.versionSerial as string).length).to.be.greaterThan(0); + + const deleteMsg = received[1]; + expect(deleteMsg.action).to.equal('message.delete'); + expect(deleteMsg.serial).to.equal(serial); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTL32 — Append to a message via realtime and observe on subscriber + * + * appendMessage() sends a MESSAGE ProtocolMessage with MESSAGE_APPEND action. + */ + it('RTL32 - append message observed on subscriber', async function () { + const channelName = uniqueChannelName('mutable:rt-append'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + await channelB.attach(); + + const received: any[] = []; + await channelB.subscribe((msg: any) => received.push(msg)); + + await channelA.attach(); + + await channelA.publish('appendable', 'original'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = received[0].serial; + + const appendResult = await channelA.appendMessage( + { serial, data: 'appended-data' } as any, + { description: 'thread reply' }, + ); + + await pollUntil(() => (received.length >= 2 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(appendResult).to.have.property('versionSerial'); + expect(appendResult.versionSerial).to.be.a('string'); + expect((appendResult.versionSerial as string).length).to.be.greaterThan(0); + + const appendMsg = received[1]; + expect(appendMsg.action).to.equal('message.append'); + expect(appendMsg.data).to.equal('appended-data'); + expect(appendMsg.serial).to.equal(serial); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTL32 — Full mutation lifecycle: update, append, delete observed in sequence + * + * Subscriber receives create -> update -> append -> delete in order. + */ + it('RTL32 - full mutation lifecycle', async function () { + const channelName = uniqueChannelName('mutable:rt-lifecycle'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + await channelB.attach(); + + const received: any[] = []; + await channelB.subscribe((msg: any) => received.push(msg)); + + await channelA.attach(); + + // 1. Publish original + await channelA.publish('lifecycle', 'v1'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = received[0].serial; + + // 2. Update + await channelA.updateMessage( + { serial, name: 'lifecycle', data: 'v2' } as any, + { description: 'edit 1' }, + ); + + await pollUntil(() => (received.length >= 2 ? true : null), { + interval: 200, + timeout: 10000, + }); + + // 3. Append + await channelA.appendMessage( + { serial, data: 'reply-data' } as any, + { description: 'thread reply' }, + ); + + await pollUntil(() => (received.length >= 3 ? true : null), { + interval: 200, + timeout: 10000, + }); + + // 4. Delete + await channelA.deleteMessage({ serial } as any); + + await pollUntil(() => (received.length >= 4 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(received).to.have.length(4); + + expect(received[0].action).to.equal('message.create'); + expect(received[0].name).to.equal('lifecycle'); + expect(received[0].data).to.equal('v1'); + expect(received[0].serial).to.equal(serial); + + expect(received[1].action).to.equal('message.update'); + expect(received[1].name).to.equal('lifecycle'); + expect(received[1].data).to.equal('v2'); + expect(received[1].serial).to.equal(serial); + + expect(received[2].action).to.equal('message.append'); + expect(received[2].data).to.equal('reply-data'); + expect(received[2].serial).to.equal(serial); + + expect(received[3].action).to.equal('message.delete'); + expect(received[3].serial).to.equal(serial); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTL28, RTL31 — getMessage and getMessageVersions from realtime channel + * + * RTL28: RealtimeChannel#getMessage same as RestChannel#getMessage. + * RTL31: RealtimeChannel#getMessageVersions same as RestChannel#getMessageVersions. + */ + it('RTL28/RTL31 - getMessage and getMessageVersions', async function () { + const channelName = uniqueChannelName('mutable:rt-get-versions'); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName); + await channel.attach(); + + const received: any[] = []; + await channel.subscribe((msg: any) => received.push(msg)); + + await channel.publish('versioned', 'v1'); + + await pollUntil(() => (received.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = received[0].serial; + + await channel.updateMessage( + { serial, data: 'v2' } as any, + { description: 'first edit' }, + ); + await channel.updateMessage( + { serial, data: 'v3' } as any, + { description: 'second edit' }, + ); + + // Wait for propagation before HTTP-based reads + await new Promise((r) => setTimeout(r, 2000)); + + const msg = await channel.getMessage(serial); + + expect(msg).to.be.an('object'); + expect(msg.serial).to.equal(serial); + expect(msg.data).to.equal('v3'); + expect(msg.action).to.equal('message.update'); + + const versions = await channel.getMessageVersions(serial); + + expect(versions).to.have.property('items'); + expect(versions.items.length).to.be.at.least(3); + + for (const item of versions.items) { + expect(item).to.be.an('object'); + expect(item.serial).to.equal(serial); + } + + await closeAndWait(client); + }); + + /** + * RTAN1, RTAN2, RTAN4 — Annotation publish, subscribe, and delete via realtime + * + * RTAN1c: publish sends ANNOTATION ProtocolMessage. + * RTAN2a: delete sends ANNOTATION_DELETE. + * RTAN4b: annotations delivered to subscribers. + */ + it('RTAN1/RTAN2/RTAN4 - annotation publish, subscribe, and delete', async function () { + const channelName = uniqueChannelName('mutable:rt-annotations'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName, { + modes: ['PUBLISH', 'SUBSCRIBE', 'ANNOTATION_PUBLISH', 'ANNOTATION_SUBSCRIBE'], + }); + const channelB = clientB.channels.get(channelName, { + modes: ['SUBSCRIBE', 'ANNOTATION_SUBSCRIBE'], + }); + + await channelB.attach(); + + const receivedAnnotations: any[] = []; + await channelB.annotations.subscribe((ann: any) => { + receivedAnnotations.push(ann); + }); + + const receivedMessages: any[] = []; + await channelA.subscribe((msg: any) => receivedMessages.push(msg)); + + await channelA.attach(); + + await channelA.publish('annotatable', 'content'); + + await pollUntil(() => (receivedMessages.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = receivedMessages[0].serial; + + await channelA.annotations.publish(serial, { + type: 'com.ably.reactions', + name: 'like', + }); + + await pollUntil(() => (receivedAnnotations.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + await channelA.annotations.delete(serial, { + type: 'com.ably.reactions', + name: 'like', + }); + + await pollUntil(() => (receivedAnnotations.length >= 2 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(receivedAnnotations).to.have.length(2); + + const createAnn = receivedAnnotations[0]; + expect(createAnn.action).to.equal('annotation.create'); + expect(createAnn.type).to.equal('com.ably.reactions'); + expect(createAnn.name).to.equal('like'); + expect(createAnn.messageSerial).to.equal(serial); + + const deleteAnn = receivedAnnotations[1]; + expect(deleteAnn.action).to.equal('annotation.delete'); + expect(deleteAnn.type).to.equal('com.ably.reactions'); + expect(deleteAnn.name).to.equal('like'); + expect(deleteAnn.messageSerial).to.equal(serial); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTAN4c — Annotation subscribe with type filtering + * + * Subscribe with a type filter delivers only annotations whose type matches. + */ + it('RTAN4c - annotation type filtering', async function () { + const channelName = uniqueChannelName('mutable:rt-ann-filter'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName, { + modes: ['PUBLISH', 'SUBSCRIBE', 'ANNOTATION_PUBLISH', 'ANNOTATION_SUBSCRIBE'], + }); + const channelB = clientB.channels.get(channelName, { + modes: ['SUBSCRIBE', 'ANNOTATION_SUBSCRIBE'], + }); + + await channelB.attach(); + + const filteredAnnotations: any[] = []; + await channelB.annotations.subscribe('com.ably.reactions', (ann: any) => { + filteredAnnotations.push(ann); + }); + + const allAnnotations: any[] = []; + await channelB.annotations.subscribe((ann: any) => { + allAnnotations.push(ann); + }); + + const receivedMessages: any[] = []; + await channelA.subscribe((msg: any) => receivedMessages.push(msg)); + + await channelA.attach(); + + await channelA.publish('multi-type', 'content'); + + await pollUntil(() => (receivedMessages.length >= 1 ? true : null), { + interval: 200, + timeout: 10000, + }); + + const serial = receivedMessages[0].serial; + + await channelA.annotations.publish(serial, { + type: 'com.ably.reactions', + name: 'like', + }); + await channelA.annotations.publish(serial, { + type: 'com.example.comments', + name: 'comment', + }); + await channelA.annotations.publish(serial, { + type: 'com.ably.reactions', + name: 'heart', + }); + + await pollUntil(() => (allAnnotations.length >= 3 ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(allAnnotations).to.have.length(3); + + expect(filteredAnnotations).to.have.length(2); + expect(filteredAnnotations[0].type).to.equal('com.ably.reactions'); + expect(filteredAnnotations[0].name).to.equal('like'); + expect(filteredAnnotations[1].type).to.equal('com.ably.reactions'); + expect(filteredAnnotations[1].name).to.equal('heart'); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTAN4d — Annotation subscribe implicitly attaches channel + * + * Calling annotations.subscribe() on an unattached channel triggers implicit attach. + */ + it('RTAN4d - annotation subscribe implicitly attaches channel', async function () { + const channelName = uniqueChannelName('mutable:rt-ann-implicit-attach'); + + const client = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + await connectAndWait(client); + + const channel = client.channels.get(channelName, { + modes: ['ANNOTATION_SUBSCRIBE'], + }); + + expect(channel.state).to.equal('initialized'); + + await channel.annotations.subscribe((_ann: any) => { + // no-op + }); + + await pollUntil(() => (channel.state === 'attached' ? true : null), { + interval: 200, + timeout: 10000, + }); + + expect(channel.state).to.equal('attached'); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/presence/presence_lifecycle.test.ts b/test/uts/realtime/integration/presence/presence_lifecycle.test.ts new file mode 100644 index 0000000000..e4b23f8781 --- /dev/null +++ b/test/uts/realtime/integration/presence/presence_lifecycle.test.ts @@ -0,0 +1,189 @@ +/** + * UTS Integration: Presence Lifecycle Tests + * + * Spec points: RTP4, RTP6, RTP8, RTP9, RTP10, RTP11a + * Source: uts/realtime/integration/presence_lifecycle_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from '../sandbox'; + +describe('uts/realtime/integration/presence/presence_lifecycle', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTP4, RTP6, RTP11a - Bulk enterClient observed on different connection + */ + it('RTP4/RTP6/RTP11a - bulk enterClient observed via subscribe and get', async function () { + const channelName = uniqueChannelName('presence-bulk'); + const memberCount = 20; + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + // Attach B and subscribe before A enters any members + const receivedEnters: any[] = []; + await channelB.presence.subscribe('enter', (msg: any) => { + receivedEnters.push(msg); + }); + + await channelA.attach(); + + // Enter members sequentially to avoid server rate limits + for (let i = 0; i < memberCount; i++) { + await channelA.presence.enterClient(`user-${i}`, `data-${i}`); + } + + await pollUntil(() => receivedEnters.length >= memberCount ? true : null, { + interval: 200, + timeout: 30000, + }); + + expect(receivedEnters).to.have.length(memberCount); + + const members = await channelB.presence.get(); + expect(members).to.have.length(memberCount); + + for (let i = 0; i < memberCount; i++) { + const member = members.find((m: any) => m.clientId === `user-${i}`); + expect(member, `user-${i} should be present`).to.not.be.undefined; + expect(member.data).to.equal(`data-${i}`); + } + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTP8, RTP9, RTP10 - Enter, update, leave lifecycle + */ + it('RTP8/RTP9/RTP10 - enter, update, leave lifecycle observed on second connection', async function () { + const channelName = uniqueChannelName('presence-lifecycle'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'lifecycle-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + await connectAndWait(clientB); + + const channelA = clientA.channels.get(channelName); + const channelB = clientB.channels.get(channelName); + + // Attach B and subscribe for all presence events before A enters + const allEvents: any[] = []; + await channelB.presence.subscribe((msg: any) => { + allEvents.push(msg); + }); + + // Now attach A and enter + await channelA.attach(); + + // Phase 1: Enter + await channelA.presence.enter('hello'); + + await pollUntil(() => allEvents.length >= 1 ? true : null, { + interval: 200, + timeout: 10000, + }); + + const membersAfterEnter = await channelB.presence.get(); + expect(membersAfterEnter).to.have.length(1); + expect(membersAfterEnter[0].clientId).to.equal('lifecycle-client'); + expect(membersAfterEnter[0].data).to.equal('hello'); + + // Phase 2: Update + await channelA.presence.update('world'); + + await pollUntil(() => allEvents.length >= 2 ? true : null, { + interval: 200, + timeout: 10000, + }); + + const membersAfterUpdate = await channelB.presence.get(); + expect(membersAfterUpdate).to.have.length(1); + expect(membersAfterUpdate[0].data).to.equal('world'); + + // Phase 3: Leave + await channelA.presence.leave('goodbye'); + + await pollUntil(() => allEvents.length >= 3 ? true : null, { + interval: 200, + timeout: 10000, + }); + + const membersAfterLeave = await channelB.presence.get(); + expect(membersAfterLeave).to.have.length(0); + + // Verify event sequence + expect(allEvents).to.have.length.at.least(3); + + // First event should be 'enter' (not 'present' from SYNC, because + // B was subscribed and attached before A entered) + expect(allEvents[0].action).to.equal('enter'); + expect(allEvents[0].clientId).to.equal('lifecycle-client'); + expect(allEvents[0].data).to.equal('hello'); + + expect(allEvents[1].action).to.equal('update'); + expect(allEvents[1].clientId).to.equal('lifecycle-client'); + expect(allEvents[1].data).to.equal('world'); + + expect(allEvents[2].action).to.equal('leave'); + expect(allEvents[2].clientId).to.equal('lifecycle-client'); + expect(allEvents[2].data).to.equal('goodbye'); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); +}); diff --git a/test/uts/realtime/integration/presence/presence_sync.test.ts b/test/uts/realtime/integration/presence/presence_sync.test.ts new file mode 100644 index 0000000000..234ab436fc --- /dev/null +++ b/test/uts/realtime/integration/presence/presence_sync.test.ts @@ -0,0 +1,127 @@ +/** + * UTS Integration: Presence Sync Tests + * + * Spec points: RTP2, RTP11a + * Source: uts/realtime/integration/presence/presence_sync_test.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, +} from '../sandbox'; + +describe('uts/realtime/integration/presence/presence_sync', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RTP2, RTP11a - Presence SYNC delivers existing members + */ + it('RTP2/RTP11a - presence SYNC delivers existing member', async function () { + const channelName = uniqueChannelName('presence-sync'); + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'sync-member-a', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + + const channelA = clientA.channels.get(channelName); + await channelA.attach(); + await channelA.presence.enter('sync-data'); + + await connectAndWait(clientB); + + const channelB = clientB.channels.get(channelName); + await channelB.attach(); + + const members = await channelB.presence.get(); + + expect(members).to.have.length(1); + expect(members[0].clientId).to.equal('sync-member-a'); + expect(members[0].data).to.equal('sync-data'); + expect(members[0].action).to.equal('present'); + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); + + /** + * RTP2 - Presence SYNC with multiple members + */ + it('RTP2 - presence SYNC delivers multiple members', async function () { + const channelName = uniqueChannelName('presence-sync-multi'); + const memberCount = 10; + + const clientA = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + const clientB = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + await connectAndWait(clientA); + + const channelA = clientA.channels.get(channelName); + await channelA.attach(); + + for (let i = 0; i < memberCount; i++) { + await channelA.presence.enterClient(`sync-user-${i}`, `data-${i}`); + } + + await connectAndWait(clientB); + + const channelB = clientB.channels.get(channelName); + await channelB.attach(); + + const members = await channelB.presence.get(); + + expect(members).to.have.length(memberCount); + + for (let i = 0; i < memberCount; i++) { + const member = members.find((m: any) => m.clientId === `sync-user-${i}`); + expect(member, `sync-user-${i} should be present`).to.not.be.undefined; + expect(member!.data).to.equal(`data-${i}`); + } + + await closeAndWait(clientA); + await closeAndWait(clientB); + }); +}); diff --git a/test/uts/realtime/integration/sandbox.ts b/test/uts/realtime/integration/sandbox.ts new file mode 100644 index 0000000000..b6de59c2f4 --- /dev/null +++ b/test/uts/realtime/integration/sandbox.ts @@ -0,0 +1,228 @@ +/** + * Sandbox app provisioning for UTS integration tests. + * + * Provisions a test app on the Ably sandbox before all tests in a suite, + * and tears it down after. Uses the standard test-app-setup.json fixture. + */ + +import * as crypto from 'crypto'; +import testAppSetup from '../../../common/ably-common/test-resources/test-app-setup.json'; +import '../../../../src/platform/nodejs'; +import { DefaultRealtime } from '../../../../src/common/lib/client/defaultrealtime'; +import { DefaultRest } from '../../../../src/common/lib/client/defaultrest'; +import ErrorInfo from '../../../../src/common/lib/types/errorinfo'; + +const Ably = { + Rest: DefaultRest, + Realtime: DefaultRealtime, + ErrorInfo, +}; + +const SANDBOX_ENDPOINT = 'nonprod:sandbox'; +const SANDBOX_REST_HOST = 'sandbox.realtime.ably-nonprod.net'; + +interface SandboxApp { + appId: string; + keys: Array<{ keyStr: string; keyName: string; keySecret: string; capability: string }>; +} + +let _sandboxApp: SandboxApp | null = null; +const _trackedClients: any[] = []; + +async function provisionSandboxApp(): Promise { + const url = `https://${SANDBOX_REST_HOST}/apps`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(testAppSetup.post_apps), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Sandbox app provisioning failed (${response.status}): ${body}`); + } + + const app = await response.json(); + return { + appId: app.appId, + keys: app.keys.map((k: any) => ({ + keyStr: k.keyStr, + keyName: k.keyName, + keySecret: k.keySecret, + capability: k.capability, + })), + }; +} + +async function deleteSandboxApp(app: SandboxApp): Promise { + const url = `https://${SANDBOX_REST_HOST}/apps/${app.appId}`; + const credentials = Buffer.from(app.keys[0].keyStr).toString('base64'); + await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Basic ${credentials}` }, + }); +} + +/** + * Get the sandbox app, provisioning it if necessary. + * Call setupSandbox() in before() and teardownSandbox() in after(). + */ +function getSandboxApp(): SandboxApp { + if (!_sandboxApp) throw new Error('Sandbox app not provisioned — call setupSandbox() in before()'); + return _sandboxApp; +} + +function getApiKey(keyIndex = 0): string { + return getSandboxApp().keys[keyIndex].keyStr; +} + +async function setupSandbox(): Promise { + _sandboxApp = await provisionSandboxApp(); +} + +async function teardownSandbox(): Promise { + // Close all tracked clients first + while (_trackedClients.length > 0) { + const client = _trackedClients.pop(); + try { + if (typeof client.close === 'function') { + client.close(); + } + } catch (_) { + // ignore + } + } + + if (_sandboxApp) { + await deleteSandboxApp(_sandboxApp); + _sandboxApp = null; + } +} + +function trackClient(client: any): void { + _trackedClients.push(client); +} + +function closeAndWait(client: any, timeout = 10000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for close')), timeout); + if (client.connection.state === 'closed' || client.connection.state === 'failed') { + clearTimeout(timer); + resolve(); + return; + } + client.connection.once('closed', () => { + clearTimeout(timer); + resolve(); + }); + client.connection.once('failed', () => { + clearTimeout(timer); + resolve(); + }); + client.close(); + }); +} + +function connectAndWait(client: any, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for connected (state: ${client.connection.state}, error: ${client.connection.errorReason})`)); + }, timeout); + + if (client.connection.state === 'connected') { + clearTimeout(timer); + resolve(); + return; + } + + client.connection.once('connected', () => { + clearTimeout(timer); + resolve(); + }); + client.connection.once('failed', (stateChange: any) => { + clearTimeout(timer); + reject(new Error(`Connection failed: ${stateChange.reason?.message || 'unknown'}`)); + }); + + if (client.connection.state === 'initialized') { + client.connect(); + } + }); +} + +function uniqueChannelName(prefix: string): string { + const rand = Math.random().toString(36).substring(2, 10); + return `${prefix}-${rand}`; +} + +function base64url(data: Buffer | string): string { + const buf = typeof data === 'string' ? Buffer.from(data) : data; + return buf.toString('base64url'); +} + +function generateJWT(opts: { + keyName: string; + keySecret: string; + clientId?: string; + ttl?: number; + expiresAt?: number; + issuedAt?: number; + capability?: string; +}): string { + const now = Math.floor(Date.now() / 1000); + const exp = opts.expiresAt != null ? Math.floor(opts.expiresAt / 1000) : now + (opts.ttl || 3600000) / 1000; + const iat = opts.issuedAt != null ? Math.floor(opts.issuedAt / 1000) : (exp < now ? exp - 60 : now); + + const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'HS256', kid: opts.keyName })); + + const payload: Record = { + iat, + exp, + }; + if (opts.clientId) payload['x-ably-clientId'] = opts.clientId; + if (opts.capability) payload['x-ably-capability'] = opts.capability; + + const payloadEncoded = base64url(JSON.stringify(payload)); + const sigInput = `${header}.${payloadEncoded}`; + const sig = base64url(crypto.createHmac('sha256', opts.keySecret).update(sigInput).digest()); + + return `${sigInput}.${sig}`; +} + +function getKeyParts(keyStr: string): { keyName: string; keySecret: string } { + const [keyName, keySecret] = keyStr.split(':'); + return { keyName, keySecret }; +} + +async function pollUntil( + fn: () => Promise | T, + opts: { interval?: number; timeout?: number } = {}, +): Promise { + const interval = opts.interval || 500; + const timeout = opts.timeout || 10000; + const start = Date.now(); + while (true) { + const result = await fn(); + if (result) return result; + if (Date.now() - start > timeout) { + throw new Error(`pollUntil timed out after ${timeout}ms`); + } + await new Promise((r) => setTimeout(r, interval)); + } +} + +export { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getSandboxApp, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + connectAndWait, + uniqueChannelName, + generateJWT, + pollUntil, +}; diff --git a/test/uts/rest/integration/auth.test.ts b/test/uts/rest/integration/auth.test.ts new file mode 100644 index 0000000000..321afbd6e8 --- /dev/null +++ b/test/uts/rest/integration/auth.test.ts @@ -0,0 +1,280 @@ +/** + * UTS Integration: REST Auth Tests + * + * Spec points: RSA4, RSA8, RSC10 + * Source: uts/rest/integration/auth.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + getAppId, + getKeyParts, + uniqueChannelName, + generateJWT, +} from './sandbox'; + +describe('uts/rest/integration/auth', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSA4 - Basic auth with API key + * + * Client can authenticate using an API key via HTTP Basic Auth. + */ + it('RSA4 - basic auth with API key', async function () { + const channelName = uniqueChannelName('test-RSA4'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.request('GET', '/channels/' + channelName); + + expect(result.statusCode).to.be.at.least(200); + expect(result.statusCode).to.be.below(300); + }); + + /** + * RSA8 - Token auth with JWT + * + * Client can authenticate using a JWT token. + */ + it('RSA8 - token auth with JWT', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const jwt = generateJWT({ + keyName, + keySecret, + ttl: 3600000, + }); + + const channelName = uniqueChannelName('test-RSA8-jwt'); + + const client = new Ably.Rest({ + token: jwt, + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.request('GET', '/channels/' + channelName); + + expect(result.statusCode).to.be.at.least(200); + expect(result.statusCode).to.be.below(300); + }); + + /** + * RSA8 - Token auth with native token + * + * Client can authenticate using an Ably native token obtained via requestToken(). + */ + it('RSA8 - token auth with native token', async function () { + const keyClient = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const tokenDetails = await keyClient.auth.requestToken(); + + expect(tokenDetails.token).to.be.a('string'); + expect(tokenDetails.token.length).to.be.greaterThan(0); + expect(tokenDetails.expires).to.be.greaterThan(Date.now()); + + const channelName = uniqueChannelName('test-RSA8-native'); + const tokenClient = new Ably.Rest({ + token: tokenDetails.token, + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await tokenClient.request('GET', '/channels/' + channelName); + + expect(result.statusCode).to.be.at.least(200); + expect(result.statusCode).to.be.below(300); + }); + + /** + * RSA8 - authCallback with TokenRequest + * + * Client can use authCallback to obtain authentication via TokenRequest. + */ + it('RSA8 - authCallback with TokenRequest', async function () { + const tokenRequestClient = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('test-RSA8-callback'); + + const client = new Ably.Rest({ + authCallback: async (_params: any, cb: any) => { + try { + const tokenRequest = await tokenRequestClient.auth.createTokenRequest(_params); + cb(null, tokenRequest); + } catch (err) { + cb(err, null); + } + }, + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.request('GET', '/channels/' + channelName); + + expect(result.statusCode).to.be.at.least(200); + expect(result.statusCode).to.be.below(300); + }); + + /** + * RSA8 - authCallback with JWT + * + * Client can use authCallback to obtain JWT tokens dynamically. + */ + it('RSA8 - authCallback with JWT', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const channelName = uniqueChannelName('test-RSA8-jwt-callback'); + + const client = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + try { + const jwt = generateJWT({ + keyName, + keySecret, + clientId: _params.clientId, + ttl: _params.ttl || 3600000, + }); + cb(null, jwt); + } catch (err) { + cb(err, null); + } + }, + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.request('GET', '/channels/' + channelName); + + expect(result.statusCode).to.be.at.least(200); + expect(result.statusCode).to.be.below(300); + }); + + /** + * RSA4 - Invalid credentials rejected + * + * Server rejects requests with invalid API key credentials. + */ + it('RSA4 - invalid credentials rejected', async function () { + const channelName = uniqueChannelName('test-RSA4-invalid'); + + const invalidKey = getAppId() + '.invalidKey:invalidSecret'; + + const client = new Ably.Rest({ + key: invalidKey, + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.request('GET', '/channels/' + channelName); + expect(result.success).to.equal(false); + expect(result.statusCode).to.equal(401); + expect(result.errorCode).to.equal(40400); + }); + + /** + * RSC10 - Token renewal with expired JWT + * + * When a REST request fails with a token error (40140-40149), the client + * should automatically renew the token and retry the request. + */ + it('RSC10 - token renewal with expired JWT', async function () { + if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js retry overwrites new auth header with stale one; see #2193 + const { keyName, keySecret } = getKeyParts(getApiKey()); + + let callbackCount = 0; + + const channelName = uniqueChannelName('test-RSC10-renewal'); + + const client = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + callbackCount++; + try { + if (callbackCount === 1) { + // First call: return a JWT that was issued 70s ago and expired 5s ago + const jwt = generateJWT({ + keyName, + keySecret, + issuedAt: Date.now() - 70000, + expiresAt: Date.now() - 5000, + }); + cb(null, jwt); + } else { + // Subsequent calls: return a valid JWT + const jwt = generateJWT({ + keyName, + keySecret, + ttl: 3600000, + }); + cb(null, jwt); + } + } catch (err) { + cb(err, null); + } + }, + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.request('GET', '/channels/' + channelName); + + // The request succeeded (token was renewed and retried) + expect(result.statusCode).to.be.at.least(200); + expect(result.statusCode).to.be.below(300); + + // The authCallback was called twice: once for expired token, once for renewal + expect(callbackCount).to.equal(2); + }); + + /** + * RSA8 - Capability restriction + * + * Tokens with restricted capabilities should only allow the permitted operations. + */ + it('RSA8 - capability restriction', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const allowedChannel = uniqueChannelName('test-RSA8-cap-allowed'); + const deniedChannel = uniqueChannelName('test-RSA8-cap-denied'); + + const jwt = generateJWT({ + keyName, + keySecret, + capability: '{"' + allowedChannel + '":["publish","subscribe"]}', + ttl: 3600000, + }); + + const client = new Ably.Rest({ + token: jwt, + endpoint: SANDBOX_ENDPOINT, + }); + + // Publish to allowed channel should succeed + await client.channels.get(allowedChannel).publish('test', 'hello'); + + // Publish to denied channel should fail with 40160 (capability refused) + try { + await client.channels.get(deniedChannel).publish('test', 'hello'); + expect.fail('Publish to denied channel should have failed'); + } catch (error: any) { + expect(error.code).to.equal(40160); + expect(error.statusCode).to.equal(401); + } + }); +}); diff --git a/test/uts/rest/integration/batch_presence.test.ts b/test/uts/rest/integration/batch_presence.test.ts new file mode 100644 index 0000000000..1a9085efa6 --- /dev/null +++ b/test/uts/rest/integration/batch_presence.test.ts @@ -0,0 +1,223 @@ +/** + * UTS Integration: Batch Presence Tests + * + * Spec points: RSC24, BGR2, BGF2 + * Source: specification/uts/rest/integration/batch_presence.md + * + * End-to-end verification of RestClient#batchPresence against the Ably sandbox. + * Client A enters presence members via Realtime, then the REST client calls + * batchPresence and verifies the response structure and content. + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, +} from './sandbox'; + +describe('uts/rest/integration/batch_presence', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSC24, BGR2 - batchPresence returns members across multiple channels + * + * Enter members on two channels via Realtime, then query both channels + * in a single batchPresence call via REST and verify the returned members. + */ + it('RSC24, BGR2 - batchPresence returns members across multiple channels', async function () { + const channelAName = uniqueChannelName('batch-presence-a'); + const channelBName = uniqueChannelName('batch-presence-b'); + + // Connect realtime and enter members on two channels + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + await connectAndWait(realtime); + + const chA = realtime.channels.get(channelAName); + await chA.attach(); + await chA.presence.enterClient('user-1', 'data-a1'); + await chA.presence.enterClient('user-2', 'data-a2'); + + const chB = realtime.channels.get(channelBName); + await chB.attach(); + await chB.presence.enterClient('user-3', 'data-b1'); + + // Query via REST batchPresence (keep realtime open so presence persists) + const rest = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: false, + }); + + const result = await rest.batchPresence([channelAName, channelBName]); + + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(0); + expect(result.results).to.have.length(2); + + // Find results by channel name + const resultA = result.results.find((r: any) => r.channel === channelAName) as any; + const resultB = result.results.find((r: any) => r.channel === channelBName) as any; + + expect(resultA).to.exist; + expect(resultA.presence).to.be.an('array').with.length(2); + const clientIdsA = resultA.presence.map((m: any) => m.clientId); + expect(clientIdsA).to.include('user-1'); + expect(clientIdsA).to.include('user-2'); + + // Verify data round-trips correctly + const member1 = resultA.presence.find((m: any) => m.clientId === 'user-1'); + expect(member1.data).to.equal('data-a1'); + + expect(resultB).to.exist; + expect(resultB.presence).to.be.an('array').with.length(1); + expect(resultB.presence[0].clientId).to.equal('user-3'); + expect(resultB.presence[0].data).to.equal('data-b1'); + + await closeAndWait(realtime); + }); + + /** + * RSC24, BGF2 - Restricted key returns per-channel failure for unauthorized channels + * + * When a key lacks capability for a channel, the per-channel result is a + * BatchPresenceFailureResult containing an ErrorInfo. Channels the key does + * have access to return success results in the same batch response. + * + * The UTS spec closes the realtime connection before the REST query. After + * closing, the presence members will have left, so the allowed channel returns + * an empty presence set. The test still validates the per-channel success vs + * failure distinction. + */ + it('RSC24, BGF2 - restricted key returns per-channel failure for unauthorized channels', async function () { + // Use the fixed channel name matching keys[2] capability from ably-common + const allowedChannel = 'channel6'; + const deniedChannel = uniqueChannelName('denied-batch'); + + // Enter members on both channels using the full-access key + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + await connectAndWait(realtime); + + const chAllowed = realtime.channels.get(allowedChannel); + await chAllowed.attach(); + await chAllowed.presence.enterClient('member-1', 'hello'); + + const chDenied = realtime.channels.get(deniedChannel); + await chDenied.attach(); + await chDenied.presence.enterClient('member-2', 'world'); + + // Close realtime before the REST query (per UTS spec). + // Presence members will have left after disconnection. + await closeAndWait(realtime); + + // Query with restricted key (keys[2], has "channel6":["*"]) + const restrictedRest = new Ably.Rest({ + key: getApiKey(2), + endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: false, + }); + + const result = await restrictedRest.batchPresence([allowedChannel, deniedChannel]); + + expect(result.successCount).to.equal(1); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.length(2); + + // Find results by channel name + const success = result.results.find((r: any) => r.channel === allowedChannel) as any; + const failure = result.results.find((r: any) => r.channel === deniedChannel) as any; + + // Allowed channel succeeds (presence is empty since realtime was closed; + // server may omit the presence field entirely for empty channels) + expect(success).to.exist; + expect('error' in success).to.be.false; + + // Denied channel fails with capability error + expect(failure).to.exist; + expect(failure.error).to.exist; + expect(failure.error.code).to.equal(40160); + expect(failure.error.statusCode).to.equal(401); + }); + + /** + * RSC24 - batchPresence with empty channel returns empty presence array + * + * A channel with no presence members returns a success result with an empty + * presence array (or no presence field, depending on server behaviour). + */ + it('RSC24 - batchPresence with empty channel returns empty presence array', async function () { + const emptyChannel = uniqueChannelName('batch-empty'); + const populatedChannel = uniqueChannelName('batch-populated'); + + // Enter a member on only the populated channel + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + await connectAndWait(realtime); + + const ch = realtime.channels.get(populatedChannel); + await ch.attach(); + await ch.presence.enterClient('someone', 'here'); + + // Keep realtime open during the REST query so the presence member persists + const rest = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: false, + }); + + const result = await rest.batchPresence([emptyChannel, populatedChannel]); + + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(0); + expect(result.results).to.have.length(2); + + const emptyResult = result.results.find((r: any) => r.channel === emptyChannel) as any; + const populatedResult = result.results.find((r: any) => r.channel === populatedChannel) as any; + + // Empty channel succeeds with no members. + // The server omits the presence field for empty channels, so we check + // that the result has no error, and that presence is either missing or empty. + expect(emptyResult).to.exist; + expect('error' in emptyResult).to.be.false; + const emptyPresence = emptyResult.presence || []; + expect(emptyPresence).to.have.length(0); + + // Populated channel succeeds with the member + expect(populatedResult).to.exist; + expect(populatedResult.presence).to.be.an('array').with.length(1); + expect(populatedResult.presence[0].clientId).to.equal('someone'); + + await closeAndWait(realtime); + }); +}); diff --git a/test/uts/rest/integration/history.test.ts b/test/uts/rest/integration/history.test.ts new file mode 100644 index 0000000000..d28828ed4a --- /dev/null +++ b/test/uts/rest/integration/history.test.ts @@ -0,0 +1,216 @@ +/** + * UTS Integration: REST Channel History Tests + * + * Spec points: RSL2a, RSL2b1, RSL2b2, RSL2b3 + * Source: specification/uts/rest/integration/history.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +describe('uts/rest/integration/history', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSL2a - History returns published messages in backwards order (newest first) + */ + it('RSL2a - history returns published messages', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('history-test-RSL2a'); + const channel = client.channels.get(channelName); + + // Publish some messages + await channel.publish('event1', 'data1'); + await channel.publish('event2', 'data2'); + await channel.publish('event3', { key: 'value' }); + + // Poll until messages appear in history + const history = await pollUntil(async () => { + const result = await channel.history(); + return result.items.length === 3 ? result : null; + }, { interval: 500, timeout: 10000 }); + + expect(history.items).to.have.length(3); + + // Default order is backwards (newest first) + expect(history.items[0].name).to.equal('event3'); + expect(history.items[0].data).to.deep.equal({ key: 'value' }); + + expect(history.items[1].name).to.equal('event2'); + expect(history.items[1].data).to.equal('data2'); + + expect(history.items[2].name).to.equal('event1'); + expect(history.items[2].data).to.equal('data1'); + + // All messages should have timestamps + for (const msg of history.items) { + expect(msg.timestamp).to.not.be.null; + expect(msg.timestamp).to.not.be.undefined; + } + }); + + /** + * RSL2b1 - History direction forwards returns messages oldest first + */ + it('RSL2b1 - history direction forwards', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('history-direction'); + const channel = client.channels.get(channelName); + + // Publish messages - ordering is determined by server timestamp + await channel.publish('first', '1'); + await channel.publish('second', '2'); + await channel.publish('third', '3'); + + // Poll until all messages appear + await pollUntil(async () => { + const result = await channel.history(); + return result.items.length === 3 ? result : null; + }, { interval: 500, timeout: 10000 }); + + const history = await channel.history({ direction: 'forwards' }); + + expect(history.items).to.have.length(3); + expect(history.items[0].name).to.equal('first'); + expect(history.items[1].name).to.equal('second'); + expect(history.items[2].name).to.equal('third'); + }); + + /** + * RSL2b2 - History limit parameter restricts number of returned messages + */ + it('RSL2b2 - history limit parameter', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('history-limit'); + const channel = client.channels.get(channelName); + + // Publish multiple messages + for (let i = 1; i <= 10; i++) { + await channel.publish(`event-${i}`, String(i)); + } + + // Poll until all messages are persisted + await pollUntil(async () => { + const result = await channel.history(); + return result.items.length === 10 ? result : null; + }, { interval: 500, timeout: 10000 }); + + const history = await channel.history({ limit: 5 }); + + expect(history.items).to.have.length(5); + + // Should get the 5 most recent (backwards direction by default) + expect(history.items[0].name).to.equal('event-10'); + expect(history.items[4].name).to.equal('event-6'); + }); + + /** + * RSL2b3 - History time range parameters filter messages by timestamp + */ + it('RSL2b3 - history time range parameters', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('history-timerange'); + const channel = client.channels.get(channelName); + + // Record start time + const timeBefore = Date.now(); + + // Publish some early messages + await channel.publish('early1', 'e1'); + await channel.publish('early2', 'e2'); + + // Record middle time + const timeMiddle = Date.now(); + + // Publish some late messages + await channel.publish('late1', 'l1'); + await channel.publish('late2', 'l2'); + + // Record end time + const timeAfter = Date.now(); + + // Poll until all messages appear + await pollUntil(async () => { + const result = await channel.history(); + return result.items.length === 4 ? result : null; + }, { interval: 500, timeout: 10000 }); + + // Query only early messages + const earlyHistory = await channel.history({ + start: timeBefore, + end: timeMiddle, + }); + + // Query only late messages + const lateHistory = await channel.history({ + start: timeMiddle, + end: timeAfter, + }); + + // Due to timing precision, exact counts may vary + // The key test is that filtering by time range works + expect(earlyHistory.items.length).to.be.at.least(1); + expect(lateHistory.items.length).to.be.at.least(1); + + // Early messages should contain "early" names + const hasEarly = earlyHistory.items.some((msg: any) => msg.name.startsWith('early')); + expect(hasEarly).to.be.true; + + // Late messages should contain "late" names + const hasLate = lateHistory.items.some((msg: any) => msg.name.startsWith('late')); + expect(hasLate).to.be.true; + }); + + /** + * RSL2 - History on channel with no messages returns empty result + */ + it('RSL2 - history on empty channel returns empty result', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // Use a fresh channel with no messages + const channelName = uniqueChannelName('history-empty'); + const channel = client.channels.get(channelName); + + const history = await channel.history(); + + expect(history.items).to.be.an('array'); + expect(history.items).to.have.length(0); + expect(history.hasNext()).to.equal(false); + expect(history.isLast()).to.equal(true); + }); +}); diff --git a/test/uts/rest/integration/mutable_messages.test.ts b/test/uts/rest/integration/mutable_messages.test.ts new file mode 100644 index 0000000000..ec2228342f --- /dev/null +++ b/test/uts/rest/integration/mutable_messages.test.ts @@ -0,0 +1,375 @@ +/** + * UTS Integration: REST Mutable Messages Tests + * + * Spec points: RSL1n, RSL11, RSL14, RSL15, RSAN1, RSAN2, RSAN3 + * Source: uts/rest/integration/mutable_messages.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +describe('uts/rest/integration/mutable_messages', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSL1n - publish returns serials from sandbox (single message) + * + * On success, returns a PublishResult containing message serials. + */ + it('RSL1n - single message publish returns result with serial', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL1n-serials'); + const channel = client.channels.get(channelName); + + const result = await channel.publish('event1', 'data1'); + + expect(result).to.have.property('serials'); + expect(result.serials).to.be.an('array'); + expect(result.serials).to.have.length(1); + expect(result.serials[0]).to.be.a('string'); + expect((result.serials[0] as string).length).to.be.greaterThan(0); + }); + + /** + * RSL1n - publish returns serials from sandbox (multiple messages) + * + * Multiple message publish returns matching count, all unique. + */ + it('RSL1n - multiple message publish returns unique serials', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL1n-serials-multi'); + const channel = client.channels.get(channelName); + + const result = await channel.publish([ + { name: 'event2', data: 'data2' }, + { name: 'event3', data: 'data3' }, + { name: 'event4', data: 'data4' }, + ]); + + expect(result.serials).to.be.an('array'); + expect(result.serials).to.have.length(3); + + for (const serial of result.serials) { + expect(serial).to.be.a('string'); + expect((serial as string).length).to.be.greaterThan(0); + } + + // All serials should be unique + expect(result.serials[0]).to.not.equal(result.serials[1]); + expect(result.serials[1]).to.not.equal(result.serials[2]); + }); + + /** + * RSL11 - getMessage retrieves published message + * + * A published message can be retrieved by its serial. + */ + it('RSL11 - getMessage retrieves a published message by serial', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL11-getMessage'); + const channel = client.channels.get(channelName); + + // Publish a message and get its serial + const publishResult = await channel.publish('test-event', 'hello world'); + const serial = publishResult.serials[0] as string; + + // Retrieve the message by serial + const msg = await channel.getMessage(serial); + + expect(msg).to.be.an('object'); + expect(msg.name).to.equal('test-event'); + expect(msg.data).to.equal('hello world'); + expect(msg.serial).to.equal(serial); + expect(msg.action).to.equal('message.create'); + expect(msg.timestamp).to.be.a('number'); + }); + + /** + * RSL15 - updateMessage updates a published message + * + * A published message can be updated and the update is visible via getMessage(). + */ + it('RSL15 - updateMessage updates a published message', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL15-update'); + const channel = client.channels.get(channelName); + + // Publish original message + const publishResult = await channel.publish('original', 'original-data'); + const serial = publishResult.serials[0] as string; + + // Update the message + const updateResult = await channel.updateMessage( + { serial, name: 'updated', data: 'updated-data' } as any, + { description: 'edited content' }, + ); + + // Update returns a version serial + expect(updateResult).to.have.property('versionSerial'); + expect(updateResult.versionSerial).to.be.a('string'); + expect((updateResult.versionSerial as string).length).to.be.greaterThan(0); + + // Verify via getMessage -- poll until the update is visible + const updatedMsg = await pollUntil( + async () => { + const msg = await channel.getMessage(serial); + if (msg.action === 'message.update') return msg; + return null; + }, + { interval: 500, timeout: 10000 }, + ); + + expect(updatedMsg.name).to.equal('updated'); + expect(updatedMsg.data).to.equal('updated-data'); + expect(updatedMsg.action).to.equal('message.update'); + expect(updatedMsg.version).to.be.an('object'); + expect(updatedMsg.version!.description).to.equal('edited content'); + }); + + /** + * RSL15 - deleteMessage deletes a published message + * + * A published message can be deleted and the delete is visible via getMessage(). + */ + it('RSL15 - deleteMessage deletes a published message', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL15-delete'); + const channel = client.channels.get(channelName); + + // Publish original message + const publishResult = await channel.publish('to-delete', 'delete-me'); + const serial = publishResult.serials[0] as string; + + // Delete the message + const deleteResult = await channel.deleteMessage({ serial } as any); + + expect(deleteResult).to.have.property('versionSerial'); + expect(deleteResult.versionSerial).to.be.a('string'); + expect((deleteResult.versionSerial as string).length).to.be.greaterThan(0); + + // Verify via getMessage -- poll until the delete is visible + const deletedMsg = await pollUntil( + async () => { + const msg = await channel.getMessage(serial); + if (msg.action === 'message.delete') return msg; + return null; + }, + { interval: 500, timeout: 10000 }, + ); + + expect(deletedMsg.action).to.equal('message.delete'); + }); + + /** + * RSL14 - getMessageVersions returns version history + * + * Version history contains the original and all updates. + */ + it('RSL14 - getMessageVersions returns version history', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL14-versions'); + const channel = client.channels.get(channelName); + + // Publish original + const publishResult = await channel.publish('versioned', 'v1'); + const serial = publishResult.serials[0] as string; + + // Update twice + await channel.updateMessage( + { serial, data: 'v2' } as any, + { description: 'first edit' }, + ); + await channel.updateMessage( + { serial, data: 'v3' } as any, + { description: 'second edit' }, + ); + + // Poll version history until all versions appear + const versions = await pollUntil( + async () => { + const result = await channel.getMessageVersions(serial); + if (result.items.length >= 3) return result; + return null; + }, + { interval: 500, timeout: 10000 }, + ); + + expect(versions.items.length).to.be.at.least(3); + + // All items should be Messages with the same serial + for (const item of versions.items) { + expect(item).to.be.an('object'); + expect(item.serial).to.equal(serial); + } + }); + + /** + * RSL15 - appendMessage appends to a published message + * + * A message can be appended to. + */ + it('RSL15 - appendMessage appends to a published message', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSL15-append'); + const channel = client.channels.get(channelName); + + // Publish original + const publishResult = await channel.publish('appendable', 'original'); + const serial = publishResult.serials[0] as string; + + // Append to the message + const appendResult = await channel.appendMessage( + { serial, data: 'appended-data' } as any, + { description: 'appended content' }, + ); + + expect(appendResult).to.have.property('versionSerial'); + expect(appendResult.versionSerial).to.be.a('string'); + expect((appendResult.versionSerial as string).length).to.be.greaterThan(0); + }); + + /** + * RSAN1, RSAN2 - publish and delete annotations on a message + * + * Tests the full annotation lifecycle: create, verify, delete. + */ + it('RSAN1/RSAN2/RSAN3 - annotation lifecycle: publish, get, delete', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSAN-lifecycle'); + const channel = client.channels.get(channelName); + + // Publish a message to annotate + const publishResult = await channel.publish('annotatable', 'content'); + const serial = publishResult.serials[0] as string; + + // Create an annotation + await channel.annotations.publish(serial, { + type: 'com.ably.reactions', + name: 'like', + }); + + // Verify annotation exists -- poll until it appears + const annotations = await pollUntil( + async () => { + const result = await channel.annotations.get(serial, null); + if (result.items.length >= 1) return result; + return null; + }, + { interval: 500, timeout: 10000 }, + ); + + expect(annotations.items.length).to.be.at.least(1); + + let found = false; + for (const ann of annotations.items) { + if (ann.type === 'com.ably.reactions' && ann.name === 'like') { + found = true; + expect(ann.action).to.equal('annotation.create'); + expect(ann.messageSerial).to.equal(serial); + } + } + expect(found).to.equal(true); + + // Delete the annotation + await channel.annotations.delete(serial, { + type: 'com.ably.reactions', + name: 'like', + }); + }); + + /** + * RSAN3 - get annotations returns PaginatedResult + * + * Multiple annotations can be retrieved as a paginated result. + */ + it('RSAN3 - paginated annotations for multiple annotation types', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('mutable:test-RSAN3-paginated'); + const channel = client.channels.get(channelName); + + // Publish a message + const publishResult = await channel.publish('multi-annotated', 'content'); + const serial = publishResult.serials[0] as string; + + // Publish multiple annotations + await channel.annotations.publish(serial, { + type: 'com.ably.reactions', + name: 'like', + }); + await channel.annotations.publish(serial, { + type: 'com.ably.reactions', + name: 'heart', + }); + + // Retrieve annotations -- poll until both appear + const result = await pollUntil( + async () => { + const r = await channel.annotations.get(serial, null); + if (r.items.length >= 2) return r; + return null; + }, + { interval: 500, timeout: 10000 }, + ); + + expect(result.items.length).to.be.at.least(2); + + for (const ann of result.items) { + expect(ann).to.be.an('object'); + expect(ann.messageSerial).to.equal(serial); + expect(ann.type).to.equal('com.ably.reactions'); + expect(ann.timestamp).to.be.a('number'); + } + }); +}); diff --git a/test/uts/rest/integration/pagination.test.ts b/test/uts/rest/integration/pagination.test.ts new file mode 100644 index 0000000000..0d44f026f2 --- /dev/null +++ b/test/uts/rest/integration/pagination.test.ts @@ -0,0 +1,264 @@ +/** + * UTS Integration: REST Pagination Tests + * + * Spec points: TG1, TG2, TG3, TG4, TG5 + * Source: uts/rest/integration/pagination.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +describe('uts/rest/integration/pagination', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * TG1, TG2 - PaginatedResult items and navigation + * + * Publish 15 messages, request with limit 5. + * TG1: items contains array of results for current page. + * TG2: hasNext() and isLast() indicate availability of more pages. + */ + it('TG1, TG2 - PaginatedResult items and navigation', async function () { + const channelName = uniqueChannelName('pagination-basic'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get(channelName); + + // Publish 15 messages + for (let i = 1; i <= 15; i++) { + await channel.publish('event-' + i, String(i)); + } + + // Poll until all messages are persisted + await pollUntil( + async () => { + const result = await channel.history(); + return result.items.length === 15 ? result : null; + }, + { interval: 500, timeout: 15000 }, + ); + + // Request with small limit to force pagination + const page1 = await channel.history({ limit: 5 }); + + // TG1 - items contains array of results + expect(page1.items).to.be.an('array'); + expect(page1.items.length).to.equal(5); + + // TG2 - hasNext/isLast indicate more pages + expect(page1.hasNext()).to.equal(true); + expect(page1.isLast()).to.equal(false); + }); + + /** + * TG3 - next() retrieves subsequent page + * + * Publish 12 messages, paginate through 3 pages with limit 5. + * Page 1: 5 items, page 2: 5 items, page 3: 2 items. + * Verify no duplicate IDs across pages, total 12. + */ + it('TG3 - next() retrieves subsequent pages', async function () { + const channelName = uniqueChannelName('pagination-next'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get(channelName); + + // Publish 12 messages + for (let i = 1; i <= 12; i++) { + await channel.publish('event-' + i, String(i)); + } + + // Poll until all messages are persisted + await pollUntil( + async () => { + const result = await channel.history(); + return result.items.length === 12 ? result : null; + }, + { interval: 500, timeout: 15000 }, + ); + + const page1 = await channel.history({ limit: 5 }); + const page2 = await page1.next(); + const page3 = await page2.next(); + + expect(page1.items.length).to.equal(5); + expect(page2.items.length).to.equal(5); + expect(page3.items.length).to.equal(2); + + // Verify no duplicate messages across pages + const allIds: string[] = []; + for (const page of [page1, page2, page3]) { + for (const item of page.items) { + expect(allIds).to.not.include(item.id); + allIds.push(item.id); + } + } + + expect(allIds.length).to.equal(12); + }); + + /** + * TG4 - first() retrieves first page + * + * Publish 10 messages, get page1 (limit 3), get page2 via next(), + * get firstPage via page2.first(). firstPage items should match page1 items by id. + */ + it('TG4 - first() retrieves first page', async function () { + const channelName = uniqueChannelName('pagination-first'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get(channelName); + + // Publish 10 messages + for (let i = 1; i <= 10; i++) { + await channel.publish('event-' + i, String(i)); + } + + // Poll until all messages are persisted + await pollUntil( + async () => { + const result = await channel.history(); + return result.items.length === 10 ? result : null; + }, + { interval: 500, timeout: 15000 }, + ); + + const page1 = await channel.history({ limit: 3 }); + const page2 = await page1.next(); + const firstPage = await page2.first(); + + // firstPage should have same items as page1 + expect(firstPage.items.length).to.equal(page1.items.length); + + for (let i = 0; i < firstPage.items.length; i++) { + expect(firstPage.items[i].id).to.equal(page1.items[i].id); + } + }); + + /** + * TG5 - Iterate through all pages + * + * Publish 25 messages, iterate through all pages with limit 7. + * Collect all messages, verify total is 25, all event names present. + */ + it('TG5 - iterate through all pages', async function () { + const channelName = uniqueChannelName('pagination-iterate'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get(channelName); + + const messageCount = 25; + + // Publish 25 messages + for (let i = 1; i <= messageCount; i++) { + await channel.publish('event-' + i, String(i)); + } + + // Poll until all messages are persisted (longer timeout for 25 messages) + await pollUntil( + async () => { + const result = await channel.history(); + return result.items.length === messageCount ? result : null; + }, + { interval: 500, timeout: 30000 }, + ); + + // Iterate through all pages + const allMessages: any[] = []; + let page = await channel.history({ limit: 7 }); + + while (true) { + allMessages.push(...page.items); + + if (!page.hasNext()) { + break; + } + + page = await page.next(); + } + + expect(allMessages.length).to.equal(messageCount); + + // Verify all messages retrieved + const eventNames = allMessages.map((msg: any) => msg.name); + for (let i = 1; i <= messageCount; i++) { + expect(eventNames).to.include('event-' + i); + } + }); + + /** + * TG - next() on last page returns null + * + * Publish 3 messages, request with limit 10 (larger than message count). + * All items fit on one page. hasNext() false, isLast() true. + * next() returns null or empty result. + */ + it('TG - next() on last page returns null', async function () { + const channelName = uniqueChannelName('pagination-lastnext'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get(channelName); + + // Publish 3 messages + for (let i = 1; i <= 3; i++) { + await channel.publish('event-' + i, String(i)); + } + + // Poll until messages are persisted + await pollUntil( + async () => { + const result = await channel.history(); + return result.items.length === 3 ? result : null; + }, + { interval: 500, timeout: 10000 }, + ); + + const page = await channel.history({ limit: 10 }); + + expect(page.items.length).to.equal(3); + expect(page.hasNext()).to.equal(false); + expect(page.isLast()).to.equal(true); + + // Calling next() should return null (or empty result) + const nextPage = await page.next(); + if (nextPage !== null) { + expect(nextPage.items.length).to.equal(0); + } + }); +}); diff --git a/test/uts/rest/integration/presence.test.ts b/test/uts/rest/integration/presence.test.ts new file mode 100644 index 0000000000..16eb04a4c6 --- /dev/null +++ b/test/uts/rest/integration/presence.test.ts @@ -0,0 +1,585 @@ +/** + * UTS Integration: REST Presence Tests + * + * Spec points: RSP1, RSP3, RSP3a, RSP4, RSP4b, RSP5 + * Source: uts/rest/integration/presence.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + trackClient, + connectAndWait, + closeAndWait, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +describe('uts/rest/integration/presence', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + // --------------------------------------------------------------------------- + // RSP1 - RestPresence accessible via channel + // --------------------------------------------------------------------------- + + /** + * RSP1_Integration - Access presence from channel + * + * channel.presence must exist and not be null. + */ + it('RSP1_Integration - presence accessible on channel', function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const presence = channel.presence; + + expect(presence).to.not.be.null; + expect(presence).to.not.be.undefined; + expect(presence).to.be.an('object'); + }); + + // --------------------------------------------------------------------------- + // RSP3 - RestPresence#get + // --------------------------------------------------------------------------- + + /** + * RSP3_Integration_1 - Get presence members from fixture channel + * + * get() returns a PaginatedResult containing current presence members. + * The fixture channel has at least 5 pre-populated members. + */ + it('RSP3_Integration_1 - get returns presence members from fixture channel', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const result = await channel.presence.get({}); + + expect(result.items).to.be.an('array'); + expect(result.items.length).to.be.at.least(5); + + // Verify expected clients are present + const clientIds = result.items.map((msg: any) => msg.clientId); + expect(clientIds).to.include('client_bool'); + expect(clientIds).to.include('client_string'); + expect(clientIds).to.include('client_json'); + }); + + /** + * RSP3_Integration_2 - Get returns PresenceMessage with correct fields + * + * Each item has action, clientId, data, and connectionId. + */ + it('RSP3_Integration_2 - get returns PresenceMessage with correct fields', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const result = await channel.presence.get({}); + + // Find client_string member + const member = result.items.find((msg: any) => msg.clientId === 'client_string'); + + expect(member).to.not.be.undefined; + expect(member!.action).to.equal('present'); + expect(member!.clientId).to.equal('client_string'); + expect(member!.data).to.equal('This is a string clientData payload'); + expect(member!.connectionId).to.not.be.null; + expect(member!.connectionId).to.not.be.undefined; + }); + + /** + * RSP3a1_Integration - Get with limit parameter + * + * limit param restricts the number of presence members returned. + */ + it('RSP3a1_Integration - get with limit parameter', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const result = await channel.presence.get({ limit: 2 }); + + expect(result.items.length).to.be.at.most(2); + + // If more members exist, pagination should be available + if (result.hasNext()) { + expect(result.items.length).to.equal(2); + } + }); + + /** + * RSP3a2_Integration - Get with clientId filter + * + * clientId param filters results to the specified client. + */ + it('RSP3a2_Integration - get with clientId filter', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const result = await channel.presence.get({ clientId: 'client_json' }); + + expect(result.items.length).to.equal(1); + expect(result.items[0].clientId).to.equal('client_json'); + expect(result.items[0].data).to.be.a('string'); + expect(result.items[0].data).to.equal('{ "test": "This is a JSONObject clientData payload"}'); + }); + + /** + * RSP3_Integration_Empty - Get on channel with no presence + * + * get() returns empty PaginatedResult when no members are present. + */ + it('RSP3_Integration_Empty - get on empty channel returns empty result', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('presence-empty'); + const channel = client.channels.get(channelName); + + const result = await channel.presence.get({}); + + expect(result.items).to.be.an('array'); + expect(result.items.length).to.equal(0); + expect(result.hasNext()).to.be.false; + }); + + // --------------------------------------------------------------------------- + // RSP4 - RestPresence#history + // --------------------------------------------------------------------------- + + /** + * RSP4_Integration_1 - History returns presence events + * + * Creates presence history by entering, updating, and leaving a channel + * via a Realtime client, then retrieves history via REST. + */ + it('RSP4_Integration_1 - history returns presence events', async function () { + const channelName = uniqueChannelName('presence-history'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // Use realtime client to generate presence history + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'test-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + + await connectAndWait(realtime); + + const realtimeChannel = realtime.channels.get(channelName); + await realtimeChannel.presence.enter('entered'); + await realtimeChannel.presence.update('updated'); + await realtimeChannel.presence.leave('left'); + + await closeAndWait(realtime); + + // Poll REST history until events appear + const restChannel = client.channels.get(channelName); + + const history = await pollUntil(async () => { + const result = await restChannel.presence.history({}); + return result.items.length >= 3 ? result : null; + }, { + interval: 500, + timeout: 10000, + }); + + expect(history!.items.length).to.be.at.least(3); + + // Check for expected actions (order depends on direction) + const actions = history!.items.map((msg: any) => msg.action); + expect(actions).to.include('enter'); + expect(actions).to.include('update'); + expect(actions).to.include('leave'); + }); + + /** + * RSP4b1_Integration - History with start/end time range + * + * start and end params filter history by timestamp range. + */ + it('RSP4b1_Integration - history with start/end time range', async function () { + const channelName = uniqueChannelName('presence-history-time'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // Record time before any presence events + const timeBefore = Date.now(); + + // Generate presence events via realtime + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'time-test-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + + await connectAndWait(realtime); + + const realtimeChannel = realtime.channels.get(channelName); + await realtimeChannel.presence.enter('test'); + await realtimeChannel.presence.leave(); + + await closeAndWait(realtime); + + const timeAfter = Date.now(); + + // Poll until events appear + const restChannel = client.channels.get(channelName); + await pollUntil(async () => { + const result = await restChannel.presence.history({}); + return result.items.length >= 2 ? true : null; + }, { + interval: 500, + timeout: 10000, + }); + + // Query with time range + const history = await restChannel.presence.history({ + start: timeBefore, + end: timeAfter, + }); + + expect(history.items.length).to.be.at.least(2); + }); + + /** + * RSP4b2_Integration - History direction forwards + * + * direction param controls event ordering (forwards = oldest first). + */ + it('RSP4b2_Integration - history direction forwards', async function () { + const channelName = uniqueChannelName('presence-direction'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // Generate ordered presence events + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'direction-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + + await connectAndWait(realtime); + + const realtimeChannel = realtime.channels.get(channelName); + await realtimeChannel.presence.enter('first'); + await realtimeChannel.presence.update('second'); + await realtimeChannel.presence.update('third'); + + await closeAndWait(realtime); + + // Poll until events appear + const restChannel = client.channels.get(channelName); + await pollUntil(async () => { + const result = await restChannel.presence.history({}); + return result.items.length >= 3 ? true : null; + }, { + interval: 500, + timeout: 10000, + }); + + // Get history forwards (oldest first) + const historyForwards = await restChannel.presence.history({ direction: 'forwards' }); + + expect(historyForwards.items.length).to.be.at.least(3); + expect(historyForwards.items[0].data).to.equal('first'); + + // Get history backwards (newest first) - default + const historyBackwards = await restChannel.presence.history({ direction: 'backwards' }); + + expect(historyBackwards.items[0].data).to.equal('third'); + }); + + /** + * RSP4b3_Integration - History with limit and pagination + * + * limit param restricts history results and enables pagination. + */ + it('RSP4b3_Integration - history with limit and pagination', async function () { + const channelName = uniqueChannelName('presence-limit'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // Generate multiple presence events + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'limit-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + + await connectAndWait(realtime); + + const realtimeChannel = realtime.channels.get(channelName); + for (let i = 1; i <= 5; i++) { + await realtimeChannel.presence.update('update-' + i); + } + + await closeAndWait(realtime); + + // Poll until all events appear + const restChannel = client.channels.get(channelName); + await pollUntil(async () => { + const result = await restChannel.presence.history({}); + return result.items.length >= 5 ? true : null; + }, { + interval: 500, + timeout: 10000, + }); + + // Request with small limit + const page1 = await restChannel.presence.history({ limit: 2 }); + + expect(page1.items.length).to.equal(2); + expect(page1.hasNext()).to.be.true; + + // Get next page + const page2 = await page1.next(); + + expect(page2).to.not.be.null; + expect(page2!.items.length).to.be.at.least(1); + }); + + // --------------------------------------------------------------------------- + // RSP5 - Presence message decoding + // --------------------------------------------------------------------------- + + /** + * RSP5_Integration_1 - String data decoded correctly + * + * Presence message data is decoded according to its encoding. + */ + it('RSP5_Integration_1 - string data decoded from fixtures', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const result = await channel.presence.get({ clientId: 'client_string' }); + + expect(result.items.length).to.equal(1); + expect(result.items[0].data).to.be.a('string'); + expect(result.items[0].data).to.equal('This is a string clientData payload'); + }); + + /** + * RSP5_Integration_2 - JSON data decoded to object + * + * JSON-encoded presence data is decoded to native objects. + */ + it('RSP5_Integration_2 - JSON data decoded from fixtures', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures'); + const result = await channel.presence.get({ clientId: 'client_decoded' }); + + expect(result.items.length).to.equal(1); + expect(result.items[0].data).to.be.an('object'); + expect(result.items[0].data.example.json).to.equal('Object'); + }); + + /** + * RSP5_Integration_3 - Encrypted data decoded with cipher + * + * Encrypted presence data is automatically decrypted when cipher is configured. + */ + it('RSP5_Integration_3 - encrypted data decoded with cipher', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channel = client.channels.get('persisted:presence_fixtures', { + cipher: { key: Buffer.from('WUP6u0K7MXI5Zeo0VppPwg==', 'base64') }, + }); + + const result = await channel.presence.get({ clientId: 'client_encoded' }); + + // The encrypted fixture should be decrypted + expect(result.items.length).to.equal(1); + expect(result.items[0].data).to.not.be.null; + expect(result.items[0].data).to.not.be.undefined; + }); + + /** + * RSP5_Integration_4 - History messages also decoded + * + * Presence history messages are decoded the same way as current presence. + */ + it('RSP5_Integration_4 - presence history with JSON data decoded', async function () { + const channelName = uniqueChannelName('presence-decode-history'); + + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // Generate presence event with JSON data + const realtime = new Ably.Realtime({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + clientId: 'decode-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(realtime); + + await connectAndWait(realtime); + + const jsonData = { key: 'value', number: 123 }; + const realtimeChannel = realtime.channels.get(channelName); + await realtimeChannel.presence.enter(jsonData); + + await closeAndWait(realtime); + + // Poll and retrieve history + const restChannel = client.channels.get(channelName); + const history = await pollUntil(async () => { + const result = await restChannel.presence.history({}); + return result.items.length >= 1 ? result : null; + }, { + interval: 500, + timeout: 10000, + }); + + expect(history!.items[0].data).to.be.an('object'); + expect(history!.items[0].data.key).to.equal('value'); + expect(history!.items[0].data.number).to.equal(123); + }); + + // --------------------------------------------------------------------------- + // Pagination + // --------------------------------------------------------------------------- + + /** + * RSP_Pagination_Integration - Full pagination through presence members + * + * Paginate through all fixture members with limit 2. + */ + it('RSP_Pagination_Integration - paginate through all fixture members', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + // The fixture channel has multiple members + const channel = client.channels.get('persisted:presence_fixtures'); + + // Request with small limit to force pagination + const page1 = await channel.presence.get({ limit: 2 }); + + const allMembers: any[] = []; + allMembers.push(...page1.items); + + let currentPage: any = page1; + while (currentPage.hasNext()) { + currentPage = await currentPage.next(); + allMembers.push(...currentPage.items); + } + + // Should have retrieved all fixture members + expect(allMembers.length).to.be.at.least(5); + + // Verify no duplicates + const clientIds = allMembers.map((m: any) => m.clientId); + const uniqueClientIds = new Set(clientIds); + expect(uniqueClientIds.size).to.equal(clientIds.length); + }); + + // --------------------------------------------------------------------------- + // Error Handling + // --------------------------------------------------------------------------- + + /** + * RSP_Error_Integration_1 - Invalid credentials rejected + * + * Presence operations with invalid credentials return authentication errors. + */ + it('RSP_Error_Integration_1 - invalid credentials rejected', async function () { + const client = new Ably.Rest({ + key: 'invalid.key:secret', + endpoint: SANDBOX_ENDPOINT, + }); + + try { + await client.channels.get('test').presence.get({}); + expect.fail('Expected presence.get() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + expect(error.code).to.be.at.least(40100); + expect(error.code).to.be.below(40200); + } + }); + + /** + * RSP_Error_Integration_2 - Subscribe-only key can still do presence.get() + * + * Subscribe capability is sufficient for presence.get(). + */ + it('RSP_Error_Integration_2 - subscribe-only key can do presence.get()', async function () { + const client = new Ably.Rest({ + key: getApiKey(3), + endpoint: SANDBOX_ENDPOINT, + }); + + // This should work - subscribe capability is sufficient for presence.get + const result = await client.channels.get('persisted:presence_fixtures').presence.get({}); + expect(result).to.not.be.null; + expect(result).to.not.be.undefined; + }); +}); diff --git a/test/uts/rest/integration/publish.test.ts b/test/uts/rest/integration/publish.test.ts new file mode 100644 index 0000000000..a1aa1c885f --- /dev/null +++ b/test/uts/rest/integration/publish.test.ts @@ -0,0 +1,195 @@ +/** + * UTS Integration: REST Channel Publish Tests + * + * Spec points: RSL1d, RSL1n, RSL1k5, RSL1l1, RSL1m4 + * Source: uts/rest/integration/publish.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, + pollUntil, +} from './sandbox'; + +describe('uts/rest/integration/publish', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSL1d - Error indication on publish failure + * + * Failed publish operations must indicate the error to the caller. + * Publishing to a channel not in the restricted key's capability should fail. + */ + it('RSL1d - publish failure with restricted key returns error', async function () { + const channelName = uniqueChannelName('forbidden-channel'); + + const restrictedClient = new Ably.Rest({ + key: getApiKey(2), // per-channel capabilities + endpoint: SANDBOX_ENDPOINT, + }); + + const restrictedChannel = restrictedClient.channels.get(channelName); + + try { + await restrictedChannel.publish('event', 'data'); + expect.fail('Publish should have failed with restricted key'); + } catch (error: any) { + expect(error.code).to.equal(40160); + expect(error.statusCode).to.equal(401); + } + }); + + /** + * RSL1n - PublishResult contains serials + * + * Successful publish returns a PublishResult containing message serials. + */ + it('RSL1n - single message publish returns result with serial', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('test-serials'); + const channel = client.channels.get(channelName); + + const result = await channel.publish('event1', 'data1'); + + expect(result.serials).to.be.an('array'); + expect(result.serials).to.have.length(1); + expect(result.serials[0]).to.be.a('string'); + expect((result.serials[0] as string).length).to.be.greaterThan(0); + }); + + it('RSL1n - multiple message publish returns result with unique serials', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('test-serials-multi'); + const channel = client.channels.get(channelName); + + const result = await channel.publish([ + { name: 'event2', data: 'data2' }, + { name: 'event3', data: 'data3' }, + { name: 'event4', data: 'data4' }, + ]); + + expect(result.serials).to.be.an('array'); + expect(result.serials).to.have.length(3); + + for (const serial of result.serials) { + expect(serial).to.be.a('string'); + expect((serial as string).length).to.be.greaterThan(0); + } + + // All serials should be unique + const uniqueSerials = new Set(result.serials); + expect(uniqueSerials.size).to.equal(result.serials.length); + }); + + /** + * RSL1k5 - Idempotent publish with client-supplied IDs + * + * Messages with client-supplied IDs are idempotent; duplicate IDs + * don't create duplicate messages. + */ + it('RSL1k5 - idempotent publish with client-supplied ID', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('idempotent-explicit'); + const channel = client.channels.get(channelName); + + const fixedId = 'client-supplied-id-' + Math.random().toString(36).substring(2, 10); + + // Publish same message ID multiple times + for (let i = 1; i <= 3; i++) { + await channel.publish({ id: fixedId, name: 'event', data: 'data-' + i }); + } + + // Poll history until message appears + const history = await pollUntil(async () => { + const result = await channel.history(null); + if (result.items.length > 0) return result; + return null; + }, { interval: 500, timeout: 10000 }); + + // Verify only one message in history (duplicates were deduplicated) + expect(history.items).to.have.length(1); + expect(history.items[0].id).to.equal(fixedId); + // The data should be from the first publish (subsequent ones are no-ops) + expect(history.items[0].data).to.equal('data-1'); + }); + + /** + * RSL1l1 - Publish params with _forceNack + * + * Additional publish params can be supplied and are transmitted to the server. + * The _forceNack test param causes the server to reject the publish. + */ + it('RSL1l1 - publish with _forceNack param is rejected', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('force-nack-test'); + const channel = client.channels.get(channelName); + + try { + await channel.publish({ name: 'event', data: 'data' }, { _forceNack: 'true' }); + expect.fail('Publish with _forceNack should have failed'); + } catch (error: any) { + expect(error.code).to.equal(40099); + } + }); + + /** + * RSL1m4 - ClientId mismatch rejection + * + * Server rejects messages where clientId doesn't match the authenticated client. + */ + it('RSL1m4 - clientId mismatch in message is rejected', async function () { + // Create a token with a specific clientId + const keyClient = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const tokenDetails = await keyClient.auth.requestToken({ clientId: 'authenticated-client-id' }); + + // Client using token with clientId + const tokenClient = new Ably.Rest({ + token: tokenDetails.token, + endpoint: SANDBOX_ENDPOINT, + }); + + const channelName = uniqueChannelName('clientid-mismatch'); + const channel = tokenClient.channels.get(channelName); + + try { + await channel.publish({ name: 'event', data: 'data', clientId: 'different-client-id' }); + expect.fail('Publish with mismatched clientId should have failed'); + } catch (error: any) { + expect(error.code).to.equal(40012); + expect(error.statusCode).to.equal(400); + } + }); +}); diff --git a/test/uts/rest/integration/push_admin.test.ts b/test/uts/rest/integration/push_admin.test.ts new file mode 100644 index 0000000000..287c1fbf10 --- /dev/null +++ b/test/uts/rest/integration/push_admin.test.ts @@ -0,0 +1,567 @@ +/** + * UTS Integration: Push Admin Tests + * + * Spec points: RSH1, RSH1a, RSH1b1, RSH1b2, RSH1b3, RSH1b4, RSH1b5, RSH1c1, RSH1c2, RSH1c3, RSH1c4, RSH1c5 + * Source: uts/rest/integration/push_admin.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, +} from './sandbox'; + +function randomId(): string { + return Math.random().toString(36).substring(2, 10); +} + +describe('uts/rest/integration/push_admin', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + // --------------------------------------------------------------------------- + // RSH1a — Push publish + // --------------------------------------------------------------------------- + + /** + * RSH1a - publish sends push notification to clientId + * + * Publishes a push notification to a clientId recipient. The sandbox + * accepts the request even though no real device receives it. + */ + it('RSH1a - publish to clientId recipient should not throw', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + await client.push.admin.publish( + { clientId: 'test-client-push' }, + { + notification: { + title: 'Integration Test', + body: 'Hello from push admin', + }, + }, + ); + }); + + /** + * RSH1a - publish rejects invalid recipient + * + * An empty recipient object should cause the server to return an error. + */ + it('RSH1a - publish with empty recipient throws error', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + try { + await client.push.admin.publish( + {}, + { notification: { title: 'Test' } }, + ); + expect.fail('Publish with empty recipient should have failed'); + } catch (error: any) { + expect(error.code).to.not.be.null; + } + }); + + // --------------------------------------------------------------------------- + // RSH1b — Device registrations + // --------------------------------------------------------------------------- + + /** + * RSH1b3, RSH1b1 - save and get device registration + * + * Saves a device registration, then retrieves it by ID and verifies + * the returned fields. + */ + it('RSH1b3, RSH1b1 - save and get device registration', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const deviceId = 'test-device-' + randomId(); + + try { + const saved = await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'test-token-' + randomId() }, + }, + }); + + expect(saved).to.be.an('object'); + expect(saved.id).to.equal(deviceId); + expect(saved.platform).to.equal('ios'); + expect(saved.formFactor).to.equal('phone'); + expect(saved.push!.recipient!.transportType).to.equal('apns'); + + // Retrieve the same device + const retrieved = await client.push.admin.deviceRegistrations.get(deviceId); + expect(retrieved).to.be.an('object'); + expect(retrieved.id).to.equal(deviceId); + expect(retrieved.platform).to.equal('ios'); + } finally { + await client.push.admin.deviceRegistrations.remove(deviceId); + } + }); + + /** + * RSH1b3 - save updates existing device registration + * + * Saving a device with the same ID but a different token should update + * the existing registration. + */ + it('RSH1b3 - save updates existing device registration', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const deviceId = 'test-device-update-' + randomId(); + + try { + // Initial save + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-v1' }, + }, + }); + + // Update with new token + const updated = await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-v2' }, + }, + }); + + expect(updated.id).to.equal(deviceId); + expect(updated.push!.recipient!.deviceToken).to.equal('token-v2'); + + // Verify via get + const retrieved = await client.push.admin.deviceRegistrations.get(deviceId); + expect(retrieved.push!.recipient!.deviceToken).to.equal('token-v2'); + } finally { + await client.push.admin.deviceRegistrations.remove(deviceId); + } + }); + + /** + * RSH1b1 - get returns error for unknown device + * + * Retrieving a nonexistent device must return a 404 error. + */ + it('RSH1b1 - get unknown device throws 404', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + try { + await client.push.admin.deviceRegistrations.get('nonexistent-device-' + randomId()); + expect.fail('Get should have failed for nonexistent device'); + } catch (error: any) { + expect(error.statusCode).to.equal(404); + } + }); + + /** + * RSH1b2 - list device registrations with filters + * + * Lists device registrations filtered by deviceId. The result should be + * a PaginatedResult containing exactly the registered device. + */ + it('RSH1b2 - list device registrations filtered by deviceId', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const deviceId = 'test-device-list-' + randomId(); + + try { + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: 'android', + formFactor: 'tablet', + push: { + recipient: { transportType: 'gcm', registrationToken: 'test-token' }, + }, + }); + + const result = await client.push.admin.deviceRegistrations.list({ deviceId }); + + expect(result.items).to.have.length(1); + expect((result.items[0] as any).id).to.equal(deviceId); + expect((result.items[0] as any).platform).to.equal('android'); + } finally { + await client.push.admin.deviceRegistrations.remove(deviceId); + } + }); + + /** + * RSH1b2 - list supports pagination with limit + * + * Registering 3 devices with the same clientId, then listing with limit=2 + * should return at most 2 items and indicate more pages are available. + */ + it('RSH1b2 - list supports pagination with limit', async function () { + if (!process.env.RUN_DEVIATIONS) this.skip(); // push admin API does not return Link headers for pagination; see ably/realtime#8380 + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const clientId = 'test-client-list-' + randomId(); + const deviceIds: string[] = []; + + try { + // Register 3 devices with the same clientId + for (let i = 1; i <= 3; i++) { + const deviceId = 'test-device-limit-' + i + '-' + randomId(); + deviceIds.push(deviceId); + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + clientId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-' + i }, + }, + }); + } + + const result = await client.push.admin.deviceRegistrations.list({ + clientId, + limit: '2', + }); + + expect(result.items.length).to.be.at.most(2); + expect(result.hasNext()).to.equal(true); + } finally { + for (const deviceId of deviceIds) { + await client.push.admin.deviceRegistrations.remove(deviceId); + } + } + }); + + /** + * RSH1b4 - remove deletes device registration + * + * Saves a device, removes it, then verifies it is no longer retrievable. + */ + it('RSH1b4 - remove deletes device registration', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const deviceId = 'test-device-remove-' + randomId(); + + // Register a device + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'test-token' }, + }, + }); + + // Remove the device + await client.push.admin.deviceRegistrations.remove(deviceId); + + // Verify it's gone + try { + await client.push.admin.deviceRegistrations.get(deviceId); + expect.fail('Get should have failed for removed device'); + } catch (error: any) { + expect(error.statusCode).to.equal(404); + } + }); + + /** + * RSH1b4 - remove succeeds for nonexistent device + * + * Removing a device that does not exist should not throw. + */ + it('RSH1b4 - remove nonexistent device does not throw', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + await client.push.admin.deviceRegistrations.remove('nonexistent-device-' + randomId()); + }); + + /** + * RSH1b5 - removeWhere deletes devices by clientId + * + * Registers two devices with the same clientId, removes them all via + * removeWhere, then verifies none remain. + */ + it('RSH1b5 - removeWhere deletes devices by clientId', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const clientId = 'test-client-removeWhere-' + randomId(); + const deviceIds: string[] = []; + + // Register two devices with the same clientId + for (let i = 1; i <= 2; i++) { + const deviceId = 'test-device-rw-' + i + '-' + randomId(); + deviceIds.push(deviceId); + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + clientId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'token-' + i }, + }, + }); + } + + // Remove all devices for this clientId + await client.push.admin.deviceRegistrations.removeWhere({ clientId }); + + // Verify both are gone + const result = await client.push.admin.deviceRegistrations.list({ clientId }); + expect(result.items).to.have.length(0); + }); + + // --------------------------------------------------------------------------- + // RSH1c — Channel subscriptions + // --------------------------------------------------------------------------- + + /** + * RSH1c3, RSH1c1 - save and list channel subscriptions + * + * Registers a device, saves a channel subscription for it, then lists + * subscriptions on that channel and verifies the subscription appears. + */ + it('RSH1c3, RSH1c1 - save and list channel subscription by deviceId', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const deviceId = 'test-device-sub-' + randomId(); + const channelName = 'pushenabled:test-sub-' + randomId(); + + try { + // Register a device first (required for deviceId subscriptions) + await client.push.admin.deviceRegistrations.save({ + id: deviceId, + platform: 'ios', + formFactor: 'phone', + push: { + recipient: { transportType: 'apns', deviceToken: 'test-token' }, + }, + }); + + // Save a channel subscription + const saved = await client.push.admin.channelSubscriptions.save({ + channel: channelName, + deviceId, + }); + + expect(saved).to.be.an('object'); + expect(saved.channel).to.equal(channelName); + expect(saved.deviceId).to.equal(deviceId); + + // List subscriptions for this channel + const result = await client.push.admin.channelSubscriptions.list({ channel: channelName }); + expect(result.items.length).to.be.at.least(1); + + let found = false; + for (const sub of result.items) { + if ((sub as any).deviceId === deviceId) { + found = true; + expect((sub as any).channel).to.equal(channelName); + } + } + expect(found).to.equal(true); + } finally { + await client.push.admin.channelSubscriptions.remove({ + channel: channelName, + deviceId, + }); + await client.push.admin.deviceRegistrations.remove(deviceId); + } + }); + + /** + * RSH1c3 - save channel subscription with clientId + * + * Saves a clientId-based channel subscription and verifies the response. + */ + it('RSH1c3 - save channel subscription with clientId', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const clientId = 'test-client-sub-' + randomId(); + const channelName = 'pushenabled:test-clientsub-' + randomId(); + + try { + const saved = await client.push.admin.channelSubscriptions.save({ + channel: channelName, + clientId, + }); + + expect(saved.channel).to.equal(channelName); + expect(saved.clientId).to.equal(clientId); + } finally { + await client.push.admin.channelSubscriptions.remove({ + channel: channelName, + clientId, + }); + } + }); + + /** + * RSH1c2 - listChannels returns channel names with subscriptions + * + * Creates a clientId subscription, then verifies the channel appears + * in the listChannels result. + */ + it('RSH1c2 - listChannels includes channel with active subscription', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const clientId = 'test-client-lc-' + randomId(); + const channelName = 'pushenabled:test-listchannels-' + randomId(); + + try { + // Create a subscription to ensure the channel appears + await client.push.admin.channelSubscriptions.save({ + channel: channelName, + clientId, + }); + + const result = await client.push.admin.channelSubscriptions.listChannels({}); + + expect(result.items).to.be.an('array'); + expect(result.items).to.include(channelName); + } finally { + await client.push.admin.channelSubscriptions.remove({ + channel: channelName, + clientId, + }); + } + }); + + /** + * RSH1c4 - remove deletes channel subscription + * + * Creates a subscription, removes it, then verifies it no longer appears + * in list results. + */ + it('RSH1c4 - remove deletes channel subscription', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const clientId = 'test-client-rm-' + randomId(); + const channelName = 'pushenabled:test-remove-' + randomId(); + + // Create a subscription + await client.push.admin.channelSubscriptions.save({ + channel: channelName, + clientId, + }); + + // Remove the subscription + await client.push.admin.channelSubscriptions.remove({ + channel: channelName, + clientId, + }); + + // Verify it's gone + const result = await client.push.admin.channelSubscriptions.list({ + channel: channelName, + clientId, + }); + expect(result.items).to.have.length(0); + }); + + /** + * RSH1c4 - remove succeeds for nonexistent subscription + * + * Removing a subscription that does not exist should not throw. + */ + it('RSH1c4 - remove nonexistent subscription does not throw', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + await client.push.admin.channelSubscriptions.remove({ + channel: 'pushenabled:nonexistent-' + randomId(), + clientId: 'nonexistent-client', + }); + }); + + /** + * RSH1c5 - removeWhere deletes subscriptions by clientId + * + * Creates subscriptions on two channels for the same clientId, removes + * them all via removeWhere, then verifies none remain. + */ + it('RSH1c5 - removeWhere deletes subscriptions by clientId', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const clientId = 'test-client-rwsub-' + randomId(); + const channelNames: string[] = []; + + // Create subscriptions on two channels for the same clientId + for (let i = 1; i <= 2; i++) { + const ch = 'pushenabled:test-rwsub-' + i + '-' + randomId(); + channelNames.push(ch); + await client.push.admin.channelSubscriptions.save({ + channel: ch, + clientId, + }); + } + + // Remove all subscriptions for this clientId + await client.push.admin.channelSubscriptions.removeWhere({ clientId }); + + // Verify they're all gone + const result = await client.push.admin.channelSubscriptions.list({ clientId }); + expect(result.items).to.have.length(0); + }); +}); diff --git a/test/uts/rest/integration/revoke_tokens.test.ts b/test/uts/rest/integration/revoke_tokens.test.ts new file mode 100644 index 0000000000..bc6714f792 --- /dev/null +++ b/test/uts/rest/integration/revoke_tokens.test.ts @@ -0,0 +1,199 @@ +/** + * UTS Integration: Revoke Tokens Tests + * + * Spec points: RSA17, RSA17b, RSA17c, RSA17d, RSA17e, RSA17f, RSA17g, TRS2, TRF2 + * Source: uts/rest/integration/revoke_tokens.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + uniqueChannelName, + generateJWT, + trackClient, + connectAndWait, + closeAndWait, +} from './sandbox'; + +describe('uts/rest/integration/revoke_tokens', function () { + this.timeout(60000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSA17g, RSA17b, RSA17c, TRS2 - Token revocation prevents subsequent use + * + * Auth#revokeTokens sends a POST to /keys/{keyName}/revokeTokens with targets + * as type:value strings, and returns a result containing per-target success + * information. Revocation is verified via a Realtime client that gets + * disconnected with error code 40141. + */ + it('RSA17g, RSA17b, RSA17c, TRS2 - token revocation prevents subsequent use', async function () { + const clientId = 'revoke-client-' + Math.random().toString(36).substring(2, 10); + + const keyClient = new Ably.Rest({ + key: getApiKey(4), + endpoint: SANDBOX_ENDPOINT, + }); + + const tokenDetails = await keyClient.auth.requestToken({ clientId }); + + const realtimeClient = new Ably.Realtime({ + token: tokenDetails, + endpoint: SANDBOX_ENDPOINT, + }); + trackClient(realtimeClient); + await connectAndWait(realtimeClient); + + const disconnectedPromise = new Promise((resolve) => { + realtimeClient.connection.once('disconnected', resolve); + }); + + const revokeResult = await keyClient.auth.revokeTokens([ + { type: 'clientId', value: clientId }, + ]); + + expect(revokeResult.successCount).to.equal(1); + expect(revokeResult.failureCount).to.equal(0); + expect(revokeResult.results).to.have.length(1); + + const success = revokeResult.results[0] as any; + expect(success.target).to.equal('clientId:' + clientId); + expect(success.issuedBefore).to.be.a('number'); + expect(success.appliesAt).to.be.a('number'); + + const stateChange = await disconnectedPromise; + expect(stateChange.reason.code).to.equal(40141); + + await closeAndWait(realtimeClient); + }); + + /** + * RSA17d - Token auth client rejected + * + * If called from a client using token authentication, should raise an error + * with code 40162 and status code 401. This is a client-side check -- no + * HTTP request is made to the server. + */ + it('RSA17d - token auth client rejected', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey(4)); + const jwt = generateJWT({ + keyName, + keySecret, + ttl: 3600000, + }); + + const tokenRest = new Ably.Rest({ + token: jwt, + endpoint: SANDBOX_ENDPOINT, + }); + + try { + await tokenRest.auth.revokeTokens([ + { type: 'clientId', value: 'anyone' }, + ]); + expect.fail('revokeTokens should have failed with token auth client'); + } catch (error: any) { + expect(error.code).to.equal(40162); + expect(error.statusCode).to.equal(401); + } + }); + + /** + * RSA17e, RSA17f - issuedBefore and allowReauthMargin + * + * When issuedBefore is provided, only tokens issued before that timestamp are + * revoked. When allowReauthMargin is true, the revocation is delayed by + * approximately 30 seconds to allow token renewal. + */ + it('RSA17e, RSA17f - issuedBefore and allowReauthMargin', async function () { + const clientId = 'revoke-margin-client-' + Math.random().toString(36).substring(2, 10); + + const keyClient = new Ably.Rest({ + key: getApiKey(4), + endpoint: SANDBOX_ENDPOINT, + }); + + const serverTime = await keyClient.time(); + const issuedBefore = serverTime - 20 * 60 * 1000; + + const revokeResult = await keyClient.auth.revokeTokens( + [{ type: 'clientId', value: clientId }], + { issuedBefore, allowReauthMargin: true }, + ); + + expect(revokeResult.successCount).to.equal(1); + expect(revokeResult.results).to.have.length(1); + + const result = revokeResult.results[0] as any; + + expect(result.issuedBefore).to.equal(issuedBefore); + + const serverTimeThirtySecondsLater = serverTime + 30 * 1000; + expect(result.appliesAt).to.be.greaterThan(serverTimeThirtySecondsLater); + }); + + /** + * RSA17c, TRF2 - Mixed success and failure (invalid specifier type) + * + * The response can contain both successful and failed per-target results. + * An invalid target type produces a failure result with an ErrorInfo. + * The valid revocation is verified via a Realtime client disconnect. + */ + it('RSA17c, TRF2 - mixed success and failure', async function () { + const clientId = 'revoke-mixed-client-' + Math.random().toString(36).substring(2, 10); + + const keyClient = new Ably.Rest({ + key: getApiKey(4), + endpoint: SANDBOX_ENDPOINT, + }); + + const tokenDetails = await keyClient.auth.requestToken({ clientId }); + + const realtimeClient = new Ably.Realtime({ + token: tokenDetails, + endpoint: SANDBOX_ENDPOINT, + }); + trackClient(realtimeClient); + await connectAndWait(realtimeClient); + + const disconnectedPromise = new Promise((resolve) => { + realtimeClient.connection.once('disconnected', resolve); + }); + + const revokeResult = await keyClient.auth.revokeTokens([ + { type: 'clientId', value: clientId }, + { type: 'invalidType', value: 'abc' }, + ]); + + expect(revokeResult.successCount).to.equal(1); + expect(revokeResult.failureCount).to.equal(1); + expect(revokeResult.results).to.have.length(2); + + const success = revokeResult.results[0] as any; + expect(success.target).to.equal('clientId:' + clientId); + expect(success.issuedBefore).to.be.a('number'); + expect(success.appliesAt).to.be.a('number'); + + const failure = revokeResult.results[1] as any; + expect(failure.target).to.equal('invalidType:abc'); + expect(failure.error).to.exist; + expect(failure.error.statusCode).to.equal(400); + + const stateChange = await disconnectedPromise; + expect(stateChange.reason.code).to.equal(40141); + + await closeAndWait(realtimeClient); + }); +}); diff --git a/test/uts/rest/integration/sandbox.ts b/test/uts/rest/integration/sandbox.ts new file mode 100644 index 0000000000..12501f2eb9 --- /dev/null +++ b/test/uts/rest/integration/sandbox.ts @@ -0,0 +1,30 @@ +/** + * Sandbox app provisioning for REST UTS integration tests. + * + * Re-exports the shared sandbox infrastructure from realtime/integration/sandbox.ts, + * plus REST-specific helpers. + */ + +export { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getSandboxApp, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + connectAndWait, + uniqueChannelName, + generateJWT, + pollUntil, +} from '../../realtime/integration/sandbox'; + +import { getSandboxApp } from '../../realtime/integration/sandbox'; + +function getAppId(): string { + return getSandboxApp().appId; +} + +export { getAppId }; diff --git a/test/uts/rest/integration/time_stats.test.ts b/test/uts/rest/integration/time_stats.test.ts new file mode 100644 index 0000000000..edc6e8447c --- /dev/null +++ b/test/uts/rest/integration/time_stats.test.ts @@ -0,0 +1,100 @@ +/** + * UTS Integration: REST Time and Stats Tests + * + * Spec points: RSC16, RSC6 + * Source: uts/rest/integration/time_stats.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, +} from './sandbox'; + +describe('uts/rest/integration/time_stats', function () { + this.timeout(30000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + /** + * RSC16 - time() returns server time + * + * `time()` obtains the current server time. The returned value should be + * reasonably close to the client's local time (within 5 seconds, allowing + * for network latency and minor clock differences). + */ + it('RSC16 - time() returns server time', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const beforeRequest = Date.now(); + const serverTime = await client.time(); + const afterRequest = Date.now(); + + // Server time should be a number (timestamp in milliseconds) + expect(serverTime).to.be.a('number'); + + // Server time should be reasonably close to client time + // (allowing for network latency and minor clock differences) + expect(serverTime).to.be.at.least(beforeRequest - 5000); + expect(serverTime).to.be.at.most(afterRequest + 5000); + }); + + /** + * RSC6 - stats() returns application statistics + * + * `stats()` returns a PaginatedResult containing application statistics. + * Stats may be empty for a new sandbox app, but the call should succeed. + */ + it('RSC6 - stats() returns a PaginatedResult', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.stats(); + + // Result should be a PaginatedResult with an items array + expect(result).to.be.an('object'); + expect(result.items).to.be.an('array'); + + // If there are items, they should have expected structure + if (result.items.length > 0) { + expect(result.items[0].intervalId).to.be.a('string'); + } + }); + + /** + * RSC6 - stats() with parameters + * + * `stats()` supports `limit`, `direction`, and `unit` parameters. + */ + it('RSC6 - stats() with parameters', async function () { + const client = new Ably.Rest({ + key: getApiKey(), + endpoint: SANDBOX_ENDPOINT, + }); + + const result = await client.stats({ + limit: 5, + direction: 'forwards', + unit: 'hour', + }); + + // Should succeed with parameters applied + expect(result).to.be.an('object'); + expect(result.items).to.be.an('array'); + expect(result.items.length).to.be.at.most(5); + }); +});