diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..abcad87 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==8.4.2 +ruff==0.14.0 +build==1.3.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/backup/conversations.json b/tests/fixtures/backup/conversations.json new file mode 100644 index 0000000..563ac9c --- /dev/null +++ b/tests/fixtures/backup/conversations.json @@ -0,0 +1,13 @@ +[ + {"uuid": "c-1", "name": "Нормализация до 3НФ", "created_at": "2026-02-03T09:00:00Z", "updated_at": "2026-02-03T09:30:00Z", + "chat_messages": [ + {"uuid": "m-1", "sender": "human", "text": "Как привести таблицу к 3НФ?", "created_at": "2026-02-03T09:00:00Z", "content": [{"type": "text", "text": "Как привести таблицу к 3НФ?"}], "attachments": [], "files": []}, + {"uuid": "m-2", "sender": "assistant", "text": "", "created_at": "2026-02-03T09:01:00Z", "content": [{"type": "text", "text": "## Шаги\n\n1. Убрать **повторяющиеся группы**\n2. Вынести *частичные* зависимости\n\n| Форма | Условие |\n|---|---|\n| 1НФ | атомарность |\n| 2НФ | нет частичных |\n\n```sql\nSELECT id, name FROM users WHERE id > 10;\n```\n\nСм. [документацию](https://www.postgresql.org/docs/) и `inline code`.\n\nОпасная ссылка: [клик](javascript:alert(1)) и картинка ![x](javascript:alert(2))\n\n"}], "attachments": [], "files": []} + ]}, + {"uuid": "c-2", "name": "", "created_at": "2026-02-10T12:00:00Z", "updated_at": "2026-02-10T12:05:00Z", + "chat_messages": [ + {"uuid": "m-3", "sender": "human", "text": "Напиши функцию на Python", "created_at": "2026-02-10T12:00:00Z", "content": [{"type": "text", "text": "Напиши функцию на Python"}], "attachments": [], "files": []}, + {"uuid": "m-4", "sender": "assistant", "text": "", "created_at": "2026-02-10T12:01:00Z", "content": [{"type": "text", "text": "```python\ndef add(a, b):\n return a + b\n```"}], "attachments": [], "files": []} + ]}, + {"uuid": "c-3", "name": "Пустой чат", "created_at": "2026-02-11T12:00:00Z", "updated_at": "2026-02-11T12:00:00Z", "chat_messages": []} +] diff --git a/tests/fixtures/backup/projects/p-1.json b/tests/fixtures/backup/projects/p-1.json new file mode 100644 index 0000000..f0e1dec --- /dev/null +++ b/tests/fixtures/backup/projects/p-1.json @@ -0,0 +1 @@ +{"uuid": "p-1", "name": "Курсовая по базам данных", "description": "Проектирование схемы и запросы", "created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-03-01T10:00:00Z", "prompt_template": "Отвечай кратко", "docs": [{"filename": "schema.sql", "content": "CREATE TABLE users (id int);", "created_at": "2026-02-02T10:00:00Z"}]} diff --git a/tests/fixtures/backup/users.json b/tests/fixtures/backup/users.json new file mode 100644 index 0000000..1e9cc8d --- /dev/null +++ b/tests/fixtures/backup/users.json @@ -0,0 +1 @@ +[{"uuid": "u-1", "full_name": "Тестовый Пользователь", "email_address": "user@example.com"}] diff --git a/tests/fixtures/mapping.json b/tests/fixtures/mapping.json new file mode 100644 index 0000000..62ebdd2 --- /dev/null +++ b/tests/fixtures/mapping.json @@ -0,0 +1 @@ +{"c-1": "p-1"} diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..2a2e38f --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,55 @@ +import json +import shutil +from pathlib import Path + +import pytest + +from declaude import DataLoader, MappingManager, SiteBuilder +from declaude.cli import main + +FIXTURE = Path(__file__).parent / "fixtures" / "backup" + + +@pytest.fixture +def archive(tmp_path): + loader = DataLoader(FIXTURE) + loader.load() + mapping = MappingManager(tmp_path / "mapping.json") + mapping.load() + mapping.assign("c-1", "p-1") + SiteBuilder(tmp_path / "out", loader, mapping).build_all() + return tmp_path / "out" + + +def test_full_build_creates_all_pages(archive): + for rel in ("index.html", "all_conversations.html", "projects/p-1/index.html", + "conversations/c-1.html", "conversations/c-2.html", "conversations/c-3.html", + "assets/style.css", "assets/app.js"): + assert (archive / rel).is_file(), rel + + +def test_conversation_page_content(archive): + page = (archive / "conversations" / "c-1.html").read_text(encoding="utf-8") + assert "Нормализация до 3НФ" in page + assert "alert" not in page + + +def test_project_page_lists_assigned_conversation(archive): + page = (archive / "projects" / "p-1" / "index.html").read_text(encoding="utf-8") + assert "Курсовая по базам данных" in page + assert "c-1.html" in page and "c-2.html" not in page + + +def test_cli_build_and_map(tmp_path, monkeypatch): + src = tmp_path / "backup" + shutil.copytree(FIXTURE, src) + out, mp = tmp_path / "out", tmp_path / "mapping.json" + monkeypatch.setattr("sys.argv", ["declaude", "--build", "--source", str(src), "--output", str(out), "--mapping", str(mp)]) + main() + assert (out / "index.html").is_file() + monkeypatch.setattr("sys.argv", ["declaude", "--map", "c-2", "p-1", "--source", str(src), "--output", str(out), "--mapping", str(mp)]) + main() + assert json.loads(mp.read_text(encoding="utf-8")).get("c-2") == "p-1" diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 0000000..d0efb92 --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,54 @@ +import pytest + +from declaude import MarkdownRenderer + +md = MarkdownRenderer() + + +@pytest.mark.parametrize("url", [ + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + " javascript:alert(1)", + "java\tscript:alert(1)", + "data:text/html;base64,PHNjcmlwdD4=", + "vbscript:msgbox", +]) +def test_dangerous_link_schemes_are_neutralized(url): + html = md.render(f"[клик]({url})") + assert 'href="#"' in html + assert "script:" not in html.lower().replace("#", "") + + +@pytest.mark.parametrize("url", ["https://example.com/a?b=1", "http://example.com", "mailto:me@example.com", "#anchor", "/local/page"]) +def test_normal_links_are_kept(url): + assert f'href="{url}"'.replace("&", "&") in md.render(f"[ссылка]({url})") + + +def test_image_rejects_javascript_and_svg_data(): + assert 'src="#"' in md.render("![x](javascript:alert(2))") + assert 'src="#"' in md.render("![x](data:image/svg+xml;base64,PHN2Zz4=)") + assert 'src="https://example.com/a.png"' in md.render("![x](https://example.com/a.png)") + + +def test_raw_html_is_escaped(): + html = md.render("") + assert "