Добавлены все базовые файлы для запуска проекта
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user