Merge pull request 'fix: migrate to X API v2 — tweepy.Client, create_tweet, remove bearer_token' (#14) from fix/api-v2 into master

Reviewed-on: http://git.hemna.com/hemna/aprsd-twitter-plugin/pulls/14
This commit is contained in:
2026-08-29 13:29:04 +00:00
4 changed files with 233 additions and 261 deletions
+17 -20
View File
@@ -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.",
),
]
+31 -49
View File
@@ -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 <message>'",
]
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!"
+5
View File
@@ -40,6 +40,11 @@ dev = [
"aprsd",
]
[project.urls]
"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"]
+180 -192
View File
@@ -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 <message>'" in help_text
assert "X (formerly Twitter)" in help_text[0]
assert "tw <message>" 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 <msg>' command works as well as 'tw <msg>'."""
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")