feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
627 lines
20 KiB
Python
627 lines
20 KiB
Python
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
import pytest
|
|
from sqlalchemy import create_engine, inspect, text
|
|
from sqlalchemy.dialects import postgresql, sqlite
|
|
from sqlalchemy.schema import CreateTable
|
|
|
|
from app.core.config import get_settings
|
|
from app.modules.ai_memory.models import AIMemoryEntry
|
|
from app.modules.business.models import MarketWatchlist
|
|
from app.tools import reconcile_platform_schema as schema_tool
|
|
|
|
|
|
def _database(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
name: str,
|
|
revision: str,
|
|
):
|
|
database_path = tmp_path / f"{name}.db"
|
|
database_url = f"sqlite:///{database_path.as_posix()}"
|
|
monkeypatch.setenv("DATABASE_URL", database_url)
|
|
get_settings.cache_clear()
|
|
config = Config("alembic.ini")
|
|
command.upgrade(config, revision)
|
|
return create_engine(database_url), config
|
|
|
|
|
|
def _remove_revision(engine) -> None:
|
|
with engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM alembic_version"))
|
|
|
|
|
|
def test_remote_drift_allowlist_snapshot_is_explicit() -> None:
|
|
operation_counts: dict[str, int] = {}
|
|
for item in schema_tool.ALLOWED_DRIFT:
|
|
operation_counts[item.operation] = operation_counts.get(item.operation, 0) + 1
|
|
|
|
assert operation_counts == {
|
|
"add_column": 61,
|
|
"add_constraint": 2,
|
|
"add_fk": 2,
|
|
"add_index": 47,
|
|
"add_table": 2,
|
|
"modify_default": 8,
|
|
"modify_nullable": 1,
|
|
"remove_constraint": 3,
|
|
"remove_index": 9,
|
|
"remove_table": 1,
|
|
}
|
|
|
|
clean_005_only = {
|
|
schema_tool.DriftKey(
|
|
"remove_constraint",
|
|
"ai_memory_entries",
|
|
"uq_ai_memory_owner_fingerprint",
|
|
),
|
|
schema_tool.DriftKey(
|
|
"remove_constraint",
|
|
"market_watchlists",
|
|
"uq_market_watchlist_owner_symbol",
|
|
),
|
|
schema_tool.DriftKey(
|
|
"modify_nullable",
|
|
"work_tasks",
|
|
"source_system",
|
|
),
|
|
*{
|
|
schema_tool.DriftKey("modify_default", table_name, column_name)
|
|
for table_name, columns in schema_tool._MODIFY_DEFAULTS.items()
|
|
for column_name in columns
|
|
},
|
|
}
|
|
assert len(schema_tool.ALLOWED_DRIFT - clean_005_only) == 125
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("table", "constraint_name"),
|
|
[
|
|
(AIMemoryEntry.__table__, "uq_ai_memory_owner_fingerprint"),
|
|
(MarketWatchlist.__table__, "uq_market_watchlist_owner_symbol"),
|
|
],
|
|
)
|
|
def test_owner_unique_constraints_use_postgres_nulls_not_distinct(
|
|
table,
|
|
constraint_name: str,
|
|
) -> None:
|
|
constraint = next(
|
|
item for item in table.constraints if item.name == constraint_name
|
|
)
|
|
|
|
assert (
|
|
constraint.dialect_options["postgresql"]["nulls_not_distinct"] is True
|
|
)
|
|
postgres_ddl = str(CreateTable(table).compile(dialect=postgresql.dialect()))
|
|
sqlite_ddl = str(CreateTable(table).compile(dialect=sqlite.dialect()))
|
|
assert "UNIQUE NULLS NOT DISTINCT" in postgres_ddl
|
|
assert "NULLS NOT DISTINCT" not in sqlite_ddl
|
|
|
|
|
|
def test_clean_005_upgrades_to_head_with_zero_metadata_drift(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, config = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"clean-upgrade",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
command.upgrade(config, "head")
|
|
with engine.connect() as connection:
|
|
audit = schema_tool.audit_connection(connection)
|
|
|
|
assert audit.revision_rows == (schema_tool.TARGET_REVISION,)
|
|
assert audit.observed_drift == frozenset()
|
|
assert "approval_requests" not in inspect(engine).get_table_names()
|
|
command.check(config)
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_baseline_stamps_and_upgrades_in_one_external_transaction(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, _ = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"baseline-success",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("CREATE TABLE approval_requests (id INTEGER PRIMARY KEY)")
|
|
)
|
|
_remove_revision(engine)
|
|
before = schema_tool.audit_engine(engine)
|
|
|
|
assert before.eligible is True
|
|
assert schema_tool.DriftKey(
|
|
"remove_table",
|
|
"approval_requests",
|
|
"",
|
|
) in before.observed_drift
|
|
|
|
after = schema_tool.apply_baseline(
|
|
engine,
|
|
before.fingerprint,
|
|
require_postgresql=False,
|
|
)
|
|
|
|
assert after.revision_rows == (schema_tool.TARGET_REVISION,)
|
|
assert after.observed_drift == frozenset()
|
|
assert "approval_requests" not in inspect(engine).get_table_names()
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_baseline_failure_rolls_back_atomic_stamp(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, _ = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"baseline-rollback",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
_remove_revision(engine)
|
|
before = schema_tool.audit_engine(engine)
|
|
|
|
def fail_upgrade(*_args, **_kwargs) -> None:
|
|
raise RuntimeError("injected migration failure")
|
|
|
|
monkeypatch.setattr(schema_tool.command, "upgrade", fail_upgrade)
|
|
with pytest.raises(RuntimeError, match="injected migration failure"):
|
|
schema_tool.apply_baseline(
|
|
engine,
|
|
before.fingerprint,
|
|
require_postgresql=False,
|
|
)
|
|
|
|
with engine.connect() as connection:
|
|
revisions = tuple(
|
|
connection.execute(
|
|
text("SELECT version_num FROM alembic_version")
|
|
).scalars()
|
|
)
|
|
assert revisions == ()
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_baseline_rejects_changed_fingerprint_before_stamp(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, _ = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"baseline-fingerprint",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
_remove_revision(engine)
|
|
with pytest.raises(schema_tool.ReconciliationError, match="fingerprint changed"):
|
|
schema_tool.apply_baseline(
|
|
engine,
|
|
"0" * 64,
|
|
require_postgresql=False,
|
|
)
|
|
with engine.connect() as connection:
|
|
assert schema_tool._revision_rows(connection) == ()
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_preflight_fingerprint_includes_full_server_default_expression() -> None:
|
|
engine = create_engine("sqlite://")
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE projects (
|
|
id INTEGER PRIMARY KEY,
|
|
lifecycle_state TEXT DEFAULT 'draft'
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
with engine.connect() as connection:
|
|
first = schema_tool._schema_fingerprint(connection)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(text("DROP TABLE projects"))
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE projects (
|
|
id INTEGER PRIMARY KEY,
|
|
lifecycle_state TEXT DEFAULT 'active'
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
with engine.connect() as connection:
|
|
second = schema_tool._schema_fingerprint(connection)
|
|
|
|
assert first != second
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_preflight_fingerprint_binds_impacted_row_counts_without_data() -> None:
|
|
engine = create_engine("sqlite://")
|
|
private_value = "must-not-appear-in-reconciliation-output"
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE approval_requests (
|
|
id INTEGER PRIMARY KEY,
|
|
private_payload TEXT
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
before = schema_tool.audit_engine(engine)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO approval_requests (id, private_payload)
|
|
VALUES (1, :private_payload)
|
|
"""
|
|
),
|
|
{"private_payload": private_value},
|
|
)
|
|
after = schema_tool.audit_engine(engine)
|
|
|
|
assert before.fingerprint != after.fingerprint
|
|
assert dict(before.impacted_table_row_counts) == {"approval_requests": 0}
|
|
assert dict(after.impacted_table_row_counts) == {"approval_requests": 1}
|
|
public_payload = after.public_dict()
|
|
assert public_payload["impacted_table_row_counts"] == {
|
|
"approval_requests": 1
|
|
}
|
|
assert "impacted_table_content_digests" not in public_payload
|
|
assert private_value not in json.dumps(public_payload)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_content_fingerprint_is_order_stable_and_detects_same_count_change() -> None:
|
|
engine = create_engine("sqlite://")
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE projects (
|
|
project_code TEXT,
|
|
private_payload TEXT
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO projects (project_code, private_payload)
|
|
VALUES ('P-1', 'first'), ('P-2', 'second')
|
|
"""
|
|
)
|
|
)
|
|
with engine.connect() as connection:
|
|
first = schema_tool._schema_fingerprint(connection)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM projects"))
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO projects (project_code, private_payload)
|
|
VALUES ('P-2', 'second'), ('P-1', 'first')
|
|
"""
|
|
)
|
|
)
|
|
with engine.connect() as connection:
|
|
reordered = schema_tool._schema_fingerprint(connection)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE projects
|
|
SET private_payload = 'changed'
|
|
WHERE project_code = 'P-1'
|
|
"""
|
|
)
|
|
)
|
|
with engine.connect() as connection:
|
|
changed = schema_tool._schema_fingerprint(connection)
|
|
|
|
assert reordered == first
|
|
assert changed != first
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_apply_rejects_same_row_count_content_change_after_dry_run(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, _ = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"baseline-content-fingerprint",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO feishu_event_receipts (
|
|
event_key,
|
|
source,
|
|
event_id,
|
|
message_id,
|
|
received_at
|
|
)
|
|
VALUES (
|
|
'legacy-content-key',
|
|
'webhook',
|
|
'event-before',
|
|
'message-stable',
|
|
'2026-07-27 00:00:00'
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
_remove_revision(engine)
|
|
before = schema_tool.audit_engine(engine)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE feishu_event_receipts
|
|
SET event_id = 'event-after'
|
|
WHERE event_key = 'legacy-content-key'
|
|
"""
|
|
)
|
|
)
|
|
changed = schema_tool.audit_engine(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(
|
|
engine,
|
|
before.fingerprint,
|
|
require_postgresql=False,
|
|
)
|
|
with engine.connect() as connection:
|
|
assert schema_tool._revision_rows(connection) == ()
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("database_url", "legacy_database_url"),
|
|
[
|
|
("sqlite:///local-platform.db", None),
|
|
(
|
|
"postgresql://platform@database/platform",
|
|
"postgresql+psycopg://legacy@database:5432/platform",
|
|
),
|
|
],
|
|
)
|
|
def test_cli_dry_run_rejects_unsafe_target_before_engine_creation(
|
|
database_url: str,
|
|
legacy_database_url: str | None,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
engine_creation_count = 0
|
|
|
|
def record_engine_creation(*_args, **_kwargs):
|
|
nonlocal engine_creation_count
|
|
engine_creation_count += 1
|
|
raise AssertionError("unsafe target reached create_engine")
|
|
|
|
monkeypatch.setattr(
|
|
schema_tool,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(
|
|
database_url=database_url,
|
|
legacy_database_url=legacy_database_url,
|
|
),
|
|
)
|
|
monkeypatch.setattr(schema_tool, "create_engine", record_engine_creation)
|
|
monkeypatch.setattr("sys.argv", ["reconcile_platform_schema"])
|
|
|
|
with pytest.raises(SystemExit) as exit_info:
|
|
schema_tool.main()
|
|
|
|
assert exit_info.value.code == 2
|
|
assert engine_creation_count == 0
|
|
output = json.loads(capsys.readouterr().out)
|
|
assert output["applied"] is False
|
|
assert "error" in output
|
|
assert database_url not in output["error"]
|
|
assert legacy_database_url is None or legacy_database_url not in output["error"]
|
|
|
|
|
|
def test_postgres_below_15_is_ineligible_and_apply_precondition_rejects() -> None:
|
|
audit = schema_tool.BaselineAudit(
|
|
dialect="postgresql",
|
|
fingerprint="a" * 64,
|
|
impacted_table_row_counts=(),
|
|
impacted_table_content_digests=(),
|
|
postgresql_server_version_num=140012,
|
|
postgresql_version_supported=False,
|
|
revision_rows=(),
|
|
observed_drift=frozenset(),
|
|
unexpected_drift=frozenset(),
|
|
approval_rows=None,
|
|
approval_inbound_foreign_keys=0,
|
|
schema_privileges_ok=True,
|
|
table_ownership_ok=True,
|
|
)
|
|
|
|
assert audit.eligible is False
|
|
assert audit.public_dict()["postgresql_server_version_num"] == 140012
|
|
assert audit.public_dict()["postgresql_version_supported"] is False
|
|
with pytest.raises(schema_tool.ReconciliationError, match="PostgreSQL 15"):
|
|
schema_tool._validate_apply_preconditions(audit, audit.fingerprint)
|
|
|
|
|
|
def test_nonempty_approval_table_is_never_removed(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, _ = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"baseline-nonempty-approval",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("CREATE TABLE approval_requests (id INTEGER PRIMARY KEY)")
|
|
)
|
|
connection.execute(text("INSERT INTO approval_requests (id) VALUES (1)"))
|
|
_remove_revision(engine)
|
|
before = schema_tool.audit_engine(engine)
|
|
|
|
assert before.eligible is False
|
|
assert before.approval_rows == 1
|
|
with pytest.raises(schema_tool.ReconciliationError, match="not empty"):
|
|
schema_tool.apply_baseline(
|
|
engine,
|
|
before.fingerprint,
|
|
require_postgresql=False,
|
|
)
|
|
with engine.connect() as connection:
|
|
assert connection.scalar(
|
|
text("SELECT COUNT(*) FROM approval_requests")
|
|
) == 1
|
|
assert schema_tool._revision_rows(connection) == ()
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_reconciliation_downgrade_is_fail_closed_and_irreversible(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, config = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"irreversible-downgrade",
|
|
"202607270001",
|
|
)
|
|
try:
|
|
with engine.connect() as connection:
|
|
before = schema_tool._schema_fingerprint(connection)
|
|
|
|
with pytest.raises(RuntimeError, match="irreversible"):
|
|
command.downgrade(config, schema_tool.PREVIOUS_REVISION)
|
|
|
|
with engine.connect() as connection:
|
|
after = schema_tool._schema_fingerprint(connection)
|
|
revisions = schema_tool._revision_rows(connection)
|
|
assert after == before
|
|
assert revisions == ("202607270001",)
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_reconciliation_backfills_required_legacy_values(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine, config = _database(
|
|
tmp_path,
|
|
monkeypatch,
|
|
"required-backfill",
|
|
schema_tool.PREVIOUS_REVISION,
|
|
)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("ALTER TABLE work_tasks DROP COLUMN source_system")
|
|
)
|
|
connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO work_tasks (
|
|
code,
|
|
title,
|
|
status,
|
|
priority,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
'legacy-task',
|
|
'Legacy task',
|
|
'todo',
|
|
'P2',
|
|
'2026-07-27 00:00:00',
|
|
'2026-07-27 00:00:00'
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
|
|
command.upgrade(config, "head")
|
|
|
|
with engine.connect() as connection:
|
|
source_system = connection.scalar(
|
|
text(
|
|
"""
|
|
SELECT source_system
|
|
FROM work_tasks
|
|
WHERE code = 'legacy-task'
|
|
"""
|
|
)
|
|
)
|
|
columns = {
|
|
column["name"]: column
|
|
for column in inspect(engine).get_columns("work_tasks")
|
|
}
|
|
assert source_system == "internal"
|
|
assert columns["source_system"]["nullable"] is False
|
|
assert columns["source_system"]["default"] is None
|
|
assert columns["is_active"]["default"] is None
|
|
finally:
|
|
engine.dispose()
|
|
get_settings.cache_clear()
|