← Harvester

Тест-кейсы

harvester · workzone
Принятые решения
Что и чем проверяем Transform → Load одинаков для всех источников — его берёт детерминированный юнит на синтетических RawItem. Source-specific только Extract и normalize: их тестируем против записанных ответов и образов, а живьём — лишь вручную.
Три уровня по воспроизводимости Воспроизводимость растёт от живого источника к образу и к HTTP-фикстуре: живые free-tier — ручная отладка, вне CI; Docker-образы — интеграция отдельным, более редким шагом CI; HTTP-фикстуры (VCR.py / WireMock) — юнит, контракт и сквозной replay по источникам на каждом PR. В make test входит только фикстурный уровень; Docker-интеграция идёт отдельным шагом.
API-слой проверяет край, не повторяет домен HTTP-тесты держат границу: форма запроса и валидация схемы, коды ответов, маска секретов, авторизация на каждом роуте. Глубину поведения — замки прогона, каскады удаления, три шага Test Connection — держат доменные тесты выше; эндпоинт обязан лишь корректно отразить их наружу. Сквозной механикой RBAC / JWT / CORS владеет Auth & Security, здесь — только что роуты Harvester за этим guard.
Контракт-кейсы — и kit для автора коннектора Тот же набор, которым платформа проверяет встроенные коннекторы (test_connector_contract + test_manifest_registry), автор внешнего коннектора прогоняет против своего класса — проверка соответствия контракту до поставки. Контракт до 1.0 не заморожен: kit меняется вместе с ним.
Стек и инфраструктура
Имеется done pytest, pytest-asyncio, pytest-cov, httpx
Фикстуры VCR.py (запись / воспроизведение HTTP) + WireMock (нештатные коды, таймауты, искусственные стыки пагинации)
Образы testcontainers — Gitea / GitLab CE для регулярного CI, Atlassian DC под номерной прогон; засев скриптом при старте
Прочее factory_boy (RawItem / Entity), time-machine (watchdog, freshness-бюджет), SAQ-обработчик вызовом напрямую — без поднятия воркера
Маркеры @pytest.mark.unit / @pytest.mark.integration — по типу; @pytest.mark.p0 / p1 — по приоритету, ортогонально типу
Unit Конвейер без источника — синтетические RawItem детерминированно · каждый PR
test_transform.py
Шаги трансформации на сконструированных RawItem
Кейсы
filter — встроенные правилапустые сущности, системные события, боты отсеиваются; базовые правила не настраиваются
filter — манифестпереопределения и доп-включения берутся из content_filters манифеста
classify → статуссущности проставляется draft / final / archived
resolve — дубли в прогонесхлопывание дублей внутри одного прогона + выбор победителя версии; межпрогонная идемпотентность — НЕ здесь
enrich — связи и refsребро строится сразу, когда целевой узел уже загружен; иначе (чужой источник или forward) — заявкой refs, ребро не материализуется
clean — копия под эмбеддингчистится после enrich и только копия; оригинал в Entity нетронут
chunk — фрагментыодна Entity → много фрагментов; механики structural / recursive / overlap
embed — модель и кешкаждый фрагмент → вектор моделью из реестра AI; кеш по контент-хешу, неизменные не переэмбеддятся
embed — назначение обязательнобез назначенной embedding-функции приём не стартует (дефолта нет); назначена встроенная модель платформы → приём идёт, фрагменты эмбеддятся ею
test_load.py
Идемпотентность загрузки и проекции
Unit → Load
Кейсы
upsert — один ключпо source_id + source_type + source_entity_id ровно одна строка; повторный прогон обновляет, не плодит дубли
синхронизация фрагментовнабор векторов сверяется целиком: устаревшие удалены, новые добавлены, осиротевших нет
неизменный фрагментвектор по контент-хешу — не переэмбеддится при повторе
три проекцииодна Entity → relational + vector + graph (раскладка полей — за Knowledge Store)
ACL фрагментафрагмент наследует ACL сущности, своего не имеет
удаление — отдельная осьпропавшая в источнике запись получает маркер удаления; статус классификации (draft / final / archived) сохраняется как был — скрыта из выдачи, физически хранится
восстановлениемаркер снят (запись вернулась / удаление было ошибкой) → прежний статус возвращается сам, без переклассификации
Contract Коннекторы — единый интерфейс на HTTP-фикстурах VCR.py + WireMock · каждый PR
test_connector_contract.py
fetch + normalize против записанных ответов
Кейсы
контракт — 4 методаконнектор реализует fetch · normalize · list_catalog · check_connection; здесь — ядро конвейера fetch + normalize, каталог и проба под api/scope-тестами
форма выдачиfetch отдаёт поток RawItem с нагрузкой, идентификаторами, метаданными сбора
инкремент по sinceFull → since = epoch; Incremental → момент прошлого прогона
пагинация по курсоруlisting → page → emit; есть следующий курсор → назад к listing
пустой / последний ответнет курсора → цикл завершается чисто, без лишнего запроса
сбой запроса (WireMock)таймаут / 429 на странице → повтор с backoff, цикл не рвётся
normalize → Entityединственный source-specific шаг трансформации даёт source_id + source_type + source_entity_id, тип, статус
test_manifest_registry.py
Манифест декларирует, реестр саморегистрирует
Кейсы
форма из манифестаUI строит форму подключения из объявленных config-полей и видов credential — фронт не знает про конкретный коннектор
не в формуrate_limit (стартовый темп) и webhook (поведение) в форму не уходят; из webhook в UI — только сам секрет
саморегистрацияконнектор регистрируется через @register / discovery; реестр — единственный источник истины о типах
внешний пакетконнектор из установленного пакета с entry point achilles.connectors встаёт в реестр как встроенный — discovery не различает дом класса
новый типдобавить тип = добавить класс, без правок мастера / фронта / диспетчера
диспетчер по типудиспетчер поднимает коннектор по connector_type
Replay · P0 Сквозной конвейер на кассетах — по типам источников VCR.py · офлайн · каждый PR
test_pipeline_replay.py
Полный E→T→L поверх записанных ответов, эталон по источнику
ReplayP0 → Extract
Кейсы
параметризация по типуодин прогон на каждый встроенный тип — Jira / GitLab / Slack — из своей закоммиченной кассеты
весь конвейер офлайнfetch из кассеты → Transform → Load без сети и без образа; сквозное покрытие там, где Docker даёт лишь Git-семейство (у Slack/Jira образа для регулярного CI нет)
эталон сущностейчисло, типы и ключи сущностей сверяются с golden-снимком; расхождение — провал, не warning
детерминизм прогонафиксированный сид чанков и эмбеддинга → повтор бит-в-бит, дифф читаем в ревью
перезапись кассетыдрейф API чинится перезаписью из manual/; кассета — закоммиченный файл, в CI сети не трогает и не «отваливается»
Integration · P0 Сквозной конвейер против образа Docker · отдельный шаг CI
test_pipeline_e2e.py
Extract → Transform → Load против засеянного источника
IntegrationP0 → Extract
Кейсы
полный путьпрогон против образа с фиксированным сидом → сущности в трёх проекциях
соответствие сидучисло и типы сущностей совпадают с засеянным набором
повтор идемпотентенвторой полный прогон не плодит дубли (Load по ключу)
инкремент берёт дельтупосле правки в источнике Incremental подхватывает только изменённое по since
векторы сохраненыфрагменты и эмбеддинги персистятся, ключ vector-проекции — id сущности
Integration · P1 Надёжность, режимы, lifecycle, канал, ACL
test_reliability.py
SyncRun, checkpoint, ретраи, DLQ
Кейсы
состояния прогонаqueued / running / succeeded / failed / cancelled + progress, heartbeat
checkpoint resumeфиксация после порции → resume с последнего чекпоинта, недописанная страница без дублей
heartbeat (time-machine)интервал 30 с; падение = молчание после 3 пропусков (90 с)
freshness-бюджетразрыв > 6 ч → чекпоинт отброшен, прогон с нуля; cancelled не возобновляется
retry → DLQbackoff на обрыв / таймаут / 429 / 5xx; после исчерпания item уходит в dead_letters, не теряется
перманентная → сразу в DLQ400 / 401 / 404 / 422 и ошибки нормализации — повтор бессмыслен, элемент уходит в dead_letters без ретраев
транзиентная → ретрайвсплеск нагрузки / кратковременная недоступность — ретрай с backoff; класс ошибки решает, не буква кода
коннектор переопределяет класс403 у GitHub / GitLab — чаще rate-limit: по Retry-After / X-RateLimit-Remaining коннектор трактует его транзиентным, а не перманентным
два уровня ретраевкороткая задержка (≤ 60 с) — in-memory удержание страницы в задаче; долгая пауза (Retry-After в минуты-часы) → SAQ defer / re-enqueue, слот воркера освобождён, не sleep
частичный отказпрогон succeeded с error_count > 0 — отличается от failed
уведомление о провалеfailed-прогон поднимает уведомление; выбор канала — за модулем уведомлений, Harvester лишь сигналит
один активный прогонpartial unique index UNIQUE (source_id) WHERE state IN ('queued','running')
дедуп DLQтот же item обновляет строку (attempts, reason), не плодит дубль; обработанный — удаляется
test_sync_modes.py
Полная, инкрементальная, сверка, восстановление
IntegrationP1 → Full Sync
Кейсы
Full Syncsince = epoch, проходит каждую сущность; повтор не плодит дубли
Incrementalwebhooks + polling по since — рабочий режим после первичной загрузки
Reconciliationсверка целиком, агрессивный resolve, чистка исчезнувшего
детекция удаления mode-специфичнаIncremental не ставит маркер исчезновения (удаление в дельту не приходит); Reconciliation полным сканом помечает пропавшее
partial_resync (12 ч)молчание > 12 ч → дешёвый polling → дельта есть → пересбор окна, авто, без человека
dlq_retry — ручнойточечный повтор по identity из очереди после починки; обработанные уходят из DLQ
per-sourceкаждый источник держит свой since / график / режим; «синхронизировать всё» = запуск всех разом
test_source_lifecycle.py
Состояния, замок прогона, отмена, удаление
Кейсы
две оси независимыstate (active/paused/disconnected) — намерение; health (idle/syncing/error) — вычисляется
замок на syncingправка / пауза / отключение / удаление недоступны; единственный рычаг — Cancel
Cancel безопасеностанов на ближайшем чекпоинте, отката не требует (Load идемпотентен)
Pause / DisconnectPause глушит расписание, не живой прогон; Disconnect снимает креды, данные и конфиг целы
удаление — два режима«только конфигурация» (данные осиротевают) либо «конфигурация + данные» (type-to-confirm, каскад, необратимо)
Test Connection — 3 шагаURL доступен → креды валидны → прав достаточно; ошибки раздельные, провал плановой пробы → health error
test_webhook_security.py
Подлинность канала, свежесть, анти-replay
IntegrationP1 → Webhook'и
Кейсы
подлинностьHMAC над телом (GitHub / Slack), статичный токен в заголовке (GitLab), fallback — секретный endpoint
верификатор по провайдерупараметризация подключаемого верификатора по 4 провайдерам, включая Atlassian (JWT Connect-приложения); ядро прогоняет единый порядок свежесть → подпись → дедуп
не подтверждена → отказподделанная подпись / токен → вызов отклонён
свежесть меткигде есть timestamp (Slack, GitLab) — вызов старше окна 5 мин отброшен до подписи; у источника без метки (GitHub, Atlassian) шаг пропускается, анти-replay целиком несёт дедуп (TTL окна больше — 24 ч)
анти-replayпо timestamp (Slack) / delivery-id (GitHub) — повтор отбрасывается (TTL-память в Redis)
только TLSканал терминируется reverse-proxy по TLS
всплеск → уведомлениеотказы в лог; всплеск → уведомление типа Security
test_rate_limit.py
Адаптивный темп per-scope, сигналы провайдера, cost-units
IntegrationP1 → Rate limiting
Кейсы
стартовый темп нащупывает границустарт с темпа из манифеста; при спокойных ответах AIMD-разгон +10% за окно — темп нащупывает реальную границу вверх, не только откатывается по 429
сигнал провайдера сильнее AIMDRetry-After — жёсткая пауза на весь scope, перекрывает адаптивный темп; уважают все воркеры
упреждение по RemainingX-RateLimit-Remaining мал → тормозим заранее: safe_rps = Remaining / (Reset − now), берём min с темпом AIMD
учёт по стоимостилимитер списывает единицы стоимости (cost-units), не число запросов; сколько единиц стоит запрос — объявляет коннектор (по умолчанию 1)
ключ по rate_limit_scopeключ лимитера строится по rate_limit_scope манифеста (tenant / account_token / workspace_method / site); два источника на одном токене делят бюджет
темп переживает прогонывыученная ёмкость API не сбрасывается между прогонами — живёт в Redis с TTL в часы, переживает рестарт воркера
test_acl_identity.py
ACL терминами источника, identity по email
Кейсы
ACL в терминах источникапроект / пространство / канал сохраняются без маппинга на платформенные уровни
двусторонний захваттег группы на сущности + членство в группе
полный импорт людейпри подключении источника люди импортируются целиком; incremental подхватывает новых
identity по точному emailсвод людей в личность по совпадению email; несовпавшие → ручной разбор в Admin, Harvester не угадывает
мост к users при upsertupsert identity при существующем users того же lower(email)identity.user_id проставляется; нет users → NULL
нечёткий свод — не здесьразные email → разные личности; «тот же человек под разными email» Harvester не склеивает — deep resolution за Knowledge Store
синхронизация правотдельного механизма нет: Incremental точечно, Reconciliation полной сверкой
отзыв eventually consistentотозванное видимо до ближайшего incremental / resync / reconciliation — принятый компромисс
применение — за Query Enginepre-filter на выдаче не здесь; Harvester только сохраняет ACL
Integration · P1 Секреты, контрольный слой БД, scope, расписание
test_secrets.py
Credential at-rest: шифрование, расшифровка по требованию, маска
IntegrationP1 → Секреты
Кейсы
шифрование at-restcredential_enc / webhook_secret_enc хранятся зашифрованными AES-256-GCM — не хэш, нужна обратимость
через крипто-ядро AuthHarvester не держит своего хранилища секретов; шифрование / расшифровка — вызовом крипто-ядра Auth & Security
расшифровка по требованиюоткрытый секрет восстанавливается только в момент запроса к источнику, в покое не держится
наружу — только маскав API / экспорт / лог уходит маска; открытое значение не утекает
Disconnect снимает секретcredential_enc обнуляется, данные и конфиг источника целы
test_data_model.py
Контрольный слой: каскады, констрейнты, выводимые поля
Кейсы
каскад от источникаDELETE sourcessync_runs и dead_letters уходят по ON DELETE CASCADE
run_id → SET NULLчистка журнала прогонов обнуляет dead_letters.run_id, строка очереди живёт — разный ondelete, легко перепутать
CHECK-полянедопустимое значение перечислимой колонки под CHECK (state, mode, trigger, scope_mode, reason, auth_method, last_probe_status, …) отвергается БД
открытый набор типовна connector_type CHECK нет — валидирует манифест, не БД
здоровье выводитсяв БД только state; idle / syncing / error деривируются из прогона + пробы, колонкой не хранятся
тип прогона выводитсяpartial_resync / dlq_retry — пара (mode + trigger + scope), отдельной колонки нет
дефолты и триггерscope_list [], content_filters {}, error_count 0, attempts 1; created_at / updated_at server_default + триггер
test_scope.py
Режим выборки: allow / deny, политика, не снимок
Кейсы
два режима«Всё» (deny-list, новые объекты подхватываются авто) / «Только выбранное» (allow-list, новые остаются снаружи)
политика, не снимокscope сверяется с каталогом источника на каждой синхронизации, не фиксируется однажды
каталог после Test Connectionсписок объектов строится после шага 2 (креды приняты)
право на листингучётке нужно листать инстанс целиком даже при суженной выборке
test_scheduling.py
Расписание, наследование, веер, watchdog
IntegrationP1 → Расписание
Кейсы
разрешение расписанияper-source sync_interval / reconcile_interval = NULL → наследует глобальный дефолт; задан → идёт по своему
reconciliation по графикусуточный / недельный прогон сверки запускается планировщиком, не вручную
health-check по расписаниюоблегчённая проба (шаги 1–2 Test Connection) между синхронизациями; провал → health error + уведомление
watchdog по таймерумолчание > 12 ч → watchdog сам поднимает partial_resync, без человека
«синхронизировать всё» = веерзапуск всех per-source разом; независимые источники бегут в running параллельно — partial index по source_id, не глобальный
API · P1 Эндпоинты источников — HTTP-контракт httpx · ASGI-приложение · БД · фейковый коннектор · → HTTP API · → Conformance
test_api_sources.py
Создание, чтение, правка источника по HTTP
Кейсы
созданиевалидная форма → 201 + тело с id; следом авто Full Sync (trigger=connect)
форма из манифестаполя и виды credential валидируются по манифесту коннектора; лишнее / чужое поле → 422 VALIDATION_ERROR
CHECK наружу как 4xxнедопустимый auth_method / scope_mode, пустое name422, не 500
тип вне реестранеизвестный connector_type422 (сверка с реестром, не enum БД)
чтениесписок и деталь → state + выведенный health + последний прогон; счётчик сущностей из Knowledge Store
маска секретаGET / list отдают credential и webhook под маской; открытое значение — только в ответе на создание
правка конфигаPATCH частично; смена credential → перешифровка, наружу новая маска, не значение
нет ресурсанесуществующий id → 404 NOT_FOUND
health — лёгкая пробаGET /healthstate + вычисленный health (idle / syncing / error) + last_probe_status, без полной детали источника — дешёвый эндпоинт для частого поллинга
test_api_lifecycle.py
Действия жизненного цикла и замок прогона
Кейсы
Pause / Resumestate paused / active; повтор идемпотентен, не ошибка
Disconnect / ReconnectDisconnect → disconnected, маска секрета пустеет; Reconnect переввод кредов без настройки заново
удаление — только конфигурация204, данные осиротевают в Knowledge Store
удаление — конфигурация + данныетребует type-to-confirm в теле, иначе 422; ответ помечает необратимый каскад
Cancel202, прогон уходит в cancelled на ближайшем чекпоинте
замок single-flightпод syncing правка / пауза / отключение / удаление / повторный запуск → 409 CONFLICT; проходит только Cancel
test_api_test_connection.py
Проба связи и каталог объектов
Кейсы
черновик и существующийпроба на конфиге из мастера и по id источника → 200 с поэтапным результатом
3 шага раздельноURL недоступен / креды невалидны / прав мало → разные машинные коды шага, не общий 500
шаг 2 проваленшаг 3 в ответе помечен «не проверялся»
каталог после шага 2GET каталога объектов доступен только после успешных кредов; до — 409 / пусто
плановая проба делит контрактфоновый health-check пишет last_probe_status (ok / unreachable / auth_failed) и роняет health error — та же логика, что ручная
test_api_sync.py
Запуск прогонов, веер, чтение журнала и DLQ
Кейсы
ручной запускmode + scope в теле → 202 + id прогона; создан sync_run (trigger=manual, queued), задача в SAQ
запуск под замкомисточник уже syncing409 CONFLICT (single-flight)
синхронизировать всё202 веером: стартуют только Active; Paused / Disconnected / уже-syncing помечены пропущенными; нечего запускать → пустой веер
dlq_retryscope = конкретные dead_letters, не окно since; обработанные уходят из DLQ
история прогоновGET журнала sync_runs с прогрессом и исходом; succeeded с error_count > 0 отдаётся отлично от failed
просмотр DLQGET → COUNT + группировка по reason
авто-режимы наружу закрытыручной reconciliation / partial_resync не выставлены → 404 / 405
API · P1 Контроль доступа к эндпоинтам
test_api_access.py
Роли, аутентификация, маска — на каждом роуте
Кейсы
Owner / Adminуправление источниками → 2xx
Memberлюбой источниковый эндпоинт → 403 FORBIDDEN
анонимбез токена → 401 UNAUTHORIZED (параметризовано по всем роутам)
истёкший токенaccess JWT старше 15 мин → 401
нет «голых» роутовкаждый write-эндпоинт реально за require(permission) — параметризованный аудит покрытия guard
API-ключ — только чтениеread-only ключ на write-операцию → отказ; шире прав владельца не бывает
webhook вне сессиипубличный приём — подписанный канал, не JWT; его контракт держит test_webhook_security
Manual Живые free-tier источники — вне CI ручная отладка · источник истины для фикстур

Бесплатные тарифы реальных SaaS с насеянными sample-данными. Назначение — ручная отладка нового коннектора вживую: видно фактическое поведение API, формы ответов, особенности пагинации и прав, которые не воспроизвести моком. Нестабильны, требуют токенов и сети — в автоматический прогон не входят; служат источником истины при записи HTTP-фикстур (токены вычищаются при записи).

Atlassian Atlassian Cloud free — Jira + Confluence через atlassian-python-api; реальная модель прав и пагинация
Slack бесплатный workspace через slack-sdk — каналы, треды, rate-limit вживую
GitLab gitlab.com + насеянный проект через python-gitlab — issues, MR, wiki
Structure Файловая структура тестов

Разделение задаётся не папками, а назначением. unit/, contract/ и replay/ входят в make test и идут на каждом PR (детерминированно). integration/ и api/ — отдельным, более редким шагом: первый на Docker-образах, второй на поднятом приложении и БД. Живые источники остаются ручным инструментом отладки и в автоматический прогон не попадают. Приоритет (P0–P1) ортогонален каталогам и задаётся маркерами (pytest -m p0).

  • tests/harvester/каталог модуля
    • conftest.pyHTTP-фикстуры VCR.py · WireMock · фабрики RawItem/Entity · SAQ напрямую
    • unit/конвейер на синтетических RawItem
      • test_transform.py · test_load.pyшаги трансформации, идемпотентность
    • contract/HTTP-фикстуры — единый интерфейс коннектора
      • test_connector_contract.py · test_manifest_registry.pyfetch/normalize, манифест/реестр
    • replay/сквозной E→T→L на кассетах, по типам источников
      • test_pipeline_replay.pyполный конвейер офлайн, эталон сущностей
    • integration/Docker-образы + БД
      • test_pipeline_e2e.pyсквозной E→T→L
      • reliability · sync-modes · source-lifecycle · webhook-security · rate-limit · acl-identityнадёжность, режимы, lifecycle, канал, темп, ACL
      • secrets · data-model · scope · schedulingсекреты at-rest, каскады/констрейнты, выборка, расписание
    • api/HTTP-контракт эндпоинтов — httpx + ASGI + БД, фейковый коннектор
      • test_api_sources · _lifecycle · _test_connection · _syncCRUD, действия, проба, запуск
      • test_api_access.pyроли, аутентификация, маска на каждом роуте
    • manual/вне CI — живые free-tier источники