From bca1decf97120b28c7f29eeb4077d6b6dc857a75 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Fri, 27 Mar 2026 13:31:44 +0100 Subject: [PATCH] test(messages): add unit tests for conversation models and link preview Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_conversation_models.py | 242 +++++++++++++++++++++++ tests/unit/test_link_preview.py | 262 +++++++++++++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 tests/unit/test_conversation_models.py create mode 100644 tests/unit/test_link_preview.py diff --git a/tests/unit/test_conversation_models.py b/tests/unit/test_conversation_models.py new file mode 100644 index 0000000..59aa699 --- /dev/null +++ b/tests/unit/test_conversation_models.py @@ -0,0 +1,242 @@ +""" +Unit Tests — Conversation Models +================================= + +Tests for the new SQLAlchemy conversation models (mock-based, no DB required): +- Conversation.display_name +- Conversation.member_count +- ConversationMember.is_owner +- ConvMessage.__repr__ +- MessageReaction unique constraint +- Model imports +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import pytest +from unittest.mock import MagicMock +from sqlalchemy import UniqueConstraint + + +# ============================================================ +# Import Tests +# ============================================================ + +class TestModelImports: + """Verify all 5 conversation models can be imported from database.""" + + def test_import_conversation(self): + from database import Conversation + assert Conversation is not None + + def test_import_conversation_member(self): + from database import ConversationMember + assert ConversationMember is not None + + def test_import_conv_message(self): + from database import ConvMessage + assert ConvMessage is not None + + def test_import_message_reaction(self): + from database import MessageReaction + assert MessageReaction is not None + + def test_import_message_attachment(self): + from database import MessageAttachment + assert MessageAttachment is not None + + +# ============================================================ +# Helpers +# ============================================================ + +def _make_mock_member(name=None, email='user@example.com'): + """Create a MagicMock simulating a ConversationMember with a User.""" + member = MagicMock() + user = MagicMock() + user.name = name + user.email = email + member.user = user + return member + + +def _make_conversation(name=None, members=None): + """Create a MagicMock simulating a Conversation instance.""" + from database import Conversation + conv = MagicMock(spec=Conversation) + # Attach the real property logic by calling it on the mock + conv.name = name + conv.members = members or [] + # Bind the real display_name property to this mock + conv.display_name = Conversation.display_name.fget(conv) + conv.member_count = Conversation.member_count.fget(conv) + return conv + + +# ============================================================ +# Conversation.display_name Tests +# ============================================================ + +class TestConversationDisplayName: + """Test Conversation.display_name property.""" + + def test_display_name_returns_name_when_set(self): + from database import Conversation + conv = MagicMock() + conv.name = 'Projekt Alpha' + conv.members = [] + result = Conversation.display_name.fget(conv) + assert result == 'Projekt Alpha' + + def test_display_name_joins_member_names_when_no_name(self): + from database import Conversation + conv = MagicMock() + conv.name = None + conv.members = [ + _make_mock_member(name='Anna'), + _make_mock_member(name='Bob'), + ] + result = Conversation.display_name.fget(conv) + assert 'Anna' in result + assert 'Bob' in result + + def test_display_name_uses_email_prefix_when_user_has_no_name(self): + from database import Conversation + conv = MagicMock() + conv.name = None + conv.members = [ + _make_mock_member(name=None, email='jan.kowalski@example.com'), + ] + result = Conversation.display_name.fget(conv) + assert 'jan.kowalski' in result + + def test_display_name_truncates_beyond_four_members(self): + from database import Conversation + conv = MagicMock() + conv.name = None + conv.members = [ + _make_mock_member(name=f'User{i}', email=f'user{i}@example.com') + for i in range(6) + ] + result = Conversation.display_name.fget(conv) + assert '+2' in result + + def test_display_name_no_suffix_for_four_or_fewer_members(self): + from database import Conversation + conv = MagicMock() + conv.name = None + conv.members = [ + _make_mock_member(name=f'User{i}', email=f'user{i}@example.com') + for i in range(4) + ] + result = Conversation.display_name.fget(conv) + assert '+' not in result + + +# ============================================================ +# Conversation.member_count Tests +# ============================================================ + +class TestConversationMemberCount: + """Test Conversation.member_count property.""" + + def test_member_count_returns_length_of_members(self): + from database import Conversation + conv = MagicMock() + conv.members = [MagicMock(), MagicMock(), MagicMock()] + assert Conversation.member_count.fget(conv) == 3 + + def test_member_count_zero_when_no_members(self): + from database import Conversation + conv = MagicMock() + conv.members = [] + assert Conversation.member_count.fget(conv) == 0 + + +# ============================================================ +# ConversationMember.is_owner Tests +# ============================================================ + +class TestConversationMemberIsOwner: + """Test ConversationMember.is_owner property.""" + + def test_is_owner_true_when_role_is_owner(self): + from database import ConversationMember + cm = MagicMock() + cm.role = 'owner' + assert ConversationMember.is_owner.fget(cm) is True + + def test_is_owner_false_when_role_is_member(self): + from database import ConversationMember + cm = MagicMock() + cm.role = 'member' + assert ConversationMember.is_owner.fget(cm) is False + + def test_is_owner_false_when_role_is_admin(self): + from database import ConversationMember + cm = MagicMock() + cm.role = 'admin' + assert ConversationMember.is_owner.fget(cm) is False + + def test_is_owner_false_when_role_is_empty(self): + from database import ConversationMember + cm = MagicMock() + cm.role = '' + assert ConversationMember.is_owner.fget(cm) is False + + +# ============================================================ +# ConvMessage.__repr__ Tests +# ============================================================ + +class TestConvMessageRepr: + """Test ConvMessage.__repr__ returns expected format.""" + + def test_repr_format(self): + from database import ConvMessage + msg = MagicMock() + msg.id = 42 + msg.conversation_id = 7 + msg.sender_id = 13 + # Call the actual __repr__ method on the mock instance + result = ConvMessage.__repr__(msg) + assert '42' in result + assert '7' in result + assert '13' in result + + def test_repr_contains_class_indicator(self): + from database import ConvMessage + msg = MagicMock() + msg.id = 1 + msg.conversation_id = 2 + msg.sender_id = 3 + assert 'ConvMessage' in ConvMessage.__repr__(msg) + + +# ============================================================ +# MessageReaction UniqueConstraint Tests +# ============================================================ + +class TestMessageReactionUniqueConstraint: + """Verify MessageReaction __table_args__ contains UniqueConstraint.""" + + def test_table_args_contains_unique_constraint(self): + from database import MessageReaction + table_args = MessageReaction.__table_args__ + assert table_args is not None + constraint_types = [type(a) for a in table_args] + assert UniqueConstraint in constraint_types + + def test_unique_constraint_covers_message_user_emoji(self): + from database import MessageReaction + table_args = MessageReaction.__table_args__ + unique_constraints = [a for a in table_args if isinstance(a, UniqueConstraint)] + assert len(unique_constraints) >= 1 + constraint = unique_constraints[0] + col_names = [col.key for col in constraint.columns] + assert 'message_id' in col_names + assert 'user_id' in col_names + assert 'emoji' in col_names diff --git a/tests/unit/test_link_preview.py b/tests/unit/test_link_preview.py new file mode 100644 index 0000000..9242425 --- /dev/null +++ b/tests/unit/test_link_preview.py @@ -0,0 +1,262 @@ +""" +Unit Tests — Link Preview +========================== + +Tests for blueprints/messages/link_preview.py: +- OGParser with og: meta tags +- OGParser fallback to and meta description +- OGParser with no meta tags +- fetch_link_preview with no URL +- fetch_link_preview skips internal URLs +- fetch_link_preview success (mocked HTTP) +- fetch_link_preview timeout handling +- fetch_link_preview non-HTML content-type +- URL extraction from HTML anchor tags +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import pytest +from unittest.mock import patch, MagicMock +from requests.exceptions import Timeout + +from blueprints.messages.link_preview import fetch_link_preview, OGParser + + +# ============================================================ +# OGParser Tests +# ============================================================ + +class TestOGParser: + """Test OGParser HTML parsing.""" + + def test_parses_og_title_description_image(self): + html = """ + <html><head> + <meta property="og:title" content="Test Title"> + <meta property="og:description" content="Test Description"> + <meta property="og:image" content="https://example.com/image.jpg"> + </head></html> + """ + parser = OGParser() + parser.feed(html) + assert parser.og['title'] == 'Test Title' + assert parser.og['description'] == 'Test Description' + assert parser.og['image'] == 'https://example.com/image.jpg' + + def test_fallback_to_title_tag_and_meta_description(self): + html = """ + <html><head> + <title>Fallback Title + + + """ + parser = OGParser() + parser.feed(html) + assert parser.title == 'Fallback Title' + assert parser.og.get('description') == 'Fallback Description' + assert 'title' not in parser.og # og:title not set + + def test_empty_html_returns_title_from_title_tag(self): + html = "Only Title" + parser = OGParser() + parser.feed(html) + assert parser.title == 'Only Title' + assert parser.og.get('description') is None + assert parser.og.get('image') is None + + def test_no_meta_tags_empty_og(self): + html = "No meta here" + parser = OGParser() + parser.feed(html) + assert parser.og == {} + assert parser.title is None + + def test_og_description_takes_precedence_over_meta_description(self): + html = """ + + + + + """ + parser = OGParser() + parser.feed(html) + assert parser.og['description'] == 'OG Desc' + + +# ============================================================ +# fetch_link_preview Tests +# ============================================================ + +class TestFetchLinkPreview: + """Test fetch_link_preview function.""" + + def test_returns_none_for_none_text(self): + result = fetch_link_preview(None) + assert result is None + + def test_returns_none_for_empty_text(self): + result = fetch_link_preview('') + assert result is None + + def test_returns_none_when_no_url_in_text(self): + result = fetch_link_preview('Cześć, jak się masz?') + assert result is None + + def test_returns_none_for_internal_nordabiznes_url(self): + result = fetch_link_preview('Sprawdź https://nordabiznes.pl/company/test') + assert result is None + + def test_returns_none_for_staging_internal_url(self): + result = fetch_link_preview('Link: https://staging.nordabiznes.pl/company/foo') + assert result is None + + def test_returns_none_for_localhost_url(self): + result = fetch_link_preview('Dev: http://localhost:5000/test') + assert result is None + + def test_success_returns_dict_with_og_data(self): + html = """ + + + + """ + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html; charset=utf-8'} + mock_resp.text = html + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview('Check out https://example.com') + + assert result is not None + assert result['url'] == 'https://example.com' + assert result['title'] == 'Example Title' + assert result['description'] == 'Example Description' + assert result['image'] == 'https://example.com/img.jpg' + + def test_success_uses_title_tag_fallback(self): + html = "Page Title" + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html'} + mock_resp.text = html + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview('See https://example.com for details') + + assert result is not None + assert result['title'] == 'Page Title' + + def test_returns_none_on_timeout(self): + with patch('blueprints.messages.link_preview.requests.get', side_effect=Timeout): + result = fetch_link_preview('Visit https://slow-site.example.com') + assert result is None + + def test_returns_none_for_non_html_content_type(self): + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'application/pdf'} + mock_resp.text = '%PDF-1.4 binary content' + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview('Download https://example.com/doc.pdf') + assert result is None + + def test_returns_none_when_page_has_no_title(self): + html = "" + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html'} + mock_resp.text = html + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview('Visit https://example.com') + assert result is None + + def test_title_truncated_to_200_chars(self): + long_title = 'A' * 300 + html = f"{long_title}" + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html'} + mock_resp.text = html + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview('https://example.com') + + assert result is not None + assert len(result['title']) <= 200 + + def test_description_truncated_to_300_chars(self): + long_desc = 'B' * 400 + html = f""" + Title + + """ + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html'} + mock_resp.text = html + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview('https://example.com') + + assert result is not None + assert len(result['description']) <= 300 + + +# ============================================================ +# URL Extraction from HTML Content Tests +# ============================================================ + +class TestURLExtractionFromHTML: + """Test that URLs inside HTML anchor tags are correctly found.""" + + def test_extracts_url_from_anchor_tag(self): + """URL inside is extracted after stripping HTML tags.""" + text = 'Visit site' + # The function strips HTML tags before extracting URLs, + # so href URL is not extracted — only bare URLs in text are. + # This test verifies the stripping behavior: no URL in visible text → None. + result = fetch_link_preview(text) + # After stripping tags, text is "Visit site" — no URL → None + assert result is None + + def test_extracts_bare_url_from_mixed_html(self): + """Bare URL in text alongside HTML is extracted correctly.""" + text = '

Check out https://example.com/news for more

' + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html'} + mock_resp.text = 'News' + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview(text) + + assert result is not None + assert result['url'] == 'https://example.com/news' + + def test_first_url_is_used_when_multiple_urls_present(self): + """When text contains multiple URLs, the first one is used.""" + text = 'First: https://first.example.com and second: https://second.example.com' + + mock_resp = MagicMock() + mock_resp.headers = {'content-type': 'text/html'} + mock_resp.text = 'First' + mock_resp.raise_for_status = MagicMock() + + with patch('blueprints.messages.link_preview.requests.get', return_value=mock_resp): + result = fetch_link_preview(text) + + assert result is not None + assert result['url'] == 'https://first.example.com'