Добавлены все базовые файлы для запуска проекта

This commit is contained in:
Даниил
2026-09-19 15:19:31 +10:00
commit 34535ac579
23 changed files with 1903 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import pytest
import config
@pytest.fixture
def env_file(tmp_path, monkeypatch):
for name in tuple(config.os.environ):
if name.startswith("N8N_"):
monkeypatch.delenv(name)
path = tmp_path / ".env"
monkeypatch.setattr(config, "ENV_FILE", path)
return path
def test_file_loads_from_other_working_directory(env_file, tmp_path, monkeypatch):
env_file.write_text(
'N8N_CHAT_WEBHOOK_URL="https://example.com/chat"\n'
'N8N_UPLOAD_WEBHOOK_URL=https://example.com/upload\n'
'N8N_CHAT_TIMEOUT_SECONDS=15\nN8N_MAX_FILE_BYTES=2048\n',
encoding="utf-8-sig",
)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
monkeypatch.chdir(elsewhere)
settings = config.Settings.from_env()
assert settings.chat_webhook_url == "https://example.com/chat"
assert settings.upload_webhook_url == "https://example.com/upload"
assert settings.chat_timeout_seconds == 15
assert settings.max_file_bytes == 2048
def test_environment_overrides_file(env_file, monkeypatch):
env_file.write_text("N8N_CHAT_WEBHOOK_URL=https://example.com/file\n")
monkeypatch.setenv("N8N_CHAT_WEBHOOK_URL", "https://example.com/environment")
assert config.Settings.from_env().chat_webhook_url.endswith("/environment")
def test_missing_file_uses_defaults(env_file):
assert config.Settings.from_env() == config.Settings()
def test_invalid_file_setting_has_friendly_error(env_file):
env_file.write_text("N8N_CHAT_TIMEOUT_SECONDS=abc\n")
with pytest.raises(config.ConfigurationError, match="числовые"):
config.Settings.from_env()
+109
View File
@@ -0,0 +1,109 @@
import asyncio
import json
import httpx
import pytest
from config import Settings
from n8n_client import N8NClient, WebhookError
CHAT_URL = "https://n8n.example.test/webhook/126bdb74-0630-415d-a8f7-6f699eac1a2c"
UPLOAD_URL = "https://n8n.example.test/webhook/993d3a50-19a7-4c64-9d36-67c0ced130dd"
def settings() -> Settings:
return Settings(chat_webhook_url=CHAT_URL, upload_webhook_url=UPLOAD_URL)
def test_chat_sends_exact_contract_and_reads_plain_text() -> None:
received: list[httpx.Request] = []
def respond(request: httpx.Request) -> httpx.Response:
received.append(request)
return httpx.Response(200, text="Ответ специалиста или агента")
client = N8NClient(settings(), httpx.MockTransport(respond))
first = asyncio.run(client.ask(" Вопрос 1 ", "my-store", "session-1"))
second = asyncio.run(client.ask("Вопрос 2", "my-store", "session-1"))
assert first == second == "Ответ специалиста или агента"
assert [request.method for request in received] == ["POST", "POST"]
assert all(str(request.url) == CHAT_URL for request in received)
assert [json.loads(request.content) for request in received] == [
{"message": "Вопрос 1", "collection_name": "my-store", "session_id": "session-1"},
{"message": "Вопрос 2", "collection_name": "my-store", "session_id": "session-1"},
]
def test_upload_uses_one_multipart_file_and_collection() -> None:
received: list[httpx.Request] = []
def respond(request: httpx.Request) -> httpx.Response:
received.append(request)
return httpx.Response(200, json={"unverified": "response shape"})
client = N8NClient(settings(), httpx.MockTransport(respond))
assert asyncio.run(client.upload("notes.md", b"# Notes\n", "my-store")) is None
assert len(received) == 1
request = received[0]
assert request.method == "POST"
assert str(request.url) == UPLOAD_URL
assert request.headers["content-type"].startswith("multipart/form-data; boundary=")
assert b'name="collection_name"' in request.content
assert b"my-store" in request.content
assert b'name="file"; filename="notes.md"' in request.content
assert b"Content-Type: text/markdown" in request.content
assert b"# Notes\n" in request.content
@pytest.mark.parametrize(
"filename,contents,collection",
[
("notes.txt", b"text", "my-store"),
("notes.md", b"", "my-store"),
("notes.md", b"text", " "),
("notes.md", b"12345", "my-store"),
],
)
def test_invalid_upload_never_calls_webhook(
filename: str, contents: bytes, collection: str
) -> None:
called = False
def respond(request: httpx.Request) -> httpx.Response:
nonlocal called
called = True
return httpx.Response(200)
options = Settings(
chat_webhook_url=CHAT_URL,
upload_webhook_url=UPLOAD_URL,
max_file_bytes=4,
)
client = N8NClient(options, httpx.MockTransport(respond))
with pytest.raises(ValueError):
asyncio.run(client.upload(filename, contents, collection))
assert not called
def test_chat_http_error_and_empty_reply_are_not_answers() -> None:
for response in (httpx.Response(502, text="failure"), httpx.Response(200, text=" ")):
client = N8NClient(settings(), httpx.MockTransport(lambda _: response))
with pytest.raises(WebhookError):
asyncio.run(client.ask("Вопрос", "my-store", "session-1"))
def test_timeout_does_not_retry_upload() -> None:
attempts = 0
def respond(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ReadTimeout("timeout", request=request)
client = N8NClient(settings(), httpx.MockTransport(respond))
with pytest.raises(WebhookError, match="ожидания загрузки"):
asyncio.run(client.upload("notes.md", b"# Notes", "my-store"))
assert attempts == 1
+99
View File
@@ -0,0 +1,99 @@
import asyncio
import io
from types import SimpleNamespace
import rio
import components
from n8n_client import WebhookError
def view():
return SimpleNamespace(collection_name='my-store', pending_files=(), upload_error='',
upload_status='', upload_progress='', is_uploading=False,
force_refresh=lambda: None)
def pick(state, name, data=b'# Notes'):
file = rio.FileInfo(name, len(data), 'text/markdown', io.BytesIO(data))
components.KnowledgeApp.pick_file(state, rio.FilePickEvent(file))
return file
def test_partial_failure_retries_only_failed_file_and_retains_bytes(monkeypatch):
state = view()
pick(state, 'first.md')
pick(state, 'second.md')
pick(state, 'third.md')
calls = []
async def upload(name, contents, collection):
calls.append(name)
assert contents == b'# Notes'
assert collection == 'my-store'
if name == 'second.md' and calls.count(name) == 1:
raise WebhookError('Временная ошибка')
monkeypatch.setattr(components, 'client', SimpleNamespace(upload=upload))
asyncio.run(components.KnowledgeApp.upload_file(state))
assert calls == ['first.md', 'second.md', 'third.md']
assert len(state.pending_files) == 1
assert state.pending_files[0].contents == b'# Notes'
assert 'second.md' in state.upload_error
assert '2 из 3' in state.upload_status
asyncio.run(components.KnowledgeApp.upload_file(state))
assert calls == ['first.md', 'second.md', 'third.md', 'second.md']
assert not state.pending_files
assert not state.upload_error
assert not state.is_uploading
def test_invalid_files_do_not_remove_valid_selection():
state = view()
good = pick(state, 'good.md')
pick(state, 'bad.txt')
pick(state, 'empty.md', b'')
pick(state, 'another.md')
assert len(state.pending_files) == 2
assert 'bad.txt' in state.upload_error and 'empty.md' in state.upload_error
components.KnowledgeApp.remove_file(state, rio.FilePickEvent(good))
assert len(state.pending_files) == 1
assert state.pending_files[0].file.name == 'another.md'
def test_busy_queue_is_immutable_and_duplicate_submission_is_ignored(monkeypatch):
state = view()
first = pick(state, 'first.md')
calls = []
async def upload(*args):
calls.append(args)
pick(state, 'late.md')
components.KnowledgeApp.remove_file(state, rio.FilePickEvent(first))
assert len(state.pending_files) == 1
await components.KnowledgeApp.upload_file(state)
monkeypatch.setattr(components, 'client', SimpleNamespace(upload=upload))
asyncio.run(components.KnowledgeApp.upload_file(state))
assert len(calls) == 1
assert not state.pending_files
def test_read_error_does_not_block_remaining_files(monkeypatch):
state = view()
broken = pick(state, 'broken.md')
state.pending_files[0].file = SimpleNamespace(name='broken.md', read_bytes=broken_read)
pick(state, 'good.md')
calls = []
async def upload(name, *_):
calls.append(name)
monkeypatch.setattr(components, 'client', SimpleNamespace(upload=upload))
asyncio.run(components.KnowledgeApp.upload_file(state))
assert calls == ['good.md']
assert not state.pending_files
assert 'Выберите его заново' in state.upload_error
async def broken_read():
raise OSError('closed stream')