Files
company-ai-platform/tests/test_schema_reconciliation_postgres.py
JiuContinent eb8267ed18 ```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
2026-07-27 17:14:37 +08:00

282 lines
9.8 KiB
Python

import os
import re
from collections.abc import Iterator
from uuid import uuid4
from alembic import command
import pytest
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine, URL, make_url
from sqlalchemy.exc import ArgumentError, DBAPIError
from sqlalchemy.pool import NullPool
from app.core.config import get_settings
from app.tools import reconcile_platform_schema as schema_tool
TEST_DATABASE_ENV = "TEST_POSTGRES_DATABASE_URL"
TEST_SCHEMA_PATTERN = re.compile(r"caip_test_[0-9a-f]{32}\Z")
pytestmark = pytest.mark.skipif(
not os.getenv(TEST_DATABASE_ENV, "").strip(),
reason=f"{TEST_DATABASE_ENV} is not configured",
)
def _configured_postgres_url() -> URL:
raw_url = os.getenv(TEST_DATABASE_ENV, "").strip()
if not raw_url:
pytest.skip(f"{TEST_DATABASE_ENV} is not configured")
try:
url = make_url(raw_url)
except (ArgumentError, TypeError, ValueError):
pytest.fail(f"{TEST_DATABASE_ENV} is not a valid SQLAlchemy URL")
if url.get_backend_name() != "postgresql":
pytest.fail(f"{TEST_DATABASE_ENV} must use PostgreSQL")
return url
def _quoted_schema(engine: Engine, schema_name: str) -> str:
if not TEST_SCHEMA_PATTERN.fullmatch(schema_name):
raise RuntimeError("Refusing unsafe PostgreSQL test schema name")
return engine.dialect.identifier_preparer.quote_identifier(schema_name)
@pytest.fixture
def postgres_schema_engine(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[Engine]:
base_url = _configured_postgres_url()
schema_name = f"caip_test_{uuid4().hex}"
admin_engine = create_engine(base_url, poolclass=NullPool)
isolated_engine: Engine | None = None
schema_created = False
try:
quoted_schema = _quoted_schema(admin_engine, schema_name)
with admin_engine.begin() as connection:
connection.exec_driver_sql(f"CREATE SCHEMA {quoted_schema}")
schema_created = True
isolated_url = base_url.update_query_dict(
{"options": f"-csearch_path={schema_name}"}
)
isolated_engine = create_engine(isolated_url, poolclass=NullPool)
monkeypatch.setenv(
"DATABASE_URL",
isolated_url.render_as_string(hide_password=False),
)
get_settings.cache_clear()
with isolated_engine.connect() as connection:
assert connection.scalar(text("SELECT current_schema()")) == schema_name
yield isolated_engine
finally:
if isolated_engine is not None:
isolated_engine.dispose()
get_settings.cache_clear()
if schema_created:
quoted_schema = _quoted_schema(admin_engine, schema_name)
with admin_engine.begin() as connection:
connection.exec_driver_sql(
f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE"
)
admin_engine.dispose()
def _upgrade_to_previous(engine: Engine) -> None:
with engine.begin() as connection:
command.upgrade(
schema_tool._alembic_config(connection),
schema_tool.PREVIOUS_REVISION,
)
def _prepare_mixed_unversioned_baseline(engine: Engine) -> None:
_upgrade_to_previous(engine)
with engine.begin() as connection:
connection.execute(
text("ALTER TABLE projects DROP COLUMN department_code")
)
connection.execute(
text("CREATE TABLE approval_requests (id INTEGER PRIMARY KEY)")
)
connection.execute(text("DELETE FROM alembic_version"))
def test_postgres_mixed_baseline_applies_to_head_without_metadata_drift(
postgres_schema_engine: Engine,
) -> None:
_prepare_mixed_unversioned_baseline(postgres_schema_engine)
before = schema_tool.audit_engine(postgres_schema_engine)
assert before.eligible is True
assert (
before.postgresql_server_version_num
and before.postgresql_server_version_num
>= schema_tool.MINIMUM_POSTGRESQL_VERSION_NUM
)
assert before.postgresql_version_supported is True
assert before.revision_rows == ()
assert schema_tool.DriftKey(
"add_column",
"projects",
"department_code",
) in before.observed_drift
assert schema_tool.DriftKey(
"remove_table",
schema_tool.APPROVAL_TABLE,
"",
) in before.observed_drift
after = schema_tool.apply_baseline(
postgres_schema_engine,
before.fingerprint,
)
assert after.revision_rows == (schema_tool.TARGET_REVISION,)
assert after.observed_drift == frozenset()
assert after.unexpected_drift == frozenset()
assert schema_tool.APPROVAL_TABLE not in inspect(
postgres_schema_engine
).get_table_names()
def test_postgres_upgrade_failure_rolls_back_ddl_and_stamp(
postgres_schema_engine: Engine,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_upgrade_to_previous(postgres_schema_engine)
with postgres_schema_engine.begin() as connection:
connection.execute(text("DELETE FROM alembic_version"))
before = schema_tool.audit_engine(postgres_schema_engine)
def fail_upgrade(config, _revision: str) -> None:
connection = config.attributes["connection"]
connection.execute(
text("CREATE TABLE injected_upgrade_artifact (id INTEGER)")
)
connection.execute(
text("ALTER TABLE projects ADD COLUMN injected_upgrade_marker TEXT")
)
raise RuntimeError("injected PostgreSQL migration failure")
monkeypatch.setattr(schema_tool.command, "upgrade", fail_upgrade)
with pytest.raises(
RuntimeError,
match="injected PostgreSQL migration failure",
):
schema_tool.apply_baseline(
postgres_schema_engine,
before.fingerprint,
)
with postgres_schema_engine.connect() as connection:
after = schema_tool.audit_connection(connection)
table_names = inspect(connection).get_table_names()
project_columns = {
column["name"]
for column in inspect(connection).get_columns("projects")
}
assert after.fingerprint == before.fingerprint
assert after.revision_rows == ()
assert "injected_upgrade_artifact" not in table_names
assert "injected_upgrade_marker" not in project_columns
def test_postgres_apply_rejects_same_count_content_change(
postgres_schema_engine: Engine,
) -> None:
_upgrade_to_previous(postgres_schema_engine)
with postgres_schema_engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO feishu_event_receipts (
event_key,
source,
event_id,
message_id,
received_at
)
VALUES (
'postgres-content-key',
'webhook',
'event-before',
'message-stable',
'2026-07-27 00:00:00'
)
"""
)
)
connection.execute(text("DELETE FROM alembic_version"))
before = schema_tool.audit_engine(postgres_schema_engine)
with postgres_schema_engine.begin() as connection:
connection.execute(
text(
"""
UPDATE feishu_event_receipts
SET event_id = 'event-after'
WHERE event_key = 'postgres-content-key'
"""
)
)
changed = schema_tool.audit_engine(postgres_schema_engine)
assert changed.impacted_table_row_counts == before.impacted_table_row_counts
assert (
changed.impacted_table_content_digests
!= before.impacted_table_content_digests
)
assert changed.fingerprint != before.fingerprint
with pytest.raises(schema_tool.ReconciliationError, match="fingerprint changed"):
schema_tool.apply_baseline(
postgres_schema_engine,
before.fingerprint,
)
with postgres_schema_engine.connect() as connection:
assert schema_tool._revision_rows(connection) == ()
def test_postgres_advisory_transaction_lock_excludes_second_connection(
postgres_schema_engine: Engine,
) -> None:
with postgres_schema_engine.begin() as connection:
connection.execute(text("CREATE TABLE projects (id INTEGER PRIMARY KEY)"))
with (
postgres_schema_engine.connect() as first,
postgres_schema_engine.connect() as second,
):
first_transaction = first.begin()
second_transaction = second.begin()
try:
assert first.scalar(text("SELECT pg_backend_pid()")) != second.scalar(
text("SELECT pg_backend_pid()")
)
schema_tool._acquire_postgres_lock(first)
schema_tool._lock_impacted_postgres_tables(first)
second_acquired = second.scalar(
text("SELECT pg_try_advisory_xact_lock(:lock_key)"),
{"lock_key": schema_tool.ADVISORY_LOCK_KEY},
)
assert second_acquired is False
second.exec_driver_sql("SET LOCAL lock_timeout = '250ms'")
with pytest.raises(DBAPIError):
second.exec_driver_sql(
"LOCK TABLE projects IN ROW EXCLUSIVE MODE NOWAIT"
)
second_transaction.rollback()
first_transaction.commit()
second_transaction = second.begin()
second_acquired_after_release = second.scalar(
text("SELECT pg_try_advisory_xact_lock(:lock_key)"),
{"lock_key": schema_tool.ADVISORY_LOCK_KEY},
)
assert second_acquired_after_release is True
finally:
if first_transaction.is_active:
first_transaction.rollback()
if second_transaction.is_active:
second_transaction.rollback()