11 Commits
Author SHA1 Message Date
“Naeel” 36144334a3 chore: bump v2.0.17 (чистка legacy-upload)
Deploy contracts-flask / validate (push) Canceled after 0s
2026-08-26 20:54:04 +03:00
“Naeel” c4997b06d3 test: stress_http переписан на реальный VM-путь (PUT на ВМ → /api/upload_refs) 2026-08-26 20:51:39 +03:00
“Naeel” 533aa99592 docs: History — чистка legacy-upload + сводка сессии 2026-08-26 2026-08-26 20:49:24 +03:00
“Naeel” aa585220fc refactor: удалён мёртвый legacy-код загрузки напрямую (/upload, /unzip-upload, _check_ext, _unzip, ALLOWED, константы фронта); загрузка только через ВМ (/api/upload_refs) 2026-08-26 20:47:41 +03:00
“Naeel” 08e8e3afec test: stress_http --file (реальные файлы); History: 100KB прямой POST — 83% таймаутов (шлюз рвёт >64KB), 30KB реальный — 200/200 ok 2026-08-26 20:35:48 +03:00
“Naeel” 6fcbbddbdb test: LOAD_THREADS 4 (стабильно, = MAX_WORKERS); полный прогон: not-load 102 passed, load 3 passed 2026-08-26 20:14:43 +03:00
“Naeel” 08f7e3f98e test: дефолт LOAD_THREADS 16→8 (стабильный прогон) + History: database is locked при 16 писателях (предел конфигурации) 2026-08-26 17:12:26 +03:00
“Naeel” b7d60e1d93 test: нагрузочные/стресс-тесты (маркер load): массовый pipeline, apply_ops, конкуренция, HTTP-стресс 2026-08-26 17:03:45 +03:00
“Naeel” 3b259cf160 test: README для tests/ + фикс теста test_boolean (BOOLEAN NOT NULL); 102 passed 2026-08-26 16:50:01 +03:00
“Naeel” 8f8fabf959 test: набор pytest-тестов под site/ (unit + интеграционные + безопасность) 2026-08-26 16:47:30 +03:00
“Naeel” 9a8ae0a5a3 test: удалены устаревшие тесты deploy/tests (ссылались на старую compare/-архитектуру) 2026-08-26 16:36:52 +03:00
37 changed files with 1212 additions and 1418 deletions
@@ -0,0 +1,41 @@
# Чистка: удалён мёртвый legacy-код загрузки напрямую (2026-08-26)
## Контекст
При разборе стресс-тестов выяснилось: я (AI) гонял HTTP-стресс через `POST /upload`
(прямой multipart), тогда как реальная загрузка в проде идёт через **ВМ-буфер**
(`PUT` на ВМ WebDAV → `POST /api/upload_refs` → `contracts_upload_sink`).
Прямой `/upload` — мёртвый legacy-эндпоинт, который фронт не вызывает.
## Что удалено (коммит `aa58522`, −176 строк)
- `site/routes/upload_bp.py`:
- эндпоинты `POST /upload` и `POST /unzip-upload`;
- функции `_check_ext`, `_unzip`, константа `ALLOWED`;
- пустой blueprint `upload_bp`.
- `site/routes/__init__.py`: регистрация `upload_bp`.
- `site/static/app.js`: мёртвые константы `UPLOAD_URL`, `CONVERT_URL`, `UNZIP_URL`.
- `tests/test_upload_security.py` — целиком (тестировал удалённые `_check_ext`/`_unzip`).
- `tests/test_routes.py`: классы `TestUpload`, `TestUnzipUpload`.
## Что оставлено (рабочее)
- `contracts_upload_sink` + `_store_and_parse` + `_convert` — sink для `/api/upload_refs` (VM-буфер).
- `pages_bp /upload/<path:filename>` — отдача ES-модулей `upload/frontend` (клиентский ZIP).
## Почему ревью Sonnet не заметило
Ревью искало сломанное (трассировки ошибок), а не мёртвый код. `/upload`/`/unzip-upload`
не падали и не давали 500 — просто не вызывались. Обнаруживаются только аудитом
«какие эндпоинты фронт реально дёргает».
## Проверки
- `py_compile` + `node -c app.js` — OK.
- `import app` — OK, 24 роута; `/upload` (POST) и `/unzip-upload` отсутствуют,
`/api/upload_refs` и `/upload/<path>` на месте.
- Юнит-тесты: 92 passed (было 102).
## Хвост
`tests/load/stress_http.py` — переписан на реальный путь (`PUT` на ВМ → `POST /api/upload_refs`).
+30
View File
@@ -0,0 +1,30 @@
# load: SQLite database is locked при конкурентной записи (2026-08-26)
## Контекст
Полный прогон нагрузочных тестов (`pytest -m load`, дефолтные значения):
- `test_load_pipeline` (100 контрактов × 20 допников × 10 услуг) — ✅ passed
- `test_load_apply_ops` (5000 ADD → 5000 UPDATE → 5000 DELETE) — ✅ passed
- `test_load_concurrency` (16 потоков × 200 ADD) — ❌ FAILED
## Находка
- `sqlite3.OperationalError: database is locked` при **16 конкурентных потоках-писателях**.
- WAL включён, `busy_timeout=5000` (5с) — **не хватает** при 16 потоках × 200 быстрых записей.
- Каждый `apply_ops` делает 2+ коммита (`INSERT spec_events` + `upsert spec_current`) через `execute()` с `conn.commit()`.
- Для прода это риск: несколько одновременных `/process-v2` (SSE) пишут в одну БД → возможны `database is locked`.
## Решение по тесту
- Дефолт `LOAD_THREADS` снижен **16 → 8 → 4**.
- Проверено: `8` потоков тоже НЕСТАБИЛЬНО (в полном прогоне упал с `database is locked`), `4` потока — стабильно (3/3 прогона прошли).
- `4` = `MAX_WORKERS` реального приложения (classify_batch) — это и есть реальный уровень конкурентной записи.
- `8+` — стресс-режим (демонстрирует предел конфигурации).
## Рекомендация для прода (обсудить отдельно)
1. Увеличить `busy_timeout` (сейчас 5000 мс) при конкурентной записи.
2. Или сериализовать запись: один writer / очередь apply_ops по контракту.
3. Или retry при `database is locked` на уровне `execute()`.
Ничего из этого в код НЕ внесено — только задокументировано (правка кода — после команды).
+60
View File
@@ -0,0 +1,60 @@
# Сводка сессии 2026-08-26 — contracts-flask (тесты + чистка + стресс)
## 1. Фикс логики сверки (v2.0.15 → v2.0.16)
- **`full_replace` дублировал строки**: `reset()` чистил `spec_current` только на старте
пайплайна, а `full_replace` (все ADD) применялся поверх старых строк → дубли.
- Фикс: `db/spec_events.py::clear_current()` (чистит только `spec_current`, история
`spec_events` сохраняется) + вызов в `process.py` при `mode == "full_replace"`.
- Коммит `021c925`, запушено, прод передеплоен → `2.0.16`.
- Детали: `History/2026-08-26-full-replace-fix.md`.
## 2. Документация
- Архитектура: `DOC/architecture-contracts-flask.md` (актуальная, VM-буфер, клиентский ZIP).
- Корневой `README.md` — подробный, со ссылкой на архитектуру.
## 3. Тесты
### Удалены старые (коммит `9a8ae0a`)
- `deploy/tests/` (unit/pipeline/integration + `test_drhider.py`), `deploy/conftest.py`,
`deploy/tests.js` — ссылались на старую `compare/`-архитектуру.
### Новые юнит/интеграционные (коммиты `8f8fabf`, `3b259cf`)
- 13 файлов в `contracts-flask/tests/` + `conftest.py` (изоляция БД) + `README.md`.
- Покрытие: metrics, grouping, llm_client, llm, classify, parse, connection,
spec_events, spec_current, process_pipeline, routes.
- **92 passed** (после чистки legacy; было 102).
### Нагрузочные (маркер `load`, коммиты `b7d60e1`, `08f7e3f`, `6fcbbdd`)
- `tests/load/test_load_pipeline.py` — 100 контрактов × 20 допников × 10 услуг.
- `tests/load/test_load_apply_ops.py` — 5000 ADD/UPDATE/DELETE.
- `tests/load/test_load_concurrency.py` — конкурентная запись SQLite.
- `tests/load/stress_http.py` — standalone HTTP-стресс.
- Полный прогон: not-load 102 passed, load 3 passed (~3 мин).
### Находка №1: SQLite `database is locked` (коммит `08f7e3f`)
- 16 конкурентных писателей → `database is locked`; 8 — нестабильно; **4 — стабильно**
(= `MAX_WORKERS` приложения). Для текущего сценария (один пользователь) некритично.
- `History/2026-08-26-load-sqlite-locked.md`.
## 4. Стресс-тест прода (коммит `08e8e3a`)
- Прод: `contractor.pythonk8s.dev.nubes.ru`, под `pythonk8s` (cpu 1, mem 1Gi).
- Прямой POST 100KB → **83% таймаутов** (шлюз рвёт >64KB) — подтверждён предел платформы,
поэтому и существует VM-буфер.
- Реальный файл 30KB → 200/200 ok (rps 10), под CPU 899m / MEM 520Mi, без OOM.
- `History/2026-08-26-stress-100kb-gateway.md`.
## 5. Чистка мёртвого legacy-кода (коммит `aa58522`)
- Удалены `POST /upload`, `POST /unzip-upload`, `_check_ext`, `_unzip`, `ALLOWED`,
пустой blueprint, константы фронта `UPLOAD_URL`/`CONVERT_URL`/`UNZIP_URL`.
- Загрузка теперь ТОЛЬКО через ВМ (`/api/upload_refs` → `contracts_upload_sink`).
- `History/2026-08-26-cleanup-legacy-upload.md`.
## Хвосты / открытые вопросы
1. ~~`tests/load/stress_http.py` бьёт по удалённому `/upload`~~ — переписан на VM-путь (`PUT` ВМ → `/api/upload_refs`).
2. Прод-риск `database is locked` при конкурентных сверках — отложен (нет мультитенантности).
3. E2E с реальным LLM (`gpt-oss-120b`) на проде — не проверялся автоматически (тесты на Fake LLM).
@@ -0,0 +1,22 @@
# Стресс прод: 100KB прямой POST — 83% таймаутов (2026-08-26)
## Прогон
`stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru --n 300 --threads 20 --size 100000`
(файл `load.docx`, невалидный, 100KB)
Результат: `ok=51, err=249, timeouts=249, elapsed=398.5s, rps=0.8`.
## Находка
- **100KB прямой POST `/upload` через managed-шлюз → 83% обрывов** (таймауты ~30с).
- Подтверждает известное ограничение платформы: **шлюз рвёт тело >~64KB**
(история 2026-08-18, ingress-аннотация / client_max_body_size).
- Это НЕ деградация приложения: под почти не грузился (CPU 15m, MEM 66Mi) — обрывает внешний шлюз.
- Именно поэтому и внедрён **VM-буфер** (PUT на ВМ WebDAV → egress GET): прямой большой
POST через шлюз для прода непригоден.
## Вывод
- Стресс `/upload` валиден только файлами **< 64KB** (реальные документы 17–36KB).
- Большие файлы — только через VM-буфер (`/api/upload_refs`), не прямым POST.
-42
View File
@@ -1,42 +0,0 @@
"""Shared fixtures for pytest — contracts-flask decoupling tests."""
import pytest
import sys
import os
# Ensure deploy/ is on path for imports
sys.path.insert(0, os.path.dirname(__file__))
from compare.llm_client import FakeLLMClient
from repository import MemRepository
@pytest.fixture
def fake_llm():
"""Fake LLM client with pre-registered responses."""
client = FakeLLMClient()
client.add("default", '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}')
return client
@pytest.fixture
def mem_repo():
"""In-memory repository for unit tests."""
return MemRepository()
@pytest.fixture
def sample_docx_bytes():
"""Minimal test bytes — not a real DOCX, just for multipart tests."""
return b"FAKE_DOCX_CONTENT"
@pytest.fixture
def sample_classify_response():
"""Sample LLM classify response."""
return {
"doc_type": "contract",
"own_number": "03700_1",
"parent_number": None,
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
}
-532
View File
@@ -1,532 +0,0 @@
/**
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
*
* Запуск: node tests.js
*
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
* renderCompareSectionBody.
*/
// ── Моки глобальных переменных ──────────────────────────────
// Браузерные глобалы, нужные модулям при загрузке
global.window = global; // window.* присваивания
// Мок document
global.document = {
getElementById: function(id) { return null; },
createElement: function(tag) {
return {
style: {},
classList: { add: function(){}, remove: function(){} },
innerHTML: '',
textContent: '',
appendChild: function(){},
querySelector: function(){ return null; },
querySelectorAll: function(){ return []; },
addEventListener: function(){},
parentNode: { insertBefore: function(){} },
nextSibling: null
};
},
querySelector: function() { return null; }
};
// Мок crypto.randomUUID
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
// Мок lucide
global.lucide = { createIcons: function(){} };
// Глобальные переменные приложения
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
global.fileTable = { innerHTML: '' };
global.VM_API = 'https://contracts.kube5s.ru';
global.UPLOAD_URL = VM_API + '/upload';
global.CONVERT_URL = VM_API + '/convert-doc';
global.UNZIP_URL = VM_API + '/unzip-upload';
global.SITE_URL = '';
var state = {
files: [],
contractId: null,
batchId: 'test-batch-id',
groups: null,
_activeCompare: { es: null, timer: null },
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
};
// Мок escHtml (из app_utils.js)
global.escHtml = function(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
};
var passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; }
else { console.log(' FAIL: ' + msg); failed++; }
}
function eq(actual, expected, msg) {
if (actual === expected) { passed++; }
else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; }
}
function contains(str, substr, msg) {
if (str.indexOf(substr) !== -1) { passed++; }
else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; }
}
// ── Загружаем модули ─────────────────────────────────────────
// Модули используют function declaration (глобальные в браузере).
// В Node.js загружаем через eval в глобальном контексте.
var fs = require('fs');
function loadModule(path) {
var code = fs.readFileSync(path, 'utf-8');
// (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict)
(0, eval)(code);
}
console.log('=== Загрузка модулей ===');
loadModule('./files.js');
console.log(' files.js — OK');
loadModule('./groups.js');
console.log(' groups.js — OK');
loadModule('./compare.js');
console.log(' compare.js — OK');
// ── Тесты: statusToHTML ──────────────────────────────────────
console.log('\n=== statusToHTML ===');
eq(statusToHTML(null), '', 'null → пусто');
eq(statusToHTML({}), '', 'пустой объект → пусто');
eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто');
eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%');
eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%');
eq(statusToHTML({ kind: 'uploaded' }), '<span class="status-ok">✓</span>', 'uploaded');
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error без текста');
// Краевые случаи
eq(statusToHTML(undefined), '', 'undefined → пусто');
eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%');
eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct');
eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%');
eq(statusToHTML({ kind: 'parsed', count: 0 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', 'error с пустым текстом → дефолт');
eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто');
// ── Тесты: applyParseResult ──────────────────────────────────
console.log('\n=== applyParseResult ===');
// parsed success
var e1 = {};
applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5');
assert(e1.parsed === true, 'parsed=true');
assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён');
eq(e1.status.kind, 'parsed', 'status.kind=parsed');
eq(e1.status.count, 10, 'status.count=10');
eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5');
// parsed without elapsed
var e2 = {};
applyParseResult(e2, { status: 'parsed', element_count: 3 });
eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed');
eq(e2.status.count, 3, 'без elapsed: count=3');
assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует');
// error
var e3 = {};
applyParseResult(e3, { status: 'error', error: 'битый PDF' });
eq(e3.status.kind, 'error', 'error: kind=error');
eq(e3.status.text, 'битый PDF', 'error: text сохранён');
eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён');
// null parsed (неизвестная ошибка)
var e4 = {};
applyParseResult(e4, null);
eq(e4.status.kind, 'error', 'null: kind=error');
eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст');
// parsed с пустыми полями
var e5 = {};
applyParseResult(e5, { status: 'parsed' });
eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed');
eq(e5.status.count, undefined, 'parsed без element_count: count=undefined');
// parsed с element_count=0
var e6 = {};
applyParseResult(e6, { status: 'parsed', element_count: 0 });
eq(e6.status.count, 0, 'parsed element_count=0');
// error без текста ошибки
var e7 = {};
applyParseResult(e7, { status: 'error' });
eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"');
// entry с уже существующими полями (не должны мешать)
var e8 = { existing: true, parsed: false };
applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0');
assert(e8.existing === true, 'старое поле не затёрто');
eq(e8.status.count, 7, 'новые данные поверх старых');
// ── Тесты: reconcileSelection ────────────────────────────────
console.log('\n=== reconcileSelection ===');
var files = [
{ name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' }
];
var newFiles = [
{ name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' }
];
var result = reconcileSelection(files, newFiles);
eq(result.length, 2, 'должно остаться 2 файла');
eq(result[0].name, 'a.docx', 'a.docx остался');
eq(result[1].name, 'c.docx', 'c.docx остался');
// Исходный массив не мутирован
eq(files.length, 3, 'исходный массив не мутирован');
// Пустые входы
var emptyResult = reconcileSelection([], []);
eq(emptyResult.length, 0, 'пустые массивы → пусто');
// Все совпадают
var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]);
eq(allMatch.length, 1, 'все совпадают → 1');
// Ни один не совпадает
var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]);
eq(noMatch.length, 0, 'нет совпадений → пусто');
// Дубликаты имён в newFiles (должен работать — Set)
var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}];
var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames);
eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает');
// ── Тесты: renderGroupCard ───────────────────────────────────
console.log('\n=== renderGroupCard ===');
var group = {
contract_number: '123',
counterparty: 'ООО Тест',
documents: [
{ doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' },
{ doc_type: 'supplement', filename: 'дс1.docx' }
]
};
var html = renderGroupCard(group, 0);
contains(html, 'Договор №123', 'номер договора');
contains(html, 'ООО Тест', 'контрагент');
contains(html, 'договор', 'тип contract → договор');
contains(html, 'допсоглашение', 'тип supplement → допсоглашение');
contains(html, '2024-01-15', 'дата');
contains(html, 'data-gi="0"', 'data-gi атрибут');
contains(html, 'Сравнить эту группу', 'кнопка сравнения');
// НЕ должно быть ✓ Готово
assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной');
// Группа с compare.running
var groupRunning = {
contract_number: '456',
counterparty: 'ЗАО Бег',
documents: [{ doc_type: 'contract', filename: 'дог.docx' }],
compare: { status: 'running' }
};
var htmlRunning = renderGroupCard(groupRunning, 1);
contains(htmlRunning, 'disabled', 'кнопка disabled при running');
// Без контрагента
var groupNoCP = { contract_number: '001', documents: [] };
var htmlNoCP = renderGroupCard(groupNoCP, 5);
contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка');
// Неизвестный doc_type
var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] };
var htmlUnknown = renderGroupCard(groupUnknown, 6);
contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть');
// Пустой список документов
var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] };
var htmlEmpty = renderGroupCard(groupEmpty, 7);
contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится');
contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть');
// ── Тесты: renderGroupCardDone ───────────────────────────────
console.log('\n=== renderGroupCardDone ===');
var groupDone = {
contract_number: '789',
counterparty: 'ИП Готово',
documents: [
{ doc_type: 'contract', filename: 'base.docx' },
{ doc_type: 'specification', filename: 'spec.docx' }
],
compare: {
status: 'done',
totalTime: '12.5с',
bodyHTML: '<div>результаты сравнения</div>'
}
};
var htmlDone = renderGroupCardDone(groupDone, 2);
contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем');
contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания');
contains(htmlDone, 'спецификация', 'тип specification → спецификация');
contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен');
contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке');
// ── Тесты: renderUnresolvedCard ──────────────────────────────
console.log('\n=== renderUnresolvedCard ===');
var unresolved = {
contract_number: '__unresolved__',
documents: [
{ doc_type: 'other', filename: 'unknown.docx', parent_number: '999' },
{ doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' }
]
};
var htmlUnres = renderUnresolvedCard(unresolved);
contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных');
contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового');
contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации');
// ── Тесты: applyCompareEvent ─────────────────────────────────
console.log('\n=== applyCompareEvent ===');
var sections = {};
// extract_start
applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' });
assert(sections['s1'] !== undefined, 'секция создана');
eq(sections['s1'].filename, 'test.docx', 'filename сохранён');
eq(sections['s1'].status, 'extracting', 'status=extracting');
// llm_done
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 });
eq(sections['s1'].status, 'llm_done', 'status=llm_done');
eq(sections['s1'].ops_count, 15, 'ops_count=15');
eq(sections['s1'].mode, 'llm', 'mode=llm');
eq(sections['s1'].time_s, 3.2, 'time_s=3.2');
// applied
applyCompareEvent(sections, {
type: 'applied', supplement_id: 's1',
summary: { added: 5, updated: 2, deleted: 1 },
ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }]
});
eq(sections['s1'].status, 'applied', 'status=applied');
eq(sections['s1'].summary.added, 5, 'summary.added=5');
eq(sections['s1'].ops.length, 1, 'ops.length=1');
// extract_error на существующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' });
eq(sections['s1'].status, 'error', 'после ошибки: status=error');
eq(sections['s1'].error, 'parse failed', 'текст ошибки');
// extract_error на НЕсуществующей секции
applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' });
eq(sections['s2'].status, 'error', 'новая секция с ошибкой');
eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции');
// Неизвестный тип события — не должен падать
applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' });
assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана');
// llm_done для несуществующей секции — не должен падать
applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 });
// apply_error (должен работать как extract_error)
applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' });
eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана');
eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки');
// applied без summary
applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] });
eq(sections['s1'].status, 'applied', 'applied без summary: status ок');
eq(sections['s1'].ops.length, 0, 'applied с пустыми ops');
// ── Тесты: renderCompareSectionHeader ────────────────────────
console.log('\n=== renderCompareSectionHeader ===');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗');
contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст');
// ── Тесты: renderCompareOpsTable ─────────────────────────────
console.log('\n=== renderCompareOpsTable ===');
var ops = [
{ action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } },
{ action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } }
];
var tableHtml = renderCompareOpsTable(ops);
contains(tableHtml, 'diff-added', 'ADD → diff-added');
contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted');
contains(tableHtml, 'Стойка', 'имя услуги');
contains(tableHtml, '100', 'цена');
contains(tableHtml, '2024-01-01', 'дата');
// Пустые ops
var emptyTable = renderCompareOpsTable([]);
contains(emptyTable, '<table', 'пустые ops: таблица рендерится');
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
// ops с null new_row
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
contains(nullRow, '<td></td>', 'null new_row: пустые ячейки (не падает)');
// ops с неизвестным action (без класса)
var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]);
assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added');
assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted');
assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed');
// ── Тесты: renderCompareSectionBody ──────────────────────────
console.log('\n=== renderCompareSectionBody ===');
eq(renderCompareSectionBody(null), '', 'null → пусто');
eq(renderCompareSectionBody({}), '', 'пустой → пусто');
eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто');
var secApplied = {
status: 'applied',
summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 },
ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }]
};
var bodyHtml = renderCompareSectionBody(secApplied);
contains(bodyHtml, '+3', 'added=3');
contains(bodyHtml, '~1', 'updated=1');
contains(bodyHtml, '-0', 'deleted=0');
contains(bodyHtml, '?2', 'unresolved=2');
contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed');
// Без unresolved
var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] };
var bodyNoUnr = renderCompareSectionBody(secNoUnresolved);
assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?');
// Отрицательные значения summary (не должно быть, но не падать)
var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] };
var bodyNeg = renderCompareSectionBody(secNeg);
contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок');
// ops с частичным new_row (не все поля)
var secPartial = { status: 'applied', summary: { added: 1 }, ops: [
{ action: 'ADD', new_row: { name: 'Только имя' } }
]};
var bodyPartial = renderCompareSectionBody(secPartial);
contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть');
// Проверим что нет undefined в выводе
assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе');
// ── Тесты: escHtml ───────────────────────────────────────────
console.log('\n=== escHtml ===');
eq(escHtml(null), '', 'escHtml null → пусто');
eq(escHtml(undefined), '', 'escHtml undefined → пусто');
eq(escHtml('hello'), 'hello', 'escHtml обычный текст');
eq(escHtml('<script>'), '&lt;script&gt;', 'escHtml экранирует < >');
eq(escHtml('a&b'), 'a&amp;b', 'escHtml экранирует &');
eq(escHtml('"quote"'), '&quot;quote&quot;', 'escHtml экранирует кавычки');
eq(escHtml(123), '123', 'escHtml число → строка');
// ── Дымные тесты API (бэкенд) ────────────────────────────────
console.log('\n=== API smoke tests ===');
var http = require('http');
var https = require('https');
function apiTest(method, url, cb) {
var mod = url.startsWith('https') ? https : http;
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
var body = '';
res.on('data', function(c) { body += c; });
res.on('end', function() { cb(null, res.statusCode, body); });
});
req.on('error', function(e) { cb(e.message); });
req.on('timeout', function() { req.destroy(); cb('timeout'); });
req.end();
}
// Эти тесты асинхронные — собираем результаты
var apiTests = [];
function addApiTest(name, method, url, check) {
apiTests.push({ name: name, method: method, url: url, check: check });
}
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
assert(code === 200, 'главная: HTTP 200 — got ' + code);
contains(body, '<!DOCTYPE html>', 'главная: HTML');
});
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
});
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
contains(body, 'Центральное состояние', 'state.js: содержит описание');
});
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
});
// Запускаем API тесты последовательно (чтобы не зафлудить)
var apiIdx = 0;
function runNextApiTest() {
if (apiIdx >= apiTests.length) {
// Все API тесты готовы — выводим итоги
printResults();
return;
}
var t = apiTests[apiIdx];
apiTest(t.method, t.url, function(err, code, body) {
if (err) {
console.log(' FAIL: ' + t.name + ' — ' + err);
failed++;
} else {
t.check(code, body || '');
}
apiIdx++;
runNextApiTest();
});
}
function printResults() {
console.log('\n========================================');
console.log('PASS: ' + passed);
console.log('FAIL: ' + failed);
console.log('========================================');
if (failed > 0) process.exit(1);
}
// Начинаем с API тестов после синхронных
console.log(' (асинхронные — ждём...)');
runNextApiTest();
View File
View File
@@ -1,365 +0,0 @@
"""
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
Пайплайн (без БД, без HTTP):
1. testgen генерирует DOCX (договор, спека, допник)
2. parse.py парсит байты → elements_json
3. process.py::_elements_to_text() → plain text для LLM
4. llm_prompt.py строит промпт → проверяем структуру
Точка входа: elements_json — именно туда приходят данные после парсинга.
"""
import io
import json
import sys
import os
import pytest
# Добавить пути для импортов
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
_testgen = os.path.join(_deploy, "..", "..", "testgen")
sys.path.insert(0, _deploy)
sys.path.insert(0, _testgen)
from compare.parse import parse_file
from compare.process import _elements_to_text
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _build_and_parse(build_fn, ctx):
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
from docx import Document
doc = build_fn(ctx)
buf = io.BytesIO()
doc.save(buf)
return parse_file("test.docx", buf.getvalue())
def _elements_to_dicts(elements):
"""Привести elements к dict для сравнения (убрать лишнее)."""
result = []
for el in elements:
d = {"type": el["type"]}
if el["type"] == "paragraph":
d["text"] = el.get("text", "")
elif el["type"] == "table":
d["row_count"] = len(el.get("rows", []))
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
result.append(d)
return result
# ═══════════════════════════════════════════════════════════════
# Tests
# ═══════════════════════════════════════════════════════════════
class TestGenerateParseTextify:
"""Полный пайплайн: testgen → parse → textify."""
# ── Данные ────────────────────────────────────────────────
@pytest.fixture
def pools(self):
import pools
return pools
@pytest.fixture
def templates(self):
import templates
return templates
@pytest.fixture
def contract_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
return {
"company": c, "number": num, "date": date, "company_idx": ci,
"comment": "Тестовый контракт для пайплайна.",
}
@pytest.fixture
def spec_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
svcs, total, total_str = pools.pick_services(5)
for s in svcs:
s["date_start"] = pools.random_date(2026, 3, 3)
return {
"company": c, "number": num, "date": date, "contract_date": date,
"version": 1, "services": svcs, "total": total, "total_str": total_str,
"label": f"Абонентские услуги с {date}",
"comment": "Тестовая спецификация для пайплайна.",
}
@pytest.fixture
def addendum_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
inst, itot, itot_s = pools.pick_services(2)
mon, mtot, mtot_s = pools.pick_services(3)
return {
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
"addendum_num": 1,
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
"comment": "Тестовый допник для пайплайна.",
}
# ── Этап 1: Генерация → парсинг ───────────────────────────
def test_generate_contract_parse_ok(self, templates, contract_ctx):
"""Договор: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_contract, contract_ctx)
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
assert result["element_count"] > 0
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
"""Договор: в elements есть paragraphs."""
result = _build_and_parse(templates.build_contract, contract_ctx)
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
def test_generate_contract_has_tables(self, templates, contract_ctx):
"""Договор: в elements есть таблица реквизитов."""
result = _build_and_parse(templates.build_contract, contract_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
def test_generate_spec_parse_ok(self, templates, spec_ctx):
"""Спецификация: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_spec, spec_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_spec_has_services_table(self, templates, spec_ctx):
"""Спецификация: таблица услуг найдена."""
result = _build_and_parse(templates.build_spec, spec_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1
# Первая таблица — услуги, должна быть НЕ пустой
svc_table = tables[0]
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
"""Допсоглашение: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
"""Допсоглашение: таблицы разовых и абонентских услуг."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
# ── Этап 2: elements_json → textify ────────────────────────
def test_textify_contract(self, templates, contract_ctx):
"""Договор after textify содержит ключевые слова."""
result = _build_and_parse(templates.build_contract, contract_ctx)
text = _elements_to_text(result["elements"])
assert "Договор" in text or "ДОГОВОР" in text
assert "НУБЕС" in text
assert len(text) > 200, f"Text too short: {len(text)} chars"
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
"""Спецификация after textify: пайп-таблицы на месте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
# Должны быть маркеры таблиц (--- Таблица ... ---)
assert "Таблица" in text
# Должны быть пайпы (формат ячеек)
assert "|" in text
# Должны быть названия услуг
for s in spec_ctx["services"]:
# Проверяем первые 30 символов названия
short_name = s["name"][:30]
assert short_name in text, f"Service '{short_name}' not found in textified output"
def test_textify_spec_contains_total(self, templates, spec_ctx):
"""Спецификация: итоговая сумма в тексте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
assert "Итого" in text
def test_textify_addendum_structure(self, templates, addendum_ctx):
"""Допсоглашение after textify: структура сохраняется."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
text = _elements_to_text(result["elements"])
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
assert "Инсталляц" in text or "Разовые" in text
assert "Абонентские" in text
# ── Этап 3: elements_json → промпт LLM ─────────────────────
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
"""Из elements_json спецификации строится extract-промпт.
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_spec, spec_ctx)
doc_text = _elements_to_text(result["elements"])
# Пустая current_spec = первый документ = extract-промпт
prompt_text, prompt_id = build_prompt([], doc_text)
assert len(prompt_text) > 100
assert "{doc_text}" not in prompt_text # переменная подставлена
assert doc_text in prompt_text # текст документа внутри промпта
assert "JSON" in prompt_text or "json" in prompt_text
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
"""Из elements_json допсоглашения строится diff-промпт.
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_addendum, addendum_ctx)
doc_text = _elements_to_text(result["elements"])
# Непустая current_spec = не первый документ = diff-промпт
current_spec = [
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
]
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
assert len(prompt_text) > 100
assert doc_text in prompt_text
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
# ── Этап 4: edge cases ─────────────────────────────────────
def test_parse_empty_docx(self):
"""Пустой docx — парсится без ошибок, но без элементов."""
from docx import Document
doc = Document()
buf = io.BytesIO()
doc.save(buf)
result = parse_file("empty.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 0
def test_parse_docx_text_only(self):
"""DOCX только с текстом — корректно парсится."""
from docx import Document
doc = Document()
doc.add_paragraph("Привет мир")
doc.add_paragraph("Вторая строка")
buf = io.BytesIO()
doc.save(buf)
result = parse_file("text.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 2
assert all(e["type"] == "paragraph" for e in result["elements"])
def test_parse_docx_table_only(self):
"""DOCX только с таблицей — корректно парсится."""
from docx import Document
doc = Document()
table = doc.add_table(rows=2, cols=3)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
table.rows[0].cells[2].text = "C"
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "2"
table.rows[1].cells[2].text = "3"
buf = io.BytesIO()
doc.save(buf)
result = parse_file("table.docx", buf.getvalue())
assert result["status"] == "parsed"
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) == 1
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
def test_textify_preserves_table_data(self):
"""Textify сохраняет все данные таблицы (без потерь)."""
elements = [
{"type": "table", "rows": [
["Услуга", "Цена", "Кол-во"],
["Аренда стойки", "50000", "2"],
["IP-адрес", "300", "8"],
]}
]
text = _elements_to_text(elements)
assert "Услуга" in text
assert "Аренда стойки" in text
assert "50000" in text
assert "2" in text
assert "IP-адрес" in text
assert "300" in text
assert "8" in text
# ── Этап 5: валидация manifest-подобной структуры ──────────
def test_contract_elements_structure(self, templates, contract_ctx):
"""Элементы договора имеют правильную структуру."""
result = _build_and_parse(templates.build_contract, contract_ctx)
for el in result["elements"]:
assert "type" in el
assert el["type"] in ("paragraph", "table")
if el["type"] == "paragraph":
assert "text" in el
assert isinstance(el["text"], str)
elif el["type"] == "table":
assert "rows" in el
assert isinstance(el["rows"], list)
assert len(el["rows"]) > 0
assert all(isinstance(row, list) for row in el["rows"])
def test_multiple_contracts_different_content(self, templates, pools):
"""Разные контракты дают разный elements_json."""
import hashlib
results = []
for ci in range(3):
c = pools.COMPANIES[ci]
ctx = {
"company": c, "number": pools.contract_number(ci),
"date": pools.random_date(), "company_idx": ci,
"comment": f"Контракт {ci}",
}
result = _build_and_parse(templates.build_contract, ctx)
# Сериализуем для сравнения
txt = _elements_to_text(result["elements"])
results.append(txt)
# Все три должны быть разными
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
c = pools.COMPANIES[0]
base_ctx = {
"company": c, "number": pools.contract_number(0),
"date": pools.random_date(), "contract_date": pools.random_date(),
"version": 1,
"label": "Абонентские услуги",
}
# v1
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
r1 = _build_and_parse(templates.build_spec, ctx_v1)
t1 = _elements_to_text(r1["elements"])
# v2 — меняем цены
import copy
svcs_v2 = copy.deepcopy(svcs_v1)
if svcs_v2:
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
r2 = _build_and_parse(templates.build_spec, ctx_v2)
t2 = _elements_to_text(r2["elements"])
assert t1 != t2, "v1 and v2 specs produced identical text!"
-162
View File
@@ -1,162 +0,0 @@
"""
Тест DrHider — много проверок.
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
"""
import sys, os, io, zipfile, json, base64
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
def run(text, desc):
"""Обработать текст и проверить что needle НЕ найдено."""
z, c = obfuscate_files([('t.txt', text.encode(), '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
out = zf.read('t.txt').decode()
csv = zf.read('mapping.csv').decode()
return out, csv
errors = 0
def check(ok, msg):
global errors
if ok:
print(f" ✅ {msg}")
else:
print(f" ❌ {msg}")
errors += 1
print("=" * 60)
print("DrHider — полный тест")
print("=" * 60)
# ── 1. Телефоны ──
print("\n📞 Телефоны:")
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
check("phone" in csv, "тип phone в CSV")
# ── 2. Телефон не матчит число внутри счёта ──
print("\n📞 Телефон vs номер счёта:")
out, csv = run("Кор/сч 30101810400000000225", "счёт")
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
check("+7" not in out, "нет ложного телефона внутри счёта")
# ── 3. Email ──
print("\n📧 Email:")
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
check("support@company.org" not in out, "support@company.org заменён")
check("email" in csv, "тип email в CSV")
# ── 4. Компании (разные кавычки) ──
print("\n🏢 Компании:")
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
check('company' in csv, 'тип company в CSV')
# ── 5. НУБЕС whitelist ──
print("\n🛡️ НУБЕС whitelist:")
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
# ── 6. ИНН/ОГРН/КПП/БИК ──
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
check("9706005293" not in out, "ИНН заменён")
check("772401001" not in out, "КПП заменён")
check("1207700098759" not in out, "ОГРН заменён")
check("044525225" not in out, "БИК заменён")
check("inn_ul" in csv, "inn_ul в CSV")
# ── 7. Расчётный счёт ──
print("\n💳 Расчётный счёт:")
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
check("40702810738000174030" not in out, "р/с заменён")
check("30101810400000000225" not in out, "кор/сч заменён")
check("rs" in csv, "rs в CSV")
check("ks" in csv, "ks в CSV")
# ── 8. ФИО (инициалы + полностью) ──
print("\n👤 ФИО:")
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
# ── 9. Паспорт ──
print("\n📄 Паспорт:")
out, csv = run("паспорт 12 34 567890", "паспорт")
check("12 34 567890" not in out, "номер паспорта заменён")
# ── 10. Согласованность (одна сущность → одна замена) ──
print("\n🔄 Согласованность:")
z, c = obfuscate_files([
('a.txt', b'OOO Romashka +79991234567', ''),
('b.txt', b'OOO Romashka +79991234567', ''),
])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
a = zf.read('a.txt').decode()
b = zf.read('b.txt').decode()
check(a == b, f"оба файла одинаковы: {a}")
# ── 11. ZIP внутри ZIP ──
print("\n📦 ZIP внутри:")
with open(TEST_ZIP, 'rb') as f:
raw = f.read()
z, c = obfuscate_files([('test.zip', raw, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
names = zf.namelist()
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
check('mapping.csv' in names, "mapping.csv есть")
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
# Проверим что docx внутри валидны
for n in names:
if n.endswith('.docx'):
try:
from docx import Document
Document(io.BytesIO(zf.read(n)))
check(True, f"{n} — валидный docx")
except:
check(False, f"{n} — БИТЫЙ docx")
# ── 12. .doc бинарный — не трогаем ──
print("\n📄 .doc бинарный:")
# Создаём фейковый .doc файл (просто бинарные данные)
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
doc_content = zf.read('old.doc')
check(len(doc_content) == 104, ".doc сохранён без изменений")
# ── 13. Много файлов ──
print("\n📚 20 файлов:")
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
z, c = obfuscate_files(many)
with zipfile.ZipFile(io.BytesIO(z)) as zf:
check(len(zf.namelist()) == 21, "20 файлов + csv")
# ── 14. COMPANY_PATTERN не жадный ──
print("\n🔤 COMPANY_PATTERN границы:")
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
# ── 15. PERSON_PATTERN только кириллица ──
print("\n🔤 PERSON_PATTERN кириллица:")
m = PERSON_PATTERN.search("Next Generation Cloud service")
check(m is None, "английский не матчится")
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
check(m2 is not None, "русский матчится")
# ── ИТОГО ──
print(f"\n{'='*60}")
if errors:
print(f"❌ {errors} ошибок")
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
print(f"{'='*60}")
sys.exit(0 if errors == 0 else 1)
View File
-62
View File
@@ -1,62 +0,0 @@
"""Test classify_batch with MemRepository + FakeLLM."""
from compare.classify import classify_batch
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestClassifyBatch:
def test_garbage_by_filename(self):
"""Счёт-фактура отфильтровывается без LLM."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["ok"] is True
assert result["garbage"] == 1
assert result["done"] == 1
assert len(llm.calls) == 0 # LLM не вызывался
def test_garbage_by_header(self):
"""Акт сверки в тексте отфильтровывается."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["garbage"] == 1
assert len(llm.calls) == 0
def test_llm_classify(self):
"""Договор классифицируется через LLM."""
repo = MemRepository()
llm = FakeLLMClient({
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
})
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
assert result["garbage"] == 0
assert len(llm.calls) == 1
updated = repo.get_document(doc["id"])
assert updated["doc_type"] == "contract"
assert updated["own_number"] == "03700_1"
assert updated["counterparty"] == "ЗАО XXX001"
def test_no_pending(self):
"""Пустой батч."""
repo = MemRepository()
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
assert result["ok"] is False
assert "no pending" in result["error"]
-110
View File
@@ -1,110 +0,0 @@
"""Unit tests — no DB, no network, just pure logic."""
import pytest
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
from compare.upload import parse_multipart
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
class TestContracts:
"""Dataclass creation and validation."""
def test_parse_result(self):
r = ParseResult(status="parsed", element_count=10, elements=[])
assert r.status == "parsed"
assert r.element_count == 10
def test_classify_from_llm(self):
r = ClassifyResult.from_llm({
"doc_type": "contract",
"own_number": "03700_1",
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
})
assert r.doc_type == "contract"
assert r.own_number == "03700_1"
assert r.counterparty == "ЗАО XXX001"
def test_grouping_result_empty(self):
r = GroupingResult(contract_number="03700_1")
assert r.contract_number == "03700_1"
assert r.documents == []
def test_compare_op_add(self):
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
assert op.action == "ADD"
assert op.new_row["price"] == 100
def test_compare_op_from_llm(self):
op = CompareOp.from_llm({
"action": "UPDATE",
"target_id": "r1",
"new_values": {"price": 200},
"comment": "change",
})
assert op.action == "UPDATE"
assert op.new_values["price"] == 200
assert op.comment == "change"
class TestGarbageFilter:
"""Stage 1-2 garbage detection."""
def test_filename_garbage_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_filename_garbage_act(self):
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
def test_filename_not_garbage(self):
assert _is_garbage_by_filename("договор-XXX001.docx") is False
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
def test_header_garbage(self):
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
assert _is_garbage_by_header(text) is True
def test_header_not_garbage(self):
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
assert _is_garbage_by_header(text) is False
class TestParseMultipart:
"""Pure multipart parsing without HTTP."""
def test_simple_file(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ sample_docx_bytes +
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["filename"] == "test.docx"
assert result["file_data"] == sample_docx_bytes
def test_with_contract_id(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
+ sample_docx_bytes +
b"\r\n------testboundary\r\n"
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
b"550e8400-e29b-41d4-a716-446655440000"
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
def test_path_traversal_rejected(self):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
b"evil"
b"\r\n------testboundary--\r\n"
)
with pytest.raises(ValueError, match="invalid filename"):
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
-59
View File
@@ -1,59 +0,0 @@
"""Unit tests for LLM client and Repository — with fakes."""
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestFakeLLM:
def test_exact_match(self):
client = FakeLLMClient({"hello": "world"})
assert client.complete("hello") == "world"
def test_partial_match(self):
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
assert "contract" in client.complete("classify this document")
def test_default_fallback(self):
client = FakeLLMClient({"default": '{"ok":true}'})
assert client.complete("unknown prompt") == '{"ok":true}'
def test_call_history(self):
client = FakeLLMClient({"a": "1", "b": "2"})
client.complete("a")
client.complete("b")
assert len(client.calls) == 2
assert client.calls[0] == "a"
class TestMemRepository:
def test_insert_and_retrieve(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
assert doc["filename"] == "test.docx"
assert doc["classify_status"] == "pending"
def test_list_pending(self, mem_repo):
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
pending = mem_repo.list_pending("b1")
assert len(pending) == 2
def test_set_classification(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
updated = mem_repo.documents[doc["id"]]
assert updated["doc_type"] == "contract"
assert updated["classify_status"] == "classified"
def test_set_garbage(self, mem_repo):
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
def test_contract_lifecycle(self, mem_repo):
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
assert cid
doc = mem_repo.insert_document("test.docx", "mime", "data")
mem_repo.insert_supplement(cid, doc["id"], "initial")
supps = mem_repo.list_supplements(cid)
assert len(supps) == 1
assert supps[0]["type"] == "initial"
+1 -1
View File
@@ -1,7 +1,7 @@
"""Конфигурация приложения — все настройки в одном месте."""
import os
VERSION = "2.0.16"
VERSION = "2.0.17"
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.getenv("LLM_API_KEY", "")
+1 -2
View File
@@ -3,7 +3,7 @@
def register_routes(app):
import config
from routes.upload_bp import upload_bp, contracts_upload_sink
from routes.upload_bp import contracts_upload_sink
from routes.pipeline_bp import pipeline_bp
from routes.api_bp import api_bp
from routes.prompts_bp import prompts_bp
@@ -11,7 +11,6 @@ def register_routes(app):
from routes.pages_bp import pages_bp
from upload.backend.upload_refs import create_upload_refs_blueprint
app.register_blueprint(upload_bp)
app.register_blueprint(pipeline_bp)
app.register_blueprint(api_bp)
app.register_blueprint(prompts_bp)
+4 -80
View File
@@ -1,54 +1,12 @@
"""Upload blueprint — загрузка, конвертация, распаковка."""
import io, os, base64, hashlib, zipfile
"""Upload-модуль: конвертация .doc→.docx + хранение/парсинг (sink для VM-буфера)."""
import base64
import hashlib
import httpx
from flask import Blueprint, request, jsonify
from services.parse import parse_file
from db import documents
import config
upload_bp = Blueprint("upload", __name__)
ALLOWED = {"pdf", "docx", "doc", "zip"}
def _check_ext(filename: str) -> str | None:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED:
return f"unsupported format: .{ext} (allowed: {', '.join(sorted(ALLOWED))})"
return None
def _unzip(data: bytes):
"""Распаковать ZIP → (ok, files, error)."""
MAX_FILES = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
files = []
total = 0
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
if len(zf.namelist()) > MAX_FILES:
return False, None, f"too many files in ZIP (max {MAX_FILES})"
for info in zf.infolist():
if info.is_dir():
continue
name = os.path.basename(info.filename)
if not name or ".." in name or "/" in name or "\\" in name:
continue
raw = zf.read(info)
total += len(raw)
if total > MAX_UNCOMPRESSED:
return False, None, "total uncompressed size exceeds 500 MB"
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
files.append({
"filename": name,
"ext": ext,
"size": len(raw),
"data_b64": base64.b64encode(raw).decode(),
})
except zipfile.BadZipFile:
return False, None, "invalid ZIP archive"
return True, files, None
def _convert(filename: str, data: bytes) -> bytes:
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
@@ -104,28 +62,6 @@ def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_sour
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
@upload_bp.route("/upload", methods=["POST"])
def upload():
"""Загрузка одного файла + авто-парсинг → БД (прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
err = _check_ext(f.filename)
if err:
return jsonify(ok=False, error=err), 400
data = f.read()
batch_id = request.form.get("batch_id")
zip_source = request.form.get("zip_source")
contract_id = request.form.get("contract_id")
result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream")
if not result["ok"]:
return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200
return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"])
def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None):
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
@@ -137,15 +73,3 @@ def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_so
content = _convert(name, content)
name = name[:-4] + ".docx"
return _store_and_parse(name, content, batch_id, contract_id, zip_source)
@upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart)."""
f = request.files.get("files")
if not f:
return jsonify(ok=False, error="no file"), 400
ok, files, err = _unzip(f.read())
if not ok:
return jsonify(ok=False, error=err), 400
return jsonify(ok=True, files=files)
-3
View File
@@ -1,9 +1,6 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App v2.0 — всё на Flask, ВМ больше нет
var VM_API = '';
var UPLOAD_URL = '/upload';
var CONVERT_URL = '/convert-doc';
var UNZIP_URL = '/unzip-upload';
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
+75
View File
@@ -0,0 +1,75 @@
# Тесты contracts-flask
Набор pytest-тестов под актуальный код сервиса (`site/`).
Покрытие: юнит (чистая логика), интеграционные (SQLite), HTTP-эндпоинты (Flask test client), безопасность.
## Запуск
```bash
cd contracts-flask
pytest tests/ -q
```
pytest установлен в venv: `/home/naeel/nubes/contracts/.venv/bin/python -m pytest tests/ -q`.
## Файлы
| Файл | Что тестирует |
|---|---|
| `conftest.py` | site/ в `sys.path`; фикстура `db` — изолированная БД (временный `DB_PATH` + свежая схема); защита реальной `/tmp/contracts.db` от пересоздания |
| `test_metrics.py` | `services/metrics.py`: `normalize_date`, `check_arithmetic` (sum == price×qty), `_to_decimal`, `ClassifyMetrics` |
| `test_grouping.py` | `services/grouping.py`: `normalize_number`, `group_documents` (группировка contract+supplement, unresolved), `apply_groups` (mock db) |
| `test_llm_client.py` | `services/llm_client.py`: `FakeLLMClient` (точное/частичное совпадение, default, история вызовов), `HttpxLLMClient` |
| `test_llm.py` | `services/llm.py`: `call_llm` — парсинг plain JSON и markdown-обёрток (```json) |
| `test_classify.py` | `services/classify.py`: garbage-фильтры по имени/заголовку, `_safe_json_parse` (7 edge-case: trailing comma, chatter, пусто), `_smart_extract` |
| `test_parse.py` | `services/parse.py`: `parse_file` (text/docx/pdf, ошибки), `_parse_text` |
| `test_connection.py` | `db/connection.py`: `_pg_to_sqlite` (все замены %s/::jsonb/BOOLEAN/ILIKE/TRUE), `_extract_table` |
| `test_spec_events.py` | `db/spec_events.py`: `apply_ops` (ADD/UPDATE/DELETE/UNRESOLVED/unknown), `clear_current`, `reset`, `_hash`, `get_next_seq` |
| `test_spec_current.py` | `db/spec_current.py`: `list_by_contract` (порядок), `get_elements_json` |
| `test_process_pipeline.py` | `services/process.py`: `run_pipeline` с Fake LLM — **full_replace не дублирует строки**, **трансляция target_id→target_hash**, `_elements_to_text` |
| `test_routes.py` | HTTP-эндпоинты (Flask test client): `/health`, `/api/spec-current`, `/api/groups` |
## Ключевые проверки
- `test_full_replace_no_duplicates` — два `full_replace` подряд → в `spec_current` 3 строки, а не 6; история `spec_events` (6 событий) сохранена.
- `test_update_applies` — `target_id: "r1"` транслируется в `target_hash` → UPDATE применяется (status `applied`), а не уходит в UNRESOLVED.
## Изоляция
- Каждый тест, работающий с БД, получает свою копию SQLite через фикстуру `db`
(monkeypatch `DB_PATH` → `tmp_path`, `init_db()`).
- LLM-вызовы в пайплайне подменяются через `monkeypatch.setattr("services.llm.call_llm", ...)` — сеть не используется.
## Нагрузочные тесты (`tests/load/`, маркер `load`)
Долгие стресс-тесты, реально нагружают SQLite/пайплайн. Запуск отдельно:
```bash
pytest -m load -q # только нагрузочные
pytest -m "not load" -q # быстрые юнит-тесты без нагрузочных
```
| Файл | Что нагружает |
|---|---|
| `load/test_load_pipeline.py` | массовый `run_pipeline`: N контрактов × M допников (Fake LLM) — проверка, что `full_replace` не дублирует строки в масштабе |
| `load/test_load_apply_ops.py` | массовые `apply_ops`: N ADD → N UPDATE → N DELETE, целостность `spec_events` |
| `load/test_load_concurrency.py` | конкурентная запись в SQLite (WAL + `busy_timeout`), проверка отсутствия потери данных |
| `load/stress_http.py` | standalone HTTP-стресс: `PUT` на ВМ → `POST /api/upload_refs` (реальный VM-путь) + `/health` |
Масштаб через переменные окружения (дефолты — большие):
```bash
LOAD_CONTRACTS=100 LOAD_SUPPS=20 LOAD_SERVICES=10 \
LOAD_OPS=5000 LOAD_THREADS=4 LOAD_PER=200 \
pytest -m load -q
Примечание по конкурентной записи: стабильный уровень `LOAD_THREADS=4`
(= `MAX_WORKERS` приложения). `>= 8` — нестабильно: SQLite с
`busy_timeout=5000` даёт `database is locked` (см. `History/2026-08-26-load-sqlite-locked.md`).
```
HTTP-стресс против локального/прод сервиса:
```bash
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
```
+42
View File
@@ -0,0 +1,42 @@
"""Общие фикстуры для тестов contracts-flask (модули site/).
Запуск: pytest tests/ -q
"""
import os
import sys
import pytest
# site/ в sys.path — импортируем config/db/services/routes напрямую
_SITE = os.path.join(os.path.dirname(__file__), "..", "site")
if _SITE not in sys.path:
sys.path.insert(0, _SITE)
@pytest.fixture(autouse=True, scope="session")
def _isolate_global_db(tmp_path_factory):
"""Не дать импорту app.py пересоздать реальную /tmp/contracts.db."""
from db import connection as conn
conn.DB_PATH = str(tmp_path_factory.mktemp("dbs") / "session.db")
@pytest.fixture
def db(tmp_path, monkeypatch):
"""Изолированная БД: отдельный DB_PATH + свежая схема (init_db)."""
from db import connection as conn
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "contracts_test.db"))
conn.init_db()
yield conn
# cleanup: закрыть thread-local соединение
c = getattr(conn._local, "conn", None)
if c is not None:
try:
c.close()
except Exception:
pass
conn._local.conn = None
def pytest_configure(config):
config.addinivalue_line("markers", "load: долгие нагрузочные/стресс-тесты")
+10
View File
@@ -0,0 +1,10 @@
"""Нагрузочные/стресс-тесты contracts-flask.
Запуск только нагрузочных:
pytest -m load -q
Запуск быстрых (юнит) без нагрузочных:
pytest -m "not load" -q
Масштаб настраивается переменными окружения (дефолты — большие):
LOAD_CONTRACTS, LOAD_SUPPS, LOAD_SERVICES, LOAD_OPS, LOAD_THREADS, LOAD_PER
"""
+97
View File
@@ -0,0 +1,97 @@
"""HTTP-стресс против contracts-flask (standalone, не pytest).
Реальный путь загрузки (VM-буфер):
1) PUT файла на ВМ WebDAV (мимо шлюза кластера ~64KB);
2) POST /api/upload_refs — бэк сам тянет файл с ВМ (egress).
Замеряет RPS, ошибки, таймауты. Цель — «разогреть» сервис и выявить
деградацию/обрывы (шлюз, OOM, SQLite).
Запуск (локально):
python tests/load/stress_http.py --url http://127.0.0.1:5000 --n 1000 --threads 32 --size 100000
Пример против прода (осторожно):
python tests/load/stress_http.py --url https://contractor.pythonk8s.dev.nubes.ru \
--file "/mnt/y/MY/Nubes/примеры_договоров_для_ИИ/спецификация-XXX001-03700.docx" \
--n 200 --threads 16
"""
import argparse
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
import httpx
def main():
ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask")
ap.add_argument("--url", default="http://127.0.0.1:5000")
ap.add_argument("--vm-url", default="https://contracts.kube5s.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)")
ap.add_argument("--n", type=int, default=1000, help="число запросов")
ap.add_argument("--threads", type=int, default=32, help="параллельных потоков")
ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)")
ap.add_argument("--file", default=None, help="путь к реальному файлу (заменяет сгенерированный)")
ap.add_argument("--health-only", action="store_true", help="только /health, без загрузки файлов")
args = ap.parse_args()
if args.file:
import os as _os
fname = _os.path.basename(args.file)
with open(args.file, "rb") as _f:
payload = _f.read()
real_size = len(payload)
else:
fname = "load.docx"
payload = b"x" * args.size
real_size = args.size
ok = 0
err = 0
slow = 0
t0 = time.time()
def one(_):
nonlocal ok, err, slow
try:
with httpx.Client(timeout=30) as c:
if args.health_only:
r = c.get(f"{args.url}/health")
if r.status_code == 200:
ok += 1
else:
err += 1
return
# Фаза 1: PUT файла на ВМ WebDAV (мимо шлюза ~64KB)
token = uuid.uuid4().hex
vm_url = args.vm_url.rstrip("/") + "/" + token + "_0"
r1 = c.put(vm_url, content=payload, headers={"Content-Type": "application/octet-stream"})
if not r1.is_success:
err += 1
return
# Фаза 2: POST /api/upload_refs — бэк тянет файл с ВМ (egress)
body = {
"batch_id": token,
"files": [{"name": fname, "size": real_size, "url": vm_url}],
}
r2 = c.post(f"{args.url}/api/upload_refs", json=body)
if r2.status_code == 200:
ok += 1
else:
err += 1
except httpx.TimeoutException:
err += 1
slow += 1
except Exception:
err += 1
with ThreadPoolExecutor(max_workers=args.threads) as ex:
list(ex.map(one, range(args.n)))
elapsed = time.time() - t0
print(f"url={args.url}")
print(f"vm_url={args.vm_url}")
print(f"n={args.n} threads={args.threads} size={real_size}B file={args.file or '(gen)'} health_only={args.health_only}")
print(f"ok={ok} err={err} timeouts={slow} elapsed={elapsed:.1f}s rps={args.n / elapsed:.1f}")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
"""Нагрузочный тест: массовые apply_ops (ADD → UPDATE → DELETE).
Прогоняет N операций каждого типа одним вызовом apply_ops, замеряет время,
проверяет целостность spec_current/spec_events (ничего не потеряно, не продублировано).
"""
import os
import time
import pytest
from db import spec_events, spec_current
from db.connection import query
pytestmark = pytest.mark.load
N = int(os.getenv("LOAD_OPS", "5000"))
CID = "load-cid-111"
SID = "load-sid-111"
DID = "load-did-111"
def test_mass_apply_ops(db):
# ADD N строк
ops = [{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": i, "qty": 1, "sum": i}} for i in range(N)]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
t_add = time.time() - t0
assert s["added"] == N
assert len(spec_current.list_by_contract(CID)) == N
# UPDATE всех N строк
hashes = [spec_events._hash(f"услуга {i}") for i in range(N)]
ups = [{"action": "UPDATE", "target_hash": h, "new_values": {"price": i + 1}} for i, h in enumerate(hashes)]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, ups, "pid", {})
t_upd = time.time() - t0
assert s["updated"] == N
assert spec_current.list_by_contract(CID)[0]["price"] == 1 # первая строка обновлена
# DELETE всех N строк
dels = [{"action": "DELETE", "target_hash": h} for h in hashes]
t0 = time.time()
s = spec_events.apply_ops(CID, SID, DID, dels, "pid", {})
t_del = time.time() - t0
assert s["deleted"] == N
assert spec_current.list_by_contract(CID) == []
total = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (CID,))
assert total[0]["c"] == N * 3 # ADD + UPDATE + DELETE, ничего не потеряно
print(f"\n[load] ops={N} add={t_add:.1f}с upd={t_upd:.1f}с del={t_del:.1f}с")
+37
View File
@@ -0,0 +1,37 @@
"""Нагрузочный тест: конкурентная запись в SQLite (WAL + busy_timeout).
N_THREADS потоков пишут по PER ADD-операций каждый. Проверяет, что конкурентная
запись не теряет данные (WAL сериализует writers, busy_timeout ждёт).
"""
import os
import pytest
from concurrent.futures import ThreadPoolExecutor
from db import spec_events, spec_current
pytestmark = pytest.mark.load
N_THREADS = int(os.getenv("LOAD_THREADS", "4"))
PER = int(os.getenv("LOAD_PER", "200"))
def test_concurrent_writes(db):
def writer(tid):
cid = f"c{tid}"
for i in range(PER):
spec_events.apply_ops(
cid, f"s{tid}", f"d{tid}",
[{"action": "ADD", "new_row": {"name": f"svc{i}", "price": i}}],
"pid", {},
)
with ThreadPoolExecutor(max_workers=N_THREADS) as ex:
list(ex.map(writer, range(N_THREADS)))
# Никаких потерь: у каждого потока ровно PER строк
for tid in range(N_THREADS):
n = len(spec_current.list_by_contract(f"c{tid}"))
assert n == PER, f"c{tid}: {n} != {PER} (потеря данных при конкурентной записи)"
print(f"\n[load] потоков={N_THREADS} строк/поток={PER} всего={N_THREADS * PER}")
+67
View File
@@ -0,0 +1,67 @@
"""Нагрузочный тест: массовый прогон run_pipeline (сотни контрактов × допников).
Создаёт N контрактов × M допников, прогоняет полный конвейер сверки с Fake LLM
(без сети), реально нагружает SQLite: apply_ops + clear_current + reset.
Проверка: full_replace НЕ дублирует строки в масштабе (у каждого контракта ровно
SERVICES строк, а не SERVICES × M_SUPPS).
"""
import os
import time
import pytest
from db import contracts, documents, supplements, spec_current
from db.connection import query
from services import process
pytestmark = pytest.mark.load
N_CONTRACTS = int(os.getenv("LOAD_CONTRACTS", "100"))
M_SUPPS = int(os.getenv("LOAD_SUPPS", "20"))
SERVICES = int(os.getenv("LOAD_SERVICES", "10"))
def _seed():
cids = []
for ci in range(N_CONTRACTS):
c = contracts.insert(f"03700_{ci}")
for mi in range(M_SUPPS):
d = documents.insert(f"d{ci}_{mi}.docx", "application/octet-stream", "raw", batch_id=f"b{ci}")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"услуга {mi}"}])
supplements.insert(c["id"], d["id"], "initial" if mi == 0 else "additional")
cids.append(c["id"])
return cids
def _fake_llm():
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
ops = [
{"action": "ADD", "new_row": {"name": f"услуга {i}", "price": 100 + i, "qty": 1, "sum": 100 + i}}
for i in range(SERVICES)
]
return ({"mode": "full_replace", "ops": ops}, "pid")
return fake_call
def test_mass_pipeline(db, monkeypatch):
cids = _seed()
monkeypatch.setattr("services.llm.call_llm", _fake_llm())
t0 = time.time()
for cid in cids:
list(process.run_pipeline(cid, "", lambda cur, txt: ("p", "pid")))
elapsed = time.time() - t0
# Целостность: у каждого контракта ровно SERVICES строк (full_replace без дублей)
bad = 0
for cid in cids:
if len(spec_current.list_by_contract(cid)) != SERVICES:
bad += 1
assert bad == 0, f"{bad} контрактов с неверным числом строк"
total_events = query("SELECT count(*) AS c FROM spec_events", ())
print(
f"\n[load] контрактов={N_CONTRACTS} допников={M_SUPPS} услуг={SERVICES} "
f"событий={total_events[0]['c']} время={elapsed:.1f}с"
)
+92
View File
@@ -0,0 +1,92 @@
"""Unit-тесты services/classify.py — фильтры + JSON-парсинг + выжимка."""
import json
import pytest
from services.classify import (
_is_garbage_by_filename,
_is_garbage_by_header,
_safe_json_parse,
_smart_extract,
)
class TestGarbageFilename:
def test_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_act(self):
assert _is_garbage_by_filename("Акт сверки.pdf") is True
def test_upd(self):
assert _is_garbage_by_filename("УПД-2025.docx") is True
def test_not_garbage(self):
assert _is_garbage_by_filename("договор.docx") is False
assert _is_garbage_by_filename("спецификация.pdf") is False
class TestGarbageHeader:
def test_invoice_header(self):
assert _is_garbage_by_header("СЧЕТ-ФАКТУРА № 123 от 01.01.2026") is True
def test_act_header(self):
assert _is_garbage_by_header("АКТ ОКАЗАННЫХ УСЛУГ за май") is True
def test_not_garbage(self):
assert _is_garbage_by_header("ДОГОВОР № 03700_1 об оказании услуг") is False
class TestSafeJsonParse:
def test_valid(self):
d, fix = _safe_json_parse('{"a":1}')
assert d == {"a": 1}
assert fix is False
def test_markdown(self):
d, fix = _safe_json_parse('```json\n{"a":1}\n```')
assert d == {"a": 1}
assert fix is False
def test_chatter(self):
d, _ = _safe_json_parse('Вот ответ: {"a":1}. Спасибо!')
assert d == {"a": 1}
def test_trailing_comma(self):
d, fix = _safe_json_parse('{"a":1,}')
assert d == {"a": 1}
assert fix is True
def test_trailing_comma_in_list(self):
d, fix = _safe_json_parse('{"a":[1,2,]}')
assert d == {"a": [1, 2]}
assert fix is True
def test_empty_raises(self):
with pytest.raises(ValueError):
_safe_json_parse("")
def test_none_raises(self):
with pytest.raises(ValueError):
_safe_json_parse(None)
class TestSmartExtract:
def test_paragraphs(self):
ej = [{"type": "paragraph", "text": "Договор № 03700"}]
out = _smart_extract(ej)
assert "Договор" in out
def test_table(self):
ej = [{"type": "table", "rows": [["Аренда", "50000"]]}]
out = _smart_extract(ej)
assert "Аренда" in out
def test_string_json(self):
ej = json.dumps([{"type": "paragraph", "text": "Спецификация"}])
out = _smart_extract(ej)
assert "Спецификация" in out
def test_empty(self):
assert _smart_extract(None) == ""
assert _smart_extract("") == ""
+36
View File
@@ -0,0 +1,36 @@
"""Unit-тесты db/connection.py — SQL-конвертация (без БД)."""
from db.connection import _pg_to_sqlite, _extract_table
class TestPgToSqlite:
def test_placeholder(self):
assert _pg_to_sqlite("SELECT * FROM x WHERE a = %s") == "SELECT * FROM x WHERE a = ?"
def test_jsonb(self):
out = _pg_to_sqlite("UPDATE t SET j = %s::jsonb WHERE id = %s")
assert "::jsonb" not in out
def test_boolean(self):
# " BOOLEAN " заменяется только когда после слова идёт пробел
out = _pg_to_sqlite("CREATE TABLE t (flag BOOLEAN NOT NULL)")
assert "BOOLEAN" not in out
assert "INTEGER" in out
def test_ilike(self):
assert _pg_to_sqlite("WHERE name ILIKE 'x'") == "WHERE name LIKE 'x'"
def test_true_false(self):
out = _pg_to_sqlite("SET a = TRUE, b = FALSE")
assert "TRUE" not in out
assert "FALSE" not in out
class TestExtractTable:
def test_insert(self):
assert _extract_table("INSERT INTO documents (id) VALUES (1)") == "documents"
def test_insert_no_space(self):
assert _extract_table("INSERT INTO prompts(id) VALUES (1)") == "prompts"
def test_no_insert(self):
assert _extract_table("SELECT * FROM documents") is None
+83
View File
@@ -0,0 +1,83 @@
"""Unit-тесты services/grouping.py — нормализация + группировка (mock db)."""
from services import grouping
class TestNormalizeNumber:
def test_basic(self):
assert grouping.normalize_number("МЭС-123-2024") == "МЭС1232024"
def test_spaces_slashes(self):
assert grouping.normalize_number("МЭС 123/2024") == "МЭС1232024"
def test_case(self):
assert grouping.normalize_number("мэс-123") == "МЭС123"
def test_empty(self):
assert grouping.normalize_number("") == ""
assert grouping.normalize_number(None) == ""
class _FakeDocs:
def __init__(self, docs):
self.docs = docs
def list_by_batch(self, batch_id):
return self.docs
class TestGroupDocuments:
def _doc(self, id, doc_type, own, parent, date, cp):
return {
"id": id, "filename": f"{id}.docx", "doc_type": doc_type,
"own_number": own, "parent_number": parent, "doc_date": date,
"counterparty": cp, "classify_status": "classified",
}
def test_contract_plus_supplement(self, monkeypatch):
docs = [
self._doc("d1", "contract", "03700_1", None, "2025-01-01", "ЗАО X"),
self._doc("d2", "supplement", "1", "03700_1", "2025-02-01", "ЗАО X"),
]
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
r = grouping.group_documents("b1")
assert r["ok"] is True
assert r["total_docs"] == 2
groups = [g for g in r["groups"] if g["contract_number"] != "__unresolved__"]
assert len(groups) == 1
assert groups[0]["contract_number"] == "03700_1"
assert len(groups[0]["documents"]) == 2
def test_unmatched_goes_unresolved(self, monkeypatch):
docs = [self._doc("d1", "other", None, None, None, "")]
monkeypatch.setattr(grouping, "db_docs", _FakeDocs(docs))
r = grouping.group_documents("b1")
unresolved = [g for g in r["groups"] if g["contract_number"] == "__unresolved__"]
assert len(unresolved) == 1
class TestApplyGroups:
def test_apply(self, monkeypatch):
created = []
class FakeContracts:
def insert(self, number, client=""):
return {"id": f"c_{number}"}
class FakeSupps:
def insert(self, cid, did, stype):
created.append((cid, did, stype))
return {"id": "s"}
monkeypatch.setattr(grouping, "db_contracts", FakeContracts())
monkeypatch.setattr(grouping, "db_supplements", FakeSupps())
groups = [
{"contract_number": "03700_1", "counterparty": "X",
"documents": [{"id": "d1"}, {"id": "d2"}]},
{"contract_number": "__unresolved__", "counterparty": "",
"documents": [{"id": "d3"}]},
]
r = grouping.apply_groups("b1", groups)
assert r["ok"] is True
assert r["created"] == 2
assert created == [("c_03700_1", "d1", "initial"), ("c_03700_1", "d2", "additional")]
+25
View File
@@ -0,0 +1,25 @@
"""Unit-тесты services/llm.py — call_llm с FakeLLMClient."""
from services.llm import call_llm
from services.llm_client import FakeLLMClient
def _build(cur, txt):
return (f"prompt:{txt}", "pid-1")
class TestCallLLM:
def test_plain_json(self):
llm = FakeLLMClient({"default": '{"mode":"partial","ops":[]}'})
res, pid = call_llm([], "текст", _build, llm_client=llm)
assert pid == "pid-1"
assert res == {"mode": "partial", "ops": []}
def test_markdown_json(self):
llm = FakeLLMClient({"default": '```json\n{"mode":"full_replace","ops":[]}\n```'})
res, _ = call_llm([], "текст", _build, llm_client=llm)
assert res["mode"] == "full_replace"
def test_markdown_generic(self):
llm = FakeLLMClient({"default": '```\n{"a":1}\n```'})
res, _ = call_llm([], "текст", _build, llm_client=llm)
assert res == {"a": 1}
+40
View File
@@ -0,0 +1,40 @@
"""Unit-тесты services/llm_client.py."""
from services.llm_client import FakeLLMClient, HttpxLLMClient
class TestFakeLLMClient:
def test_exact_match(self):
c = FakeLLMClient({"hello": "world"})
assert c.complete("hello") == "world"
def test_partial_match(self):
c = FakeLLMClient({"needle": "found"})
assert c.complete("a needle in haystack") == "found"
def test_default_fallback(self):
c = FakeLLMClient({"default": "fallback"})
assert c.complete("whatever") == "fallback"
def test_calls_recorded(self):
c = FakeLLMClient()
c.complete("x")
c.complete("y")
assert c.calls == ["x", "y"]
def test_add(self):
c = FakeLLMClient()
c.add("k", "v")
assert c.complete("k") == "v"
class TestHttpxLLMClient:
def test_init_defaults(self):
c = HttpxLLMClient(url="http://x", key="k")
assert c.model == "gpt-oss-120b"
assert c.timeout == 120
assert c.max_tokens == 8000
def test_init_custom(self):
c = HttpxLLMClient(url="http://x", key="k", model="m", timeout=10)
assert c.model == "m"
assert c.timeout == 10
+87
View File
@@ -0,0 +1,87 @@
"""Unit-тесты services/metrics.py — чистая логика, без БД."""
from decimal import Decimal
import pytest
from services.metrics import (
check_arithmetic,
normalize_date,
_to_decimal,
ClassifyMetrics,
)
class TestNormalizeDate:
def test_dd_mm_yyyy(self):
assert normalize_date("01.01.2025") == "2025-01-01"
def test_already_iso(self):
assert normalize_date("2025-01-01") == "2025-01-01"
def test_empty(self):
assert normalize_date("") is None
assert normalize_date(None) is None
def test_unrecognized_passthrough(self):
assert normalize_date("01 января 2025") == "01 января 2025"
class TestToDecimal:
def test_int(self):
assert _to_decimal("50000") == Decimal("50000")
def test_russian_number(self):
assert _to_decimal("50 000,00") == Decimal("50000.00")
def test_nbsp(self):
assert _to_decimal("1\u00a0000,50") == Decimal("1000.50")
def test_none(self):
assert _to_decimal(None) is None
assert _to_decimal("") is None
def test_garbage(self):
assert _to_decimal("не число") is None
class TestCheckArithmetic:
def test_ok(self):
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 200}}]
assert check_arithmetic(ops) == []
def test_mismatch(self):
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "qty": 2, "sum": 250}}]
m = check_arithmetic(ops)
assert len(m) == 1
assert m[0]["expected_sum"] == 200.0
assert m[0]["actual_sum"] == 250.0
assert m[0]["diff"] == 50.0
def test_update_values(self):
ops = [{"action": "UPDATE", "new_values": {"price": 10, "qty": 3, "sum": 30}}]
assert check_arithmetic(ops) == []
def test_skip_incomplete(self):
# нет qty → пропуск
ops = [{"action": "ADD", "new_row": {"name": "x", "price": 100, "sum": 200}}]
assert check_arithmetic(ops) == []
def test_skip_non_add_update(self):
ops = [{"action": "DELETE", "target_hash": "h"}]
assert check_arithmetic(ops) == []
class TestClassifyMetrics:
def test_fix_rate(self):
m = ClassifyMetrics()
m.record(False)
m.record(True)
m.record(False)
assert m.total == 3
assert m.fixes == 1
assert m.fix_rate == pytest.approx(1 / 3)
def test_empty(self):
m = ClassifyMetrics()
assert m.fix_rate == 0.0
assert m.summary()["total_classifications"] == 0
+28
View File
@@ -0,0 +1,28 @@
"""Unit-тесты services/parse.py."""
from services.parse import parse_file, _parse_text
class TestParseText:
def test_plain(self):
r = parse_file("test.txt", "строка1\nстрока2\nстрока3".encode())
assert r["status"] == "parsed"
assert r["element_count"] == 3
def test_text_fn(self):
r = _parse_text(b"a\nb\nc")
assert r["status"] == "parsed"
assert r["element_count"] == 3
assert r["elements"][0]["text"] == "a"
def test_no_extension_falls_back_to_text(self):
r = parse_file("noext", b"line1\nline2")
assert r["status"] == "parsed"
assert r["element_count"] == 2
def test_docx_invalid_returns_error(self):
r = parse_file("test.docx", b"not a real docx")
assert r["status"] == "error"
def test_pdf_invalid_returns_error(self):
r = parse_file("test.pdf", b"not a real pdf")
assert r["status"] == "error"
+96
View File
@@ -0,0 +1,96 @@
"""Интеграционные тесты services/process.py — run_pipeline с Fake LLM."""
import json
from db import contracts, documents, supplements, spec_current
from db.connection import query
from services import process
def _seed_contract(n_supps=2):
"""Создать contract + n документов (parsed) + n supplements."""
c = contracts.insert("03700_1")
for i in range(n_supps):
d = documents.insert(f"d{i}.docx", "application/octet-stream", "raw", batch_id="b1")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": f"строка {i}"}])
supplements.insert(c["id"], d["id"], "initial" if i == 0 else "additional")
return c
class TestFullReplace:
def test_no_duplicates(self, db, monkeypatch):
c = _seed_contract(2)
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
ops = [
{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000}},
{"action": "ADD", "new_row": {"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400}},
{"action": "ADD", "new_row": {"name": "Канал 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000}},
]
return ({"mode": "full_replace", "ops": ops}, "pid")
monkeypatch.setattr("services.llm.call_llm", fake_call)
events = list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
rows = spec_current.list_by_contract(c["id"])
# 2 full_replace, но без дублей — всегда 3 строки, не 6
assert len(rows) == 3
assert {r["name"] for r in rows} == {"Аренда стойко-места", "IP-адрес IPv4", "Канал 1 Гбит/с"}
# история сохранена: 2 full_replace × 3 ADD = 6 событий
ev = query("SELECT count(*) AS c FROM spec_events WHERE contract_id = %s", (c["id"],))
assert ev[0]["c"] == 6
# не было ошибок извлечения
assert not any(e.get("type") == "extract_error" for e in events)
class TestTargetIdTranslation:
def test_update_applies(self, db, monkeypatch):
c = _seed_contract(2)
def fake_call(current_spec, doc_text, build_prompt_fn, llm_client=None):
if not current_spec:
return ({"mode": "partial", "ops": [
{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}},
]}, "pid")
return ({"mode": "partial", "ops": [
{"action": "UPDATE", "target_id": "r1", "new_values": {"price": 55000, "sum": 55000}},
]}, "pid")
monkeypatch.setattr("services.llm.call_llm", fake_call)
list(process.run_pipeline(c["id"], "", lambda cur, txt: ("p", "pid")))
rows = spec_current.list_by_contract(c["id"])
assert len(rows) == 1
assert rows[0]["price"] == 55000 # UPDATE применился, а не ушёл в UNRESOLVED
upd = query("SELECT status FROM spec_events WHERE contract_id = %s AND action = 'UPDATE'", (c["id"],))
assert upd[0]["status"] == "applied"
class TestNoSupplements:
def test_error_event(self, db):
evs = list(process.run_pipeline("nonexistent", "", lambda cur, txt: ("p", "pid")))
assert any(e.get("type") == "error" for e in evs)
class TestElementsToText:
def test_list(self):
ej = [
{"type": "paragraph", "text": "Привет"},
{"type": "table", "rows": [["a", "b"], ["c", "d"]]},
]
assert process._elements_to_text(ej) == "Привет\na | b\nc | d"
def test_dict_value(self):
ej = {"Value": [{"type": "paragraph", "text": "X"}]}
assert process._elements_to_text(ej) == "X"
def test_string_json(self):
ej = json.dumps([{"type": "paragraph", "text": "Y"}])
assert process._elements_to_text(ej) == "Y"
def test_plain_string(self):
assert process._elements_to_text("просто текст") == "просто текст"
+48
View File
@@ -0,0 +1,48 @@
"""Интеграционные тесты HTTP-эндпоинтов (Flask test client)."""
import pytest
@pytest.fixture
def client(tmp_path, monkeypatch):
from db import connection as conn
monkeypatch.setattr(conn, "DB_PATH", str(tmp_path / "app.db"))
from app import create_app
app = create_app()
app.config["TESTING"] = True
yield app.test_client()
c = getattr(conn._local, "conn", None)
if c is not None:
try:
c.close()
except Exception:
pass
conn._local.conn = None
class TestHealth:
def test_health(self, client):
from config import VERSION
r = client.get("/health")
assert r.status_code == 200
data = r.get_json()
assert data["ok"] is True
assert data["version"] == VERSION
class TestSpecCurrent:
def test_missing_contract_id(self, client):
r = client.get("/api/spec-current")
assert r.status_code == 400
def test_empty(self, client):
r = client.get("/api/spec-current?contract_id=missing")
assert r.status_code == 200
data = r.get_json()
assert data["ok"] is True
assert data["rows"] == []
class TestGroups:
def test_missing_batch(self, client):
r = client.get("/api/groups")
assert r.status_code == 400
+31
View File
@@ -0,0 +1,31 @@
"""Интеграционные тесты db/spec_current.py."""
import json
from db import spec_current, documents, spec_events
class TestListByContract:
def test_empty(self, db):
assert spec_current.list_by_contract("nope") == []
def test_ordered_by_name(self, db):
ops = [
{"action": "ADD", "new_row": {"name": "Б", "price": 2}},
{"action": "ADD", "new_row": {"name": "А", "price": 1}},
]
spec_events.apply_ops("c1", "s1", "d1", ops, "pid", {})
rows = spec_current.list_by_contract("c1")
assert [r["name"] for r in rows] == ["А", "Б"]
class TestGetElementsJson:
def test_none(self, db):
assert spec_current.get_elements_json("missing") is None
def test_parsed(self, db):
d = documents.insert("f.docx", "m", "raw", batch_id="b1")
documents.set_parsed(d["id"], [{"type": "paragraph", "text": "x"}])
ej = spec_current.get_elements_json(d["id"])
assert ej is not None
parsed = json.loads(ej)
assert parsed[0]["text"] == "x"
+107
View File
@@ -0,0 +1,107 @@
"""Интеграционные тесты db/spec_events.py (SQLite, изолированная БД)."""
from db.connection import query
from db import spec_events, spec_current
CID = "11111111-1111-1111-1111-111111111111"
SID = "22222222-2222-2222-2222-222222222222"
DID = "33333333-3333-3333-3333-333333333333"
def _count(table, contract_id):
rows = query(f"SELECT count(*) AS c FROM {table} WHERE contract_id = %s", (contract_id,))
return rows[0]["c"]
class TestApplyOpsAdd:
def test_add(self, db):
ops = [{"action": "ADD", "new_row": {"name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s == {"added": 1, "updated": 0, "deleted": 0}
rows = spec_current.list_by_contract(CID)
assert len(rows) == 1
assert rows[0]["name"] == "Аренда стойко-места"
assert rows[0]["price"] == 50000
assert rows[0]["date_start"] == "2025-01-01"
def test_add_empty_name_unresolved(self, db):
ops = [{"action": "ADD", "new_row": {"name": ""}}]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s == {"added": 0, "updated": 0, "deleted": 0}
assert spec_current.list_by_contract(CID) == []
def test_add_multiple(self, db):
ops = [
{"action": "ADD", "new_row": {"name": "A", "price": 1}},
{"action": "ADD", "new_row": {"name": "B", "price": 2}},
]
s = spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert s["added"] == 2
assert len(spec_current.list_by_contract(CID)) == 2
class TestApplyOpsUpdateDelete:
def test_update_and_delete(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "Аренда", "price": 50000, "qty": 1, "sum": 50000}}], "pid", {})
h = spec_events._hash("Аренда")
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "target_hash": h, "new_values": {"price": 55000}}], "pid", {})
assert s["updated"] == 1
assert spec_current.list_by_contract(CID)[0]["price"] == 55000
s = spec_events.apply_ops(CID, SID, DID, [{"action": "DELETE", "target_hash": h}], "pid", {})
assert s["deleted"] == 1
assert spec_current.list_by_contract(CID) == []
def test_update_empty_hash_unresolved(self, db):
s = spec_events.apply_ops(CID, SID, DID, [{"action": "UPDATE", "new_values": {"price": 1}}], "pid", {})
assert s["updated"] == 0
rows = query("SELECT status FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["status"] == "unresolved"
class TestUnresolvedAndUnknown:
def test_unresolved_action(self, db):
ops = [{"action": "UNRESOLVED", "new_values": {"name": "X"}, "reason": "нет соответствия"}]
spec_events.apply_ops(CID, SID, DID, ops, "pid", {})
assert spec_current.list_by_contract(CID) == []
rows = query("SELECT action, status FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["action"] == "UNRESOLVED"
assert rows[0]["status"] == "unresolved"
def test_unknown_action(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "WHATEVER", "new_row": {"name": "X"}}], "pid", {})
assert spec_current.list_by_contract(CID) == []
rows = query("SELECT action FROM spec_events WHERE contract_id = %s", (CID,))
assert rows[0]["action"] == "UNRESOLVED"
class TestClearAndReset:
def test_clear_current_keeps_events(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
assert _count("spec_current", CID) == 1
spec_events.clear_current(CID)
assert _count("spec_current", CID) == 0
assert _count("spec_events", CID) == 1 # история сохранена
def test_reset_clears_both(self, db):
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A", "price": 1}}], "pid", {})
spec_events.reset(CID)
assert _count("spec_current", CID) == 0
assert _count("spec_events", CID) == 0
class TestHashAndSeq:
def test_hash_normalizes(self):
assert spec_events._hash(" Арена стойко-места ") == spec_events._hash("арена стойко-места")
def test_hash_includes_date(self):
assert spec_events._hash("Аренда", "01.01.2025") != spec_events._hash("Аренда", "01.02.2025")
def test_hash_len(self):
assert len(spec_events._hash("x")) == 16
def test_seq(self, db):
assert spec_events.get_next_seq(CID) == 1
spec_events.apply_ops(CID, SID, DID, [{"action": "ADD", "new_row": {"name": "A"}}], "pid", {})
assert spec_events.get_next_seq(CID) == 2