Repository: sopel-irc/sopel

Issue:
isup responds twice
<!-- Before reporting a bug, please search both open *and closed* issues to
see if it has already been reported. If you can, try to reproduce the problem
on an unmodified copy of the `master` branch first, as sometimes bugs are found
and fixed without a report. If the problem is unreported and persists in
`master`, please help us fix it quickly by filling out as much of this
information as you can. Thanks! -->

### Description
when I run .isup or .isupinsecure, the bot responds twice. first is the error (if any) then it says that the site is up
### Reproduction steps
1. setup a sopel bot using the master branch.
2. in the irc channel run .isup with some url that is down
3. it responds twice

### Expected behavior
only responds with an error or else up, not up and error

### Environment
- Sopel `.version`: [e.g. 7.0.0 or d416e19] master branch
- Sopel installed via: [apt, pip, `setup.py install`, source, ?] source
- Python version: [e.g. 3.6.9] 3.7
- Operating system: [e.g. Debian 10] debian buster
- IRCd `/version`: [e.g. InspIRCd 3.0.1] freenode
- Relevant plugins: [adminchannel, weather, custom\_thing.py, ?] isup

### Notes
seems to be because in https://github.com/sopel-irc/sopel/blob/master/sopel/modules/isup.py#L89 none of the except statements return and so the bot.say for "website is up" is always executed.



Discussion:
15:09 <+MacFan4000> .isupinsecure https://bots1.mirahezebots.org
15:09 <+MirahezeBotBeta> [isup] https://bots1.mirahezebots.org looks down to me (SSL error). Try using `.isupinsecure`.
15:09 <+MirahezeBotBeta> [isup] https://bots1.mirahezebots.org looks fine to me.
> * Sopel `.version`: [e.g. 7.0.0 or [d416e19](https://github.com/sopel-irc/sopel/commit/d416e1966e4e2b74cce49b8f100ef68fb07a4731)] master branch

The specific commit would be helpful, for bisecting the history.
https://github.com/MirahezeBots/sopel/commit/8dbce960d1ef1f65b2c1560cbd4fa8d6f5aa1361 this commit, later commits on that fork are just ci modifications. as far as I can tell there haven't been any changes to isup since last year
Seems to have been caused by 5af0eea249238485d70c8e14743358a50115b0f4. I will investigate further.

Resolving patch:
diff --git a/sopel/modules/isup.py b/sopel/modules/isup.py
--- a/sopel/modules/isup.py
+++ b/sopel/modules/isup.py
@@ -84,9 +84,9 @@ def handle_isup(bot, trigger, secure=True):
         bot.say(
             '{} looks down to me (connection error).'
             .format(site))
-
-    # If no exception happened, the request succeeded.
-    bot.say(site + ' looks fine to me.')
+    else:
+        # If no exception happened, the request must have succeeded.
+        bot.say(site + ' looks fine to me.')
 
 
 @plugin.command('isupinsecure')
@@ -97,9 +97,7 @@ def isup_insecure(bot, trigger):
 
 
 @plugin.command('isup')
-@plugin.example('.isup google.com',
-                'http://google.com looks fine to me.',
-                online=True, vcr=True)
+@plugin.example('.isup google.com')
 @plugin.output_prefix(PLUGIN_OUTPUT_PREFIX)
 def isup(bot, trigger):
     """Check if a website is up or not."""


Test patch:
diff --git a/test/modules/test_modules_isup.py b/test/modules/test_modules_isup.py
--- a/test/modules/test_modules_isup.py
+++ b/test/modules/test_modules_isup.py
@@ -3,8 +3,10 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 import pytest
+import requests.exceptions
 
 from sopel.modules import isup
+from sopel.tests import rawlist
 
 
 VALID_SITE_URLS = (
@@ -48,3 +50,151 @@ def test_get_site_url(site, expected):
 def test_get_site_url_invalid(site):
     with pytest.raises(ValueError):
         isup.get_site_url(site)
+
+
+TMP_CONFIG = """
+[core]
+owner = Admin
+nick = Sopel
+enable =
+    isup
+host = chat.freenode.net
+"""
+
+
+@pytest.fixture
+def bot(botfactory, configfactory):
+    settings = configfactory('default.ini', TMP_CONFIG)
+    return botfactory.preloaded(settings, ['isup'])
+
+
+@pytest.fixture
+def irc(bot, ircfactory):
+    return ircfactory(bot)
+
+
+@pytest.fixture
+def user(userfactory):
+    return userfactory('User')
+
+
+def test_isup_command_ok(irc, bot, user, requests_mock):
+    """Test working URL."""
+    requests_mock.head(
+        'http://example.com',
+        status_code=301,
+    )
+
+    irc.pm(user, '.isup example.com')
+
+    for t in bot.running_triggers:
+        # TODO: remove when botfactory can force everything to be unthreaded
+        t.join()
+
+    assert len(bot.backend.message_sent) == 1, (
+        '.isup command should output exactly one line')
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG User :[isup] http://example.com looks fine to me.'
+    )
+
+
+def test_isup_command_http_error(irc, bot, user, requests_mock):
+    """Test URL that returns an HTTP error code."""
+    requests_mock.head(
+        'http://example.com',
+        status_code=503,
+        reason='Service Unavailable',
+    )
+
+    irc.pm(user, '.isup example.com')
+
+    for t in bot.running_triggers:
+        # TODO: remove when botfactory can force everything to be unthreaded
+        t.join()
+
+    assert len(bot.backend.message_sent) == 1, (
+        '.isup command should output exactly one line')
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG User :[isup] http://example.com looks down to me (HTTP 503 "Service Unavailable").'
+    )
+
+
+def test_isup_command_unparseable(irc, bot, user, requests_mock):
+    """Test URL that can't be parsed."""
+    requests_mock.head(
+        'http://.foo',
+        exc=ValueError("Failed to parse: '.foo', label empty or too long"),
+    )
+
+    irc.pm(user, '.isup .foo')
+
+    for t in bot.running_triggers:
+        # TODO: remove when botfactory can force everything to be unthreaded
+        t.join()
+
+    assert len(bot.backend.message_sent) == 1, (
+        '.isup command should output exactly one line')
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG User :User: Failed to parse: \'.foo\', label empty or too long'
+    )
+
+
+ISUP_EXCEPTIONS = (
+    (
+        requests.exceptions.ConnectionError,
+        'http://127.0.0.1:1 looks down to me (connection error).'
+    ),
+    (
+        requests.exceptions.ReadTimeout,
+        'https://httpbingo.org/delay/10 looks down to me (timed out waiting for reply).'
+    ),
+    (
+        requests.exceptions.ConnectTimeout,
+        'http://10.0.0.0 looks down to me (timed out while connecting).'
+    ),
+    (
+        requests.exceptions.SSLError,
+        'https://expired.badssl.com/ looks down to me (SSL error). Try using `.isupinsecure`.'
+    ),
+)
+
+
+@pytest.mark.parametrize('exc, result', ISUP_EXCEPTIONS)
+def test_isup_command_requests_error(irc, bot, user, requests_mock, exc, result):
+    """Test various error cases."""
+    url = result.split()[0]
+    requests_mock.head(
+        url,
+        exc=exc,
+    )
+
+    irc.pm(user, '.isup {}'.format(url))
+
+    for t in bot.running_triggers:
+        # TODO: remove when botfactory can force everything to be unthreaded
+        t.join()
+
+    assert len(bot.backend.message_sent) == 1, (
+        '.isup command should output exactly one line')
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG User :[isup] {}'.format(result)
+    )
+
+
+def test_isupinsecure_command(irc, bot, user, requests_mock):
+    """Test working URL."""
+    requests_mock.head(
+        'https://example.com',
+    )
+
+    irc.pm(user, '.isupinsecure https://example.com')
+
+    for t in bot.running_triggers:
+        # TODO: remove when botfactory can force everything to be unthreaded
+        t.join()
+
+    assert len(bot.backend.message_sent) == 1, (
+        '.isupinsecure command should output exactly one line')
+    assert bot.backend.message_sent == rawlist(
+        'PRIVMSG User :[isup] https://example.com looks fine to me.'
+    )
diff --git a/test/vcr/modules/isup/test_example_isup_0.yaml b/test/vcr/modules/isup/test_example_isup_0.yaml
deleted file mode 100644
--- a/test/vcr/modules/isup/test_example_isup_0.yaml
+++ /dev/null
@@ -1,24 +0,0 @@
-interactions:
-- request:
-    body: null
-    headers:
-      Accept: ['*/*']
-      Accept-Encoding: ['gzip, deflate']
-      Connection: [keep-alive]
-      User-Agent: [python-requests/2.24.0]
-    method: HEAD
-    uri: http://google.com/
-  response:
-    body: {string: ''}
-    headers:
-      Cache-Control: ['public, max-age=2592000']
-      Content-Length: ['219']
-      Content-Type: [text/html; charset=UTF-8]
-      Date: ['Fri, 09 Oct 2020 04:40:25 GMT']
-      Expires: ['Sun, 08 Nov 2020 04:40:25 GMT']
-      Location: ['http://www.google.com/']
-      Server: [gws]
-      X-Frame-Options: [SAMEORIGIN]
-      X-XSS-Protection: ['0']
-    status: {code: 301, message: Moved Permanently}
-version: 1