diff --git a/functional_tests/test_per_message_export.py b/functional_tests/test_per_message_export.py new file mode 100644 index 00000000..37ee9a9b --- /dev/null +++ b/functional_tests/test_per_message_export.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# test_per_message_export.py +""" +Functional tests for the per-message export feature (Markdown client-side +equivalent + Word backend). +Version: 0.239.008 +Implemented in: 0.239.008 + +Covers: + - Happy path: Word document built successfully from a valid message. + - Markdown export logic: correct header, timestamp and content rendered. + - Auth failure: unauthenticated caller receives 401. + - Ownership failure: caller who does not own the conversation receives 403. +""" + +import sys +import os +import io + +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app') +) + + +# --------------------------------------------------------------------------- +# Helpers โ€“ replicate key logic from route_backend_conversation_export.py +# so the tests run without a live Flask + Cosmos DB environment. +# --------------------------------------------------------------------------- + +def _normalize_content(content): + """Replicate _normalize_content from route_backend_conversation_export.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get('type') == 'text': + parts.append(item.get('text', '')) + elif item.get('type') == 'image_url': + parts.append('[Image]') + else: + parts.append(str(item)) + else: + parts.append(str(item)) + return '\n'.join(parts) + if isinstance(content, dict): + if content.get('type') == 'text': + return content.get('text', '') + return str(content) + return str(content) if content else '' + + +def _verify_ownership(conversation, requesting_user_id): + """Return (ok, status_code, error_msg).""" + if conversation is None: + return False, 404, 'Conversation not found' + if conversation.get('user_id') != requesting_user_id: + return False, 403, 'Access denied' + return True, 200, None + + +def _check_auth(user_id): + """Simulate the authentication guard at the start of the endpoint.""" + if not user_id: + return False, 401, 'User not authenticated' + return True, 200, None + + +def _build_markdown_export(role, content, sender, timestamp): + """Replicate the client-side Markdown export logic from chat-message-export.js.""" + lines = [] + lines.append(f"### {sender}") + if timestamp: + lines.append(f"*{timestamp}*") + lines.append('') + lines.append(content) + lines.append('') + return '\n'.join(lines) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_happy_path_word_export(): + """Happy path: Word document is built without error for a valid message.""" + print("๐Ÿ” Testing happy path โ€“ Word document generation...") + + try: + from docx import Document as DocxDocument + from docx.shared import Pt + except ImportError as exc: + print(f" โš ๏ธ python-docx not installed, skipping Word generation check: {exc}") + print("โœ… test_happy_path_word_export skipped (dependency missing)") + return True + + # Arrange + requesting_user_id = 'user-alice' + conversation = {'id': 'conv-001', 'user_id': 'user-alice'} + message = { + 'id': 'msg-001', + 'role': 'assistant', + 'content': '**Hello**, world!\n\nThis is a test.', + 'timestamp': '2025-06-01T10:00:00Z', + 'citations': [ + {'title': 'Reference Doc', 'url': 'https://example.com/ref'} + ] + } + + # Auth check + auth_ok, auth_status, auth_err = _check_auth(requesting_user_id) + assert auth_ok, f"Auth should pass, got {auth_status}: {auth_err}" + + # Ownership check + ok, status, err = _verify_ownership(conversation, requesting_user_id) + assert ok, f"Ownership check should pass, got {status}: {err}" + + # Build Word document + doc = DocxDocument() + doc.add_heading('Message Export', level=1) + + role = message.get('role', 'unknown').capitalize() + timestamp = message.get('timestamp', '') + + meta_para = doc.add_paragraph() + meta_run = meta_para.add_run(f"Role: {role}") + meta_run.bold = True + if timestamp: + meta_para.add_run(f" {timestamp}") + + doc.add_paragraph('') + + content = _normalize_content(message.get('content', '')) + + # Add content as a paragraph (simplified โ€“ full logic tested in route unit) + doc.add_paragraph(content) + + citations = message.get('citations', []) + if citations: + doc.add_heading('Citations', level=2) + for cit in citations: + source = cit.get('title') or cit.get('url', 'Unknown') + doc.add_paragraph(source, style='List Bullet') + + # Serialise to buffer โ€“ if this raises, the test fails + buffer = io.BytesIO() + doc.save(buffer) + buffer.seek(0) + docx_bytes = buffer.read() + + assert len(docx_bytes) > 0, "Generated docx should be non-empty" + + # Round-trip verify + buffer.seek(0) + loaded = DocxDocument(io.BytesIO(docx_bytes)) + headings = [p.text for p in loaded.paragraphs if p.style.name.startswith('Heading')] + assert 'Message Export' in headings, "Document should have 'Message Export' heading" + + print("โœ… test_happy_path_word_export passed!") + return True + + +def test_happy_path_markdown_export(): + """Happy path: Markdown file content is correctly formatted.""" + print("๐Ÿ” Testing happy path โ€“ Markdown export...") + + role = 'assistant' + content = 'Here is a **bold** answer.' + sender = 'Assistant' + timestamp = '2025-06-01T10:05:00Z' + + markdown = _build_markdown_export(role, content, sender, timestamp) + + assert '### Assistant' in markdown, "Should have role heading" + assert f'*{timestamp}*' in markdown, "Should include timestamp" + assert content in markdown, "Should include message content" + # File should start with the heading line + assert markdown.startswith('### Assistant'), "Heading should be first line" + + print("โœ… test_happy_path_markdown_export passed!") + return True + + +def test_auth_failure_unauthenticated(): + """Auth failure: an unauthenticated caller (no user_id) should get 401.""" + print("๐Ÿ” Testing auth failure โ€“ unauthenticated request...") + + for bad_user_id in (None, '', False): + ok, status, err = _check_auth(bad_user_id) + assert not ok, f"Auth should fail for user_id={bad_user_id!r}" + assert status == 401, f"Expected 401, got {status}" + assert err == 'User not authenticated', f"Unexpected error message: {err}" + + print("โœ… test_auth_failure_unauthenticated passed!") + return True + + +def test_ownership_failure_wrong_user(): + """Ownership failure: user requesting another user's conversation gets 403.""" + print("๐Ÿ” Testing ownership failure โ€“ wrong user...") + + conversation = {'id': 'conv-bob', 'user_id': 'user-bob'} + requesting_user = 'user-alice' + + ok, status, err = _verify_ownership(conversation, requesting_user) + + assert not ok, "Ownership check should fail" + assert status == 403, f"Expected 403, got {status}" + assert err == 'Access denied', f"Unexpected error message: {err}" + + print("โœ… test_ownership_failure_wrong_user passed!") + return True + + +def test_ownership_failure_missing_conversation(): + """Ownership failure: conversation not found should return 404.""" + print("๐Ÿ” Testing ownership failure โ€“ conversation not found...") + + ok, status, err = _verify_ownership(None, 'user-alice') + + assert not ok, "Ownership check should fail for missing conversation" + assert status == 404, f"Expected 404, got {status}" + assert err == 'Conversation not found', f"Unexpected error message: {err}" + + print("โœ… test_ownership_failure_missing_conversation passed!") + return True + + +def test_normalize_content_variants(): + """Content normalisation handles strings, lists, and dicts correctly.""" + print("๐Ÿ” Testing content normalisation...") + + # Plain string โ€“ unchanged + assert _normalize_content('hello') == 'hello' + + # List of text parts + result = _normalize_content([ + {'type': 'text', 'text': 'Part 1'}, + {'type': 'text', 'text': 'Part 2'}, + ]) + assert result == 'Part 1\nPart 2', f"Unexpected: {result!r}" + + # Image entry in list + result = _normalize_content([ + {'type': 'text', 'text': 'Before image'}, + {'type': 'image_url', 'image_url': {'url': 'https://example.com/img.png'}}, + ]) + assert '[Image]' in result, "Image entries should render as [Image]" + + # Dict with type=text + assert _normalize_content({'type': 'text', 'text': 'Hi'}) == 'Hi' + + # None / empty + assert _normalize_content(None) == '' + assert _normalize_content('') == '' + + print("โœ… test_normalize_content_variants passed!") + return True + + +def test_markdown_export_no_timestamp(): + """Markdown export omits the timestamp line when timestamp is empty.""" + print("๐Ÿ” Testing Markdown export without timestamp...") + + markdown = _build_markdown_export('user', 'Hello!', 'User', '') + + assert '### User' in markdown + assert 'Hello!' in markdown + # No italicised timestamp line should be present + lines = markdown.splitlines() + italic_lines = [line for line in lines if line.startswith('*') and line.endswith('*')] + assert not italic_lines, f"Should be no timestamp lines, found: {italic_lines}" + + print("โœ… test_markdown_export_no_timestamp passed!") + return True + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + tests = [ + test_happy_path_word_export, + test_happy_path_markdown_export, + test_auth_failure_unauthenticated, + test_ownership_failure_wrong_user, + test_ownership_failure_missing_conversation, + test_normalize_content_variants, + test_markdown_export_no_timestamp, + ] + results = [] + + for test_fn in tests: + print(f"\n๐Ÿงช Running {test_fn.__name__}...") + try: + results.append(test_fn()) + except Exception as exc: + print(f"โŒ {test_fn.__name__} failed: {exc}") + import traceback + traceback.print_exc() + results.append(False) + + success = all(results) + passed = sum(1 for r in results if r) + print(f"\n๐Ÿ“Š Results: {passed}/{len(results)} tests passed") + sys.exit(0 if success else 1)