test: prepare isolated PostgreSQL publication integration checks
CI / test (pull_request) Successful in 9m12s
CI / deploy (pull_request) Has been skipped

This commit is contained in:
codex committed 2026-10-05 12:29:17 +03:00
1 parent e0f9ab7411
commit 8adaa4d6ea
10 files changed
+451 -6

No files matched your search

+3 -1
View File
@@ -141,8 +141,10 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.10.2`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.10.3`. Админка всегда показывает версии backend и frontend в footer.
## Разовый импорт публикаций
Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.
Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md).
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.10.2"
FRONTEND_VERSION = "0.10.2"
BACKEND_VERSION = "0.10.2"
APP_VERSION = "0.10.3"
FRONTEND_VERSION = "0.10.3"
BACKEND_VERSION = "0.10.3"
+96
View File
@@ -0,0 +1,96 @@
# Проверка импорта на изолированном PostgreSQL
Запускается средствами проекта: pytest, SQLAlchemy, psycopg и отдельный Docker
Compose. Не использует production Compose, `.env`, SSH или данные сотрудников.
PostgreSQL 16 и runner работают в отдельной `internal`-сети без опубликованных
портов. Данные PostgreSQL находятся в tmpfs, без постоянных volumes. Базы:
`miem_import_test` и `miem_crawl_test`. Все сотрудники, статьи и credentials
синтетические; тестовые пароли непригодны для реальных систем.
Dockerfile копирует только нужный код и requirements. Его отдельный dockerignore
исключает `.git`, `.env`, файлы ключей/credentials, CSV владельца, данные
`app/data` и остальные файлы workspace. Runtime не получает переменные хоста.
CLI subprocess дополнительно получает только явно заданные тестовые libpq
параметры; `.pgpass`/service-файлы отключены. Не изменяйте адрес `postgres-test`
или имена тестовых БД для подключения к существующему серверу.
Синтетическая роль `import_test` создаётся только init-скриптом одноразового
тестового кластера. Fixture выдаёт ей документированные column grants и USAGE
sequence. Проверяется отсутствие CREATE схемы, INSERT сотрудников, DELETE
публикаций и sequence UPDATE. Это не создание временного доступа к рабочей БД.
Таблицы создаются моделью приложения и очищаются только в тестовых БД.
HTTP заблокирован test fixture; входные профили полного обхода подставляются
локально. Это проверяет настоящий SQL, транзакции, savepoint и сериализацию JSON,
но не парсинг сайта ВШЭ. Fixtures SQLite и подмена psycopg для интеграционных
тестов не используются.
## Что проверяется
1. Dry-run: все строки всех таблиц и состояния sequences совпадают до/после.
2. Apply: одна вставка, одно обновление, пропуск отсутствующего сотрудника.
3. Повторный импорт: ноль INSERT/UPDATE, неизменные таблицы/sequences.
4. CHECK constraint тестовой БД вызывает ошибку третьего действия после UPDATE
и успешного INSERT; все строки возвращаются в исходное состояние. Sequence
может продвинуться при rollback — это ожидаемое поведение PostgreSQL.
5. Обновление только года без raw_data сохраняет существующий JSONB.
6. Поздние строки CSV дополняют метаданные; первое непустое значение сохраняется
при конфликте, отчёт содержит названия конфликтующих полей. Повтор идемпотентен.
7. Два последовательных crawl сохраняют CSV-статью, отсутствующую у ВШЭ;
появившаяся статья получает HSE ID без второй записи и потери метаданных.
8. Полный `run_crawl` обрабатывает двух сотрудников. Два HSE ID на одну CSV-запись
откатывают savepoint первого, сохраняя JSONB, авторов, ID, хеш и updated_at;
публикация второго сохраняется. Проверяются обе очередности ID и уже
установленная привязка; неоднозначность отражается в CrawlError.
Всего 10 PostgreSQL-тестов (восьмой сценарий параметризован). В обычном
`python -m pytest -q` они пропускаются. Opt-in `MIEM_POSTGRES_INTEGRATION=1`
разрешён только с точным тестовым DATABASE_URL Compose-проекта.
## Команды владельцу
Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория
после независимого ревью. Команды явно выбирают локальный Unix socket и очищают
наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с
доступным локальным `/var/run/docker.sock`; не подменяйте его удалённым VPS.
Первый шаг:
```sh
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version
```
Ожидается exit code 0 и секции `Client` и `Server`. `command not found`,
`permission denied` или только Client с ошибкой подключения означает, что
до запуска тестов нужно восстановить локальный Docker; не подключайтесь к VPS.
Дальнейшие точные команды сохранены здесь для воспроизводимого запуска:
```sh
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml config --quiet
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml build tests
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml up --abort-on-container-exit --exit-code-from tests
```
Первые две команды проверяют Compose и собирают runner с уже используемыми
зависимостями. Сборка/скачивание образов требуют registry/PyPI, но тестовая
runtime-сеть не имеет внешнего доступа и запросов к ВШЭ нет. При успехе третьей
команды ожидаются `10 passed` и exit code 0. Фактическую совместимость образа,
column grants и PostgreSQL можно считать подтверждённой **только после такого
выполнения**, не по unit-тестам или статическому чтению файлов.
После сохранения вывода тестов удалите именно этот тестовый проект:
```sh
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml down
```
Это уничтожит только синтетические данные в tmpfs тестового PostgreSQL. Не
используйте `docker system prune`, production Compose или удаление рабочих
volumes. Перед повторным прогоном выполните этот же `down`, чтобы следующий
кластер начинался с чистого init.sql; не запускайте два прогона одновременно.
## Статус подготовки
В среде подготовки Docker отсутствует; настоящие PostgreSQL-тесты не запускались.
Локально доступны только unit-тесты, проверка Python и статические проверки.
Production/VPS и доступ к рабочей БД не изменялись.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.10.2"
version = "0.10.3"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY scripts/import_publications.py ./scripts/import_publications.py
COPY tests/conftest.py tests/test_publication_import_postgres.py ./tests/
+18
View File
@@ -0,0 +1,18 @@
**
!requirements.txt
!app/
!app/**
!scripts/
!scripts/import_publications.py
!tests/
!tests/conftest.py
!tests/test_publication_import_postgres.py
!tests/postgres/
!tests/postgres/Dockerfile
!tests/postgres/Dockerfile.dockerignore
app/data/
**/__pycache__/
**/.env*
**/*credentials*
**/*.key
**/*.pem
+36
View File
@@ -0,0 +1,36 @@
name: miem-publication-integration
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: test_admin
POSTGRES_PASSWORD: isolated_test_admin
POSTGRES_DB: miem_import_test
tmpfs:
- /var/lib/postgresql/data
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/10-test.sql:ro
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"]
interval: 2s
timeout: 5s
retries: 30
networks: [isolated]
tests:
build:
context: ../..
dockerfile: tests/postgres/Dockerfile
environment:
DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test
MIEM_POSTGRES_INTEGRATION: "1"
command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"]
depends_on:
postgres-test:
condition: service_healthy
networks: [isolated]
networks:
isolated:
internal: true
+6
View File
@@ -0,0 +1,6 @@
-- Synthetic credentials for this ephemeral, private test cluster only.
CREATE DATABASE miem_crawl_test OWNER test_admin;
CREATE ROLE import_test LOGIN PASSWORD 'isolated_test_import'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS
CONNECTION LIMIT 1;
GRANT CONNECT ON DATABASE miem_import_test, miem_crawl_test TO import_test;
+1 -1
View File
@@ -18,7 +18,7 @@ def test_health_returns_versions():
response = TestClient(app).get("/api/health")
assert response.status_code == 200
assert response.json()["backend_version"] == "0.10.2"
assert response.json()["backend_version"] == "0.10.3"
def test_api_employees_and_stats_require_admin_session():
+280
View File
@@ -0,0 +1,280 @@
"""Real PostgreSQL checks; opt in only inside tests/postgres/compose.yml."""
import copy
import csv
import json
import os
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
import pytest
import requests
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import Base
from app.models import CrawlError, CrawlRun, Employee, EmployeePublication
from app.services.crawler import _try_sync_employee_publications, run_crawl
ADMIN_URL = "postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test"
pytestmark = pytest.mark.skipif(
os.environ.get("MIEM_POSTGRES_INTEGRATION") != "1", reason="Requires isolated PostgreSQL Compose project"
)
@pytest.fixture(autouse=True)
def isolated_environment(monkeypatch):
assert os.environ.get("DATABASE_URL") == ADMIN_URL, "Use the dedicated test Compose project"
def forbidden_request(*args, **kwargs):
raise AssertionError("External HTTP requests are forbidden in PostgreSQL integration tests")
monkeypatch.setattr(requests.sessions.Session, "request", forbidden_request)
@contextmanager
def database(name):
assert name in {"miem_import_test", "miem_crawl_test"}
engine = create_engine(ADMIN_URL.replace("/miem_import_test", "/" + name))
try:
with engine.connect() as connection:
assert connection.scalar(text("SELECT current_database()")) == name
Base.metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(text("GRANT USAGE ON SCHEMA public TO import_test"))
connection.execute(text("GRANT SELECT (id, full_name) ON public.employees TO import_test"))
connection.execute(text("GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO import_test"))
connection.execute(text("GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO import_test"))
connection.execute(text("GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO import_test"))
# Created from the actual model: not an assumed sequence name.
sequence = connection.scalar(text("SELECT pg_get_serial_sequence('public.employee_publications', 'id')"))
assert sequence == "public.employee_publications_id_seq"
connection.execute(text("GRANT USAGE ON SEQUENCE public.employee_publications_id_seq TO import_test"))
permissions = connection.execute(text("""SELECT
has_schema_privilege('import_test', 'public', 'CREATE'),
has_table_privilege('import_test', 'public.employees', 'INSERT'),
has_table_privilege('import_test', 'public.employee_publications', 'DELETE'),
has_sequence_privilege('import_test', 'public.employee_publications_id_seq', 'UPDATE')
""")).one()
assert not any(permissions)
with Session(engine) as session:
yield session, name
finally:
# Only the dedicated ephemeral test DB; never DROP DATABASE or roles.
Base.metadata.drop_all(engine)
engine.dispose()
@pytest.fixture
def pg_import_db():
with database("miem_import_test") as db:
yield db
@pytest.fixture
def pg_crawl_db():
with database("miem_crawl_test") as db:
yield db
def employee(session, name="Synthetic One", key="one"):
record = Employee(full_name=name, profile_key="staff:" + key,
canonical_url="https://synthetic.invalid/staff/" + key)
session.add(record)
session.commit()
return record
def article(title="Synthetic article", identifier="10.1234/test", year="2024", author="Synthetic One", **metadata):
return {"Название публикации": title, "Год": year, "DOI": identifier,
"Авторы НИУ ВШЭ": author, **metadata}
def write_csv(path, rows):
columns = list(dict.fromkeys(key for row in rows for key in row))
with path.open("w", encoding="utf-8-sig", newline="") as output:
writer = csv.DictWriter(output, fieldnames=columns, delimiter=";")
writer.writeheader()
writer.writerows(rows)
def cli(tmp_path, db, rows, *, apply=False, expected_code=0):
_, name = db
source = tmp_path / "articles.csv"
report = tmp_path / "report.json"
write_csv(source, rows)
command = [sys.executable, str(Path(__file__).resolve().parents[1] / "scripts/import_publications.py"),
str(source), "--report", str(report)]
if apply:
command.append("--apply")
# Explicit whitelist: no production env, .env, pg_service or ~/.pgpass.
environment = {"PATH": os.environ["PATH"], "LANG": "C.UTF-8", "PGHOST": "postgres-test",
"PGPORT": "5432", "PGDATABASE": name, "PGUSER": "import_test",
"PGPASSWORD": "isolated_test_import", "PGPASSFILE": "/dev/null",
"PGSERVICEFILE": "/dev/null", "PGCONNECT_TIMEOUT": "5"}
result = subprocess.run(command, env=environment, cwd=tmp_path, capture_output=True, text=True, timeout=60)
assert result.returncode == expected_code, result.stdout + result.stderr
assert "Synthetic One" not in result.stdout and "isolated_test_import" not in result.stdout
return json.loads(report.read_text()) if report.exists() else None
def snapshot(session):
session.expire_all()
tables = {table.name: session.execute(select(table).order_by(*table.primary_key.columns)).all()
for table in Base.metadata.sorted_tables}
sequences = session.execute(text("SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = 'public' ORDER BY sequencename")).all()
session.commit()
return tables, sequences
def test_postgres_dry_run_changes_neither_rows_nor_sequences(pg_import_db, tmp_path):
session, _ = pg_import_db
employee(session)
before = snapshot(session)
report = cli(tmp_path, pg_import_db, [article()])
assert report["summary"]["counts"] == {"insert": 1, "update": 0, "skip": 0}
assert snapshot(session) == before
def test_postgres_apply_and_reimport(pg_import_db, tmp_path):
session, _ = pg_import_db
person = employee(session)
existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024,
source_hash="existing", raw_data={"hse": "keep"})
session.add(existing)
session.commit()
rows = [article(title="Existing"), article(title="New", identifier="10.1234/new"),
article(title="Unknown author", identifier="10.1234/unknown", author="Synthetic Missing")]
report = cli(tmp_path, pg_import_db, rows, apply=True)
assert report["summary"]["counts"] == {"insert": 1, "update": 1, "skip": 1}
session.expire_all()
assert session.query(Employee).count() == 1
assert session.query(EmployeePublication).count() == 2
assert existing.doi_url == "https://doi.org/10.1234/test" and existing.raw_data["hse"] == "keep"
before = snapshot(session)
repeated = cli(tmp_path, pg_import_db, rows, apply=True)
assert repeated["summary"]["counts"] == {"insert": 0, "update": 0, "skip": 3}
assert snapshot(session) == before
def test_postgres_apply_failure_rolls_back_update_and_insert(pg_import_db, tmp_path):
session, _ = pg_import_db
person = employee(session)
existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024,
source_hash="existing", raw_data={"hse": "keep"})
session.add(existing)
session.commit()
# Test-only constraint forces a real server error after update + successful INSERT.
session.execute(text("ALTER TABLE public.employee_publications ADD CONSTRAINT test_failure CHECK (title <> 'Reject')"))
session.commit()
before, _ = snapshot(session)
rows = [article(title="Existing"), article(title="Accepted", identifier="10.1234/accepted"),
article(title="Reject", identifier="10.1234/reject")]
assert cli(tmp_path, pg_import_db, rows, apply=True, expected_code=1) is None
after, _ = snapshot(session)
assert after == before # nextval may advance on rollback; rows must not change.
assert (tmp_path / "report.json.pending").exists()
def test_postgres_year_only_update_preserves_json(pg_import_db, tmp_path):
session, _ = pg_import_db
person = employee(session)
row = article()
raw = {"hse": {"keep": True}, "csv_enrichment": row.copy()}
record = EmployeePublication(employee_id=person.id, title=row["Название публикации"],
year=None, doi_url="https://doi.org/10.1234/test",
source_hash="existing", raw_data=raw)
session.add(record)
session.commit()
report = cli(tmp_path, pg_import_db, [row], apply=True)
session.expire_all()
assert report["summary"]["counts"]["update"] == 1
assert record.year == 2024 and record.raw_data == raw
def test_postgres_late_csv_metadata_and_conflicts(pg_import_db, tmp_path):
session, _ = pg_import_db
employee(session)
rows = [article(), article(**{"EID (Scopus)": "late-id", "Квартиль scopus": "Q1"}),
article(**{"EID (Scopus)": "conflict-id", "UT (WoS)": "late-wos"})]
report = cli(tmp_path, pg_import_db, rows, apply=True)
session.expire_all()
assert session.query(EmployeePublication).count() == 1
metadata = session.query(EmployeePublication).one().raw_data["csv_enrichment"]
assert metadata["EID (Scopus)"] == "late-id" and metadata["Квартиль scopus"] == "Q1"
assert metadata["UT (WoS)"] == "late-wos"
assert report["summary"]["metadata_conflicts"] == 1
assert report["records"][2]["conflict_fields"] == ["EID (Scopus)"]
before = snapshot(session)
repeated = cli(tmp_path, pg_import_db, rows, apply=True)
assert repeated["summary"]["counts"]["insert"] == repeated["summary"]["counts"]["update"] == 0
assert snapshot(session) == before
def test_postgres_crawler_preserves_import_and_adopts_hse_id(pg_crawl_db, tmp_path):
session, _ = pg_crawl_db
person = employee(session)
cli(tmp_path, pg_crawl_db, [article(), article(title="CSV only", identifier="10.1234/only")], apply=True)
session.expire_all()
ids = {p.title: p.id for p in session.query(EmployeePublication).all()}
run = CrawlRun(source_url="https://synthetic.invalid/persons", status="running")
session.add(run)
session.commit()
parsed = {"sections": [{"type": "publications", "publications": [
{"publication_id": "hse-one", "title": "Synthetic article", "year": 2024,
"doi_url": "https://doi.org/10.1234/test", "raw_data": {"id": "hse-one"}}]}]}
for _ in range(2):
_try_sync_employee_publications(session, run, person, copy.deepcopy(parsed))
session.commit()
session.expire_all()
assert session.query(EmployeePublication).count() == 2
kept = session.get(EmployeePublication, ids["Synthetic article"])
assert kept.publication_id == "hse-one" and "csv_enrichment" in kept.raw_data
assert session.get(EmployeePublication, ids["CSV only"]).raw_data["csv_enrichment"]
assert session.query(CrawlError).count() == 0
@pytest.mark.parametrize("bound_id", [None, "hse-A"])
@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")])
def test_postgres_hse_id_conflict_does_not_block_other_employee(pg_crawl_db, tmp_path, monkeypatch, bound_id, incoming_ids):
session, _ = pg_crawl_db
first = employee(session)
second = employee(session, "Synthetic Two", "two")
cli(tmp_path, pg_crawl_db, [article(identifier="")], apply=True)
session.expire_all()
original = session.query(EmployeePublication).filter_by(employee_id=first.id).one()
original.publication_id = bound_id
original.authors = [{"id": "original-author"}]
session.commit()
fields = ("publication_id", "source_hash", "raw_data", "authors", "updated_at")
before = {field: copy.deepcopy(getattr(original, field)) for field in fields}
session.commit()
def profile(person, publications):
return {"source_url": person.canonical_url, "profile_type": "staff",
"profile_id": person.profile_key.split(":")[1], "full_name": person.full_name,
"parser_version": "integration-test", "tabs": [],
"sections": [{"type": "publications", "publications": publications}]}
profiles = {
first.canonical_url: profile(first, [{"publication_id": key, "title": "Synthetic article", "year": 2024,
"authors": [{"id": key}], "raw_data": {"id": key}} for key in incoming_ids]),
second.canonical_url: profile(second, [{"publication_id": "second-article", "title": "Other employee article",
"year": 2024, "raw_data": {"id": "second-article"}}]),
}
monkeypatch.setattr("app.services.crawler.collect_profile_links", lambda *a, **k: list(profiles))
monkeypatch.setattr("app.services.crawler.parse_person_profile", lambda _, url, *a, **k: copy.deepcopy(profiles[url]))
settings = Settings(_env_file=None, database_url=ADMIN_URL.replace("miem_import_test", "miem_crawl_test"),
source_url="https://synthetic.invalid/persons", request_delay_seconds=0)
run = run_crawl(session, settings)
session.expire_all()
assert run.status == "completed" and run.parsed_count == 2
kept = session.query(EmployeePublication).filter_by(employee_id=first.id).one()
assert {field: getattr(kept, field) for field in fields} == before
assert session.query(EmployeePublication).filter_by(employee_id=second.id).one().publication_id == "second-article"
error = session.query(CrawlError).filter_by(crawl_run_id=run.id).one()
assert "Ambiguous HSE IDs" in error.message