Изолированная PostgreSQL-проверка импорта и сохранности публикаций #55
No files matched your search
@@ -141,8 +141,12 @@ 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.4`. Админка всегда показывает версии 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).
|
||||
|
||||
Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов.
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.10.2"
|
||||
FRONTEND_VERSION = "0.10.2"
|
||||
BACKEND_VERSION = "0.10.2"
|
||||
APP_VERSION = "0.10.4"
|
||||
FRONTEND_VERSION = "0.10.4"
|
||||
BACKEND_VERSION = "0.10.4"
|
||||
@@ -0,0 +1,99 @@
|
||||
# Проверка импорта на изолированном 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 используйте
|
||||
[отдельную инструкцию](postgresql-import-vps.md) и `compose.vps.yml`: этот локальный
|
||||
Compose не предназначен для запуска на сервере с работающими сервисами.
|
||||
Первый шаг:
|
||||
|
||||
```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. При его отсутствии сначала
|
||||
выполните только read-only диагностику из инструкции 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 и доступ к рабочей БД не изменялись.
|
||||
@@ -0,0 +1,153 @@
|
||||
# PostgreSQL-проверка на отдельном стенде VPS
|
||||
|
||||
Команды выполняет владелец под root. Подготовка не подключается к VPS и не
|
||||
запускает стенд. Не используйте production Compose/`.env`, Docker context,
|
||||
volumes, сети, mounts или production credentials. Никаких production-ролей БД,
|
||||
SSH-ключей и правил firewall эти команды не создают и не изменяют.
|
||||
|
||||
## Сначала только read-only проверка
|
||||
|
||||
Следующий блок не создаёт контейнеров, не скачивает образов и не меняет данные.
|
||||
Docker CLI использует локальный Unix socket и очищенное окружение; конфигурация
|
||||
CLI указывает на отдельный каталог, который здесь не создаётся.
|
||||
|
||||
```sh
|
||||
free -h
|
||||
nproc
|
||||
uptime
|
||||
df -h / /var/lib/docker
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock info --format 'CPUs={{.NCPU}} RAM_bytes={{.MemTotal}} cgroup={{.CgroupVersion}} DockerRootDir={{.DockerRootDir}} warnings={{json .Warnings}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose version
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect postgres:16-alpine python:3.12-slim miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
```
|
||||
|
||||
Как читать результат:
|
||||
|
||||
- `free`: нужна колонка **available**, а не только free. Предварительный запас —
|
||||
не менее 2 GiB: стенд ограничен суммарно 1 GiB, ещё минимум 1 GiB остаётся
|
||||
системе/сервисам. Это ориентир, решение зависит от их нагрузки и пиков.
|
||||
- `nproc` и `uptime`: сравните число CPU и load average. Постоянный load на уровне
|
||||
числа CPU или выше — основание отложить тест, а не останавливать сервисы.
|
||||
- `df`: желательно не менее 4 GiB свободно для загрузки образов и запаса работы
|
||||
Docker. Если DockerRootDir отличается от `/var/lib/docker`, отдельно выполните
|
||||
`df -h` для показанного каталога. Не используйте prune для освобождения места.
|
||||
- `stats`: показывает потребление уже работающих контейнеров, но не их пиковые
|
||||
требования. Docker warnings о недоступных memory/swap/cgroup limits нужно
|
||||
устранить до запуска; без действующих лимитов этот вариант не запускать.
|
||||
- `image inspect`: существующие образы показывают tags, ID и revision.
|
||||
`No such image` означает отсутствие образа, **не** разрешение на pull/build.
|
||||
Для запуска нужны два образа `miem-publication-integration-*:pr55` с revision
|
||||
проверенного commit PR #55. Base-образы нужны только для сборки, не для самого
|
||||
запуска уже подготовленного стенда.
|
||||
|
||||
Пришлите только этот диагностический вывод. Не присылайте `docker inspect`
|
||||
production-контейнеров целиком, env, конфигурации подключения или секреты.
|
||||
До оценки ресурсов и завершения независимого review остальные действия ниже
|
||||
не выполнять.
|
||||
|
||||
## Изоляция и лимиты подготовленного стенда
|
||||
|
||||
Используется только `tests/postgres/compose.vps.yml`, не override production или
|
||||
локального Compose. Project name фиксирован:
|
||||
`miem-publication-integration-vps`.
|
||||
|
||||
| Ресурс | Лимит / область |
|
||||
|---|---|
|
||||
| PostgreSQL | Отдельный контейнер `postgres-test`, 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов |
|
||||
| PostgreSQL настройки | shared_buffers 64 MiB, max_connections 12 |
|
||||
| Test runner | 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов; UID 65534, read-only rootfs, no-new-privileges, без capabilities |
|
||||
| Данные PostgreSQL | Только named volume `miem-publication-integration-vps_pgdata`; local driver tmpfs до 256 MiB |
|
||||
| Временные файлы runner | tmpfs `/tmp` до 128 MiB; входят в memory limit контейнера |
|
||||
| Сеть | Только `miem-publication-integration-vps_isolated`, internal; опубликованных портов нет |
|
||||
| Логи | json-file, до 5 MiB на контейнер, один файл |
|
||||
|
||||
Volume с tmpfs выбран намеренно: отдельное имя для адресной очистки и предел
|
||||
размера синтетических данных. Данные не сохраняются после остановки/размонтирования.
|
||||
Образы содержат только код и синтетический init.sql. Host bind mounts,
|
||||
Docker socket, production networks и env_file отсутствуют. Docker socket
|
||||
доступен только CLI владельца на хосте, ни одному контейнеру он не передаётся.
|
||||
Нет restart policy. Pull запрещён; ресурсы не создаются автоматически при
|
||||
отсутствии заранее подготовленного образа.
|
||||
|
||||
Лимиты процесса сборки/загрузки образов отличаются от runtime: Compose CPU/RAM
|
||||
limits **не ограничивают Docker build**. Поэтому на VPS с рабочими сервисами
|
||||
не выполняйте незапланированную сборку. Если готовых образов нет, отдельно
|
||||
согласуйте место сборки и окно загрузки после read-only диагностики.
|
||||
|
||||
## Подготовка образов после review (не запуск стенда)
|
||||
|
||||
На отдельной согласованной машине сборки с Docker, из проверенной копии PR #55,
|
||||
укажите полный SHA проверенного commit в переменной `TEST_COMMIT` (не секрет).
|
||||
Сначала `git status --short` должен быть пустым, `git rev-parse HEAD` — совпадать
|
||||
с TEST_COMMIT. Это не команды для немедленного выполнения на VPS.
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
git rev-parse HEAD
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-runner:pr55 -f tests/postgres/Dockerfile .
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-postgres:pr55 -f tests/postgres/Dockerfile.postgres tests/postgres
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock image save --output miem-publication-integration-images.tar miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55
|
||||
sha256sum miem-publication-integration-images.tar tests/postgres/compose.vps.yml
|
||||
```
|
||||
|
||||
Dockerfile-specific ignore-файлы исключают секреты, реальные CSV и остальные
|
||||
файлы workspace из обоих контекстов сборки. Передайте владельцу только архив
|
||||
этих двух образов и `compose.vps.yml` с проверенными SHA256, без полного workspace
|
||||
и `.env`. Транспорт выбирает владелец; инструкция не получает SSH/production
|
||||
credentials. Base-образы могут скачиваться только на согласованной машине сборки.
|
||||
|
||||
На VPS после оценки ресурсов/отдельного разрешения владельца: файлы размещаются
|
||||
в `/root/miem-publication-integration`, Compose переименован в `compose.vps.yml`,
|
||||
архив — в `images.tar`. Проверка checksum должна совпасть с источником.
|
||||
|
||||
```sh
|
||||
install -d -m 700 /root/miem-publication-integration /root/miem-publication-integration/docker-config
|
||||
sha256sum /root/miem-publication-integration/images.tar /root/miem-publication-integration/compose.vps.yml
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image load --input /root/miem-publication-integration/images.tar
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml config --quiet
|
||||
```
|
||||
|
||||
Проверьте отсутствие уже существующих ресурсов с этим project name. Если они
|
||||
есть, выясните их назначение; не запускайте второй стенд и не удаляйте чужие
|
||||
ресурсы. Следующие команды только читают labels Docker Compose:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock ps -a --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock network ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock volume ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
```
|
||||
|
||||
## Запуск и адресная очистка (пока не выполнять)
|
||||
|
||||
Только после согласования владельцем ресурсов и проверенного состава стенда:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml up --no-build --pull never --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Ожидаемый успех — `10 passed`, exit code 0. Сохраните вывод/код выхода и логи:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml logs --no-color > /root/miem-publication-integration/test.log
|
||||
```
|
||||
|
||||
**Следующая команда удаляет контейнеры, внутреннюю сеть и named volume именно
|
||||
этого теста; все его синтетические данные будут потеряны.** Перед ней сохраните
|
||||
отчёт. Она не затрагивает ресурсы других Compose-проектов:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml down --volumes
|
||||
```
|
||||
|
||||
Не используйте `--remove-orphans`, `docker system prune`, общие rm/rmi/volume prune
|
||||
или down рабочего проекта. Base-образы и образы теста оставьте: они могут быть
|
||||
общими для других проверок. Архив и журнал также не удаляются этой командой.
|
||||
Для подтверждения очистки повторите три read-only команды с project label:
|
||||
в выводе должны остаться только заголовки.
|
||||
|
||||
## Статус
|
||||
|
||||
VPS-стенд не запускался, Docker/Compose-проверка не выполнялась в среде
|
||||
подготовки из-за отсутствия Docker. PostgreSQL-интеграция ещё не подтверждена.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.10.2"
|
||||
version = "0.10.4"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM postgres:16-alpine
|
||||
COPY init.sql /docker-entrypoint-initdb.d/10-test.sql
|
||||
@@ -0,0 +1,4 @@
|
||||
**
|
||||
!init.sql
|
||||
!Dockerfile.postgres
|
||||
!Dockerfile.postgres.dockerignore
|
||||
@@ -0,0 +1,69 @@
|
||||
name: miem-publication-integration-vps
|
||||
|
||||
services:
|
||||
postgres-test:
|
||||
image: miem-publication-integration-postgres:pr55
|
||||
pull_policy: never
|
||||
environment:
|
||||
POSTGRES_USER: test_admin
|
||||
POSTGRES_PASSWORD: isolated_test_admin
|
||||
POSTGRES_DB: miem_import_test
|
||||
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=12"]
|
||||
cpus: 0.5
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
pids_limit: 128
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "1"
|
||||
networks: [isolated]
|
||||
|
||||
tests:
|
||||
image: miem-publication-integration-runner:pr55
|
||||
pull_policy: never
|
||||
user: "65534:65534"
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cpus: 0.5
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
pids_limit: 128
|
||||
tmpfs:
|
||||
- /tmp:size=128m,mode=1777
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test
|
||||
MIEM_POSTGRES_INTEGRATION: "1"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
PYTEST_ADDOPTS: "-p no:cacheprovider"
|
||||
command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"]
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "1"
|
||||
networks: [isolated]
|
||||
|
||||
networks:
|
||||
isolated:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
o: size=256m
|
||||
@@ -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
|
||||
@@ -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
@@ -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.4"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user