""" Tests for Action links (link_type=ACTION, e.g. send-to-Discord). Covers: model persistence, form validation/assembly, and the redirect_to_original execution path (with mocked webhook). """ import pytest from django.test import Client from unittest.mock import patch from links.models import Link from links.forms import LinkForm from links.actions import render_template, execute_action from links import views @pytest.fixture def action_link(db): return Link.objects.create( alias='testaction', link_type=Link.LinkType.ACTION, action_config={ 'action_type': 'discord_send', 'message_template': '@小黑 {query}', }, ) # ── render_template ────────────────────────────────────────────────────────── class TestRenderTemplate: def test_replaces_query_placeholder(self): assert render_template('@小黑 {query}', '今天天气') == '@小黑 今天天气' def test_empty_template_returns_query(self): assert render_template('', 'hello') == 'hello' def test_template_without_placeholder_keeps_query_appended(self): # 模板里没有 {query} 时,至少保留 query 内容(用默认拼接) assert render_template('@小黑', 'hi') == '@小黑' # ── execute_action ──────────────────────────────────────────────────────────── class TestExecuteAction: @patch('links.actions.send_discord_message') def test_discord_send_success(self, mock_send): result = execute_action( {'action_type': 'discord_send', 'message_template': '@小黑 {query}'}, '帮我查天气', ) mock_send.assert_called_once_with('@小黑 帮我查天气') assert result['success'] is True assert 'discord_deeplink' in result @patch('links.actions.send_discord_message') def test_unknown_action_type(self, mock_send): result = execute_action({'action_type': 'teleport'}, 'x') mock_send.assert_not_called() assert result['success'] is False @patch('links.actions.send_discord_message') def test_empty_query_does_not_send(self, mock_send): result = execute_action( {'action_type': 'discord_send', 'message_template': '@小黑 {query}'}, ' ', ) mock_send.assert_not_called() assert result['success'] is False assert '缺少参数' in result['message'] @patch('links.actions.send_discord_message', side_effect=RuntimeError('boom')) def test_webhook_failure_propagates(self, mock_send): with pytest.raises(RuntimeError): execute_action({'action_type': 'discord_send', 'message_template': '{query}'}, 'x') # ── LinkForm validation ─────────────────────────────────────────────────────── class TestLinkFormActionValidation: def _form(self, **overrides): data = { 'alias': 'formaction', 'link_type': Link.LinkType.ACTION, 'action_type': 'discord_send', 'action_message_template': '@小黑 {query}', 'description': '', } data.update(overrides) return LinkForm(data) def test_valid_action_form_assembles_config(self, db): form = self._form() assert form.is_valid(), form.errors assert form.cleaned_data['action_config'] == { 'action_type': 'discord_send', 'message_template': '@小黑 {query}', } def test_action_requires_type(self, db): form = self._form(action_type='') assert not form.is_valid() assert 'Please choose an action' in str(form.errors) def test_action_requires_template(self, db): form = self._form(action_message_template='') assert not form.is_valid() assert 'Message template is required' in str(form.errors) def test_link_type_clears_action_config(self, db): form = self._form(link_type=Link.LinkType.LINK, original_url='https://example.com') assert form.is_valid(), form.errors assert form.cleaned_data['action_config'] == {} def test_link_type_requires_url(self, db): form = self._form(link_type=Link.LinkType.LINK, original_url='') assert not form.is_valid() assert 'Original URL is required' in str(form.errors) # ── redirect_to_original execution ──────────────────────────────────────────── class TestRedirectAction: def test_action_link_executes_and_redirects(self, action_link): """成功:发送消息并 302 跳转 Discord,不渲染页面。""" client = Client() with patch('links.actions.send_discord_message') as mock_send: resp = client.get('/testaction/今天有什么新闻/') assert resp.status_code == 302 assert resp['Location'].startswith('https://discord.com/channels/') mock_send.assert_called_once_with('@小黑 今天有什么新闻') def test_action_link_with_query_param(self, action_link): client = Client() with patch('links.actions.send_discord_message') as mock_send: resp = client.get('/testaction/', {'q': '用查询参数提问'}) assert resp.status_code == 302 mock_send.assert_called_once_with('@小黑 用查询参数提问') def test_action_link_dedup_same_query_in_window(self, action_link, monkeypatch): """10 分钟内同一 query 只发一次,刷新不重复发送。""" monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') client = Client() with patch('links.actions.requests.post') as mock_post: resp1 = client.get('/testaction/相同问题/') resp2 = client.get('/testaction/相同问题/') # 刷新/重复访问 assert resp1.status_code == 302 assert resp2.status_code == 302 assert mock_post.call_count == 1, '同一问题 10 分钟内只应发送一次' def test_action_link_different_query_not_deduped(self, action_link, monkeypatch): monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') client = Client() with patch('links.actions.requests.post') as mock_post: client.get('/testaction/问题甲/') client.get('/testaction/问题乙/') assert mock_post.call_count == 2, '不同问题应各自发送' def test_action_link_webhook_failure_shows_error(self, action_link, monkeypatch): import requests as req_lib monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') client = Client() with patch('links.actions.requests.post', side_effect=req_lib.exceptions.RequestException('connection refused')): resp = client.get('/testaction/测试/') assert resp.status_code == 200 assert '发送到 Discord 失败' in resp.content.decode() assert '打开 Discord' not in resp.content.decode() def test_normal_link_unaffected(self, db): Link.objects.create(alias='plainlink', link_type=Link.LinkType.LINK, original_url='https://example.com/path') client = Client() resp = client.get('/plainlink/') assert resp.status_code in (301, 302) assert resp['Location'] == 'https://example.com/path' # ── ask 作为普通 ACTION Link(alias='ask',非固定路由)──────────────────────── class TestAskActionLink: """Ask 不再有固定路由:它是一条 link_type=ACTION 的普通 Link(alias='ask'), 由 data migration 0053 创建。访问 go/ask/问题 走 alias catch-all → redirect_to_original → ACTION 分支,与其他动作链接行为一致。""" def _create_ask_link(self, db): # data migration 0053 已创建 alias='ask';测试用 get_or_create 复用 link, _ = Link.objects.get_or_create( alias='ask', defaults={ 'link_type': Link.LinkType.ACTION, 'action_config': { 'action_type': 'discord_send', 'message_template': '<@1467845106831855796> {query}', }, }, ) return link def test_ask_with_question_redirects(self, db, monkeypatch): self._create_ask_link(db) monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') client = Client() with patch('links.actions.requests.post') as mock_post: resp = client.get('/ask/今天有什么新闻/') assert resp.status_code == 302 assert resp['Location'].startswith('https://discord.com/channels/') body = mock_post.call_args.kwargs['json']['content'] assert '今天有什么新闻' in body assert '<@1467845106831855796>' in body def test_ask_with_question_dedup(self, db, monkeypatch): self._create_ask_link(db) monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') client = Client() with patch('links.actions.requests.post') as mock_post: client.get('/ask/相同问题/') client.get('/ask/相同问题/') assert mock_post.call_count == 1 def test_ask_empty_query_renders_error_not_input_page(self, db): self._create_ask_link(db) client = Client() resp = client.get('/ask/') # 无参数时 ACTION 链接渲染错误提示页(不再有 ask.html 输入页) assert resp.status_code == 200 assert '缺少参数' in resp.content.decode() assert 'ask.html' not in resp.content.decode() # ── 编辑页 UI(Apple-style,不暴露 JSON)────────────────────────────────── class TestActionEditPage: def test_edit_page_hides_action_config_json(self, db): link = Link.objects.create( alias='ask2ui', link_type=Link.LinkType.ACTION, action_config={ 'action_type': 'discord_send', 'message_template': '<@1467845106831855796> {query}', }, ) client = Client() html = client.get(f'/link/{link.pk}/edit/').content.decode() # JSON 不应暴露给用户(key 形态 `"action_type":`,区别于表单字段名 action_type) assert '"action_type":' not in html assert 'action_config' not in html # 应显示苹果式卡片 + 中文标签 + 预览 assert 'action-card' in html assert '发消息到 Discord' in html assert '消息内容' in html assert 'msg-preview' in html # 旧英文标签不应出现 assert 'Message template' not in html