diff --git a/modules/response_template.py b/modules/response_template.py index 36030ca..a39eb3a 100644 --- a/modules/response_template.py +++ b/modules/response_template.py @@ -272,11 +272,13 @@ class _TemplateParser: # A quote immediately after ':' opts into the quoted-argument grammar. # Anything else stays greedy, so existing unquoted literals keep their # pipes and their leading/trailing whitespace exactly as written. + # An *unterminated* quote is not a quoted argument at all: falling back + # to greedy keeps `prefix_if_nonempty:"` prepending a literal quote the + # way it always has, rather than voiding the placeholder to raw text. if i < self.n and self.s[i] == '"': value, j = self._parse_quoted_string(i) - if value is None: - return None, '', j - return name, value, j + if value is not None: + return name, value, j close = self.s.find('}', i) if close == -1: return None, '', self.n diff --git a/tests/test_feed_format.py b/tests/test_feed_format.py index 34e32b1..054d9e1 100644 --- a/tests/test_feed_format.py +++ b/tests/test_feed_format.py @@ -138,3 +138,51 @@ class TestFormatFeedMessage: shorten_feed_urls=False, ) assert msg == "https://example.com/long" + + +class TestShortenUrlAlias: + """`shorten_url` is the response-template spelling; feed formats accept it too. + + Without the alias a chain copied from a command response_format hits the unknown + -function branch and the long URL goes out unshortened, with no warning. + """ + + @patch("modules.feed_format.shorten_url_sync", return_value="https://v.gd/x") + def test_shorten_url_is_accepted_as_a_function(self, mock_shorten): + out = apply_feed_field_function( + "https://example.com/long", "shorten_url", config=object() + ) + assert out == "https://v.gd/x" + mock_shorten.assert_called_once() + + @patch("modules.feed_format.shorten_url_sync", return_value="https://v.gd/x") + def test_shorten_url_chains_like_shorten(self, _mock_shorten): + assert ( + apply_feed_field_function( + "https://example.com/long", "shorten_url|truncate:9", config=object() + ) + == apply_feed_field_function( + "https://example.com/long", "shorten|truncate:9", config=object() + ) + ) + + @patch("modules.feed_format.shorten_url_sync", return_value="") + def test_shorten_url_falls_back_to_the_original(self, _mock_shorten): + out = apply_feed_field_function( + "https://example.com/long", "shorten_url", config=object() + ) + assert out == "https://example.com/long" + + @patch("modules.feed_format.shorten_url_sync", return_value="https://v.gd/x") + def test_explicit_shorten_url_is_not_double_shortened(self, mock_shorten): + """shorten_feed_urls must see through the alias, as it does through `shorten`.""" + format_feed_message( + {"title": "t", "description": "", "link": "https://example.com/long"}, + "{link|shorten_url}", + shorten_feed_urls=True, + config=object(), + ) + assert mock_shorten.call_count == 1 + + def test_an_unrelated_name_starting_with_shorten_url_is_untouched(self): + assert apply_feed_field_function("abc", "shorten_urlish") == "abc" diff --git a/tests/unit/test_response_template.py b/tests/unit/test_response_template.py index 9bb63b2..6f94b30 100644 --- a/tests/unit/test_response_template.py +++ b/tests/unit/test_response_template.py @@ -569,8 +569,12 @@ async def test_async_render_does_not_warn_about_the_event_loop(): logger = MagicMock() with pytest.MonkeyPatch.context() as mp: + import modules.response_template as rt import modules.url_shortener as us mp.setattr(us.requests, "get", lambda *a, **k: resp) + # Without this reset the assertion below passes vacuously whenever an + # earlier test has already tripped the warn-once flag. + mp.setattr(rt, "_warned_blocking_render", False) out = await format_piped_template_async( "{link|shorten_url}", {"link": "https://example.com/long"}, @@ -580,3 +584,24 @@ async def test_async_render_does_not_warn_about_the_event_loop(): assert out == "https://v.gd/abc123" logger.warning.assert_not_called() + + +@pytest.mark.unit +def test_a_bare_quote_argument_is_still_a_greedy_literal(): + """Regression: opting into quoted args must not void an unterminated quote. + + `prefix_if_nonempty:"` prepends a literal quote character and always has. Reading + it as the start of a quoted argument leaves the string unterminated, which would + reject the whole placeholder and emit raw template text over RF. + """ + assert format_piped_template('{d|prefix_if_nonempty:"}', {"d": "12.4km"}) == '"12.4km' + assert format_piped_template( + '{d|prefix_if_nonempty:"unterminated}', {"d": "12.4km"} + ) == '"unterminated12.4km' + + +@pytest.mark.unit +def test_a_quote_after_whitespace_is_a_greedy_literal_not_a_quoted_argument(): + """Only a quote *immediately* after ':' opts in, so spacing is preserved.""" + out = format_piped_template('{d|prefix_if_nonempty: "L" }', {"d": "12.4km"}) + assert out == ' "L" 12.4km'