From ab946a6c16023227b3b8b26d12566414553f3bb5 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Fri, 28 Aug 2026 23:54:29 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20migrate=20to=20X=20API=20v2=20?= =?UTF-8?q?=E2=80=94=20tweepy.Client,=20create=5Ftweet,=20remove=20bearer?= =?UTF-8?q?=5Ftoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace tweepy.API (v1.1) with tweepy.Client (v2) in _create_client() - Replace update_status() with create_tweet() in process() - Remove bearer_token config option (not needed for OAuth 1.0a write access) - Add proper error handling for tweepy.errors.Forbidden and TweepyException - Update hashtag URL from github.com to git.hemna.com - Update conf help text to reference developer.x.com Closes #8 Closes #9 Closes #10 Closes #11 Closes #13 --- aprsd_twitter_plugin/conf/twitter.py | 37 ++- aprsd_twitter_plugin/twitter.py | 80 +++--- tests/test_aprsd_twitter_plugin.py | 372 +++++++++++++-------------- 3 files changed, 228 insertions(+), 261 deletions(-) diff --git a/aprsd_twitter_plugin/conf/twitter.py b/aprsd_twitter_plugin/conf/twitter.py index 0fd1407..bee3b05 100644 --- a/aprsd_twitter_plugin/conf/twitter.py +++ b/aprsd_twitter_plugin/conf/twitter.py @@ -2,47 +2,44 @@ from oslo_config import cfg twitter_group = cfg.OptGroup( name="aprsd_twitter_plugin", - title="APRSD Twitter Plugin settings", + title="APRSD X (Twitter) Plugin settings", ) twitter_opts = [ cfg.StrOpt( "callsign", - help="Callsign allowed to send tweets! " - "Any callsign starting with this will be allowed to tweet to" - "the configured twitter account. " - "For example, if you set this to WB4BOR then any" - "callsign starting with WB4BOR will be allowed to tweet." - "This way WB4BOR-1 can tweet from this instance.", - ), - cfg.StrOpt( - "bearer_token", - help="Your twitter Bearer Token" - "Information for creating your api keys is here: " - "https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code", + help=( + "Callsign allowed to post to X. " + "Any callsign starting with this value will be allowed to post. " + "For example, setting WB4BOR allows WB4BOR-1, WB4BOR-9, etc." + ), ), cfg.StrOpt( "apiKey", - help="Your twitter apiKey" - "Information for creating your api keys is here: " - "https://developer.twitter.com/en/docs/authentication/oauth-1-0a/api-key-and-secret", + help=( + "Your X (Twitter) API Key (Consumer Key). " + "Obtain from https://developer.x.com/en/portal/dashboard" + ), ), cfg.StrOpt( "apiKey_secret", - help="Your twitter accounts apikey secret.", + help="Your X (Twitter) API Key Secret (Consumer Secret).", ), cfg.StrOpt( "access_token", - help="The twitter access_token for your Twitter account", + help=( + "The X (Twitter) Access Token for your account. " + "Generate under 'Keys and tokens' in the developer portal." + ), ), cfg.StrOpt( "access_token_secret", - help="The twitter access token secret for your Twitter account", + help="The X (Twitter) Access Token Secret for your account.", ), cfg.BoolOpt( "add_aprs_hashtag", default=True, - help="Automatically add #aprs hash tag to every tweet?", + help="Automatically add #aprs #aprsd #hamradio hashtags to every post.", ), ] diff --git a/aprsd_twitter_plugin/twitter.py b/aprsd_twitter_plugin/twitter.py index dbdf41d..45d867f 100644 --- a/aprsd_twitter_plugin/twitter.py +++ b/aprsd_twitter_plugin/twitter.py @@ -19,32 +19,29 @@ class SendTweetPlugin(plugin.APRSDRegexCommandPluginBase): # Look for any command that starts with tw or tW or TW or Tw # or case insensitive version of 'twitter' command_regex = r"^([t][w]\s|twitter)" - # the command is for ? command_name = "tweet" enabled = False def help(self): _help = [ - "twitter: Send a Tweet!!", + "twitter: Post to X (formerly Twitter)!", "twitter: Format 'tw '", ] return _help def setup(self): - # Do some checks here? self.enabled = True if not CONF.aprsd_twitter_plugin.callsign: LOG.error( - "No aprsd_twitter_pligin.callsign is set. Callsign is needed to allow tweets!", + "No aprsd_twitter_plugin.callsign is set. Callsign is needed to allow posting!", ) self.enabled = False - # Ensure the access token exists. if not CONF.aprsd_twitter_plugin.apiKey: LOG.error( - "No aprsd_twitter_plugin.apiKey is set!. Plugin Disabled.", + "No aprsd_twitter_plugin.apiKey is set. Plugin Disabled.", ) self.enabled = False @@ -56,53 +53,32 @@ class SendTweetPlugin(plugin.APRSDRegexCommandPluginBase): if not CONF.aprsd_twitter_plugin.access_token: LOG.error( - "No aprsd_twitter_plugin.access_token exists. Plugin Disabled.", + "No aprsd_twitter_plugin.access_token is set. Plugin Disabled.", ) self.enabled = False if not CONF.aprsd_twitter_plugin.access_token_secret: LOG.error( - "No aprsd_twitter_plugin.access_token_secret exists. Plugin Disabled.", + "No aprsd_twitter_plugin.access_token_secret is set. Plugin Disabled.", ) self.enabled = False def _create_client(self): - """Create the twitter client object.""" - auth = tweepy.OAuthHandler( - CONF.aprsd_twitter_plugin.apiKey, - CONF.aprsd_twitter_plugin.apiKey_secret, - ) - - auth.set_access_token( - CONF.aprsd_twitter_plugin.access_token, - CONF.aprsd_twitter_plugin.access_token_secret, - ) - - bearer_token = CONF.aprsd_twitter_plugin.bearer_token - - api = tweepy.API( - bearer_token, - wait_on_rate_limit=True, - ) - - tweepy.OAuth2UserHandler( - client_id="Client ID here", - redirect_uri="Callback / Redirect URI / URL here", - scope=["tweet.write"], - # Client Secret is only necessary if using a confidential client - client_secret="Client Secret here", - ) - + """Create the X/Twitter API v2 client using OAuth 1.0a.""" try: - api.verify_credentials() - LOG.debug("Logged in to Twitter Authentication OK") + client = tweepy.Client( + consumer_key=CONF.aprsd_twitter_plugin.apiKey, + consumer_secret=CONF.aprsd_twitter_plugin.apiKey_secret, + access_token=CONF.aprsd_twitter_plugin.access_token, + access_token_secret=CONF.aprsd_twitter_plugin.access_token_secret, + ) + LOG.debug("X/Twitter client created OK") + return client except Exception as ex: - LOG.error("Failed to auth to Twitter") + LOG.error("Failed to create X/Twitter client") LOG.exception(ex) return None - return api - def process(self, packet): """This is called when a received packet matches self.command_regex.""" @@ -114,22 +90,28 @@ class SendTweetPlugin(plugin.APRSDRegexCommandPluginBase): del message[0] message = " ".join(message) - # Now we can process + # Only allow the configured callsign to post auth_call = CONF.aprsd_twitter_plugin.callsign - - # Only allow the owner of aprsd to send a tweet if not from_callsign.startswith(auth_call): - return f"{from_callsign} not authorized to tweet!" + return f"{from_callsign} not authorized to post!" client = self._create_client() if not client: - LOG.error("No twitter client!!") - return "Failed to Auth" + LOG.error("No X/Twitter client!") + return "Failed to create client" if CONF.aprsd_twitter_plugin.add_aprs_hashtag: - message += " #aprs #aprsd #hamradio https://github.com/hemna/aprsd-twitter-plugin" + message += " #aprs #aprsd #hamradio http://git.hemna.com/hemna/aprsd-twitter-plugin" - # Now lets tweet! - client.update_status(message) + try: + client.create_tweet(text=message) + except tweepy.errors.Forbidden as ex: + LOG.error("Forbidden — check your X developer account has write permissions") + LOG.exception(ex) + return "Failed: no write permission" + except tweepy.errors.TweepyException as ex: + LOG.error("Failed to post to X") + LOG.exception(ex) + return "Failed to post" - return "Tweet sent!" + return "Post sent!" diff --git a/tests/test_aprsd_twitter_plugin.py b/tests/test_aprsd_twitter_plugin.py index f01e613..0423e43 100644 --- a/tests/test_aprsd_twitter_plugin.py +++ b/tests/test_aprsd_twitter_plugin.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest +import tweepy import aprsd_twitter_plugin from aprsd_twitter_plugin.twitter import SendTweetPlugin @@ -20,33 +21,27 @@ class TestVersion: def test_version_not_unknown(self): """Version must not be 'unknown' when installed via pip install -e .""" - # When installed with setuptools-scm and a git tag the version is - # derived from the tag. 'unknown' means the package could not be found - # in the metadata at all — that should never happen for an editable - # install. assert aprsd_twitter_plugin.__version__ != "unknown" @pytest.fixture def mock_conf(): - """Create a mock configuration object.""" + """Create a mock configuration object with all required credentials set.""" conf = MagicMock() conf.aprsd_twitter_plugin.callsign = "WB4BOR" conf.aprsd_twitter_plugin.apiKey = "test_api_key" conf.aprsd_twitter_plugin.apiKey_secret = "test_api_secret" conf.aprsd_twitter_plugin.access_token = "test_access_token" conf.aprsd_twitter_plugin.access_token_secret = "test_access_secret" - conf.aprsd_twitter_plugin.bearer_token = "test_bearer_token" conf.aprsd_twitter_plugin.add_aprs_hashtag = True return conf @pytest.fixture -def plugin(mock_conf): +def plugin_instance(mock_conf): """Create a plugin instance with mocked config.""" with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin_instance = SendTweetPlugin() - return plugin_instance + return SendTweetPlugin() @pytest.fixture @@ -54,259 +49,252 @@ def mock_packet(): """Create a mock APRS packet.""" packet = MagicMock() packet.from_call = "WB4BOR" - packet.message_text = "tw This is a test tweet" + packet.message_text = "tw This is a test post" return packet -class TestSendTweetPlugin: - """Test cases for SendTweetPlugin.""" +class TestSendTweetPluginMetadata: + """Tests for plugin metadata and constants.""" - def test_plugin_initialization(self, mock_conf): - """Test that plugin initializes correctly.""" + def test_command_name(self, plugin_instance): + assert plugin_instance.command_name == "tweet" + + def test_command_regex(self, plugin_instance): + assert plugin_instance.command_regex == r"^([t][w]\s|twitter)" + + def test_initial_enabled_false(self, mock_conf): + """enabled must be False before setup() is called.""" with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): with patch.object(SendTweetPlugin, "setup"): - plugin = SendTweetPlugin() - assert plugin.command_name == "tweet" - assert plugin.command_regex == r"^([t][w]\s|twitter)" - # enabled should be False before setup() is called - assert plugin.enabled is False + p = SendTweetPlugin() + assert p.enabled is False - def test_help_method(self, plugin): - """Test the help method returns correct help text.""" - help_text = plugin.help() + def test_help_method(self, plugin_instance): + help_text = plugin_instance.help() assert isinstance(help_text, list) assert len(help_text) == 2 - assert "twitter: Send a Tweet!!" in help_text - assert "twitter: Format 'tw '" in help_text + assert "X (formerly Twitter)" in help_text[0] + assert "tw " in help_text[1] - def test_setup_with_all_config(self, mock_conf): - """Test setup method when all configuration is present.""" + +class TestSetup: + """Tests for setup() credential validation.""" + + def test_setup_all_config_present(self, mock_conf): with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin = SendTweetPlugin() - plugin.setup() - assert plugin.enabled is True + p = SendTweetPlugin() + p.setup() + assert p.enabled is True def test_setup_missing_callsign(self, mock_conf): - """Test setup method when callsign is missing.""" mock_conf.aprsd_twitter_plugin.callsign = None with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin = SendTweetPlugin() - plugin.setup() - assert plugin.enabled is False + p = SendTweetPlugin() + p.setup() + assert p.enabled is False def test_setup_missing_api_key(self, mock_conf): - """Test setup method when apiKey is missing.""" mock_conf.aprsd_twitter_plugin.apiKey = None with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin = SendTweetPlugin() - plugin.setup() - assert plugin.enabled is False + p = SendTweetPlugin() + p.setup() + assert p.enabled is False def test_setup_missing_api_key_secret(self, mock_conf): - """Test setup method when apiKey_secret is missing.""" mock_conf.aprsd_twitter_plugin.apiKey_secret = None with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin = SendTweetPlugin() - plugin.setup() - assert plugin.enabled is False + p = SendTweetPlugin() + p.setup() + assert p.enabled is False def test_setup_missing_access_token(self, mock_conf): - """Test setup method when access_token is missing.""" mock_conf.aprsd_twitter_plugin.access_token = None with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin = SendTweetPlugin() - plugin.setup() - assert plugin.enabled is False + p = SendTweetPlugin() + p.setup() + assert p.enabled is False def test_setup_missing_access_token_secret(self, mock_conf): - """Test setup method when access_token_secret is missing.""" mock_conf.aprsd_twitter_plugin.access_token_secret = None with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin = SendTweetPlugin() - plugin.setup() - assert plugin.enabled is False + p = SendTweetPlugin() + p.setup() + assert p.enabled is False - @patch("aprsd_twitter_plugin.twitter.tweepy.API") - @patch("aprsd_twitter_plugin.twitter.tweepy.OAuthHandler") - def test_create_client_success(self, mock_oauth, mock_api, plugin, mock_conf): - """Test _create_client method when authentication succeeds.""" - mock_api_instance = MagicMock() - mock_api_instance.verify_credentials.return_value = True - mock_api.return_value = mock_api_instance + def test_setup_no_bearer_token_field(self, mock_conf): + """bearer_token must NOT be a config option — it was removed.""" + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + p = SendTweetPlugin() + p.setup() + # If bearer_token was still being accessed in setup(), + # this would raise an AttributeError on MagicMock since we + # intentionally don't set it. + assert p.enabled is True + + +class TestCreateClient: + """Tests for _create_client() using tweepy.Client (API v2).""" + + @patch("aprsd_twitter_plugin.twitter.tweepy.Client") + def test_create_client_success(self, mock_client_class, plugin_instance, mock_conf): + """Client is constructed with the 4 OAuth 1.0a credentials.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - client = plugin._create_client() + client = plugin_instance._create_client() - assert client is not None - mock_api_instance.verify_credentials.assert_called_once() - - @patch("aprsd_twitter_plugin.twitter.tweepy.API") - @patch("aprsd_twitter_plugin.twitter.tweepy.OAuthHandler") - def test_create_client_auth_failure(self, mock_oauth, mock_api, plugin, mock_conf): - """Test _create_client method when authentication fails.""" - mock_api_instance = MagicMock() - mock_api_instance.verify_credentials.side_effect = Exception("Auth failed") - mock_api.return_value = mock_api_instance + assert client is mock_client + mock_client_class.assert_called_once_with( + consumer_key=mock_conf.aprsd_twitter_plugin.apiKey, + consumer_secret=mock_conf.aprsd_twitter_plugin.apiKey_secret, + access_token=mock_conf.aprsd_twitter_plugin.access_token, + access_token_secret=mock_conf.aprsd_twitter_plugin.access_token_secret, + ) + @patch("aprsd_twitter_plugin.twitter.tweepy.Client") + def test_create_client_exception_returns_none( + self, mock_client_class, plugin_instance, mock_conf + ): + """Returns None when tweepy.Client raises.""" + mock_client_class.side_effect = Exception("connection error") with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - client = plugin._create_client() - + client = plugin_instance._create_client() assert client is None - def test_process_authorized_callsign(self, plugin, mock_packet, mock_conf): - """Test process method with authorized callsign.""" - mock_client = MagicMock() - mock_client.update_status = MagicMock() + def test_create_client_does_not_use_oauthhandler(self, plugin_instance, mock_conf): + """_create_client must not use the deprecated tweepy.OAuthHandler.""" + with patch("aprsd_twitter_plugin.twitter.tweepy.Client") as mock_client_class: + with patch("aprsd_twitter_plugin.twitter.tweepy.OAuthHandler") as mock_oauth: + mock_client_class.return_value = MagicMock() + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + plugin_instance._create_client() + mock_oauth.assert_not_called() - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - result = plugin.process(mock_packet) + def test_create_client_does_not_use_bearer_token(self, plugin_instance, mock_conf): + """_create_client must not pass bearer_token to tweepy.Client.""" + with patch("aprsd_twitter_plugin.twitter.tweepy.Client") as mock_client_class: + mock_client_class.return_value = MagicMock() + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + plugin_instance._create_client() + call_kwargs = mock_client_class.call_args.kwargs + assert "bearer_token" not in call_kwargs - assert result == "Tweet sent!" - mock_client.update_status.assert_called_once() - # Check that the message was parsed correctly (command removed) - call_args = mock_client.update_status.call_args[0][0] - assert "This is a test tweet" in call_args - # Check that the command prefix "tw " is removed (not just "tw" which appears in "test") - assert not call_args.startswith("tw ") - assert call_args.startswith("This is a test tweet") - def test_process_unauthorized_callsign(self, plugin, mock_conf): - """Test process method with unauthorized callsign.""" +class TestProcess: + """Tests for the process() message handler.""" + + def test_unauthorized_callsign_rejected(self, plugin_instance, mock_conf): packet = MagicMock() packet.from_call = "N0CALL" - packet.message_text = "tw This is a test tweet" - + packet.message_text = "tw Hello" with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - result = plugin.process(packet) + result = plugin_instance.process(packet) + assert result == "N0CALL not authorized to post!" - assert result == "N0CALL not authorized to tweet!" - - def test_process_callsign_with_suffix(self, plugin, mock_conf): - """Test process method with authorized callsign with suffix (e.g., WB4BOR-1).""" + def test_authorized_callsign_with_ssid(self, plugin_instance, mock_conf): + """WB4BOR-1 is authorized when callsign is WB4BOR.""" packet = MagicMock() packet.from_call = "WB4BOR-1" - packet.message_text = "tw This is a test tweet" + packet.message_text = "tw Hello" mock_client = MagicMock() - mock_client.update_status = MagicMock() - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - result = plugin.process(packet) + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + result = plugin_instance.process(packet) + assert result == "Post sent!" - assert result == "Tweet sent!" - mock_client.update_status.assert_called_once() - - def test_process_client_creation_failure(self, plugin, mock_packet, mock_conf): - """Test process method when client creation fails.""" + def test_client_creation_failure_returns_error(self, plugin_instance, mock_packet, mock_conf): with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=None): - result = plugin.process(mock_packet) + with patch.object(plugin_instance, "_create_client", return_value=None): + result = plugin_instance.process(mock_packet) + assert result == "Failed to create client" - assert result == "Failed to Auth" + def test_calls_create_tweet_not_update_status(self, plugin_instance, mock_packet, mock_conf): + """Must use create_tweet() (API v2), not the removed update_status().""" + mock_client = MagicMock() + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + plugin_instance.process(mock_packet) + mock_client.create_tweet.assert_called_once() + mock_client.update_status.assert_not_called() - def test_process_message_parsing(self, plugin, mock_conf): - """Test that message parsing correctly removes the command.""" + def test_message_command_stripped(self, plugin_instance, mock_conf): + """'tw ' prefix is removed before posting.""" packet = MagicMock() packet.from_call = "WB4BOR" packet.message_text = "tw Hello world from APRS!" mock_client = MagicMock() - mock_client.update_status = MagicMock() - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - plugin.process(packet) + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + plugin_instance.process(packet) + call_kwargs = mock_client.create_tweet.call_args.kwargs + assert call_kwargs["text"].startswith("Hello world from APRS!") - call_args = mock_client.update_status.call_args[0][0] - assert ( - call_args - == "Hello world from APRS! #aprs #aprsd #hamradio https://github.com/hemna/aprsd-twitter-plugin" - ) - - def test_process_with_hashtag_enabled(self, plugin, mock_packet, mock_conf): - """Test that hashtags are added when add_aprs_hashtag is enabled.""" + def test_hashtags_appended_when_enabled(self, plugin_instance, mock_packet, mock_conf): mock_conf.aprsd_twitter_plugin.add_aprs_hashtag = True mock_client = MagicMock() - mock_client.update_status = MagicMock() - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - plugin.process(mock_packet) + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + plugin_instance.process(mock_packet) + text = mock_client.create_tweet.call_args.kwargs["text"] + assert "#aprs" in text + assert "#aprsd" in text + assert "#hamradio" in text - call_args = mock_client.update_status.call_args[0][0] - assert "#aprs" in call_args - assert "#aprsd" in call_args - assert "#hamradio" in call_args - - def test_process_with_hashtag_disabled(self, plugin, mock_packet, mock_conf): - """Test that hashtags are not added when add_aprs_hashtag is disabled.""" + def test_hashtags_not_appended_when_disabled(self, plugin_instance, mock_packet, mock_conf): mock_conf.aprsd_twitter_plugin.add_aprs_hashtag = False mock_client = MagicMock() - mock_client.update_status = MagicMock() - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - plugin.process(mock_packet) + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + plugin_instance.process(mock_packet) + text = mock_client.create_tweet.call_args.kwargs["text"] + assert "#aprs" not in text - call_args = mock_client.update_status.call_args[0][0] - assert "#aprs" not in call_args - assert call_args == "This is a test tweet" + def test_hashtag_url_points_to_forgejo(self, plugin_instance, mock_packet, mock_conf): + """Hashtag URL must point to git.hemna.com, not github.com.""" + mock_conf.aprsd_twitter_plugin.add_aprs_hashtag = True + mock_client = MagicMock() + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + plugin_instance.process(mock_packet) + text = mock_client.create_tweet.call_args.kwargs["text"] + assert "git.hemna.com" in text + assert "github.com" not in text - def test_process_twitter_command_variant(self, plugin, mock_conf): - """Test process method with 'twitter' command instead of 'tw'.""" + def test_returns_post_sent_on_success(self, plugin_instance, mock_packet, mock_conf): + mock_client = MagicMock() + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + result = plugin_instance.process(mock_packet) + assert result == "Post sent!" + + def test_returns_error_on_forbidden(self, plugin_instance, mock_packet, mock_conf): + """403 Forbidden → meaningful error returned to the APRS caller.""" + mock_client = MagicMock() + mock_client.create_tweet.side_effect = tweepy.errors.Forbidden(MagicMock()) + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + result = plugin_instance.process(mock_packet) + assert result == "Failed: no write permission" + + def test_returns_error_on_tweepy_exception(self, plugin_instance, mock_packet, mock_conf): + """General tweepy error → error returned to the APRS caller.""" + mock_client = MagicMock() + mock_client.create_tweet.side_effect = tweepy.errors.TweepyException("rate limit") + with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + result = plugin_instance.process(mock_packet) + assert result == "Failed to post" + + def test_twitter_command_variant(self, plugin_instance, mock_conf): + """'twitter ' command works as well as 'tw '.""" packet = MagicMock() packet.from_call = "WB4BOR" - packet.message_text = "twitter This is another test" + packet.message_text = "twitter Hello from ham radio" mock_client = MagicMock() - mock_client.update_status = MagicMock() - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - result = plugin.process(packet) - - assert result == "Tweet sent!" - call_args = mock_client.update_status.call_args[0][0] - assert "This is another test" in call_args - # Check that command prefix "twitter " is removed - # (not just "twitter" which appears in URL) - assert not call_args.startswith("twitter ") - assert call_args.startswith("This is another test") - - def test_process_empty_message(self, plugin, mock_conf): - """Test process method with empty message after command.""" - packet = MagicMock() - packet.from_call = "WB4BOR" - packet.message_text = "tw" - mock_client = MagicMock() - mock_client.update_status = MagicMock() - - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - with patch.object(plugin, "_create_client", return_value=mock_client): - plugin.process(packet) - - call_args = mock_client.update_status.call_args[0][0] - # Should be empty or just hashtags - assert call_args == " #aprs #aprsd #hamradio https://github.com/hemna/aprsd-twitter-plugin" - - def test_create_client_oauth_handler_initialization(self, plugin, mock_conf): - """Test that OAuthHandler is initialized with correct credentials.""" - mock_oauth_instance = MagicMock() - mock_oauth_class = MagicMock(return_value=mock_oauth_instance) - - with patch("aprsd_twitter_plugin.twitter.tweepy.OAuthHandler", mock_oauth_class): - with patch("aprsd_twitter_plugin.twitter.tweepy.API") as mock_api: - mock_api_instance = MagicMock() - mock_api_instance.verify_credentials.return_value = True - mock_api.return_value = mock_api_instance - - with patch("aprsd_twitter_plugin.twitter.CONF", mock_conf): - plugin._create_client() - - mock_oauth_class.assert_called_once_with( - mock_conf.aprsd_twitter_plugin.apiKey, - mock_conf.aprsd_twitter_plugin.apiKey_secret, - ) - mock_oauth_instance.set_access_token.assert_called_once_with( - mock_conf.aprsd_twitter_plugin.access_token, - mock_conf.aprsd_twitter_plugin.access_token_secret, - ) + with patch.object(plugin_instance, "_create_client", return_value=mock_client): + result = plugin_instance.process(packet) + assert result == "Post sent!" + text = mock_client.create_tweet.call_args.kwargs["text"] + assert text.startswith("Hello from ham radio") From b8813279bc9529ab84a83aaa9710e414fe7cbe31 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Sat, 29 Aug 2026 00:24:38 -0400 Subject: [PATCH 2/3] chore: add [project.urls] pointing to git.hemna.com --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 04003d6..91e8296 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,11 @@ dev = [ "aprsd", ] +[project.urls] +"Homepage" = "http://git.hemna.com/hemna/aprsd-twitter-plugin" +"Bug Reports" = "http://git.hemna.com/hemna/aprsd-twitter-plugin/issues" +"Source" = "http://git.hemna.com/hemna/aprsd-twitter-plugin" + [tool.setuptools] packages = ["aprsd_twitter_plugin"] From 1c496e168c382adcc7516e6d0b47bcf6b7fa1294 Mon Sep 17 00:00:00 2001 From: Walter Boring Date: Sat, 29 Aug 2026 09:19:16 -0400 Subject: [PATCH 3/3] chore: use github.com for [project.urls] per review feedback --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 91e8296..05f2c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ dev = [ ] [project.urls] -"Homepage" = "http://git.hemna.com/hemna/aprsd-twitter-plugin" -"Bug Reports" = "http://git.hemna.com/hemna/aprsd-twitter-plugin/issues" -"Source" = "http://git.hemna.com/hemna/aprsd-twitter-plugin" +"Homepage" = "https://github.com/hemna/aprsd-twitter-plugin" +"Bug Reports" = "https://github.com/hemna/aprsd-twitter-plugin/issues" +"Source" = "https://github.com/hemna/aprsd-twitter-plugin" [tool.setuptools] packages = ["aprsd_twitter_plugin"]