test: гигиена тестов Среза 2c и нормализация email seed в одном месте

- test_api_documents: тавтология по counterparty_name строки 18 ОСВ_66 заменена
  значениями снимка (T9.3).
- test_mapping_golden: план счетов не пуст и есть строки с account_id (T8.6).
- e2e: код ответа DELETE проверяется после try/finally — не маскирует причину
  падения (T11.2).
- test_counterparty_resolve: единственное точное имя побеждает неоднозначность
  нормализованного (T6.1).
- test_domain_osv_mapping: шапка «ООО» без ИНН → UNKNOWN (T5.1).
- seed_lib/apply: email пользователя нормализуется один раз для upsert и ролей,
  поведение не меняется (T1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 00:39:40 +04:00
parent d14295b6d0
commit 75feab6015
6 changed files with 26 additions and 8 deletions

View File

@ -192,8 +192,8 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
).all()
}
user_ids: dict[str, Any] = {}
for user in data.users:
email = user.email.strip().lower()
users = [(user.email.strip().lower(), user) for user in data.users] # email нормализуется один раз
for email, user in users:
values = {
"full_name": user.full_name,
"employee_no": user.employee_no,
@ -219,9 +219,9 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
user_stat.unchanged += 1
existing = set((await session.execute(select(UserRole.user_id, UserRole.role_id))).all())
new_rows = []
for user in data.users:
for email, user in users:
for role_code in user.role_codes:
pair = (user_ids[user.email.strip().lower()], role_ids[role_code])
pair = (user_ids[email], role_ids[role_code])
if pair in existing:
link_stat.unchanged += 1
else:

View File

@ -157,7 +157,8 @@ async def test_real_osv_is_parsed_end_to_end(
root = entries["items"][0]
assert (root["account_code"], root["level"]) == ("66", 0)
finally:
# очистка: PROCESSED без подтверждения удаляется (204); временная БД снимается фикстурой
# очистка: PROCESSED без подтверждения удаляется (204); временная БД снимается фикстурой. Код ответа
# проверяется после блока: assert в finally заменил бы исключение проверки выше и скрыл причину падения
deleted = await client.delete(f"/files/{file_id}")
assert deleted.status_code == HTTP_NO_CONTENT, deleted.text
assert (await client.get(f"/files/{file_id}")).status_code == HTTP_NOT_FOUND

View File

@ -192,7 +192,8 @@ async def test_entries_as_tree_on_golden_osv_66(
assert row["group_type"] == "COUNTERPARTY"
yug = next(row for row in rows if row["row_number"] == 18) # noqa: PLR2004
assert yug["account_id"] is not None
assert yug["counterparty_name"] is None or isinstance(yug["counterparty_name"], str)
# значения снимка ОСВ_66 за 2024, строка 18: контрагент без ИНН в колонке
assert (yug["counterparty_name"], yug["counterparty_inn"]) == ("Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ", None)
assert set(first) == {
"id",
"row_number",

View File

@ -69,6 +69,18 @@ async def test_exact_name_unique_and_ambiguous(db_session: AsyncSession) -> None
assert index.resolve(None, "ООО Дубль Имени") is None
async def test_unique_exact_name_wins_over_ambiguous_normalized(db_session: AsyncSession) -> None:
"""Приоритет шагов: единственное точное имя побеждает неоднозначность нормализованного (оба ЮЛ нормализуются в
одно имя, но точное совпадение у одного)."""
ooo = await make_legal_entity(db_session, name="ООО Альфа Приоритет")
await make_legal_entity(db_session, name="АО Альфа Приоритет")
index = await CounterpartyIndex.load(db_session)
assert index.resolve(None, "ООО Альфа Приоритет") == ooo.id
# без точного совпадения нормализованное имя неоднозначно → None
assert index.resolve(None, "Альфа Приоритет") is None
async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSession) -> None:
entity = await make_legal_entity(db_session, name="ООО «Нормализуемое Имя»")
index = await CounterpartyIndex.load(db_session)

View File

@ -252,6 +252,7 @@ def test_document_fields_on_golden_osv_66(fixtures_dir: Path) -> None:
({"organization_name": "Пекарня Счастливочная"}, DocumentCheckResult.MISMATCH),
({"organization_name": "", "organization_inn": None}, DocumentCheckResult.UNKNOWN), # пустая шапка
({"organization_name": " ", "organization_inn": ""}, DocumentCheckResult.UNKNOWN),
({"organization_name": "ООО"}, DocumentCheckResult.UNKNOWN), # только ОПФ: нормализуется в пустое имя
],
)
def test_counterparty_check(header: dict[str, Any], expected: DocumentCheckResult) -> None:

View File

@ -101,6 +101,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
chart = {
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
}
assert chart, "план счетов seed пуст — сверка account_id ничего не проверит"
index = await CounterpartyIndex.load(session)
mapped: list[tuple[Path, uuid.UUID, RecognitionResult]] = []
@ -121,7 +122,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
problems: list[str] = []
missing_codes: set[str] = set()
documents_total = entries_total = 0
documents_total = entries_total = with_account = 0
for path, file_id, _ in mapped:
name = str(path.relative_to(fixtures_dir / GOLDEN_DIR))
raw = json.loads(path.read_text("utf-8"))
@ -148,6 +149,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
missing_codes |= sheet_missing
documents_total += 1
entries_total += len(entries)
with_account += sum(1 for entry in entries if entry.account_id is not None)
file_status = (await session.execute(select(StoredFile.status).where(StoredFile.id == file_id))).scalar_one()
if file_status not in {FileStatus.PROCESSED, FileStatus.SUPERSEDED}:
problems.append(f"{name}: статус файла {file_status}")
@ -158,4 +160,5 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
f"{sorted(missing_codes)}"
)
assert problems == []
assert with_account > 0 # строки со счётом плана счетов есть — сверка account_id не вырождена
assert mapping_seconds <= TIME_BUDGET_SECONDS