"""Unit tests for src/exporters/.""" import json import os import tempfile from pathlib import Path import pytest from src.exporters.markdown import MarkdownExporter, _yaml_escape, _format_timestamp from src.exporters.json_export import JSONExporter SAMPLE_CONV = { "id": "abc12345def67890", "title": "Test Conversation", "provider": "claude", "project": "my-project", "created_at": "2024-06-10T14:32:00Z", "updated_at": "2024-06-10T15:00:00Z", "message_count": 2, "messages": [ { "role": "user", "content": "Hello, how are you?", "content_type": "text", "timestamp": "2024-06-10T14:32:00Z", }, { "role": "assistant", "content": "I'm doing well, thank you! How can I help?", "content_type": "text", "timestamp": "2024-06-10T14:32:10Z", }, ], } NO_PROJECT_CONV = { **SAMPLE_CONV, "id": "noproj12345", "project": None, "title": "No Project Chat", } CODE_CONV = { **SAMPLE_CONV, "id": "code12345", "messages": [ { "role": "user", "content": "Here is some code:\n```python\nprint('hello')\n```", "content_type": "text", "timestamp": None, } ], } class TestMarkdownFrontmatter: def test_yaml_frontmatter_present(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) content = path.read_text() assert content.startswith("---\n") assert "title: " in content assert "provider: claude" in content assert "conversation_id: abc12345def67890" in content assert "created_at: 2024-06-10T14:32:00Z" in content assert "exported_at: " in content assert "message_count: 2" in content assert "tags: [claude, my-project]" in content def test_no_project_uses_no_project_label(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(NO_PROJECT_CONV) content = path.read_text() assert "project: no-project" in content assert "tags: [claude]" in content def test_metadata_table_present(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) content = path.read_text() assert "| Provider | Claude |" in content assert "| Project | my-project |" in content assert "| Date | 2024-06-10 |" in content assert "| Messages | 2 |" in content def test_messages_rendered(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) content = path.read_text() assert "Hello, how are you?" in content assert "I'm doing well" in content assert "🧑 Human" in content assert "🤖 Assistant" in content def test_code_fences_preserved(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(CODE_CONV) content = path.read_text() assert "```python" in content assert "print('hello')" in content class TestMarkdownFilenameGeneration: def test_filename_format(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) assert path.name == "2024-06-10_test-conversation_abc12345.md" def test_no_project_goes_to_no_project_dir(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(NO_PROJECT_CONV) assert "no-project" in str(path) def test_project_slug_in_path(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) assert "my-project" in str(path) def test_year_in_path(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) assert "/2024/" in str(path) def test_output_structure_provider_project(self, tmp_path): exp = MarkdownExporter(tmp_path, output_structure="provider/project") path = exp.export(SAMPLE_CONV) # Should NOT have year subdirectory parts = path.parts assert "2024" not in parts class TestMarkdownEmptyMessages: def test_empty_message_skipped(self, tmp_path, caplog): import logging conv = { **SAMPLE_CONV, "messages": [ {"role": "user", "content": " ", "content_type": "text", "timestamp": None}, {"role": "assistant", "content": "Real response", "content_type": "text", "timestamp": None}, ], } exp = MarkdownExporter(tmp_path) with caplog.at_level(logging.WARNING, logger="src.exporters.markdown"): path = exp.export(conv) content = path.read_text() assert "Real response" in content assert any("empty" in r.message.lower() for r in caplog.records) class TestMarkdownAtomicWrite: def test_permissions_600(self, tmp_path): exp = MarkdownExporter(tmp_path) path = exp.export(SAMPLE_CONV) mode = oct(os.stat(path).st_mode)[-3:] assert mode == "600" def test_no_tmp_files_left(self, tmp_path): exp = MarkdownExporter(tmp_path) exp.export(SAMPLE_CONV) tmp_files = list(tmp_path.rglob("*.tmp")) assert tmp_files == [] class TestJSONExporter: def test_produces_valid_json(self, tmp_path): exp = JSONExporter(tmp_path) path = exp.export(SAMPLE_CONV) data = json.loads(path.read_text()) assert data["id"] == "abc12345def67890" assert data["title"] == "Test Conversation" assert len(data["messages"]) == 2 def test_includes_exported_at(self, tmp_path): exp = JSONExporter(tmp_path) path = exp.export(SAMPLE_CONV) data = json.loads(path.read_text()) assert "exported_at" in data def test_permissions_600(self, tmp_path): exp = JSONExporter(tmp_path) path = exp.export(SAMPLE_CONV) mode = oct(os.stat(path).st_mode)[-3:] assert mode == "600" def test_json_extension(self, tmp_path): exp = JSONExporter(tmp_path) path = exp.export(SAMPLE_CONV) assert path.suffix == ".json" def test_pretty_printed(self, tmp_path): exp = JSONExporter(tmp_path) path = exp.export(SAMPLE_CONV) raw = path.read_text() # Pretty-printed JSON has newlines and indentation assert "\n" in raw assert " " in raw class TestBothFormats: """T-38: Markdown and JSON exporters produce matching filenames for the same conversation.""" def test_both_formats_produce_files(self, tmp_path): md_exp = MarkdownExporter(tmp_path) json_exp = JSONExporter(tmp_path) md_path = md_exp.export(SAMPLE_CONV) json_path = json_exp.export(SAMPLE_CONV) assert md_path.exists() assert json_path.exists() def test_both_formats_have_matching_stems(self, tmp_path): md_exp = MarkdownExporter(tmp_path) json_exp = JSONExporter(tmp_path) md_path = md_exp.export(SAMPLE_CONV) json_path = json_exp.export(SAMPLE_CONV) assert md_path.suffix == ".md" assert json_path.suffix == ".json" assert md_path.stem == json_path.stem def test_both_formats_same_directory(self, tmp_path): md_exp = MarkdownExporter(tmp_path) json_exp = JSONExporter(tmp_path) md_path = md_exp.export(SAMPLE_CONV) json_path = json_exp.export(SAMPLE_CONV) assert md_path.parent == json_path.parent class TestYamlEscape: def test_escapes_double_quotes(self): assert _yaml_escape('Say "hello"') == 'Say \\"hello\\"' def test_escapes_backslash(self): assert _yaml_escape("path\\to\\file") == "path\\\\to\\\\file" def test_no_change_for_plain_string(self): assert _yaml_escape("Hello World") == "Hello World" class TestFormatTimestamp: def test_strips_fractional_seconds(self): result = _format_timestamp("2024-06-10T14:32:00.123456Z") assert "." not in result def test_replaces_T_with_space(self): result = _format_timestamp("2024-06-10T14:32:00Z") assert "T" not in result assert "2024-06-10 14:32:00" == result def test_empty_string(self): assert _format_timestamp("") == ""