diff --git a/tests/rest_api/stasis_broadcast/claim_already_taken/claim_already_taken.py b/tests/rest_api/stasis_broadcast/claim_already_taken/claim_already_taken.py new file mode 100644 index 000000000..f681f9806 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_already_taken/claim_already_taken.py @@ -0,0 +1,78 @@ +""" +Copyright (C) 2026, Aurora Innovation AB + +This program is free software, distributed under the terms of +the GNU General Public License Version 2. + +StasisBroadcast duplicate-claim (409) test. + +Two ARI apps ('app-one' and 'app-two') are both registered. Both receive +a CallBroadcast event. The first claim attempt succeeds (HTTP 204); the +second must be rejected with HTTP 409 Conflict. The winning app is +verified via StasisStart. +""" + +import logging + +LOGGER = logging.getLogger(__name__) + + +class _State(object): + success_app = None + conflict_app = None + + +STATE = _State() + + +def on_broadcast(ari, event, test_object): + """Attempt to claim the channel; record 204 success or 409 conflict.""" + channel_id = event['channel']['id'] + application = event.get('application', '') + LOGGER.info("CallBroadcast for app '%s', channel %s", application, channel_id) + + # Allow non-2xx so we can inspect the status code rather than catching + # an HTTPError raised by the default ari.post() error handling. + ari.set_allow_errors(True) + resp = ari.post('events', 'claim', + channelId=channel_id, application=application) + status = resp.status_code + + LOGGER.info("Claim by '%s': HTTP %d", application, status) + + if status == 204: + if STATE.success_app is not None: + LOGGER.error("More than one claim succeeded (first: '%s', now: '%s')", + STATE.success_app, application) + test_object.set_passed(False) + return False + STATE.success_app = application + elif status == 409: + if STATE.conflict_app is not None: + LOGGER.error("More than one claim got 409 (first: '%s', now: '%s')", + STATE.conflict_app, application) + test_object.set_passed(False) + return False + STATE.conflict_app = application + else: + LOGGER.error("Unexpected HTTP %d from claim by '%s'", status, application) + test_object.set_passed(False) + return False + + return True + + +def on_stasis_start(ari, event, test_object): + """Verify the winner app received StasisStart and clean up.""" + channel_id = event['channel']['id'] + application = event.get('application', '') + LOGGER.info("StasisStart for channel %s in app '%s'", channel_id, application) + + if STATE.success_app is not None and application != STATE.success_app: + LOGGER.error("StasisStart arrived in '%s' but winning app was '%s'", + application, STATE.success_app) + test_object.set_passed(False) + return False + + ari.delete('channels', channel_id) + return True diff --git a/tests/rest_api/stasis_broadcast/claim_already_taken/configs/ast1/extensions.conf b/tests/rest_api/stasis_broadcast/claim_already_taken/configs/ast1/extensions.conf new file mode 100644 index 000000000..6cdac741d --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_already_taken/configs/ast1/extensions.conf @@ -0,0 +1,6 @@ +[default] + +exten => s,1,NoOp() + same => n,Answer() + same => n,StasisBroadcast(5000) + same => n,Hangup() diff --git a/tests/rest_api/stasis_broadcast/claim_already_taken/test-config.yaml b/tests/rest_api/stasis_broadcast/claim_already_taken/test-config.yaml new file mode 100644 index 000000000..186e7166c --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_already_taken/test-config.yaml @@ -0,0 +1,69 @@ +testinfo: + summary: 'StasisBroadcast returns HTTP 409 when channel is already claimed.' + description: | + Verifies that when two ARI applications both receive a CallBroadcast + event and both attempt to claim the same channel, the first claim + succeeds (HTTP 204) and the second claim is rejected (HTTP 409 Conflict). + + Two apps ('app-one' and 'app-two') are registered on the same + WebSocket. Both receive CallBroadcast. The first to be processed + claims the channel; the second receives a 409. The winner app is + confirmed via StasisStart. + +test-modules: + add-test-to-search-path: True + test-object: + config-section: test-object-config + typename: ari.AriTestObject + modules: + - + config-section: ari-config + typename: ari.WebSocketEventModule + - + config-section: ari-test-stopper + typename: pluggable_modules.EventActionModule + +test-object-config: + apps: app-one,app-two + stop-on-end: False + reactor-timeout: 15 + +ari-test-stopper: + - + ari-events: + match: + type: StasisEnd + stop_test: + +ari-config: + events: + - + conditions: + match: + type: CallBroadcast + count: 2 + callback: + module: claim_already_taken + method: on_broadcast + - + conditions: + match: + type: StasisStart + count: 1 + callback: + module: claim_already_taken + method: on_stasis_start + +properties: + dependencies: + - python : autobahn.websocket + - python : requests + - python : twisted + - python : starpy + - asterisk : res_stasis_broadcast + - asterisk : app_stasis_broadcast + - asterisk : res_ari_events + - asterisk : res_ari_channels + - asterisk : app_echo + tags: + - ARI diff --git a/tests/rest_api/stasis_broadcast/claim_filter/claim_filter.py b/tests/rest_api/stasis_broadcast/claim_filter/claim_filter.py new file mode 100644 index 000000000..95df27283 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_filter/claim_filter.py @@ -0,0 +1,61 @@ +""" +Copyright (C) 2026, Aurora Innovation AB + +This program is free software, distributed under the terms of +the GNU General Public License Version 2. + +StasisBroadcast app_filter test. + +Two ARI apps are registered: 'ivr-main' and 'support'. The dialplan +invokes StasisBroadcast with app_filter=^ivr-.*, so only 'ivr-main' +should receive the CallBroadcast event. + +The count: 1 constraint on CallBroadcast in the YAML acts as the filter +verification — a second delivery (to 'support') would increment the count +to 2 and cause the test to fail at teardown. +""" + +import logging + +LOGGER = logging.getLogger(__name__) + + +def on_broadcast(ari, event, test_object): + """Claim the channel on behalf of 'ivr-main', asserting it is the recipient.""" + channel_id = event['channel']['id'] + application = event.get('application', '') + LOGGER.info("CallBroadcast for app '%s', channel %s", application, channel_id) + + if application != 'ivr-main': + LOGGER.error("Expected CallBroadcast for 'ivr-main'; received for '%s'", + application) + test_object.set_passed(False) + return False + + ari.set_allow_errors(True) + resp = ari.post('events', 'claim', + channelId=channel_id, application='ivr-main') + + if resp.status_code != 204: + LOGGER.error("Expected HTTP 204 from claim, got %d: %s", + resp.status_code, resp.text) + test_object.set_passed(False) + return False + + LOGGER.info("Channel %s claimed by 'ivr-main'", channel_id) + return True + + +def on_stasis_start(ari, event, test_object): + """Verify the channel entered the correct Stasis app and clean up.""" + channel_id = event['channel']['id'] + application = event.get('application', '') + LOGGER.info("StasisStart for channel %s in app '%s'", channel_id, application) + + if application != 'ivr-main': + LOGGER.error("Expected StasisStart in 'ivr-main', got '%s'", application) + test_object.set_passed(False) + return False + + ari.delete('channels', channel_id) + return True diff --git a/tests/rest_api/stasis_broadcast/claim_filter/configs/ast1/extensions.conf b/tests/rest_api/stasis_broadcast/claim_filter/configs/ast1/extensions.conf new file mode 100644 index 000000000..5a304e960 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_filter/configs/ast1/extensions.conf @@ -0,0 +1,6 @@ +[default] + +exten => s,1,NoOp() + same => n,Answer() + same => n,StasisBroadcast(5000,^ivr-.*) + same => n,Hangup() diff --git a/tests/rest_api/stasis_broadcast/claim_filter/test-config.yaml b/tests/rest_api/stasis_broadcast/claim_filter/test-config.yaml new file mode 100644 index 000000000..9e8104e48 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_filter/test-config.yaml @@ -0,0 +1,73 @@ +testinfo: + summary: 'StasisBroadcast app_filter restricts which apps receive CallBroadcast.' + description: | + Verifies that the app_filter argument (a POSIX extended regex) limits + delivery of CallBroadcast events to matching ARI applications only. + + Two apps are registered: 'ivr-main' (matches '^ivr-.*') and 'support' + (does not match). Only 'ivr-main' should receive CallBroadcast. The + count: 1 assertion on CallBroadcast acts as the filter correctness + check — if 'support' also received the event the count would be 2 and + the test would fail. + + 'ivr-main' then claims the channel and confirms via StasisStart. + +test-modules: + add-test-to-search-path: True + test-object: + config-section: test-object-config + typename: ari.AriTestObject + modules: + - + config-section: ari-config + typename: ari.WebSocketEventModule + - + config-section: ari-test-stopper + typename: pluggable_modules.EventActionModule + +test-object-config: + apps: ivr-main,support + stop-on-end: False + reactor-timeout: 15 + +ari-test-stopper: + - + ari-events: + match: + type: StasisEnd + application: ivr-main + stop_test: + +ari-config: + events: + - + conditions: + match: + type: CallBroadcast + count: 1 + callback: + module: claim_filter + method: on_broadcast + - + conditions: + match: + type: StasisStart + application: ivr-main + count: 1 + callback: + module: claim_filter + method: on_stasis_start + +properties: + dependencies: + - python : autobahn.websocket + - python : requests + - python : twisted + - python : starpy + - asterisk : res_stasis_broadcast + - asterisk : app_stasis_broadcast + - asterisk : res_ari_events + - asterisk : res_ari_channels + - asterisk : app_echo + tags: + - ARI diff --git a/tests/rest_api/stasis_broadcast/claim_success/claim_success.py b/tests/rest_api/stasis_broadcast/claim_success/claim_success.py new file mode 100644 index 000000000..0b4b6d927 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_success/claim_success.py @@ -0,0 +1,54 @@ +""" +Copyright (C) 2026, Aurora Innovation AB + +This program is free software, distributed under the terms of +the GNU General Public License Version 2. + +Nominal StasisBroadcast claim test. + +A channel enters StasisBroadcast via dialplan. The 'testsuite' ARI +application receives a CallBroadcast event, claims the channel via +POST /events/claim, and expects the channel to appear in StasisStart. +""" + +import logging + +LOGGER = logging.getLogger(__name__) + + +def on_broadcast(ari, event, test_object): + """Handle the CallBroadcast event and claim the channel.""" + channel_id = event['channel']['id'] + application = event.get('application', '') + LOGGER.info("CallBroadcast for app '%s', channel %s", application, channel_id) + + # Allow non-2xx responses so we can inspect the status code ourselves + # (the ARI helper raises by default, and 204 triggers a spurious log + # entry due to integer vs. float division in Python 3). + ari.set_allow_errors(True) + resp = ari.post('events', 'claim', + channelId=channel_id, application='testsuite') + + if resp.status_code != 204: + LOGGER.error("Expected HTTP 204 from claim, got %d: %s", + resp.status_code, resp.text) + test_object.set_passed(False) + return False + + LOGGER.info("Channel %s claimed successfully", channel_id) + return True + + +def on_stasis_start(ari, event, test_object): + """Handle StasisStart for the claimed channel and clean up.""" + channel_id = event['channel']['id'] + application = event.get('application', '') + LOGGER.info("StasisStart for channel %s in app '%s'", channel_id, application) + + if application != 'testsuite': + LOGGER.error("Expected StasisStart in 'testsuite', got '%s'", application) + test_object.set_passed(False) + return False + + ari.delete('channels', channel_id) + return True diff --git a/tests/rest_api/stasis_broadcast/claim_success/configs/ast1/extensions.conf b/tests/rest_api/stasis_broadcast/claim_success/configs/ast1/extensions.conf new file mode 100644 index 000000000..6cdac741d --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_success/configs/ast1/extensions.conf @@ -0,0 +1,6 @@ +[default] + +exten => s,1,NoOp() + same => n,Answer() + same => n,StasisBroadcast(5000) + same => n,Hangup() diff --git a/tests/rest_api/stasis_broadcast/claim_success/test-config.yaml b/tests/rest_api/stasis_broadcast/claim_success/test-config.yaml new file mode 100644 index 000000000..ccd34a873 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_success/test-config.yaml @@ -0,0 +1,69 @@ +testinfo: + summary: 'StasisBroadcast nominal claim via ARI.' + description: | + Verifies that when a channel enters StasisBroadcast, a CallBroadcast + event is dispatched to all registered ARI applications, the claiming + application successfully claims the channel via POST /events/claim + (HTTP 204), and the channel subsequently receives a StasisStart event + in the claiming application. + +test-modules: + add-test-to-search-path: True + test-object: + config-section: test-object-config + typename: ari.AriTestObject + modules: + - + config-section: ari-config + typename: ari.WebSocketEventModule + - + config-section: ari-test-stopper + typename: pluggable_modules.EventActionModule + +test-object-config: + apps: testsuite + stop-on-end: False + reactor-timeout: 15 + +ari-test-stopper: + - + ari-events: + match: + type: StasisEnd + application: testsuite + stop_test: + +ari-config: + events: + - + conditions: + match: + type: CallBroadcast + application: testsuite + count: 1 + callback: + module: claim_success + method: on_broadcast + - + conditions: + match: + type: StasisStart + application: testsuite + count: 1 + callback: + module: claim_success + method: on_stasis_start + +properties: + dependencies: + - python : autobahn.websocket + - python : requests + - python : twisted + - python : starpy + - asterisk : res_stasis_broadcast + - asterisk : app_stasis_broadcast + - asterisk : res_ari_events + - asterisk : res_ari_channels + - asterisk : app_echo + tags: + - ARI diff --git a/tests/rest_api/stasis_broadcast/claim_timeout/claim_timeout.py b/tests/rest_api/stasis_broadcast/claim_timeout/claim_timeout.py new file mode 100644 index 000000000..7f80a522f --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_timeout/claim_timeout.py @@ -0,0 +1,45 @@ +""" +Copyright (C) 2026, Aurora Innovation AB + +This program is free software, distributed under the terms of +the GNU General Public License Version 2. + +StasisBroadcast timeout test. + +A channel enters StasisBroadcast with a short timeout. No ARI application +claims the channel. After the timeout expires the dialplan emits a +UserEvent(BroadcastStatus, Status:TIMEOUT). This module verifies that the +UserEvent carries the expected value and then stops the reactor. +""" + +import logging + +LOGGER = logging.getLogger(__name__) + + +class TimeoutTest(object): + """Pluggable module that verifies STASISSTATUS=TIMEOUT via AMI UserEvent.""" + + def __init__(self, module_config, test_object): + self.test_object = test_object + test_object.register_ami_observer(self.on_ami_connect) + + def on_ami_connect(self, ami): + """Called when AMI connects; register for UserEvent notifications.""" + if ami.id != 0: + return + ami.registerEvent('UserEvent', self.on_user_event) + + def on_user_event(self, ami, event): + """Verify the BroadcastStatus UserEvent contains Status=TIMEOUT.""" + if event.get('userevent') != 'BroadcastStatus': + return + + status = event.get('status', '') + LOGGER.info("BroadcastStatus UserEvent received: Status=%s", status) + + if status != 'TIMEOUT': + LOGGER.error("Expected STASISSTATUS=TIMEOUT, got '%s'", status) + self.test_object.set_passed(False) + + self.test_object.stop_reactor() diff --git a/tests/rest_api/stasis_broadcast/claim_timeout/configs/ast1/extensions.conf b/tests/rest_api/stasis_broadcast/claim_timeout/configs/ast1/extensions.conf new file mode 100644 index 000000000..67c111176 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_timeout/configs/ast1/extensions.conf @@ -0,0 +1,7 @@ +[default] + +exten => s,1,NoOp() + same => n,Answer() + same => n,StasisBroadcast(1000) + same => n,UserEvent(BroadcastStatus,Status:${STASISSTATUS}) + same => n,Hangup() diff --git a/tests/rest_api/stasis_broadcast/claim_timeout/test-config.yaml b/tests/rest_api/stasis_broadcast/claim_timeout/test-config.yaml new file mode 100644 index 000000000..0aaf677e1 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/claim_timeout/test-config.yaml @@ -0,0 +1,49 @@ +testinfo: + summary: 'StasisBroadcast sets STASISSTATUS=TIMEOUT when no app claims.' + description: | + Verifies that when no ARI application claims the channel within the + configured timeout period, StasisBroadcast returns control to the + dialplan with STASISSTATUS set to TIMEOUT. + + The test confirms both that the CallBroadcast event was dispatched + (count: 1) and that the AMI UserEvent fired by the dialplan carries + Status=TIMEOUT. + +test-modules: + add-test-to-search-path: True + test-object: + config-section: test-object-config + typename: ari.AriTestObject + modules: + - + config-section: ari-config + typename: ari.WebSocketEventModule + - + typename: claim_timeout.TimeoutTest + +test-object-config: + apps: testsuite + stop-on-end: False + reactor-timeout: 15 + +ari-config: + events: + - + conditions: + match: + type: CallBroadcast + application: testsuite + count: 1 + +properties: + dependencies: + - python : autobahn.websocket + - python : requests + - python : twisted + - python : starpy + - asterisk : res_stasis_broadcast + - asterisk : app_stasis_broadcast + - asterisk : res_ari_events + - asterisk : app_echo + tags: + - ARI diff --git a/tests/rest_api/stasis_broadcast/tests.yaml b/tests/rest_api/stasis_broadcast/tests.yaml new file mode 100644 index 000000000..4fefc89d5 --- /dev/null +++ b/tests/rest_api/stasis_broadcast/tests.yaml @@ -0,0 +1,5 @@ +tests: + - test: 'claim_success' + - test: 'claim_timeout' + - test: 'claim_already_taken' + - test: 'claim_filter' diff --git a/tests/rest_api/tests.yaml b/tests/rest_api/tests.yaml index 08e2bebaa..5cfb3fb52 100644 --- a/tests/rest_api/tests.yaml +++ b/tests/rest_api/tests.yaml @@ -19,4 +19,5 @@ tests: - dir: 'message' - dir: 'external_interaction' - test: 'move' + - dir: 'stasis_broadcast'