```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
60
tests/conftest.py
Normal file
60
tests/conftest.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_database_file = tempfile.NamedTemporaryFile(
|
||||
prefix="company-ai-platform-tests-",
|
||||
suffix=".db",
|
||||
delete=False,
|
||||
)
|
||||
_database_file.close()
|
||||
_database_path = Path(_database_file.name)
|
||||
|
||||
# This module is loaded before test modules are imported. Keep every suite run isolated
|
||||
# from a developer's local .env, database, queues, and external AI/Feishu credentials.
|
||||
os.environ.update(
|
||||
{
|
||||
"COMPANY_AI_DISABLE_DOTENV": "true",
|
||||
"APP_ENV": "test",
|
||||
"DATABASE_URL": f"sqlite:///{_database_path.as_posix()}",
|
||||
"LEGACY_DATABASE_URL": "",
|
||||
"LEGACY_ALLOWED_QUERIES": "{}",
|
||||
"LEGACY_PROJECT_QUERY": "",
|
||||
"LEGACY_TASK_QUERY": "",
|
||||
"API_KEY": "test-key",
|
||||
"API_KEYS": "[]",
|
||||
"AUDIT_API_KEY": "audit-key",
|
||||
"AUDIT_API_KEYS": "[]",
|
||||
"AUDIT_API_ACTOR": "audit-manager",
|
||||
"MODEL_PROVIDER": "noop",
|
||||
"DIRECT_LLM_API_KEY": "",
|
||||
"HERMES_API_KEY": "",
|
||||
"OPENCLAW_API_KEY": "",
|
||||
"OPENCLAW_GATEWAY_TOKEN": "",
|
||||
"FEISHU_APP_ID": "",
|
||||
"FEISHU_APP_SECRET": "",
|
||||
"FEISHU_ENCRYPT_KEY": "",
|
||||
"FEISHU_DEFAULT_CHAT_ID": "",
|
||||
"FEISHU_VERIFICATION_TOKEN": "test-feishu-token",
|
||||
"FEISHU_ADMIN_IDENTITIES": "",
|
||||
"FEISHU_USER_FEATURES_ENABLED": "false",
|
||||
"MARKET_DATA_TOKEN": "",
|
||||
"SCHEDULER_ENABLED": "false",
|
||||
"TASK_QUEUE_ENABLED": "false",
|
||||
"TASK_QUEUE_ALWAYS_EAGER": "false",
|
||||
"LEGACY_SYNC_ENABLED": "false",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def pytest_sessionfinish() -> None:
|
||||
"""Remove the suite database after every test module has finished."""
|
||||
|
||||
database_module = sys.modules.get("app.core.database.session")
|
||||
if database_module is not None:
|
||||
database_module.engine.dispose()
|
||||
if database_module.legacy_engine is not None:
|
||||
database_module.legacy_engine.dispose()
|
||||
_database_path.unlink(missing_ok=True)
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from app.core.config import get_settings
|
||||
from app.core.config import Settings
|
||||
from app.core.database import Base
|
||||
from app.core.security import OperationsDisabledError
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
@@ -39,13 +38,19 @@ def test_audit_and_outbox_rollback_with_business_transaction() -> None:
|
||||
assert db.scalar(select(func.count()).select_from(DomainEvent)) == 0
|
||||
|
||||
|
||||
def test_business_service_enforces_read_only_policy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_read_only_mode_allows_local_risk_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with pytest.raises(OperationsDisabledError):
|
||||
RiskService(Session()).generate_events(actor="pytest")
|
||||
with Session(engine) as db:
|
||||
result = RiskService(db).generate_events(actor="pytest")
|
||||
|
||||
assert result["created"] == 0
|
||||
assert db.scalar(select(func.count()).select_from(AuditLog)) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
|
||||
101
tests/test_event_dispatch_fencing.py
Normal file
101
tests/test_event_dispatch_fencing.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, select, update
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.events import EventDispatchService
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.audit.constants import AuditAction
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.events.services import EventService
|
||||
|
||||
|
||||
def test_stale_worker_is_fenced_after_expired_lease_is_reclaimed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'event-fencing.db'}")
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
DomainEvent.__table__.create(engine)
|
||||
AuditLog.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
with factory() as db:
|
||||
event = EventService(db).emit(
|
||||
event_type="test.fencing",
|
||||
source="pytest",
|
||||
aggregate_type="test",
|
||||
aggregate_id="fencing",
|
||||
idempotency_key="test-event-fencing",
|
||||
)
|
||||
event_id = event.event_id
|
||||
|
||||
second_result: dict[str, DomainEvent] = {}
|
||||
with factory() as first_db:
|
||||
first_worker = EventDispatchService(first_db)
|
||||
|
||||
def fail_after_lease_is_reclaimed(record: DomainEvent) -> None:
|
||||
first_db.add(
|
||||
AuditLog(
|
||||
actor="worker-one",
|
||||
action="stale-worker-side-effect",
|
||||
target_id=record.event_id,
|
||||
)
|
||||
)
|
||||
with factory() as second_db:
|
||||
second_db.execute(
|
||||
update(DomainEvent)
|
||||
.where(DomainEvent.event_id == record.event_id)
|
||||
.values(locked_until=utc_now() - timedelta(seconds=1))
|
||||
)
|
||||
second_db.commit()
|
||||
|
||||
second_worker = EventDispatchService(second_db)
|
||||
monkeypatch.setattr(
|
||||
second_worker,
|
||||
"_handle_event",
|
||||
lambda claimed: None,
|
||||
)
|
||||
second_result["event"] = second_worker.dispatch_event(
|
||||
record.event_id,
|
||||
worker_id="worker-two",
|
||||
)
|
||||
raise RuntimeError("worker one failed after losing its lease")
|
||||
|
||||
monkeypatch.setattr(
|
||||
first_worker,
|
||||
"_handle_event",
|
||||
fail_after_lease_is_reclaimed,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
first_worker.dispatch_event(event_id, worker_id="worker-one")
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
assert second_result["event"].status == EventStatus.PROCESSED
|
||||
with factory() as db:
|
||||
stored = db.execute(
|
||||
select(DomainEvent).where(DomainEvent.event_id == event_id)
|
||||
).scalar_one()
|
||||
audit_actions = list(
|
||||
db.execute(
|
||||
select(AuditLog.action).order_by(AuditLog.id.asc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
assert stored.status == EventStatus.PROCESSED
|
||||
assert stored.attempts == 2
|
||||
assert stored.last_error is None
|
||||
assert stored.locked_by is None
|
||||
assert stored.locked_until is None
|
||||
assert audit_actions == [AuditAction.EVENT_DISPATCH]
|
||||
assert "stale-worker-side-effect" not in audit_actions
|
||||
finally:
|
||||
engine.dispose()
|
||||
269
tests/test_feishu_app_ticket.py
Normal file
269
tests/test_feishu_app_ticket.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.application.feishu.events import FeishuEventService
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.feishu import long_connection
|
||||
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
||||
from app.modules.feishu.constants import FeishuEventSource
|
||||
from app.modules.feishu.models import FeishuAppTicket, FeishuEventReceipt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[sessionmaker[Session]]:
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
|
||||
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
AuditLog.__table__,
|
||||
FeishuEventReceipt.__table__,
|
||||
FeishuAppTicket.__table__,
|
||||
],
|
||||
)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
Base.metadata.drop_all(
|
||||
engine,
|
||||
tables=[
|
||||
FeishuAppTicket.__table__,
|
||||
FeishuEventReceipt.__table__,
|
||||
AuditLog.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _v2_ticket_event(
|
||||
event_id: str,
|
||||
ticket: str,
|
||||
*,
|
||||
app_id: str = "cli-ticket-app",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": event_id,
|
||||
"event_type": "app_ticket",
|
||||
"token": "ticket-token",
|
||||
"app_id": app_id,
|
||||
},
|
||||
"event": {"app_ticket": ticket},
|
||||
}
|
||||
|
||||
|
||||
def _v1_ticket_event(
|
||||
event_id: str,
|
||||
ticket: str,
|
||||
*,
|
||||
app_id: str = "cli-ticket-app",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"ts": "1785081600.000",
|
||||
"uuid": event_id,
|
||||
"token": "ticket-token",
|
||||
"type": "app_ticket",
|
||||
"event": {
|
||||
"app_id": app_id,
|
||||
"app_ticket": ticket,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_verified_ticket_is_deduplicated_rotated_and_never_leaked(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
first_ticket = "ticket-secret-first"
|
||||
rotated_ticket = "ticket-secret-rotated"
|
||||
with session_factory() as db:
|
||||
service = FeishuEventService(db)
|
||||
first = service._handle_verified_event(
|
||||
_v2_ticket_event("ticket-event-1", first_ticket),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
)
|
||||
stored = db.scalar(select(FeishuAppTicket))
|
||||
assert stored is not None
|
||||
stored_id = stored.id
|
||||
first_received_at = stored.received_at
|
||||
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == first_ticket
|
||||
assert first_ticket not in json.dumps(first, ensure_ascii=False)
|
||||
|
||||
duplicate_payload = _v2_ticket_event(
|
||||
"ticket-event-1",
|
||||
"ticket-secret-duplicate-must-not-win",
|
||||
)
|
||||
duplicate = service._handle_verified_event(
|
||||
duplicate_payload,
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
)
|
||||
assert duplicate["duplicate"] is True
|
||||
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == first_ticket
|
||||
|
||||
rotated = service._handle_verified_event(
|
||||
_v2_ticket_event("ticket-event-2", rotated_ticket),
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
)
|
||||
db.expire_all()
|
||||
current = db.scalar(select(FeishuAppTicket))
|
||||
assert current is not None
|
||||
assert current.id == stored_id
|
||||
assert current.app_ticket == rotated_ticket
|
||||
assert current.received_at >= first_received_at
|
||||
assert db.scalar(select(func.count()).select_from(FeishuAppTicket)) == 1
|
||||
assert db.scalar(select(func.count()).select_from(FeishuEventReceipt)) == 2
|
||||
assert rotated_ticket not in json.dumps(rotated, ensure_ascii=False)
|
||||
|
||||
audits = list(db.execute(select(AuditLog)).scalars())
|
||||
assert len(audits) == 2
|
||||
serialized_audits = json.dumps(
|
||||
[
|
||||
{
|
||||
"request": item.request_payload,
|
||||
"response": item.response_payload,
|
||||
"target": item.target_id,
|
||||
}
|
||||
for item in audits
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
assert first_ticket not in serialized_audits
|
||||
assert rotated_ticket not in serialized_audits
|
||||
assert "ticket-secret-duplicate-must-not-win" not in serialized_audits
|
||||
|
||||
|
||||
def test_only_verified_matching_app_ticket_events_can_write(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
service = FeishuEventService(db)
|
||||
unverified = _v2_ticket_event("unverified-ticket", "unverified-secret")
|
||||
unverified["header"]["token"] = "invalid-token"
|
||||
with pytest.raises(HTTPException) as unverified_error:
|
||||
service.handle_event(unverified, source=FeishuEventSource.WEBHOOK)
|
||||
assert unverified_error.value.status_code == 401
|
||||
|
||||
with pytest.raises(HTTPException) as mismatch_error:
|
||||
service._handle_verified_event(
|
||||
_v2_ticket_event(
|
||||
"wrong-app-ticket",
|
||||
"wrong-app-secret",
|
||||
app_id="cli-other-app",
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
)
|
||||
assert mismatch_error.value.status_code == 401
|
||||
|
||||
missing_ticket = _v2_ticket_event("missing-ticket", "")
|
||||
with pytest.raises(HTTPException) as missing_error:
|
||||
service._handle_verified_event(
|
||||
missing_ticket,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
)
|
||||
assert missing_error.value.status_code == 400
|
||||
|
||||
not_ticket_event = _v2_ticket_event("ordinary-event", "must-not-store")
|
||||
not_ticket_event["header"]["event_type"] = "im.message.receive_v1"
|
||||
result = service._handle_verified_event(
|
||||
not_ticket_event,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
assert result["handled"] is False
|
||||
assert db.scalar(select(FeishuAppTicket)) is None
|
||||
|
||||
|
||||
def test_v1_app_ticket_payload_uses_uuid_receipt(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
ticket = "v1-ticket-secret"
|
||||
with session_factory() as db:
|
||||
result = FeishuEventService(db).handle_event(
|
||||
_v1_ticket_event("v1-ticket-uuid", ticket),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
)
|
||||
|
||||
receipt = db.scalar(select(FeishuEventReceipt))
|
||||
assert result == {"ok": True, "handled": True}
|
||||
assert receipt is not None
|
||||
assert receipt.event_id == "v1-ticket-uuid"
|
||||
assert receipt.event_key == "cli-ticket-app:app_ticket:v1-ticket-uuid"
|
||||
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == ticket
|
||||
assert ticket not in json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_long_connection_registers_custom_app_ticket_handler(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
registrations: dict[str, Any] = {}
|
||||
|
||||
class FakeBuilder:
|
||||
def register_p2_im_message_receive_v1(self, handler: Any) -> "FakeBuilder":
|
||||
registrations["message"] = handler
|
||||
return self
|
||||
|
||||
def register_p1_customized_event(
|
||||
self,
|
||||
event_type: str,
|
||||
handler: Any,
|
||||
) -> "FakeBuilder":
|
||||
registrations[event_type] = handler
|
||||
return self
|
||||
|
||||
def build(self) -> "FakeBuilder":
|
||||
return self
|
||||
|
||||
class FakeDispatcherHandler:
|
||||
@staticmethod
|
||||
def builder(encrypt_key: str, verification_token: str) -> FakeBuilder:
|
||||
registrations["builder_args"] = (encrypt_key, verification_token)
|
||||
return FakeBuilder()
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs: Any):
|
||||
registrations["client_kwargs"] = kwargs
|
||||
|
||||
def start(self) -> None:
|
||||
registrations["started"] = True
|
||||
|
||||
fake_lark = SimpleNamespace(
|
||||
EventDispatcherHandler=FakeDispatcherHandler,
|
||||
LogLevel=SimpleNamespace(WARNING="warning"),
|
||||
ws=SimpleNamespace(Client=FakeClient),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "lark_oapi", fake_lark)
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
|
||||
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
long_connection.run_long_connection()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
assert registrations["app_ticket"] is long_connection._handle_app_ticket_event
|
||||
assert registrations["message"] is long_connection._handle_message_event
|
||||
assert registrations["started"] is True
|
||||
487
tests/test_feishu_event_identity.py
Normal file
487
tests/test_feishu_event_identity.py
Normal file
@@ -0,0 +1,487 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.application.feishu.commands import FeishuCommandService
|
||||
from app.application.feishu.events import FeishuEventService
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.feishu.constants import (
|
||||
FeishuCommandName,
|
||||
FeishuEventSource,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.routes import router as feishu_router
|
||||
from app.modules.feishu_users.constants import (
|
||||
FeishuUserAuditAction,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
)
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuMention
|
||||
from app.modules.feishu_users.services import (
|
||||
FeishuIdentityService,
|
||||
FeishuUserManagementService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[sessionmaker[Session]]:
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
|
||||
monkeypatch.setenv("FEISHU_ADMIN_IDENTITIES", "tenant-a:ou-admin")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verified-token")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
AuditLog.__table__,
|
||||
FeishuEventReceipt.__table__,
|
||||
FeishuAdminBootstrapTombstone.__table__,
|
||||
FeishuUser.__table__,
|
||||
],
|
||||
)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
Base.metadata.drop_all(
|
||||
engine,
|
||||
tables=[
|
||||
FeishuAdminBootstrapTombstone.__table__,
|
||||
FeishuUser.__table__,
|
||||
FeishuEventReceipt.__table__,
|
||||
AuditLog.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _message_event(
|
||||
*,
|
||||
event_id: str,
|
||||
text: str,
|
||||
tenant_key: str | None = "tenant-a",
|
||||
open_id: str | None = "ou-user",
|
||||
chat_id: str = "oc-chat",
|
||||
chat_type: str = "p2p",
|
||||
mentions: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
header: dict[str, Any] = {
|
||||
"event_id": event_id,
|
||||
"event_type": "im.message.receive_v1",
|
||||
"token": "verified-token",
|
||||
}
|
||||
if tenant_key is not None:
|
||||
header["tenant_key"] = tenant_key
|
||||
sender_id: dict[str, str] = {"user_id": "legacy-user-id"}
|
||||
if open_id is not None:
|
||||
sender_id["open_id"] = open_id
|
||||
message: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"chat_type": chat_type,
|
||||
"message_id": f"om-{event_id}",
|
||||
"message_type": "text",
|
||||
"content": json.dumps({"text": text}, ensure_ascii=False),
|
||||
}
|
||||
if mentions is not None:
|
||||
message["mentions"] = mentions
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"header": header,
|
||||
"event": {
|
||||
"sender": {"sender_id": sender_id},
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _mention(
|
||||
key: str,
|
||||
open_id: str,
|
||||
*,
|
||||
name: str,
|
||||
tenant_key: str = "tenant-a",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"key": key,
|
||||
"name": name,
|
||||
"tenant_key": tenant_key,
|
||||
"id": {
|
||||
"open_id": open_id,
|
||||
"union_id": f"on-{open_id}",
|
||||
"user_id": f"u-{open_id}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_verified_event_requires_tenant_and_open_id_without_registering(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
missing_tenant = FeishuEventService(db)._handle_verified_event(
|
||||
_message_event(
|
||||
event_id="missing-tenant",
|
||||
text="risk",
|
||||
tenant_key=None,
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
missing_open_id = FeishuEventService(db)._handle_verified_event(
|
||||
_message_event(
|
||||
event_id="missing-open",
|
||||
text="risk",
|
||||
open_id=None,
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert missing_tenant["result"]["command"] == "permission_denied"
|
||||
assert missing_open_id["result"]["command"] == "permission_denied"
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
denial_logs = list(
|
||||
db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.action == FeishuUserAuditAction.PERMISSION_DENIED
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert len(denial_logs) == 2
|
||||
assert all(item.actor == "feishu" for item in denial_logs)
|
||||
|
||||
|
||||
def test_unverified_event_cannot_create_identity(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
payload = _message_event(
|
||||
event_id="invalid-token",
|
||||
text="问 你好",
|
||||
open_id="ou-unverified",
|
||||
)
|
||||
payload["header"]["token"] = "invalid-token"
|
||||
with session_factory() as db:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuEventService(db).handle_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
|
||||
|
||||
def test_group_event_uses_sender_principal_and_structured_context(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_handle_text(
|
||||
self: FeishuCommandService,
|
||||
text: str,
|
||||
chat_id: str | None = None,
|
||||
actor: str = "feishu",
|
||||
auto_reply: bool = True,
|
||||
principal: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
captured.update(
|
||||
{
|
||||
"text": text,
|
||||
"chat_id": chat_id,
|
||||
"actor": actor,
|
||||
"principal": principal,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"command": "ai_ask",
|
||||
"reply_type": "text",
|
||||
"title": "test",
|
||||
"content": "ok",
|
||||
"provider_response": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(FeishuCommandService, "handle_text", fake_handle_text)
|
||||
payload = _message_event(
|
||||
event_id="group-principal",
|
||||
text="@_bot 问 项目状态",
|
||||
open_id="ou-sender",
|
||||
chat_id="oc-group",
|
||||
chat_type="group",
|
||||
mentions=[_mention("@_target", "ou-target", name="目标用户")],
|
||||
)
|
||||
with session_factory() as db:
|
||||
result = FeishuEventService(db)._handle_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
auto_reply=False,
|
||||
)
|
||||
principal = captured["principal"]
|
||||
|
||||
assert result["handled"] is True
|
||||
assert principal.open_id == "ou-sender"
|
||||
assert principal.chat_id == "oc-group"
|
||||
assert principal.chat_type == "group"
|
||||
assert principal.mentions[0].open_id == "ou-target"
|
||||
assert captured["actor"] == principal.user_code
|
||||
assert captured["chat_id"] == "oc-group"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"日报",
|
||||
"项目资金 P-001",
|
||||
"风险",
|
||||
"学习公司规则:所有人共享",
|
||||
"设为管理员 @_target",
|
||||
],
|
||||
)
|
||||
def test_ordinary_user_cannot_run_company_commands(
|
||||
session_factory: sessionmaker[Session],
|
||||
command: str,
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id=f"ou-{abs(hash(command))}",
|
||||
)
|
||||
result = FeishuCommandService(db).handle_text(
|
||||
command,
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert result["command"] == FeishuCommandName.PERMISSION_DENIED
|
||||
assert "公司级功能" in result["content"]
|
||||
|
||||
|
||||
def test_disabled_user_is_rejected_before_command_execution(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-disabled",
|
||||
)
|
||||
FeishuUserManagementService(db).update_user(
|
||||
principal.user_code,
|
||||
changes={"status": FeishuUserStatus.DISABLED},
|
||||
actor="service-admin",
|
||||
)
|
||||
disabled = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-disabled",
|
||||
)
|
||||
result = FeishuCommandService(db).handle_text(
|
||||
"问 你好",
|
||||
principal=disabled,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert result["command"] == FeishuCommandName.PERMISSION_DENIED
|
||||
assert "已停用" in result["content"]
|
||||
|
||||
|
||||
def test_admin_command_uses_target_mention_and_ignores_bot_mention(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
payload = _message_event(
|
||||
event_id="admin-promote",
|
||||
text="@_bot 设为管理员 @_target",
|
||||
open_id="ou-admin",
|
||||
chat_id="oc-group",
|
||||
chat_type="group",
|
||||
mentions=[
|
||||
_mention("@_bot", "ou-bot", name="机器人"),
|
||||
_mention("@_target", "ou-target", name="张三"),
|
||||
],
|
||||
)
|
||||
with session_factory() as db:
|
||||
result = FeishuEventService(db)._handle_verified_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
target = FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-target",
|
||||
)
|
||||
|
||||
assert result["result"]["command"] == FeishuCommandName.USER_SET_ADMIN
|
||||
assert "张三 已设为管理员" in result["result"]["content"]
|
||||
assert target is not None
|
||||
assert target.role == FeishuUserRole.ADMIN
|
||||
assert target.union_id == "on-ou-target"
|
||||
|
||||
|
||||
def test_admin_command_rejects_text_identity_cross_tenant_and_last_admin(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
admin = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
)
|
||||
admin = replace(admin, chat_id="oc-group", chat_type="group")
|
||||
service = FeishuCommandService(db)
|
||||
|
||||
forged = service.handle_text(
|
||||
"设为管理员 @ou-forged",
|
||||
principal=admin,
|
||||
auto_reply=False,
|
||||
)
|
||||
assert forged["command"] == FeishuCommandName.USER_SET_ADMIN
|
||||
assert "真实的飞书 @用户" in forged["content"]
|
||||
assert (
|
||||
FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-forged",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
cross_tenant = service.handle_text(
|
||||
"设为管理员 @_target",
|
||||
principal=replace(
|
||||
admin,
|
||||
mentions=(
|
||||
FeishuMention(
|
||||
key="@_target",
|
||||
name="其他租户用户",
|
||||
tenant_key="tenant-b",
|
||||
open_id="ou-other",
|
||||
),
|
||||
),
|
||||
),
|
||||
auto_reply=False,
|
||||
)
|
||||
assert "当前租户" in cross_tenant["content"]
|
||||
assert (
|
||||
FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-b",
|
||||
open_id="ou-other",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
self_demote = service.handle_text(
|
||||
"设为普通用户 @_self",
|
||||
principal=replace(
|
||||
admin,
|
||||
mentions=(
|
||||
FeishuMention(
|
||||
key="@_self",
|
||||
name="管理员",
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
),
|
||||
),
|
||||
),
|
||||
auto_reply=False,
|
||||
)
|
||||
assert "最后一个有效管理员" in self_demote["content"]
|
||||
stored_admin = FeishuIdentityService(db).get_by_identity(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
)
|
||||
assert stored_admin is not None
|
||||
assert stored_admin.role == FeishuUserRole.ADMIN
|
||||
|
||||
|
||||
def test_preview_cannot_forge_feishu_principal_when_features_enabled(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_KEY", "service-key")
|
||||
monkeypatch.setenv("API_ACTOR", "service-principal")
|
||||
get_settings.cache_clear()
|
||||
app = FastAPI()
|
||||
app.include_router(feishu_router, prefix="/api/v1/integrations/feishu")
|
||||
|
||||
def override_db() -> Iterator[Session]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/integrations/feishu/commands/preview",
|
||||
headers={"X-API-Key": "service-key"},
|
||||
json={
|
||||
"text": "日报",
|
||||
"actor": "ou-admin",
|
||||
"auto_reply": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["command"] == "permission_denied"
|
||||
with session_factory() as db:
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
|
||||
|
||||
def test_disabled_feature_flag_preserves_legacy_actor_flow(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_handle_text(
|
||||
self: FeishuCommandService,
|
||||
text: str,
|
||||
chat_id: str | None = None,
|
||||
actor: str = "feishu",
|
||||
auto_reply: bool = True,
|
||||
principal: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
captured["actor"] = actor
|
||||
captured["principal"] = principal
|
||||
return {
|
||||
"command": "fallback_ai",
|
||||
"reply_type": "text",
|
||||
"title": "test",
|
||||
"content": "ok",
|
||||
"provider_response": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(FeishuCommandService, "handle_text", fake_handle_text)
|
||||
with session_factory() as db:
|
||||
FeishuEventService(db)._handle_verified_event(
|
||||
_message_event(
|
||||
event_id="legacy-flow",
|
||||
text="hello",
|
||||
tenant_key=None,
|
||||
open_id=None,
|
||||
),
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert captured["actor"] == "legacy-user-id"
|
||||
assert captured["principal"] is None
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
77
tests/test_feishu_message_delivery.py
Normal file
77
tests/test_feishu_message_delivery.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.feishu.client import FeishuClient
|
||||
from app.modules.feishu.errors import FeishuAPIError
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload: dict[str, Any], status_code: int = 200):
|
||||
self.payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self.payload
|
||||
|
||||
|
||||
class _Client:
|
||||
response = _Response({"code": 0, "data": {"message_id": "om-ok"}})
|
||||
request: dict[str, Any] | None = None
|
||||
|
||||
def __init__(self, **_: Any):
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "_Client":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> _Response:
|
||||
_Client.request = {"url": url, **kwargs}
|
||||
return self.response
|
||||
|
||||
|
||||
def _configured_client(monkeypatch: pytest.MonkeyPatch) -> FeishuClient:
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "test-app")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "test-secret")
|
||||
get_settings.cache_clear()
|
||||
client = FeishuClient()
|
||||
client._tenant_access_token = "tenant-token"
|
||||
client._token_expires_at = time.time() + 60
|
||||
monkeypatch.setattr("app.modules.feishu.client.httpx.Client", _Client)
|
||||
return client
|
||||
|
||||
|
||||
def test_message_uuid_is_forwarded_to_feishu(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _configured_client(monkeypatch)
|
||||
try:
|
||||
result = client.send_text(
|
||||
"hello",
|
||||
receive_id="ou-user",
|
||||
receive_id_type="open_id",
|
||||
uuid="stable-delivery-uuid",
|
||||
)
|
||||
assert result["code"] == 0
|
||||
assert _Client.request is not None
|
||||
assert _Client.request["json"]["uuid"] == "stable-delivery-uuid"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_nonzero_feishu_business_code_is_not_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = _configured_client(monkeypatch)
|
||||
_Client.response = _Response({"code": 99991400, "msg": "rate limited"})
|
||||
try:
|
||||
with pytest.raises(FeishuAPIError) as exc_info:
|
||||
client.send_text("hello", receive_id="ou-user", receive_id_type="open_id")
|
||||
assert exc_info.value.provider_code == 99991400
|
||||
assert exc_info.value.retryable is True
|
||||
finally:
|
||||
_Client.response = _Response({"code": 0, "data": {"message_id": "om-ok"}})
|
||||
get_settings.cache_clear()
|
||||
267
tests/test_feishu_multitenant_auth.py
Normal file
267
tests/test_feishu_multitenant_auth.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.feishu.client import FeishuClient
|
||||
from app.modules.feishu.errors import FeishuAPIError
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload: Any, status_code: int = 200):
|
||||
self.payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self) -> Any:
|
||||
if isinstance(self.payload, Exception):
|
||||
raise self.payload
|
||||
return self.payload
|
||||
|
||||
|
||||
class _HTTPClient:
|
||||
responses: list[_Response] = []
|
||||
requests: list[dict[str, Any]] = []
|
||||
|
||||
def __init__(self, *, timeout: int):
|
||||
self.timeout = timeout
|
||||
|
||||
def __enter__(self) -> "_HTTPClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> _Response:
|
||||
self.requests.append({"url": url, "timeout": self.timeout, **kwargs})
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_settings_and_http(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_HTTPClient.responses = []
|
||||
_HTTPClient.requests = []
|
||||
monkeypatch.setattr("app.modules.feishu.client.httpx.Client", _HTTPClient)
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _configure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
app_type: str,
|
||||
app_ticket: str = "",
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_BASE_URL", "https://open.feishu.test/open-apis")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret-value")
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", app_type)
|
||||
monkeypatch.setenv("FEISHU_APP_TICKET", app_ticket)
|
||||
monkeypatch.delenv("FEISHU_DEFAULT_TENANT_KEY", raising=False)
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_self_app_uses_internal_tenant_token_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="self")
|
||||
_HTTPClient.responses = [
|
||||
_Response(
|
||||
{
|
||||
"code": 0,
|
||||
"tenant_access_token": "self-tenant-token",
|
||||
"expire": 7200,
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
token = FeishuClient()._get_tenant_access_token("ignored-tenant")
|
||||
|
||||
assert token == "self-tenant-token"
|
||||
assert len(_HTTPClient.requests) == 1
|
||||
request = _HTTPClient.requests[0]
|
||||
assert request["url"].endswith("/auth/v3/tenant_access_token/internal")
|
||||
assert request["json"] == {
|
||||
"app_id": "cli-test",
|
||||
"app_secret": "app-secret-value",
|
||||
}
|
||||
|
||||
|
||||
def test_store_app_caches_app_token_and_isolates_tenant_tokens(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
||||
_HTTPClient.responses = [
|
||||
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
|
||||
_Response(
|
||||
{"code": 0, "tenant_access_token": "tenant-a-token", "expire": 7200}
|
||||
),
|
||||
_Response(
|
||||
{"code": 0, "tenant_access_token": "tenant-b-token", "expire": 7200}
|
||||
),
|
||||
]
|
||||
client = FeishuClient()
|
||||
|
||||
tenant_a = client._get_tenant_access_token("tenant-a")
|
||||
tenant_b = client._get_tenant_access_token("tenant-b")
|
||||
tenant_a_again = client._get_tenant_access_token("tenant-a")
|
||||
|
||||
assert (tenant_a, tenant_b, tenant_a_again) == (
|
||||
"tenant-a-token",
|
||||
"tenant-b-token",
|
||||
"tenant-a-token",
|
||||
)
|
||||
assert len(_HTTPClient.requests) == 3
|
||||
app_request, tenant_a_request, tenant_b_request = _HTTPClient.requests
|
||||
assert app_request["url"].endswith("/auth/v3/app_access_token")
|
||||
assert app_request["json"] == {
|
||||
"app_id": "cli-test",
|
||||
"app_secret": "app-secret-value",
|
||||
"app_ticket": "latest-ticket",
|
||||
}
|
||||
assert tenant_a_request["url"].endswith("/auth/v3/tenant_access_token")
|
||||
assert tenant_a_request["json"] == {
|
||||
"app_access_token": "app-token",
|
||||
"tenant_key": "tenant-a",
|
||||
}
|
||||
assert tenant_b_request["json"] == {
|
||||
"app_access_token": "app-token",
|
||||
"tenant_key": "tenant-b",
|
||||
}
|
||||
|
||||
|
||||
def test_store_app_prefers_persisted_ticket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store", app_ticket="environment-ticket")
|
||||
|
||||
class _TicketService:
|
||||
def __init__(self, db: object):
|
||||
assert db is database
|
||||
|
||||
def get_ticket(self, app_id: str) -> str:
|
||||
assert app_id == "cli-test"
|
||||
return "persisted-ticket"
|
||||
|
||||
database = object()
|
||||
ticket_module = ModuleType("app.modules.feishu.app_tickets")
|
||||
ticket_module.FeishuAppTicketService = _TicketService
|
||||
monkeypatch.setitem(sys.modules, ticket_module.__name__, ticket_module)
|
||||
_HTTPClient.responses = [
|
||||
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
|
||||
_Response(
|
||||
{"code": 0, "tenant_access_token": "tenant-token", "expire": 7200}
|
||||
),
|
||||
]
|
||||
|
||||
FeishuClient(database)._get_tenant_access_token("tenant-a")
|
||||
|
||||
assert _HTTPClient.requests[0]["json"]["app_ticket"] == "persisted-ticket"
|
||||
|
||||
|
||||
def test_store_app_requires_tenant_key_before_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuClient()._get_tenant_access_token()
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "tenant_key is required for Feishu store apps"
|
||||
assert _HTTPClient.requests == []
|
||||
|
||||
|
||||
def test_store_app_requires_ticket_before_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuClient()._get_tenant_access_token("tenant-a")
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Feishu store app ticket is not available"
|
||||
assert _HTTPClient.requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "retryable"),
|
||||
[(401, False), (429, True), (500, True)],
|
||||
)
|
||||
def test_token_http_errors_are_classified_without_exposing_secrets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status_code: int,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store", app_ticket="secret-ticket-value")
|
||||
_HTTPClient.responses = [
|
||||
_Response(
|
||||
{
|
||||
"code": 1,
|
||||
"app_ticket": "secret-ticket-value",
|
||||
"tenant_access_token": "secret-token-value",
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
]
|
||||
|
||||
with pytest.raises(FeishuAPIError) as exc_info:
|
||||
FeishuClient()._get_tenant_access_token("tenant-a")
|
||||
|
||||
error = exc_info.value
|
||||
assert error.retryable is retryable
|
||||
serialized = f"{error.detail} {error.provider_response}"
|
||||
assert "secret-ticket-value" not in serialized
|
||||
assert "secret-token-value" not in serialized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "retryable"),
|
||||
[
|
||||
({"code": 99991400, "msg": "rate limited"}, True),
|
||||
({"code": 10003, "msg": "invalid app credentials"}, False),
|
||||
],
|
||||
)
|
||||
def test_token_business_errors_are_classified(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
payload: dict[str, Any],
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
||||
_HTTPClient.responses = [_Response(payload)]
|
||||
|
||||
with pytest.raises(FeishuAPIError) as exc_info:
|
||||
FeishuClient()._get_tenant_access_token("tenant-a")
|
||||
|
||||
assert exc_info.value.provider_code == payload["code"]
|
||||
assert exc_info.value.retryable is retryable
|
||||
|
||||
|
||||
def test_send_text_uses_the_requested_tenant_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
||||
_HTTPClient.responses = [
|
||||
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
|
||||
_Response(
|
||||
{"code": 0, "tenant_access_token": "tenant-a-token", "expire": 7200}
|
||||
),
|
||||
_Response({"code": 0, "data": {"message_id": "om-message"}}),
|
||||
]
|
||||
|
||||
result = FeishuClient().send_text(
|
||||
"hello",
|
||||
receive_id="ou-user",
|
||||
receive_id_type="open_id",
|
||||
uuid="stable-uuid",
|
||||
tenant_key="tenant-a",
|
||||
)
|
||||
|
||||
assert result["code"] == 0
|
||||
message_request = _HTTPClient.requests[2]
|
||||
assert message_request["headers"]["Authorization"] == "Bearer tenant-a-token"
|
||||
assert message_request["json"]["uuid"] == "stable-uuid"
|
||||
486
tests/test_feishu_personalization_commands.py
Normal file
486
tests/test_feishu_personalization_commands.py
Normal file
@@ -0,0 +1,486 @@
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.application.feishu.commands import FeishuCommandService
|
||||
from app.application.feishu.handlers import personal_data as personal_data_handler
|
||||
from app.application.feishu.personal_data import PERSONAL_DATA_ERASURE_ACTION
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.modules.ai_agent.constants import AIResponseKey
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.constants import AuditAction
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.feishu.constants import FeishuCommandName
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.feishu_users.services import FeishuIdentityService
|
||||
from app.modules.personalization.models import UserPreference
|
||||
from app.modules.personalization.services import ConversationService
|
||||
from app.modules.subscriptions.models import PushSubscription
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[sessionmaker[Session]]:
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _principal(
|
||||
db: Session,
|
||||
suffix: str,
|
||||
*,
|
||||
role: str = FeishuUserRole.USER,
|
||||
chat_id: str | None = None,
|
||||
chat_type: str = "p2p",
|
||||
) -> FeishuPrincipal:
|
||||
user = FeishuUser(
|
||||
code=f"FSU-COMMAND-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
role=role,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return FeishuPrincipal.from_user(
|
||||
user,
|
||||
chat_id=chat_id or f"chat-{suffix}",
|
||||
chat_type=chat_type,
|
||||
)
|
||||
|
||||
|
||||
def test_personal_rules_are_owner_scoped_and_company_rules_require_admin(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
owner = _principal(db, "owner")
|
||||
other = _principal(db, "other")
|
||||
admin = _principal(db, "admin", role=FeishuUserRole.ADMIN)
|
||||
|
||||
created = FeishuCommandService(db).handle_text(
|
||||
"学习规则 80:回答尽量简洁",
|
||||
principal=owner,
|
||||
auto_reply=False,
|
||||
)
|
||||
denied = FeishuCommandService(db).handle_text(
|
||||
"学习公司规则:所有人使用中文",
|
||||
principal=other,
|
||||
auto_reply=False,
|
||||
)
|
||||
company = FeishuCommandService(db).handle_text(
|
||||
"学习公司规则:所有人使用中文",
|
||||
principal=admin,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
records = list(
|
||||
db.execute(
|
||||
select(AIMemoryEntry).order_by(AIMemoryEntry.id.asc())
|
||||
).scalars()
|
||||
)
|
||||
assert created["command"] == FeishuCommandName.RULE_CREATE
|
||||
assert denied["command"] == FeishuCommandName.PERMISSION_DENIED
|
||||
assert company["command"] == FeishuCommandName.RULE_CREATE
|
||||
assert len(records) == 2
|
||||
assert records[0].owner_id == owner.owner_id
|
||||
assert records[1].owner_id is None
|
||||
|
||||
owner_list = FeishuCommandService(db).handle_text(
|
||||
"查看规则",
|
||||
principal=owner,
|
||||
auto_reply=False,
|
||||
)
|
||||
other_list = FeishuCommandService(db).handle_text(
|
||||
"查看规则",
|
||||
principal=other,
|
||||
auto_reply=False,
|
||||
)
|
||||
assert "回答尽量简洁" in owner_list["content"]
|
||||
assert "回答尽量简洁" not in other_list["content"]
|
||||
assert "所有人使用中文" not in owner_list["content"]
|
||||
|
||||
|
||||
def test_preferences_and_topics_are_isolated_and_sensitive_content_is_rejected(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
owner = _principal(db, "preference-owner")
|
||||
other = _principal(db, "preference-other")
|
||||
service = FeishuCommandService(db)
|
||||
|
||||
preference = service.handle_text(
|
||||
"记住偏好 语气:简洁",
|
||||
principal=owner,
|
||||
auto_reply=False,
|
||||
)
|
||||
topic = service.handle_text(
|
||||
"关注主题:人工智能",
|
||||
principal=owner,
|
||||
auto_reply=False,
|
||||
)
|
||||
code = str(preference["content"]).split("编号:", 1)[1].splitlines()[0]
|
||||
cross_owner_delete = service.handle_text(
|
||||
f"删除偏好 {code}",
|
||||
principal=other,
|
||||
auto_reply=False,
|
||||
)
|
||||
sensitive = service.handle_text(
|
||||
"记住偏好 兴趣:我的银行卡是 123456",
|
||||
principal=owner,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert preference["command"] == FeishuCommandName.PREFERENCE_SET
|
||||
assert topic["command"] == FeishuCommandName.PREFERENCE_SET
|
||||
assert "没有找到" in cross_owner_delete["content"]
|
||||
assert "敏感" in sensitive["content"]
|
||||
assert db.scalar(
|
||||
select(func.count())
|
||||
.select_from(UserPreference)
|
||||
.where(UserPreference.owner_id == owner.owner_id)
|
||||
) == 2
|
||||
assert db.scalar(
|
||||
select(func.count())
|
||||
.select_from(UserPreference)
|
||||
.where(UserPreference.owner_id == other.owner_id)
|
||||
) == 0
|
||||
|
||||
|
||||
def test_conversation_reset_only_clears_current_user_and_chat(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
owner = _principal(
|
||||
db,
|
||||
"conversation-owner",
|
||||
chat_id="group-current",
|
||||
chat_type="group",
|
||||
)
|
||||
other = _principal(
|
||||
db,
|
||||
"conversation-other",
|
||||
chat_id="group-current",
|
||||
chat_type="group",
|
||||
)
|
||||
conversations = ConversationService(db)
|
||||
conversations.record_turn(
|
||||
owner.owner_id,
|
||||
"group",
|
||||
"group-current",
|
||||
user_content="owner question",
|
||||
assistant_content="owner answer",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
conversations.record_turn(
|
||||
owner.owner_id,
|
||||
"group",
|
||||
"group-other",
|
||||
user_content="other chat question",
|
||||
assistant_content="other chat answer",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
conversations.record_turn(
|
||||
other.owner_id,
|
||||
"group",
|
||||
"group-current",
|
||||
user_content="other user question",
|
||||
assistant_content="other user answer",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
|
||||
result = FeishuCommandService(db).handle_text(
|
||||
"重置对话",
|
||||
principal=owner,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert result["command"] == FeishuCommandName.CONVERSATION_RESET
|
||||
assert conversations.history(owner.owner_id, "group", "group-current") == []
|
||||
assert conversations.history(owner.owner_id, "group", "group-other")
|
||||
assert conversations.history(other.owner_id, "group", "group-current")
|
||||
|
||||
|
||||
def test_ai_and_subscription_commands_are_wired_to_verified_principal(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_personalized(
|
||||
self: AIService,
|
||||
owner_id: int,
|
||||
chat_type: str,
|
||||
chat_key: str,
|
||||
prompt: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
captured.update(
|
||||
{
|
||||
"owner_id": owner_id,
|
||||
"chat_type": chat_type,
|
||||
"chat_key": chat_key,
|
||||
"prompt": prompt,
|
||||
}
|
||||
)
|
||||
return {
|
||||
AIResponseKey.OK: True,
|
||||
AIResponseKey.ANSWER: "账号隔离回答",
|
||||
AIResponseKey.PROVIDER: "test",
|
||||
AIResponseKey.RAW: {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(AIService, "ask_personalized", fake_personalized)
|
||||
with session_factory() as db:
|
||||
principal = _principal(
|
||||
db,
|
||||
"wiring",
|
||||
chat_id="verified-private-chat",
|
||||
)
|
||||
service = FeishuCommandService(db)
|
||||
|
||||
answer = service.handle_text(
|
||||
"问 你好",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
subscription = service.handle_text(
|
||||
"订阅 每天 09:00:给我一个问候",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert answer["content"] == "账号隔离回答"
|
||||
assert captured == {
|
||||
"owner_id": principal.owner_id,
|
||||
"chat_type": "p2p",
|
||||
"chat_key": "verified-private-chat",
|
||||
"prompt": "你好",
|
||||
}
|
||||
assert subscription["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
|
||||
stored = db.scalar(select(PushSubscription))
|
||||
assert stored is not None
|
||||
assert stored.owner_id == principal.owner_id
|
||||
assert stored.target_id == principal.open_id
|
||||
|
||||
|
||||
def test_help_lists_only_role_allowed_company_commands(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
user = _principal(db, "help-user")
|
||||
admin = _principal(db, "help-admin", role=FeishuUserRole.ADMIN)
|
||||
|
||||
user_help = FeishuCommandService(db).handle_text(
|
||||
"帮助",
|
||||
principal=user,
|
||||
auto_reply=False,
|
||||
)
|
||||
admin_help = FeishuCommandService(db).handle_text(
|
||||
"帮助",
|
||||
principal=admin,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert "管理员还可以使用" not in user_help["content"]
|
||||
assert "管理员还可以使用" in admin_help["content"]
|
||||
|
||||
|
||||
def test_my_data_summary_and_two_step_erasure_use_current_identity_only(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = _principal(db, "personal-data")
|
||||
other = _principal(db, "personal-data-other")
|
||||
service = FeishuCommandService(db)
|
||||
service.handle_text(
|
||||
"学习规则:只属于我的规则",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
service.handle_text(
|
||||
"记住偏好 语言:中文",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
service.handle_text(
|
||||
"关注主题:低空经济",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
service.handle_text(
|
||||
"记住偏好 语气:不应出现在另一用户摘要",
|
||||
principal=other,
|
||||
auto_reply=False,
|
||||
)
|
||||
service.handle_text(
|
||||
"订阅 每天 09:00:个人提醒",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
summary = service.handle_text(
|
||||
"我的数据",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
request = service.handle_text(
|
||||
"忘记我",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
confirmation_code = (
|
||||
str(request["content"]).split("确认码:", 1)[1].splitlines()[0]
|
||||
)
|
||||
erased = service.handle_text(
|
||||
f"确认忘记我 {confirmation_code}",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert summary["command"] == FeishuCommandName.PERSONAL_DATA_SUMMARY
|
||||
assert "个人规则:1 条" in summary["content"]
|
||||
assert "language=中文" in summary["content"]
|
||||
assert "低空经济" in summary["content"]
|
||||
assert "不应出现在另一用户摘要" not in summary["content"]
|
||||
assert request["command"] == (
|
||||
FeishuCommandName.PERSONAL_DATA_ERASURE_REQUEST
|
||||
)
|
||||
assert erased["command"] == (
|
||||
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM
|
||||
)
|
||||
assert db.get(FeishuUser, principal.owner_id) is None
|
||||
assert db.get(FeishuUser, other.owner_id) is not None
|
||||
|
||||
recreated = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key=principal.tenant_key,
|
||||
open_id=principal.open_id,
|
||||
)
|
||||
assert recreated.owner_id != principal.owner_id
|
||||
assert recreated.role == FeishuUserRole.USER
|
||||
|
||||
|
||||
def test_erasure_confirmation_reply_does_not_create_identifying_send_audit(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: list[dict[str, Any]] = []
|
||||
|
||||
def fake_send_text(
|
||||
_feishu: Any,
|
||||
chat_id: str | None,
|
||||
text: str,
|
||||
actor: str,
|
||||
*,
|
||||
record_audit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
captured.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"actor": actor,
|
||||
"record_audit": record_audit,
|
||||
}
|
||||
)
|
||||
return {"code": 0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
personal_data_handler,
|
||||
"send_text_if_configured",
|
||||
fake_send_text,
|
||||
)
|
||||
with session_factory() as db:
|
||||
principal = _principal(db, "erasure-reply")
|
||||
service = FeishuCommandService(db)
|
||||
request = service.handle_text(
|
||||
"忘记我",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
confirmation_code = (
|
||||
str(request["content"]).split("确认码:", 1)[1].splitlines()[0]
|
||||
)
|
||||
|
||||
service.handle_text(
|
||||
f"确认忘记我 {confirmation_code}",
|
||||
principal=principal,
|
||||
auto_reply=True,
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["actor"].startswith("anonymous-")
|
||||
assert captured[0]["record_audit"] is False
|
||||
anonymous_logs = list(
|
||||
db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.actor == captured[0]["actor"]
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert anonymous_logs
|
||||
assert any(
|
||||
item.action == PERSONAL_DATA_ERASURE_ACTION
|
||||
for item in anonymous_logs
|
||||
)
|
||||
assert all(
|
||||
item.action != AuditAction.FEISHU_SEND_TEXT
|
||||
for item in anonymous_logs
|
||||
)
|
||||
assert all(
|
||||
item.target_type is None
|
||||
and item.target_id is None
|
||||
and item.request_payload is None
|
||||
and item.response_payload is None
|
||||
and item.request_id is None
|
||||
for item in anonymous_logs
|
||||
)
|
||||
|
||||
|
||||
def test_group_chat_does_not_disclose_summary_or_erasure_code(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = _principal(
|
||||
db,
|
||||
"group-personal-data",
|
||||
chat_id="group-personal-data",
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
summary = FeishuCommandService(db).handle_text(
|
||||
"我的数据",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
erasure = FeishuCommandService(db).handle_text(
|
||||
"忘记我",
|
||||
principal=principal,
|
||||
auto_reply=False,
|
||||
)
|
||||
|
||||
assert "请私聊" in summary["content"]
|
||||
assert "请私聊" in erasure["content"]
|
||||
assert "确认码" not in erasure["content"]
|
||||
assert db.get(FeishuUser, principal.owner_id) is not None
|
||||
193
tests/test_feishu_personalization_migration.py
Normal file
193
tests/test_feishu_personalization_migration.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def test_personalization_migration_preserves_and_classifies_legacy_data(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
database_path = tmp_path / "personalization-migration.db"
|
||||
database_url = f"sqlite:///{database_path.as_posix()}"
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
get_settings.cache_clear()
|
||||
config = Config("alembic.ini")
|
||||
engine = create_engine(database_url)
|
||||
timestamp = datetime(2026, 7, 26, 8, 0)
|
||||
|
||||
try:
|
||||
command.upgrade(config, "202607260002")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO ai_memory_entries (
|
||||
code,
|
||||
fingerprint,
|
||||
scope,
|
||||
subject,
|
||||
content,
|
||||
source,
|
||||
importance,
|
||||
status,
|
||||
actor,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
:code,
|
||||
:fingerprint,
|
||||
'global',
|
||||
:subject,
|
||||
:content,
|
||||
:source,
|
||||
1,
|
||||
'active',
|
||||
'legacy-user',
|
||||
:created_at,
|
||||
:updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
[
|
||||
{
|
||||
"code": "legacy-rule",
|
||||
"fingerprint": "legacy-rule-fingerprint",
|
||||
"subject": "rule",
|
||||
"content": "Use concise Chinese.",
|
||||
"source": "user_rule",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
{
|
||||
"code": "legacy-auto-memory",
|
||||
"fingerprint": "legacy-auto-fingerprint",
|
||||
"subject": "preference",
|
||||
"content": "Prefers market summaries.",
|
||||
"source": "auto",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
{
|
||||
"code": "legacy-hermes-memory",
|
||||
"fingerprint": "legacy-hermes-fingerprint",
|
||||
"subject": "preference",
|
||||
"content": "Prefers short answers.",
|
||||
"source": "hermes",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
},
|
||||
],
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO market_watchlists (
|
||||
actor,
|
||||
symbol,
|
||||
enabled,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
'legacy-user',
|
||||
'600000.SH',
|
||||
1,
|
||||
:created_at,
|
||||
:updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"created_at": timestamp, "updated_at": timestamp},
|
||||
)
|
||||
|
||||
command.upgrade(config, "202607260005")
|
||||
|
||||
inspector = inspect(engine)
|
||||
expected_tables = {
|
||||
"feishu_app_tickets",
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
"feishu_users",
|
||||
"user_preferences",
|
||||
"ai_conversations",
|
||||
"ai_conversation_messages",
|
||||
"personal_data_erasure_requests",
|
||||
"push_subscriptions",
|
||||
"push_deliveries",
|
||||
}
|
||||
assert expected_tables <= set(inspector.get_table_names())
|
||||
tombstone_columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns(
|
||||
"feishu_admin_bootstrap_tombstones"
|
||||
)
|
||||
}
|
||||
assert tombstone_columns == {"id", "identity_hash", "created_at"}
|
||||
|
||||
memory_columns = {
|
||||
column["name"] for column in inspector.get_columns("ai_memory_entries")
|
||||
}
|
||||
assert {"owner_id", "kind"} <= memory_columns
|
||||
memory_indexes = {
|
||||
index["name"]: index for index in inspector.get_indexes("ai_memory_entries")
|
||||
}
|
||||
assert memory_indexes["ix_ai_memory_entries_fingerprint"]["unique"] == 0
|
||||
assert {
|
||||
"ix_ai_memory_entries_kind",
|
||||
"ix_ai_memory_entries_owner_id",
|
||||
} <= set(memory_indexes)
|
||||
memory_constraints = {
|
||||
constraint["name"]
|
||||
for constraint in inspector.get_unique_constraints("ai_memory_entries")
|
||||
}
|
||||
assert "uq_ai_memory_owner_fingerprint" in memory_constraints
|
||||
memory_foreign_keys = {
|
||||
foreign_key["name"]
|
||||
for foreign_key in inspector.get_foreign_keys("ai_memory_entries")
|
||||
}
|
||||
assert "fk_ai_memory_entries_owner_id" in memory_foreign_keys
|
||||
|
||||
with engine.connect() as connection:
|
||||
memories = {
|
||||
row.code: row
|
||||
for row in connection.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT code, owner_id, kind, source, status
|
||||
FROM ai_memory_entries
|
||||
ORDER BY code
|
||||
"""
|
||||
)
|
||||
)
|
||||
}
|
||||
watchlist_owner_id = connection.scalar(
|
||||
text(
|
||||
"""
|
||||
SELECT owner_id
|
||||
FROM market_watchlists
|
||||
WHERE actor = 'legacy-user' AND symbol = '600000.SH'
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
legacy_rule = memories["legacy-rule"]
|
||||
assert legacy_rule.owner_id is None
|
||||
assert legacy_rule.kind == "company_rule"
|
||||
assert legacy_rule.source == "legacy_company"
|
||||
assert legacy_rule.status == "active"
|
||||
for code in ("legacy-auto-memory", "legacy-hermes-memory"):
|
||||
assert memories[code].owner_id is None
|
||||
assert memories[code].kind == "memory"
|
||||
assert memories[code].status == "archived"
|
||||
assert watchlist_owner_id is None
|
||||
|
||||
command.check(config)
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
322
tests/test_feishu_subscription_commands.py
Normal file
322
tests/test_feishu_subscription_commands.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.handlers.subscriptions import handle_subscription_command
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu.constants import FeishuCommandName
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.subscriptions.constants import (
|
||||
DAILY_DELIVERY_LIMIT_REACHED,
|
||||
MAX_ACTIVE_SUBSCRIPTIONS,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
|
||||
|
||||
def _user(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
role: str = FeishuUserRole.USER,
|
||||
) -> FeishuUser:
|
||||
record = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
role=role,
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def _handle(
|
||||
db: Session,
|
||||
text: str,
|
||||
principal: FeishuPrincipal,
|
||||
) -> dict:
|
||||
result = handle_subscription_command(
|
||||
db,
|
||||
FeishuService(db),
|
||||
text,
|
||||
principal,
|
||||
False,
|
||||
)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def test_create_private_subscription_replies_with_normalized_plan() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="private-command")
|
||||
principal = FeishuPrincipal.from_user(
|
||||
user,
|
||||
chat_id="private-chat",
|
||||
chat_type="p2p",
|
||||
)
|
||||
|
||||
result = _handle(
|
||||
db,
|
||||
"订阅 每天 09:00:请提醒我:喝水",
|
||||
principal,
|
||||
)
|
||||
|
||||
subscription = db.scalar(select(PushSubscription))
|
||||
assert subscription is not None
|
||||
assert result["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
|
||||
assert "订阅已启用" in result["content"]
|
||||
assert "计划:每天 09:00" in result["content"]
|
||||
assert "下次执行:" in result["content"]
|
||||
assert f"暂停订阅 {subscription.code}" in result["content"]
|
||||
assert subscription.owner_id == user.id
|
||||
assert subscription.target_type == SubscriptionTargetType.USER
|
||||
assert subscription.target_id == user.open_id
|
||||
assert subscription.prompt == "请提醒我:喝水"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_group_subscription_uses_current_verified_chat_and_requires_admin() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
ordinary = _user(db, suffix="ordinary-group")
|
||||
denied = _handle(
|
||||
db,
|
||||
"订阅 每周一 09:00:群提醒",
|
||||
FeishuPrincipal.from_user(
|
||||
ordinary,
|
||||
chat_id="verified-group",
|
||||
chat_type="group",
|
||||
),
|
||||
)
|
||||
assert "无权" in denied["content"]
|
||||
assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0
|
||||
|
||||
admin = _user(db, suffix="admin-group", role=FeishuUserRole.ADMIN)
|
||||
accepted = _handle(
|
||||
db,
|
||||
"订阅 每周一 09:00:群提醒",
|
||||
FeishuPrincipal.from_user(
|
||||
admin,
|
||||
chat_id="verified-group",
|
||||
chat_type="group",
|
||||
),
|
||||
)
|
||||
subscription = db.scalar(select(PushSubscription))
|
||||
assert accepted["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
|
||||
assert subscription is not None
|
||||
assert subscription.target_type == SubscriptionTargetType.CHAT
|
||||
assert subscription.target_id == "verified-group"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_list_pause_resume_and_cancel_subscription_commands() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="lifecycle-command")
|
||||
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
|
||||
_handle(db, "订阅 每天 10:00:生命周期测试", principal)
|
||||
subscription = db.scalar(select(PushSubscription))
|
||||
assert subscription is not None
|
||||
|
||||
listed = _handle(db, "我的订阅", principal)
|
||||
assert listed["command"] == FeishuCommandName.SUBSCRIPTION_LIST
|
||||
assert subscription.code in listed["content"]
|
||||
assert "每天 10:00" in listed["content"]
|
||||
|
||||
paused = _handle(db, f"暂停订阅 {subscription.code}", principal)
|
||||
db.refresh(subscription)
|
||||
assert paused["command"] == FeishuCommandName.SUBSCRIPTION_PAUSE
|
||||
assert subscription.status == PushSubscriptionStatus.PAUSED
|
||||
|
||||
resumed = _handle(db, f"恢复订阅 {subscription.code}", principal)
|
||||
db.refresh(subscription)
|
||||
assert resumed["command"] == FeishuCommandName.SUBSCRIPTION_RESUME
|
||||
assert subscription.status == PushSubscriptionStatus.ACTIVE
|
||||
assert "下次执行:" in resumed["content"]
|
||||
|
||||
cancelled = _handle(db, f"退订 {subscription.code}", principal)
|
||||
db.refresh(subscription)
|
||||
assert cancelled["command"] == FeishuCommandName.SUBSCRIPTION_CANCEL
|
||||
assert subscription.status == PushSubscriptionStatus.CANCELLED
|
||||
assert subscription.next_run_at is None
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_timezone_and_quiet_hour_commands_update_current_user() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="settings-command")
|
||||
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
|
||||
|
||||
timezone_result = _handle(db, "设置时区 Asia/Tokyo", principal)
|
||||
db.refresh(user)
|
||||
assert timezone_result["command"] == FeishuCommandName.SUBSCRIPTION_TIMEZONE
|
||||
assert user.timezone == "Asia/Tokyo"
|
||||
|
||||
quiet_result = _handle(db, "设置安静时段 22:00-07:00", principal)
|
||||
db.refresh(user)
|
||||
assert quiet_result["command"] == FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
|
||||
assert user.quiet_hours_start.strftime("%H:%M") == "22:00"
|
||||
assert user.quiet_hours_end.strftime("%H:%M") == "07:00"
|
||||
|
||||
closed = _handle(db, "关闭安静时段", principal)
|
||||
db.refresh(user)
|
||||
assert closed["command"] == FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
|
||||
assert user.quiet_hours_start is None
|
||||
assert user.quiet_hours_end is None
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_invalid_schedule_and_capacity_error_create_no_partial_record() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="invalid-command")
|
||||
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
|
||||
|
||||
invalid = _handle(
|
||||
db,
|
||||
"订阅 每隔10分钟:过于频繁",
|
||||
principal,
|
||||
)
|
||||
assert "示例" not in invalid["content"]
|
||||
assert "订阅 每天 09:00" in invalid["content"]
|
||||
assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0
|
||||
|
||||
for index in range(MAX_ACTIVE_SUBSCRIPTIONS):
|
||||
db.add(
|
||||
PushSubscription(
|
||||
code=f"SUB-CAPACITY-{index}",
|
||||
owner_id=user.id,
|
||||
target_type=SubscriptionTargetType.USER,
|
||||
target_id=user.open_id,
|
||||
prompt=f"已有订阅 {index}",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone=user.timezone,
|
||||
next_run_at=datetime(2026, 7, 27, 1, 0),
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
limited = _handle(
|
||||
db,
|
||||
"订阅 每天 11:00:第 51 条",
|
||||
principal,
|
||||
)
|
||||
assert "最多 50 个" in limited["content"]
|
||||
assert (
|
||||
db.scalar(select(func.count()).select_from(PushSubscription))
|
||||
== MAX_ACTIVE_SUBSCRIPTIONS
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_unrelated_text_is_not_claimed_by_subscription_handler() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="unrelated-command")
|
||||
|
||||
assert (
|
||||
handle_subscription_command(
|
||||
db,
|
||||
FeishuService(db),
|
||||
"今天怎么样",
|
||||
FeishuPrincipal.from_user(user),
|
||||
False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_list_explains_daily_delivery_limit_skip() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="delivery-note")
|
||||
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
|
||||
_handle(db, "订阅 每天 10:00:投递说明", principal)
|
||||
subscription = db.scalar(select(PushSubscription))
|
||||
assert subscription is not None
|
||||
now = datetime(2026, 7, 26, 1, 0)
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code="DEL-DAILY-LIMIT",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=now,
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.SKIPPED,
|
||||
next_attempt_at=None,
|
||||
last_error=DAILY_DELIVERY_LIMIT_REACHED,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
listed = _handle(db, "我的订阅", principal)
|
||||
|
||||
assert "每日最多 96 条限制" in listed["content"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"设置时区 Invalid/Timezone",
|
||||
"设置安静时段 25:00-07:00",
|
||||
"订阅 每隔999999999999999999小时:不会创建",
|
||||
],
|
||||
)
|
||||
def test_invalid_settings_and_oversized_interval_return_command_error(
|
||||
command: str,
|
||||
) -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix=f"invalid-{abs(hash(command))}")
|
||||
principal = FeishuPrincipal.from_user(user, chat_type="p2p")
|
||||
|
||||
result = _handle(db, command, principal)
|
||||
|
||||
assert "未执行" in result["content"] or "无法识别" in result["content"]
|
||||
assert db.scalar(select(func.count()).select_from(PushSubscription)) == 0
|
||||
finally:
|
||||
engine.dispose()
|
||||
327
tests/test_feishu_subscription_readiness.py
Normal file
327
tests/test_feishu_subscription_readiness.py
Normal file
@@ -0,0 +1,327 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu.app_tickets import FeishuAppTicketService
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.observability.constants import (
|
||||
ObservabilityKey,
|
||||
ObservabilityStatus,
|
||||
)
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
from app.modules.subscriptions.constants import (
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_settings() -> None:
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _add_active_subscription(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_key: str,
|
||||
suffix: str,
|
||||
) -> PushSubscription:
|
||||
owner = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=tenant_key,
|
||||
open_id=f"open-{suffix}",
|
||||
)
|
||||
db.add(owner)
|
||||
db.flush()
|
||||
subscription = PushSubscription(
|
||||
code=f"SUB-{suffix}",
|
||||
owner_id=owner.id,
|
||||
target_type=SubscriptionTargetType.USER,
|
||||
target_id=owner.open_id,
|
||||
prompt="发送个人提醒",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone="Asia/Shanghai",
|
||||
next_run_at=datetime(2026, 7, 27, 1, 0),
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=datetime(2026, 7, 26, 1, 0),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
return subscription
|
||||
|
||||
|
||||
def _add_delivery(
|
||||
db: Session,
|
||||
subscription: PushSubscription,
|
||||
*,
|
||||
suffix: str,
|
||||
status_value: str,
|
||||
scheduled_for: datetime,
|
||||
) -> None:
|
||||
future = datetime(2030, 1, 1, 0, 0)
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code=f"DEL-{suffix}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=scheduled_for,
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=status_value,
|
||||
next_attempt_at=(
|
||||
future
|
||||
if status_value
|
||||
in {
|
||||
PushDeliveryStatus.PENDING,
|
||||
PushDeliveryStatus.RETRY,
|
||||
}
|
||||
else None
|
||||
),
|
||||
locked_by=(
|
||||
"readiness-worker"
|
||||
if status_value == PushDeliveryStatus.PROCESSING
|
||||
else None
|
||||
),
|
||||
locked_until=(
|
||||
future
|
||||
if status_value == PushDeliveryStatus.PROCESSING
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_subscription_readiness_requires_basic_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_add_active_subscription(db, tenant_key="tenant-a", suffix="missing")
|
||||
|
||||
result = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
|
||||
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert result["credentials_configured"] is False
|
||||
assert result["reasons"] == ["credentials_missing"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_self_app_readiness_rejects_multiple_active_tenants(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_add_active_subscription(db, tenant_key="tenant-a", suffix="self-a")
|
||||
single = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
assert single[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
||||
assert single["active_tenant_count"] == 1
|
||||
|
||||
_add_active_subscription(db, tenant_key="tenant-b", suffix="self-b")
|
||||
multiple = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
assert multiple[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert multiple["active_tenant_count"] == 2
|
||||
assert multiple["reasons"] == ["self_app_multiple_tenants"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_store_app_readiness_accepts_persisted_ticket_without_exposing_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ticket = "readiness-ticket-secret"
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
||||
monkeypatch.setenv("FEISHU_APP_TICKET", "")
|
||||
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "tenant-a")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_add_active_subscription(db, tenant_key="tenant-a", suffix="store")
|
||||
missing = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
assert missing[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert missing["reasons"] == ["app_ticket_missing"]
|
||||
|
||||
FeishuAppTicketService(db).store_verified("cli-store", ticket)
|
||||
configured = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
|
||||
assert configured[ObservabilityKey.STATUS] == ObservabilityStatus.OK
|
||||
assert configured["ticket_configured"] is True
|
||||
assert configured["default_tenant_configured"] is True
|
||||
assert configured["reasons"] == []
|
||||
assert ticket not in json.dumps(configured)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_store_app_readiness_requires_default_tenant(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "store")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-store")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
||||
monkeypatch.setenv("FEISHU_APP_TICKET", "environment-ticket")
|
||||
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_add_active_subscription(db, tenant_key="tenant-a", suffix="default")
|
||||
|
||||
result = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
|
||||
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert result["ticket_configured"] is True
|
||||
assert result["default_tenant_configured"] is False
|
||||
assert result["reasons"] == ["default_tenant_missing"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_readiness_checks_credentials_for_processable_deliveries_without_active_plan(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
subscription = _add_active_subscription(
|
||||
db,
|
||||
tenant_key="tenant-a",
|
||||
suffix="durable",
|
||||
)
|
||||
subscription.status = PushSubscriptionStatus.COMPLETED
|
||||
subscription.next_run_at = None
|
||||
db.commit()
|
||||
scheduled_for = datetime(2026, 7, 27, 1, 0)
|
||||
for index, status_value in enumerate(
|
||||
[
|
||||
PushDeliveryStatus.PENDING,
|
||||
PushDeliveryStatus.RETRY,
|
||||
PushDeliveryStatus.PROCESSING,
|
||||
]
|
||||
):
|
||||
_add_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix=f"durable-{index}",
|
||||
status_value=status_value,
|
||||
scheduled_for=scheduled_for + timedelta(minutes=index),
|
||||
)
|
||||
|
||||
result = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
|
||||
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert result["active"] == 0
|
||||
assert result["processable_deliveries"] == 3
|
||||
assert result["active_tenant_count"] == 1
|
||||
assert result["reasons"] == ["credentials_missing"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_readiness_ignores_terminal_deliveries_without_active_plan() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
subscription = _add_active_subscription(
|
||||
db,
|
||||
tenant_key="tenant-a",
|
||||
suffix="terminal",
|
||||
)
|
||||
subscription.status = PushSubscriptionStatus.COMPLETED
|
||||
subscription.next_run_at = None
|
||||
db.commit()
|
||||
scheduled_for = datetime(2026, 7, 27, 1, 0)
|
||||
for index, status_value in enumerate(
|
||||
[
|
||||
PushDeliveryStatus.SENT,
|
||||
PushDeliveryStatus.FAILED,
|
||||
PushDeliveryStatus.SKIPPED,
|
||||
]
|
||||
):
|
||||
_add_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix=f"terminal-{index}",
|
||||
status_value=status_value,
|
||||
scheduled_for=scheduled_for + timedelta(minutes=index),
|
||||
)
|
||||
|
||||
result = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
|
||||
assert result == {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
|
||||
"active": 0,
|
||||
"processable_deliveries": 0,
|
||||
}
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_self_app_counts_tenants_from_processable_deliveries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-self")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
scheduled_for = datetime(2026, 7, 27, 1, 0)
|
||||
for index, tenant_key in enumerate(["tenant-a", "tenant-b"]):
|
||||
subscription = _add_active_subscription(
|
||||
db,
|
||||
tenant_key=tenant_key,
|
||||
suffix=f"delivery-tenant-{index}",
|
||||
)
|
||||
subscription.status = PushSubscriptionStatus.COMPLETED
|
||||
subscription.next_run_at = None
|
||||
db.commit()
|
||||
_add_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix=f"delivery-tenant-{index}",
|
||||
status_value=PushDeliveryStatus.PENDING,
|
||||
scheduled_for=scheduled_for + timedelta(minutes=index),
|
||||
)
|
||||
|
||||
result = ObservabilityService(db)._feishu_subscriptions_check()
|
||||
|
||||
assert result[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert result["active"] == 0
|
||||
assert result["processable_deliveries"] == 2
|
||||
assert result["active_tenant_count"] == 2
|
||||
assert result["reasons"] == ["self_app_multiple_tenants"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
157
tests/test_feishu_tenant_routing.py
Normal file
157
tests/test_feishu_tenant_routing.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.commands import FeishuCommandService
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.constants import FeishuUserRole, FeishuUserStatus
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
|
||||
|
||||
class RecordingFeishuClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
text: str,
|
||||
receive_id: str | None,
|
||||
receive_id_type: str,
|
||||
uuid: str | None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(
|
||||
{
|
||||
"operation": "text",
|
||||
"receive_id": receive_id,
|
||||
"tenant_key": tenant_key,
|
||||
"uuid": uuid,
|
||||
}
|
||||
)
|
||||
return {"code": 0, "data": {"message_id": "om-text"}}
|
||||
|
||||
def send_card(
|
||||
self,
|
||||
card: dict[str, Any],
|
||||
receive_id: str | None,
|
||||
receive_id_type: str,
|
||||
uuid: str | None,
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(
|
||||
{
|
||||
"operation": "card",
|
||||
"receive_id": receive_id,
|
||||
"tenant_key": tenant_key,
|
||||
"uuid": uuid,
|
||||
}
|
||||
)
|
||||
return {"code": 0, "data": {"message_id": "om-card"}}
|
||||
|
||||
def upload_image(
|
||||
self,
|
||||
image: bytes,
|
||||
filename: str = "lifecycle-report.png",
|
||||
tenant_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(
|
||||
{
|
||||
"operation": "image",
|
||||
"filename": filename,
|
||||
"tenant_key": tenant_key,
|
||||
}
|
||||
)
|
||||
return {"code": 0, "data": {"image_key": "img-key"}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[Session]:
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli-routing")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "routing-secret")
|
||||
monkeypatch.setenv("FEISHU_DEFAULT_TENANT_KEY", "tenant-default")
|
||||
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine, tables=[AuditLog.__table__])
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
yield db
|
||||
finally:
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_service_routes_text_card_and_image_through_selected_tenant(
|
||||
database: Session,
|
||||
) -> None:
|
||||
service = FeishuService(database)
|
||||
client = RecordingFeishuClient()
|
||||
service.client = client
|
||||
|
||||
service.send_text("默认消息", receive_id="oc-default")
|
||||
service.set_tenant_key("tenant-a")
|
||||
service.send_card({"elements": []}, receive_id="oc-a")
|
||||
service.upload_image(b"png")
|
||||
service.send_text(
|
||||
"显式覆盖",
|
||||
receive_id="oc-b",
|
||||
tenant_key="tenant-b",
|
||||
)
|
||||
|
||||
assert [
|
||||
(item["operation"], item["tenant_key"])
|
||||
for item in client.calls
|
||||
] == [
|
||||
("text", "tenant-default"),
|
||||
("card", "tenant-a"),
|
||||
("image", "tenant-a"),
|
||||
("text", "tenant-b"),
|
||||
]
|
||||
|
||||
|
||||
def test_command_reply_uses_verified_principal_tenant(
|
||||
database: Session,
|
||||
) -> None:
|
||||
commands = FeishuCommandService(database)
|
||||
client = RecordingFeishuClient()
|
||||
commands.feishu.client = client
|
||||
principal = FeishuPrincipal(
|
||||
owner_id=1,
|
||||
user_code="FSU-routing",
|
||||
tenant_key="tenant-principal",
|
||||
open_id="ou-routing",
|
||||
union_id=None,
|
||||
feishu_user_id=None,
|
||||
role=FeishuUserRole.USER,
|
||||
status=FeishuUserStatus.ACTIVE,
|
||||
timezone="Asia/Shanghai",
|
||||
quiet_hours_start=None,
|
||||
quiet_hours_end=None,
|
||||
chat_id="oc-routing",
|
||||
chat_type="p2p",
|
||||
)
|
||||
|
||||
result = commands.handle_text(
|
||||
"帮助",
|
||||
auto_reply=True,
|
||||
principal=principal,
|
||||
)
|
||||
|
||||
assert result["command"] == "help"
|
||||
assert commands.feishu.tenant_key == "tenant-principal"
|
||||
assert client.calls == [
|
||||
{
|
||||
"operation": "text",
|
||||
"receive_id": "oc-routing",
|
||||
"tenant_key": "tenant-principal",
|
||||
"uuid": None,
|
||||
}
|
||||
]
|
||||
418
tests/test_feishu_users.py
Normal file
418
tests/test_feishu_users.py
Normal file
@@ -0,0 +1,418 @@
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.feishu_users.constants import (
|
||||
FeishuCapability,
|
||||
FeishuUserAuditAction,
|
||||
FeishuUserRole,
|
||||
FeishuUserStatus,
|
||||
parse_admin_identities,
|
||||
)
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
)
|
||||
from app.modules.feishu_users.routes import router
|
||||
from app.modules.feishu_users.services import (
|
||||
FeishuIdentityService,
|
||||
FeishuUserManagementService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory() -> Iterator[sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
AuditLog.__table__,
|
||||
FeishuAdminBootstrapTombstone.__table__,
|
||||
FeishuUser.__table__,
|
||||
MarketWatchlist.__table__,
|
||||
],
|
||||
)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
Base.metadata.drop_all(
|
||||
engine,
|
||||
tables=[
|
||||
MarketWatchlist.__table__,
|
||||
FeishuAdminBootstrapTombstone.__table__,
|
||||
FeishuUser.__table__,
|
||||
AuditLog.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_identity_registration_is_tenant_scoped_and_bootstraps_admin(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
service = FeishuIdentityService(
|
||||
db,
|
||||
admin_identities="tenant-a:ou-admin",
|
||||
)
|
||||
admin = service.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
union_id="on-admin",
|
||||
user_id="u-admin",
|
||||
)
|
||||
ordinary = service.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-user",
|
||||
)
|
||||
other_tenant = service.resolve_or_register(
|
||||
tenant_key="tenant-b",
|
||||
open_id="ou-user",
|
||||
)
|
||||
|
||||
assert admin.role == FeishuUserRole.ADMIN
|
||||
assert admin.is_admin is True
|
||||
assert admin.has_capability(FeishuCapability.USER_ADMINISTRATION) is True
|
||||
assert ordinary.role == FeishuUserRole.USER
|
||||
assert ordinary.has_capability(FeishuCapability.PERSONAL_AI) is True
|
||||
assert ordinary.has_capability(FeishuCapability.COMPANY_REPORTS) is False
|
||||
assert ordinary.owner_id != other_tenant.owner_id
|
||||
assert db.scalar(select(FeishuUser).where(FeishuUser.id == admin.owner_id)).union_id == (
|
||||
"on-admin"
|
||||
)
|
||||
|
||||
registrations = list(
|
||||
db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.action == FeishuUserAuditAction.REGISTER
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert len(registrations) == 3
|
||||
assert all("ou-" not in (item.request_payload or "") for item in registrations)
|
||||
|
||||
|
||||
def test_each_configured_admin_identity_can_bootstrap_once(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
service = FeishuIdentityService(
|
||||
db,
|
||||
admin_identities="tenant-a:ou-admin-a,tenant-b:ou-admin-b",
|
||||
)
|
||||
|
||||
first = service.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin-a",
|
||||
)
|
||||
second = service.resolve_or_register(
|
||||
tenant_key="tenant-b",
|
||||
open_id="ou-admin-b",
|
||||
)
|
||||
|
||||
assert first.role == FeishuUserRole.ADMIN
|
||||
assert second.role == FeishuUserRole.ADMIN
|
||||
|
||||
|
||||
def test_identity_refreshes_existing_sender_without_reapplying_admin_bootstrap(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-existing",
|
||||
)
|
||||
refreshed = FeishuIdentityService(
|
||||
db,
|
||||
admin_identities="tenant-a:ou-existing",
|
||||
).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-existing",
|
||||
union_id="on-existing",
|
||||
)
|
||||
|
||||
assert refreshed.owner_id == principal.owner_id
|
||||
assert refreshed.role == FeishuUserRole.USER
|
||||
assert refreshed.union_id == "on-existing"
|
||||
assert db.scalar(
|
||||
select(AuditLog).where(
|
||||
AuditLog.action == FeishuUserAuditAction.AUTHENTICATE
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_first_registration_claims_only_matching_legacy_watchlist(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
MarketWatchlist(
|
||||
actor="ou-claim",
|
||||
symbol="600000.SH",
|
||||
enabled=True,
|
||||
),
|
||||
MarketWatchlist(
|
||||
actor="ou-other",
|
||||
symbol="000001.SZ",
|
||||
enabled=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-claim",
|
||||
open_id="ou-claim",
|
||||
)
|
||||
|
||||
records = list(
|
||||
db.execute(
|
||||
select(MarketWatchlist).order_by(MarketWatchlist.id.asc())
|
||||
).scalars()
|
||||
)
|
||||
assert records[0].owner_id == principal.owner_id
|
||||
assert records[1].owner_id is None
|
||||
|
||||
|
||||
def test_identity_rejects_missing_parts_and_invalid_admin_config(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
assert parse_admin_identities("tenant-a:ou-a, tenant-b:ou-b") == {
|
||||
("tenant-a", "ou-a"),
|
||||
("tenant-b", "ou-b"),
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
parse_admin_identities("missing-separator")
|
||||
|
||||
with session_factory() as db:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id=" ",
|
||||
)
|
||||
assert exc_info.value.status_code == 422
|
||||
assert db.scalar(select(FeishuUser)) is None
|
||||
|
||||
|
||||
def test_management_protects_last_active_admin_and_audits_denial(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
identity = FeishuIdentityService(
|
||||
db,
|
||||
admin_identities="tenant-a:ou-admin",
|
||||
)
|
||||
admin = identity.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
)
|
||||
user = identity.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-user",
|
||||
)
|
||||
management = FeishuUserManagementService(db)
|
||||
|
||||
with pytest.raises(HTTPException) as demote_error:
|
||||
management.update_user(
|
||||
admin.user_code,
|
||||
changes={"role": FeishuUserRole.USER},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert demote_error.value.status_code == 409
|
||||
|
||||
with pytest.raises(HTTPException) as disable_error:
|
||||
management.update_user(
|
||||
admin.user_code,
|
||||
changes={"status": FeishuUserStatus.DISABLED},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert disable_error.value.status_code == 409
|
||||
|
||||
promoted = management.update_user(
|
||||
user.user_code,
|
||||
changes={"role": FeishuUserRole.ADMIN},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert promoted.role == FeishuUserRole.ADMIN
|
||||
demoted = management.update_user(
|
||||
admin.user_code,
|
||||
changes={"role": FeishuUserRole.USER},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert demoted.role == FeishuUserRole.USER
|
||||
|
||||
denied = list(
|
||||
db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.action == FeishuUserAuditAction.UPDATE_DENIED
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert len(denied) == 2
|
||||
assert all(item.actor == "service-admin" for item in denied)
|
||||
|
||||
|
||||
def test_management_validates_timezone_and_quiet_hours(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
user = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-user",
|
||||
)
|
||||
management = FeishuUserManagementService(db)
|
||||
updated = management.update_user(
|
||||
user.user_code,
|
||||
changes={
|
||||
"timezone": "Europe/London",
|
||||
"quiet_hours_start": "22:30",
|
||||
"quiet_hours_end": "07:15",
|
||||
},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert updated.timezone == "Europe/London"
|
||||
assert updated.quiet_hours_start.isoformat() == "22:30:00"
|
||||
assert updated.quiet_hours_end.isoformat() == "07:15:00"
|
||||
|
||||
with pytest.raises(HTTPException) as timezone_error:
|
||||
management.update_user(
|
||||
user.user_code,
|
||||
changes={"timezone": "Invalid/Nowhere"},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert timezone_error.value.status_code == 422
|
||||
|
||||
with pytest.raises(HTTPException) as quiet_error:
|
||||
management.update_user(
|
||||
user.user_code,
|
||||
changes={"quiet_hours_end": None},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert quiet_error.value.status_code == 422
|
||||
|
||||
cleared = management.update_user(
|
||||
user.user_code,
|
||||
changes={
|
||||
"quiet_hours_start": None,
|
||||
"quiet_hours_end": None,
|
||||
},
|
||||
actor="service-admin",
|
||||
)
|
||||
assert cleared.quiet_hours_start is None
|
||||
assert cleared.quiet_hours_end is None
|
||||
|
||||
|
||||
def test_disabled_principal_has_no_capabilities(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
principal = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-user",
|
||||
)
|
||||
FeishuUserManagementService(db).update_user(
|
||||
principal.user_code,
|
||||
changes={"status": FeishuUserStatus.DISABLED},
|
||||
actor="service-admin",
|
||||
)
|
||||
refreshed = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-user",
|
||||
)
|
||||
assert refreshed.is_active is False
|
||||
assert refreshed.has_capability(FeishuCapability.PERSONAL_AI) is False
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
refreshed.require_capability(FeishuCapability.PERSONAL_AI)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_internal_user_routes_use_api_principal_and_ignore_body_identity(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_KEY", "service-key")
|
||||
monkeypatch.setenv("API_ACTOR", "service-admin")
|
||||
monkeypatch.setenv("API_KEYS", "[]")
|
||||
get_settings.cache_clear()
|
||||
|
||||
with session_factory() as db:
|
||||
identity = FeishuIdentityService(
|
||||
db,
|
||||
admin_identities="tenant-a:ou-admin",
|
||||
)
|
||||
identity.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-admin",
|
||||
)
|
||||
user = identity.resolve_or_register(
|
||||
tenant_key="tenant-a",
|
||||
open_id="ou-user",
|
||||
)
|
||||
user_code = user.user_code
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/integrations/feishu")
|
||||
|
||||
def override_db() -> Iterator[Session]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
try:
|
||||
assert (
|
||||
client.get("/api/v1/integrations/feishu/users").status_code
|
||||
== 401
|
||||
)
|
||||
response = client.patch(
|
||||
f"/api/v1/integrations/feishu/users/{user_code}",
|
||||
headers={"X-API-Key": "service-key"},
|
||||
json={
|
||||
"role": "admin",
|
||||
"actor": "forged-user",
|
||||
"open_id": "ou-forged",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "admin"
|
||||
assert response.json()["open_id"] == "ou-user"
|
||||
|
||||
listed = client.get(
|
||||
"/api/v1/integrations/feishu/users?role=admin",
|
||||
headers={"X-API-Key": "service-key"},
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["total"] == 2
|
||||
detail = client.get(
|
||||
f"/api/v1/integrations/feishu/users/{user_code}",
|
||||
headers={"X-API-Key": "service-key"},
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
|
||||
with session_factory() as db:
|
||||
update_log = db.execute(
|
||||
select(AuditLog)
|
||||
.where(AuditLog.action == FeishuUserAuditAction.UPDATE)
|
||||
.order_by(AuditLog.id.desc())
|
||||
).scalars().first()
|
||||
assert update_log is not None
|
||||
assert update_log.actor == "service-admin"
|
||||
assert "forged-user" not in (update_log.request_payload or "")
|
||||
assert "ou-forged" not in (update_log.request_payload or "")
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
84
tests/test_feishu_webhook_verification.py
Normal file
84
tests/test_feishu_webhook_verification.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from hashlib import sha256
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.feishu.event_verification import FeishuWebhookVerifier
|
||||
|
||||
|
||||
def _encrypt_event(payload: dict, encrypt_key: str) -> str:
|
||||
key = sha256(encrypt_key.encode("utf-8")).digest()
|
||||
padder = PKCS7(algorithms.AES.block_size).padder()
|
||||
cleartext = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
padded = padder.update(cleartext) + padder.finalize()
|
||||
encryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).encryptor()
|
||||
return base64.b64encode(encryptor.update(padded) + encryptor.finalize()).decode()
|
||||
|
||||
|
||||
def test_plain_webhook_requires_the_configured_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verification-token")
|
||||
monkeypatch.delenv("FEISHU_ENCRYPT_KEY", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
raw = json.dumps(
|
||||
{"header": {"token": "verification-token"}, "event": {}}
|
||||
).encode()
|
||||
assert FeishuWebhookVerifier().verify(raw, {})["event"] == {}
|
||||
|
||||
invalid = json.dumps(
|
||||
{"header": {"token": "wrong-token"}, "event": {}}
|
||||
).encode()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuWebhookVerifier().verify(invalid, {})
|
||||
assert exc_info.value.status_code == 401
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_encrypted_webhook_requires_valid_signature_and_decrypts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
encrypt_key = "test-encrypt-key"
|
||||
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verification-token")
|
||||
monkeypatch.setenv("FEISHU_ENCRYPT_KEY", encrypt_key)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
event = {
|
||||
"header": {
|
||||
"token": "verification-token",
|
||||
"tenant_key": "tenant-a",
|
||||
},
|
||||
"event": {"sender": {"sender_id": {"open_id": "ou-a"}}},
|
||||
}
|
||||
raw = json.dumps(
|
||||
{"encrypt": _encrypt_event(event, encrypt_key)},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = "nonce"
|
||||
signature = sha256(
|
||||
timestamp.encode()
|
||||
+ nonce.encode()
|
||||
+ encrypt_key.encode()
|
||||
+ raw
|
||||
).hexdigest()
|
||||
headers = {
|
||||
"X-Lark-Request-Timestamp": timestamp,
|
||||
"X-Lark-Request-Nonce": nonce,
|
||||
"X-Lark-Signature": signature,
|
||||
}
|
||||
|
||||
assert FeishuWebhookVerifier().verify(raw, headers) == event
|
||||
|
||||
headers["X-Lark-Signature"] = "invalid"
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
FeishuWebhookVerifier().verify(raw, headers)
|
||||
assert exc_info.value.status_code == 401
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
69
tests/test_lifecycle_queue_failure.py
Normal file
69
tests/test_lifecycle_queue_failure.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.background.task_queue import lifecycle as lifecycle_queue
|
||||
from app.core.database import Base
|
||||
from app.modules.reports.constants import ReportType
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||
from app.tasks import celery_app
|
||||
|
||||
|
||||
def test_lifecycle_enqueue_failure_marks_workflow_failed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
class BrokenSignature:
|
||||
def apply_async(self) -> None:
|
||||
raise RuntimeError("broker unavailable")
|
||||
|
||||
monkeypatch.setattr(lifecycle_queue, "SessionLocal", factory)
|
||||
monkeypatch.setattr(
|
||||
lifecycle_queue,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(task_queue_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
celery_app,
|
||||
"signature",
|
||||
lambda *args, **kwargs: BrokenSignature(),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="broker unavailable"):
|
||||
lifecycle_queue.enqueue_lifecycle_report(
|
||||
report_type=ReportType.WEEKLY,
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
with factory() as db:
|
||||
workflow = db.execute(
|
||||
select(WorkflowInstance).where(
|
||||
WorkflowInstance.workflow_type
|
||||
== WorkflowType.LIFECYCLE_REPORT
|
||||
)
|
||||
).scalar_one()
|
||||
actions = list(
|
||||
db.execute(
|
||||
select(WorkflowAction)
|
||||
.where(WorkflowAction.workflow_code == workflow.code)
|
||||
.order_by(WorkflowAction.id.asc())
|
||||
).scalars()
|
||||
)
|
||||
|
||||
assert workflow.status == WorkflowStatus.FAILED
|
||||
assert workflow.current_step == "enqueue_failed"
|
||||
assert workflow.completed_at is not None
|
||||
assert workflow.payload == {"error": "broker unavailable"}
|
||||
assert [action.action for action in actions] == [
|
||||
"queued",
|
||||
"enqueue_failed",
|
||||
]
|
||||
assert actions[-1].to_status == WorkflowStatus.FAILED
|
||||
finally:
|
||||
engine.dispose()
|
||||
95
tests/test_memory_retention_boundaries.py
Normal file
95
tests/test_memory_retention_boundaries.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.security import ApiPrincipal
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.constants import AIMemoryResponseKey, AIMemoryStatus
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.ai_memory.routes import list_memory
|
||||
from app.modules.dashboard.routes import dashboard_summary
|
||||
from app.modules.observability.routes import metrics as observability_metrics
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _seed_expired_memory(factory: sessionmaker, code: str, subject: str) -> None:
|
||||
with factory() as db:
|
||||
db.add(
|
||||
AIMemoryEntry(
|
||||
code=code,
|
||||
scope="project",
|
||||
subject=subject,
|
||||
content="expired",
|
||||
status=AIMemoryStatus.ACTIVE,
|
||||
expires_at=utc_now() - timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _assert_archived(factory: sessionmaker, code: str) -> None:
|
||||
with factory() as db:
|
||||
stored = db.execute(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.code == code)
|
||||
).scalar_one()
|
||||
assert stored.status == AIMemoryStatus.ARCHIVED
|
||||
|
||||
|
||||
def test_memory_list_route_persists_expiration_at_request_boundary() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
code = "MEM-LIST-EXPIRED"
|
||||
_seed_expired_memory(factory, code, "P-LIST")
|
||||
|
||||
with factory() as db:
|
||||
result = list_memory(
|
||||
scope="project",
|
||||
subject="P-LIST",
|
||||
status=AIMemoryStatus.ACTIVE,
|
||||
limit=100,
|
||||
db=db,
|
||||
)
|
||||
assert result[AIMemoryResponseKey.ITEMS] == []
|
||||
|
||||
_assert_archived(factory, code)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_observability_metrics_route_persists_memory_expiration() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
code = "MEM-METRICS-EXPIRED"
|
||||
_seed_expired_memory(factory, code, "P-METRICS")
|
||||
|
||||
with factory() as db:
|
||||
observability_metrics(db=db)
|
||||
|
||||
_assert_archived(factory, code)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_dashboard_route_excludes_and_archives_expired_memory() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
code = "MEM-DASHBOARD-EXPIRED"
|
||||
_seed_expired_memory(factory, code, "P-DASHBOARD")
|
||||
|
||||
with factory() as db:
|
||||
result = dashboard_summary(
|
||||
db=db,
|
||||
principal=ApiPrincipal(actor="pytest"),
|
||||
)
|
||||
assert result["metrics"]["active_ai_memory"] == 0
|
||||
|
||||
_assert_archived(factory, code)
|
||||
finally:
|
||||
engine.dispose()
|
||||
469
tests/test_personal_data_erasure.py
Normal file
469
tests/test_personal_data_erasure.py
Normal file
@@ -0,0 +1,469 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.application.feishu.personal_data import (
|
||||
PERSONAL_DATA_ERASURE_ACTION,
|
||||
FeishuPersonalDataService,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.feishu_users.routes import router
|
||||
from app.modules.feishu_users.services import FeishuIdentityService
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
PersonalDataErasureRequest,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.schemas import ErasureResult
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory() -> Iterator[sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def enable_foreign_keys(dbapi_connection, _connection_record) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _create_user(
|
||||
db: Session,
|
||||
suffix: str,
|
||||
*,
|
||||
role: str = FeishuUserRole.USER,
|
||||
) -> FeishuUser:
|
||||
user = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"ou-open-{suffix}",
|
||||
union_id=f"on-union-{suffix}",
|
||||
user_id=f"cli-user-{suffix}",
|
||||
role=role,
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def _seed_personal_data(db: Session, user: FeishuUser) -> None:
|
||||
db.add(
|
||||
UserPreference(
|
||||
owner_id=user.id,
|
||||
category="tone",
|
||||
value="简洁",
|
||||
normalized_value="简洁",
|
||||
source="explicit",
|
||||
)
|
||||
)
|
||||
conversation = AIConversation(
|
||||
owner_id=user.id,
|
||||
chat_type="private",
|
||||
chat_key=user.open_id,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.flush()
|
||||
db.add(
|
||||
AIConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="user",
|
||||
content="仅属于我的历史",
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AIMemoryEntry(
|
||||
code=f"MEM-{uuid4().hex}",
|
||||
owner_id=user.id,
|
||||
kind="memory",
|
||||
scope="user",
|
||||
subject="preference",
|
||||
content="个人记忆",
|
||||
source="explicit",
|
||||
actor=user.open_id,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
MarketWatchlist(
|
||||
owner_id=user.id,
|
||||
actor=user.open_id,
|
||||
symbol="600000.SH",
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
subscription = PushSubscription(
|
||||
code=f"SUB-{uuid4().hex}",
|
||||
owner_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.open_id,
|
||||
prompt="个人提醒",
|
||||
schedule_type="daily",
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone="Asia/Shanghai",
|
||||
next_run_at=utc_now() + timedelta(days=1),
|
||||
status="active",
|
||||
consented_at=utc_now(),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.flush()
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code=f"DEL-{uuid4().hex}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=utc_now(),
|
||||
idempotency_key=uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status="pending",
|
||||
next_attempt_at=utc_now(),
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor=f"feishu:{user.open_id}",
|
||||
source="feishu",
|
||||
action="test.personal.action",
|
||||
target_type="feishu-user",
|
||||
target_id=user.code,
|
||||
request_payload=json.dumps(
|
||||
{
|
||||
"open_id": user.open_id,
|
||||
"union_id": user.union_id,
|
||||
"private_note": "仅属于该用户的请求内容",
|
||||
}
|
||||
),
|
||||
response_payload=json.dumps(
|
||||
{
|
||||
"user_id": user.user_id,
|
||||
"code": user.code,
|
||||
"private_answer": "仅属于该用户的响应内容",
|
||||
}
|
||||
),
|
||||
request_id=f"request-{user.code}",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_confirmation_is_one_user_only_and_expires(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
first = _create_user(db, "first")
|
||||
second = _create_user(db, "second")
|
||||
db.commit()
|
||||
first_principal = FeishuPrincipal.from_user(first)
|
||||
second_principal = FeishuPrincipal.from_user(second)
|
||||
service = FeishuPersonalDataService(db)
|
||||
|
||||
confirmation = service.request_confirmation(first_principal)
|
||||
with pytest.raises(HTTPException) as wrong_code:
|
||||
service.confirm(first_principal, "WRONG")
|
||||
assert wrong_code.value.status_code == 422
|
||||
|
||||
with pytest.raises(HTTPException) as cross_user:
|
||||
service.confirm(second_principal, confirmation.confirmation_code)
|
||||
assert cross_user.value.status_code == 422
|
||||
|
||||
request = db.execute(
|
||||
select(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.owner_id == first.id
|
||||
)
|
||||
).scalar_one()
|
||||
request.expires_at = utc_now() - timedelta(seconds=1)
|
||||
db.commit()
|
||||
with pytest.raises(HTTPException) as expired:
|
||||
service.confirm(first_principal, confirmation.confirmation_code)
|
||||
assert expired.value.status_code == 422
|
||||
assert db.get(FeishuUser, first.id) is not None
|
||||
assert db.get(FeishuUser, second.id) is not None
|
||||
|
||||
|
||||
def test_confirm_erases_all_personal_data_and_anonymizes_audit(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
user = _create_user(db, "erase")
|
||||
other = _create_user(db, "other")
|
||||
_seed_personal_data(db, user)
|
||||
_seed_personal_data(db, other)
|
||||
owner_id = user.id
|
||||
tenant_key = user.tenant_key
|
||||
open_id = user.open_id
|
||||
identifiers = (user.code, user.open_id, user.union_id, user.user_id)
|
||||
principal = FeishuPrincipal.from_user(user)
|
||||
service = FeishuPersonalDataService(db)
|
||||
|
||||
confirmation = service.request_confirmation(principal)
|
||||
result = service.confirm(principal, confirmation.confirmation_code)
|
||||
|
||||
assert result.deleted["deliveries"] == 1
|
||||
assert result.deleted["subscriptions"] == 1
|
||||
assert result.deleted["preferences"] == 1
|
||||
assert result.deleted["conversation_messages"] == 1
|
||||
assert result.deleted["conversations"] == 1
|
||||
assert result.deleted["ai_memory"] == 1
|
||||
assert result.deleted["watchlist"] == 1
|
||||
assert result.deleted["identity"] == 1
|
||||
assert db.get(FeishuUser, owner_id) is None
|
||||
assert db.get(FeishuUser, other.id) is not None
|
||||
assert db.scalar(
|
||||
select(PushSubscription).where(PushSubscription.owner_id == owner_id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(UserPreference).where(UserPreference.owner_id == owner_id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(AIConversation).where(AIConversation.owner_id == owner_id)
|
||||
) is None
|
||||
|
||||
logs = list(db.execute(select(AuditLog)).scalars())
|
||||
serialized_logs = "\n".join(
|
||||
" ".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
log.actor,
|
||||
log.target_id,
|
||||
log.request_payload,
|
||||
log.response_payload,
|
||||
)
|
||||
)
|
||||
for log in logs
|
||||
)
|
||||
assert all(identifier not in serialized_logs for identifier in identifiers)
|
||||
final_log = db.execute(
|
||||
select(AuditLog).where(AuditLog.action == PERSONAL_DATA_ERASURE_ACTION)
|
||||
).scalar_one()
|
||||
assert final_log.actor == result.anonymous_id
|
||||
assert final_log.target_type is None
|
||||
assert final_log.target_id is None
|
||||
assert final_log.request_payload is None
|
||||
assert final_log.response_payload is None
|
||||
assert final_log.request_id is None
|
||||
assert final_log.status == "success"
|
||||
|
||||
anonymized_history = db.execute(
|
||||
select(AuditLog).where(
|
||||
AuditLog.action == "test.personal.action",
|
||||
AuditLog.actor == result.anonymous_id,
|
||||
)
|
||||
).scalar_one()
|
||||
assert anonymized_history.target_type is None
|
||||
assert anonymized_history.target_id is None
|
||||
assert anonymized_history.request_payload is None
|
||||
assert anonymized_history.response_payload is None
|
||||
assert anonymized_history.request_id is None
|
||||
assert anonymized_history.status == "success"
|
||||
|
||||
recreated = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key=tenant_key,
|
||||
open_id=open_id,
|
||||
)
|
||||
assert recreated.owner_id != owner_id
|
||||
assert recreated.user_code not in identifiers
|
||||
assert recreated.role == FeishuUserRole.USER
|
||||
|
||||
|
||||
def test_last_active_admin_is_protected_until_another_admin_exists(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
admin = _create_user(db, "admin", role=FeishuUserRole.ADMIN)
|
||||
db.commit()
|
||||
principal = FeishuPrincipal.from_user(admin)
|
||||
service = FeishuPersonalDataService(db)
|
||||
confirmation = service.request_confirmation(principal)
|
||||
|
||||
with pytest.raises(HTTPException) as protected:
|
||||
service.confirm(principal, confirmation.confirmation_code)
|
||||
assert protected.value.status_code == 409
|
||||
assert db.get(FeishuUser, admin.id) is not None
|
||||
|
||||
second_admin = _create_user(
|
||||
db,
|
||||
"second-admin",
|
||||
role=FeishuUserRole.ADMIN,
|
||||
)
|
||||
db.commit()
|
||||
result = service.confirm(principal, confirmation.confirmation_code)
|
||||
assert result.deleted["identity"] == 1
|
||||
assert db.get(FeishuUser, admin.id) is None
|
||||
assert db.get(FeishuUser, second_admin.id) is not None
|
||||
|
||||
|
||||
def test_erased_configured_admin_is_not_bootstrapped_again(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FEISHU_ADMIN_IDENTITIES",
|
||||
"tenant-bootstrap:ou-bootstrap",
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with session_factory() as db:
|
||||
identity = FeishuIdentityService(db)
|
||||
initial = identity.resolve_or_register(
|
||||
tenant_key="tenant-bootstrap",
|
||||
open_id="ou-bootstrap",
|
||||
)
|
||||
assert initial.role == FeishuUserRole.ADMIN
|
||||
_create_user(db, "replacement-admin", role=FeishuUserRole.ADMIN)
|
||||
db.commit()
|
||||
|
||||
service = FeishuPersonalDataService(db)
|
||||
confirmation = service.request_confirmation(initial)
|
||||
result = service.confirm(initial, confirmation.confirmation_code)
|
||||
|
||||
tombstone = db.scalar(select(FeishuAdminBootstrapTombstone))
|
||||
assert tombstone is not None
|
||||
assert len(tombstone.identity_hash) == 64
|
||||
assert "tenant-bootstrap" not in tombstone.identity_hash
|
||||
assert "ou-bootstrap" not in tombstone.identity_hash
|
||||
|
||||
recreated = FeishuIdentityService(db).resolve_or_register(
|
||||
tenant_key="tenant-bootstrap",
|
||||
open_id="ou-bootstrap",
|
||||
)
|
||||
assert recreated.owner_id != initial.owner_id
|
||||
assert recreated.role == FeishuUserRole.USER
|
||||
assert result.deleted["identity"] == 1
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_extra_hook_failure_rolls_back_the_deletion_transaction(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
user = _create_user(db, "rollback")
|
||||
_seed_personal_data(db, user)
|
||||
owner_id = user.id
|
||||
principal = FeishuPrincipal.from_user(user)
|
||||
confirmation = FeishuPersonalDataService(db).request_confirmation(principal)
|
||||
|
||||
def fail_hook(_db: Session, _owner_id: int, _anonymous_id: str) -> None:
|
||||
raise RuntimeError("integration erasure failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="integration erasure failed"):
|
||||
FeishuPersonalDataService(
|
||||
db,
|
||||
extra_hooks=(fail_hook,),
|
||||
).confirm(principal, confirmation.confirmation_code)
|
||||
|
||||
assert db.get(FeishuUser, owner_id) is not None
|
||||
assert db.scalar(
|
||||
select(UserPreference).where(UserPreference.owner_id == owner_id)
|
||||
) is not None
|
||||
assert db.scalar(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
|
||||
) is not None
|
||||
assert db.scalar(
|
||||
select(PushSubscription).where(PushSubscription.owner_id == owner_id)
|
||||
) is not None
|
||||
assert db.scalar(
|
||||
select(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.owner_id == owner_id
|
||||
)
|
||||
) is not None
|
||||
|
||||
|
||||
def test_delete_api_uses_service_principal_actor_and_ignores_body_identity(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("API_KEY", "service-key")
|
||||
monkeypatch.setenv("API_ACTOR", "service-admin")
|
||||
monkeypatch.setenv("API_KEYS", "[]")
|
||||
get_settings.cache_clear()
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def fake_erase(
|
||||
_service: FeishuPersonalDataService,
|
||||
code: str,
|
||||
*,
|
||||
actor: str,
|
||||
) -> ErasureResult:
|
||||
seen.update(code=code, actor=actor)
|
||||
return ErasureResult(
|
||||
anonymous_id="anonymous-test",
|
||||
deleted={"identity": 1},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
FeishuPersonalDataService,
|
||||
"erase_by_user_code",
|
||||
fake_erase,
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/integrations/feishu")
|
||||
|
||||
def override_db() -> Iterator[Session]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
try:
|
||||
path = "/api/v1/integrations/feishu/users/FSU-target/personal-data"
|
||||
assert client.delete(path).status_code == 401
|
||||
assert (
|
||||
client.delete(path, headers={"X-API-Key": "wrong-key"}).status_code
|
||||
== 401
|
||||
)
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
path,
|
||||
headers={"X-API-Key": "service-key"},
|
||||
json={
|
||||
"actor": "forged-user",
|
||||
"open_id": "ou-forged",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["anonymous_id"] == "anonymous-test"
|
||||
assert seen == {
|
||||
"code": "FSU-target",
|
||||
"actor": "service-admin",
|
||||
}
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
365
tests/test_personalization.py
Normal file
365
tests/test_personalization.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.constants import AIMemoryScope
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.market.service import MarketService
|
||||
from app.modules.personalization.constants import (
|
||||
CONVERSATION_MAX_MESSAGES,
|
||||
PersonalizationContextKey,
|
||||
)
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
PersonalDataErasureRequest,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.services import (
|
||||
ConversationService,
|
||||
PersonalDataErasureService,
|
||||
PersonalizationContextService,
|
||||
PreferenceService,
|
||||
)
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _user(db: Session, suffix: str, *, open_id: str | None = None) -> FeishuUser:
|
||||
record = FeishuUser(
|
||||
code=f"USR-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=open_id or f"open-{suffix}",
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def test_rules_memory_and_watchlists_are_owner_scoped(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
|
||||
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
first = _user(db, "first", open_id="same-open-id")
|
||||
second = _user(db, "second", open_id="same-open-id")
|
||||
memory = AIMemoryService(db)
|
||||
company_rule = memory.create_rule(
|
||||
content="公司规则",
|
||||
scope=AIMemoryScope.GLOBAL,
|
||||
subject="company",
|
||||
priority=90,
|
||||
tags=[],
|
||||
actor="service",
|
||||
)
|
||||
first_rule = memory.create_rule(
|
||||
content="相同个人规则",
|
||||
scope=AIMemoryScope.USER,
|
||||
subject="profile",
|
||||
priority=50,
|
||||
tags=[],
|
||||
actor="first",
|
||||
owner_id=first.id,
|
||||
)
|
||||
second_rule = memory.create_rule(
|
||||
content="相同个人规则",
|
||||
scope=AIMemoryScope.USER,
|
||||
subject="profile",
|
||||
priority=50,
|
||||
tags=[],
|
||||
actor="second",
|
||||
owner_id=second.id,
|
||||
)
|
||||
|
||||
assert [item["code"] for item in memory.list_rules()] == [company_rule["code"]]
|
||||
assert [item["code"] for item in memory.list_rules(owner_id=first.id)] == [
|
||||
first_rule["code"]
|
||||
]
|
||||
assert [item["code"] for item in memory.list_rules(owner_id=second.id)] == [
|
||||
second_rule["code"]
|
||||
]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
memory.update_rule(
|
||||
code=second_rule["code"],
|
||||
content="越权修改",
|
||||
priority=None,
|
||||
tags=None,
|
||||
enabled=None,
|
||||
actor="first",
|
||||
owner_id=first.id,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
first_memory = memory.auto_write(
|
||||
prompt="Remember concise project risk summaries",
|
||||
context={"scope": "user", "subject": "profile"},
|
||||
answer="Use concise project risk summaries.",
|
||||
actor="first",
|
||||
owner_id=first.id,
|
||||
)
|
||||
second_memory = memory.auto_write(
|
||||
prompt="Remember concise project risk summaries",
|
||||
context={"scope": "user", "subject": "profile"},
|
||||
answer="Use concise project risk summaries.",
|
||||
actor="second",
|
||||
owner_id=second.id,
|
||||
)
|
||||
assert first_memory is not None
|
||||
assert second_memory is not None
|
||||
assert first_memory.code != second_memory.code
|
||||
assert first_memory.fingerprint != second_memory.fingerprint
|
||||
|
||||
market = MarketService(db)
|
||||
market.add_watchlist("same-open-id", "600000", owner_id=first.id)
|
||||
market.add_watchlist("same-open-id", "600000", owner_id=second.id)
|
||||
assert market.watchlist("ignored", owner_id=first.id) == [{"symbol": "600000.SH"}]
|
||||
assert market.watchlist("ignored", owner_id=second.id) == [{"symbol": "600000.SH"}]
|
||||
|
||||
market.add_watchlist("legacy-open", "000001")
|
||||
assert market.watchlist("legacy-open") == [{"symbol": "000001.SZ"}]
|
||||
assert market.claim_legacy_watchlist(first.id, "legacy-open") == 1
|
||||
assert market.watchlist("legacy-open") == []
|
||||
assert market.watchlist("ignored", owner_id=first.id) == [
|
||||
{"symbol": "600000.SH"},
|
||||
{"symbol": "000001.SZ"},
|
||||
]
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_preferences_are_allowlisted_sensitive_safe_and_owner_scoped() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
first = _user(db, "pref-first")
|
||||
second = _user(db, "pref-second")
|
||||
service = PreferenceService(db)
|
||||
first_pref = service.upsert(first.id, "tone", "简洁直接")
|
||||
second_pref = service.upsert(second.id, "tone", "简洁直接")
|
||||
|
||||
assert first_pref["code"] != second_pref["code"]
|
||||
assert service.list_preferences(first.id) == [first_pref]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
service.delete(first.id, second_pref["code"])
|
||||
assert exc_info.value.status_code == 404
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
service.upsert(first.id, "topic", "记住我的银行账号 123456")
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
assert (
|
||||
service.save_auto_extraction(
|
||||
first.id,
|
||||
provider_name="noop",
|
||||
user_text="以后请用中文",
|
||||
structured_payload={
|
||||
"preferences": [{"category": "language", "value": "中文"}]
|
||||
},
|
||||
)
|
||||
== []
|
||||
)
|
||||
assert (
|
||||
service.save_auto_extraction(
|
||||
first.id,
|
||||
provider_name="direct_llm",
|
||||
user_text="今天怎么样",
|
||||
structured_payload={
|
||||
"preferences": [{"category": "language", "value": "中文"}]
|
||||
},
|
||||
)
|
||||
== []
|
||||
)
|
||||
saved = service.save_auto_extraction(
|
||||
first.id,
|
||||
provider_name="direct_llm",
|
||||
user_text="以后请用中文,并记住我的健康诊断",
|
||||
structured_payload={
|
||||
"preferences": [
|
||||
{"category": "language", "value": "中文"},
|
||||
{"category": "topic", "value": "我的健康诊断"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert [(item["category"], item["value"]) for item in saved] == [
|
||||
("language", "中文")
|
||||
]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_conversations_keep_twenty_turns_and_isolate_sessions() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
first = _user(db, "conversation-first")
|
||||
second = _user(db, "conversation-second")
|
||||
service = ConversationService(db)
|
||||
assert (
|
||||
service.record_turn(
|
||||
second.id,
|
||||
"private",
|
||||
"chat-1",
|
||||
user_content="placeholder",
|
||||
assistant_content="placeholder",
|
||||
provider_name="noop",
|
||||
)
|
||||
is False
|
||||
)
|
||||
for index in range(21):
|
||||
assert service.record_turn(
|
||||
first.id,
|
||||
"private",
|
||||
"chat-1",
|
||||
user_content=f"user-{index}",
|
||||
assistant_content=f"assistant-{index}",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
|
||||
history = service.history(first.id, "private", "chat-1")
|
||||
assert len(history) == CONVERSATION_MAX_MESSAGES
|
||||
assert history[0]["content"] == "user-1"
|
||||
assert history[-1]["content"] == "assistant-20"
|
||||
assert service.history(second.id, "private", "chat-1") == []
|
||||
assert service.provider_session_id(first.id, "private", "chat-1") == (
|
||||
service.provider_session_id(first.id, "p2p", "chat-1")
|
||||
)
|
||||
assert service.provider_session_id(first.id, "group", "chat-1") != (
|
||||
service.provider_session_id(second.id, "group", "chat-1")
|
||||
)
|
||||
|
||||
conversation = db.execute(
|
||||
select(AIConversation).where(AIConversation.owner_id == first.id)
|
||||
).scalar_one()
|
||||
db.execute(
|
||||
update(AIConversationMessage)
|
||||
.where(AIConversationMessage.conversation_id == conversation.id)
|
||||
.values(created_at=utc_now() - timedelta(days=31))
|
||||
)
|
||||
db.commit()
|
||||
assert service.history(first.id, "private", "chat-1") == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_context_order_and_erasure_core_hooks() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
owner = _user(db, "context-owner")
|
||||
other = _user(db, "context-other")
|
||||
memory = AIMemoryService(db)
|
||||
memory.create_rule(
|
||||
content="公司规则优先",
|
||||
scope="global",
|
||||
subject="company",
|
||||
priority=100,
|
||||
tags=[],
|
||||
actor="service",
|
||||
)
|
||||
memory.create_rule(
|
||||
content="个人规则",
|
||||
scope="global",
|
||||
subject="profile",
|
||||
priority=80,
|
||||
tags=[],
|
||||
actor="owner",
|
||||
owner_id=owner.id,
|
||||
)
|
||||
PreferenceService(db).upsert(owner.id, "tone", "简洁")
|
||||
PreferenceService(db).upsert(owner.id, "interest", "人工智能")
|
||||
MarketService(db).add_watchlist("owner-open", "600000", owner_id=owner.id)
|
||||
ConversationService(db).record_turn(
|
||||
owner.id,
|
||||
"private",
|
||||
"chat-context",
|
||||
user_content="上一问",
|
||||
assistant_content="上一答",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
|
||||
context = PersonalizationContextService(db).build(
|
||||
owner_id=owner.id,
|
||||
request="当前请求",
|
||||
system_constraints="只读安全规则",
|
||||
chat_type="private",
|
||||
chat_key="chat-context",
|
||||
subject="profile",
|
||||
actor="owner",
|
||||
)
|
||||
assert [str(key) for key in context.as_ordered_dict()] == [
|
||||
PersonalizationContextKey.SYSTEM_CONSTRAINTS,
|
||||
PersonalizationContextKey.COMPANY_RULES,
|
||||
PersonalizationContextKey.PERSONAL_RULES,
|
||||
PersonalizationContextKey.CURRENT_REQUEST,
|
||||
PersonalizationContextKey.PREFERENCES,
|
||||
PersonalizationContextKey.INTERESTS,
|
||||
PersonalizationContextKey.PERSONAL_MEMORY,
|
||||
PersonalizationContextKey.CONVERSATION_HISTORY,
|
||||
]
|
||||
assert context.company_rules[0]["rule"] == "公司规则优先"
|
||||
assert context.personal_rules[0]["rule"] == "个人规则"
|
||||
assert {item["value"] for item in context.interests} == {
|
||||
"人工智能",
|
||||
"600000.SH",
|
||||
}
|
||||
assert context.conversation_history[-1]["content"] == "上一答"
|
||||
|
||||
confirmation = PersonalDataErasureService(db).request_confirmation(owner.id)
|
||||
with pytest.raises(HTTPException):
|
||||
PersonalDataErasureService(db).confirm_and_erase(owner.id, "BAD-CODE")
|
||||
|
||||
seen: dict[str, str | int] = {}
|
||||
|
||||
def integration_hook(
|
||||
_db: Session,
|
||||
owner_id: int,
|
||||
anonymous_id: str,
|
||||
) -> dict[str, int]:
|
||||
seen.update(owner_id=owner_id, anonymous_id=anonymous_id)
|
||||
return {"subscriptions": 0}
|
||||
|
||||
erased = PersonalDataErasureService(db).confirm_and_erase(
|
||||
owner.id,
|
||||
confirmation.confirmation_code,
|
||||
extra_hooks=(integration_hook,),
|
||||
)
|
||||
assert erased.deleted["preferences"] == 2
|
||||
assert erased.deleted["conversations"] == 1
|
||||
assert erased.deleted["ai_memory"] == 1
|
||||
assert erased.deleted["watchlist"] == 1
|
||||
assert seen["owner_id"] == owner.id
|
||||
assert seen["anonymous_id"] == erased.anonymous_id
|
||||
assert db.scalar(
|
||||
select(UserPreference).where(UserPreference.owner_id == owner.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
select(PersonalDataErasureRequest).where(
|
||||
PersonalDataErasureRequest.owner_id == owner.id
|
||||
)
|
||||
) is None
|
||||
assert db.get(FeishuUser, other.id) is not None
|
||||
assert memory.active_rules()[0]["rule"] == "公司规则优先"
|
||||
finally:
|
||||
engine.dispose()
|
||||
167
tests/test_personalization_cleanup.py
Normal file
167
tests/test_personalization_cleanup.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from collections.abc import Iterator
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.application.scheduling import create_scheduler
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.personalization.models import AIConversation, AIConversationMessage
|
||||
from app.modules.personalization.services import ConversationService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_settings() -> Iterator[None]:
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory() -> Iterator[sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_global_cleanup_removes_inactive_user_history(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
expired = _conversation(db, "expired")
|
||||
mixed = _conversation(db, "mixed")
|
||||
fresh = _conversation(db, "fresh")
|
||||
old_time = utc_now() - timedelta(days=31)
|
||||
fresh_time = utc_now() - timedelta(days=29)
|
||||
db.add_all(
|
||||
[
|
||||
AIConversationMessage(
|
||||
conversation_id=expired.id,
|
||||
role="user",
|
||||
content="expired question",
|
||||
created_at=old_time,
|
||||
),
|
||||
AIConversationMessage(
|
||||
conversation_id=expired.id,
|
||||
role="assistant",
|
||||
content="expired answer",
|
||||
created_at=old_time,
|
||||
),
|
||||
AIConversationMessage(
|
||||
conversation_id=mixed.id,
|
||||
role="user",
|
||||
content="old mixed question",
|
||||
created_at=old_time,
|
||||
),
|
||||
AIConversationMessage(
|
||||
conversation_id=mixed.id,
|
||||
role="assistant",
|
||||
content="fresh mixed answer",
|
||||
created_at=fresh_time,
|
||||
),
|
||||
AIConversationMessage(
|
||||
conversation_id=fresh.id,
|
||||
role="user",
|
||||
content="fresh question",
|
||||
created_at=fresh_time,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
deleted = ConversationService(db).cleanup_expired_globally()
|
||||
|
||||
assert deleted == {
|
||||
"conversation_messages": 3,
|
||||
"conversations": 1,
|
||||
}
|
||||
assert db.get(AIConversation, expired.id) is None
|
||||
assert db.get(AIConversation, mixed.id) is not None
|
||||
assert db.get(AIConversation, fresh.id) is not None
|
||||
assert db.scalar(
|
||||
select(func.count()).select_from(AIConversationMessage)
|
||||
) == 2
|
||||
|
||||
with session_factory() as verification_db:
|
||||
assert verification_db.get(AIConversation, expired.id) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("features_enabled", "job_expected"),
|
||||
[(False, False), (True, True)],
|
||||
)
|
||||
def test_scheduler_wires_one_global_retention_job(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
features_enabled: bool,
|
||||
job_expected: bool,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FEISHU_USER_FEATURES_ENABLED",
|
||||
str(features_enabled).lower(),
|
||||
)
|
||||
monkeypatch.setattr("app.core.database.SessionLocal", session_factory)
|
||||
get_settings.cache_clear()
|
||||
app = FastAPI()
|
||||
scheduler = create_scheduler(app)
|
||||
|
||||
job = scheduler.get_job("personalization_retention_cleanup")
|
||||
|
||||
assert (job is not None) is job_expected
|
||||
if job is None:
|
||||
return
|
||||
assert job.trigger.interval == timedelta(minutes=1)
|
||||
|
||||
with session_factory() as db:
|
||||
expired = _conversation(db, "scheduled")
|
||||
db.add(
|
||||
AIConversationMessage(
|
||||
conversation_id=expired.id,
|
||||
role="user",
|
||||
content="expired scheduled message",
|
||||
created_at=utc_now() - timedelta(days=31),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
expired_id = expired.id
|
||||
|
||||
job.func()
|
||||
|
||||
assert app.state.last_personalization_retention_cleanup == {
|
||||
"conversation_messages": 1,
|
||||
"conversations": 1,
|
||||
}
|
||||
with session_factory() as db:
|
||||
assert db.get(AIConversation, expired_id) is None
|
||||
|
||||
|
||||
def _conversation(db: Session, suffix: str) -> AIConversation:
|
||||
owner = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
)
|
||||
db.add(owner)
|
||||
db.flush()
|
||||
conversation = AIConversation(
|
||||
owner_id=owner.id,
|
||||
chat_type="private",
|
||||
chat_key=f"chat-{suffix}",
|
||||
)
|
||||
db.add(conversation)
|
||||
db.flush()
|
||||
return conversation
|
||||
361
tests/test_personalized_ai.py
Normal file
361
tests/test_personalized_ai.py
Normal file
@@ -0,0 +1,361 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.modules.ai_agent.adapters.common import _ordered_context
|
||||
from app.modules.ai_agent.constants import (
|
||||
PREFERENCE_EXTRACTION_INSTRUCTIONS,
|
||||
AIContextKey,
|
||||
AIExecutionMode,
|
||||
AIResponseKey,
|
||||
)
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.ai_memory.constants import AIMemoryKind
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.business.models import MarketWatchlist
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.market.service import MarketService
|
||||
from app.modules.personalization.models import (
|
||||
AIConversation,
|
||||
AIConversationMessage,
|
||||
UserPreference,
|
||||
)
|
||||
from app.modules.personalization.services import ConversationService, PreferenceService
|
||||
|
||||
|
||||
class CapturingAdapter:
|
||||
provider_name = "direct_llm"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def ask(
|
||||
self,
|
||||
prompt: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append((prompt, dict(context or {})))
|
||||
if prompt == PREFERENCE_EXTRACTION_INSTRUCTIONS:
|
||||
return {
|
||||
AIResponseKey.ANSWER: (
|
||||
'{"preferences":[{"category":"language","value":"中文"}]}'
|
||||
),
|
||||
AIResponseKey.RAW: {},
|
||||
}
|
||||
return {
|
||||
AIResponseKey.ANSWER: "personalized answer",
|
||||
AIResponseKey.RAW: {"request": len(self.calls)},
|
||||
}
|
||||
|
||||
|
||||
class FailingNoopAdapter:
|
||||
provider_name = "noop"
|
||||
|
||||
def ask(
|
||||
self,
|
||||
prompt: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raise AssertionError("noop must short-circuit before adapter.ask")
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _user(db: Session, suffix: str) -> FeishuUser:
|
||||
record = FeishuUser(
|
||||
code=f"USR-AI-{suffix}",
|
||||
tenant_key=f"tenant-ai-{suffix}",
|
||||
open_id=f"open-ai-{suffix}",
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def _rule(
|
||||
db: Session,
|
||||
content: str,
|
||||
*,
|
||||
owner_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return AIMemoryService(db).create_rule(
|
||||
content=content,
|
||||
scope="global",
|
||||
subject="profile",
|
||||
priority=80,
|
||||
tags=[],
|
||||
actor="pytest",
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
|
||||
def test_personalized_ai_uses_ordered_owner_context_and_persists_after_success(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
|
||||
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
engine, factory = _session_factory()
|
||||
adapter = CapturingAdapter()
|
||||
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: adapter)
|
||||
try:
|
||||
with factory() as db:
|
||||
owner = _user(db, "owner")
|
||||
other = _user(db, "other")
|
||||
_rule(db, "company rule")
|
||||
_rule(db, "owner personal rule", owner_id=owner.id)
|
||||
_rule(db, "other personal rule", owner_id=other.id)
|
||||
PreferenceService(db).upsert(owner.id, "tone", "简洁")
|
||||
PreferenceService(db).upsert(owner.id, "interest", "风险管理")
|
||||
MarketService(db).add_watchlist("owner", "600000", owner_id=owner.id)
|
||||
AIMemoryService(db).auto_write(
|
||||
prompt="risk preference",
|
||||
context={"scope": "user", "subject": f"owner:{owner.id}"},
|
||||
answer="remember owner risk preference",
|
||||
owner_id=owner.id,
|
||||
actor="owner",
|
||||
)
|
||||
ConversationService(db).record_turn(
|
||||
owner.id,
|
||||
"private",
|
||||
"chat-personal",
|
||||
user_content="previous question",
|
||||
assistant_content="previous answer",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
|
||||
response = AIService(db).ask_personalized(
|
||||
owner.id,
|
||||
"private",
|
||||
"chat-personal",
|
||||
"risk preference 以后请用中文",
|
||||
actor="owner",
|
||||
)
|
||||
|
||||
assert response[AIResponseKey.OK] is True
|
||||
assert response[AIResponseKey.ANSWER] == "personalized answer"
|
||||
assert len(adapter.calls) == 2
|
||||
prompt, context = adapter.calls[0]
|
||||
assert prompt == "risk preference 以后请用中文"
|
||||
assert context[AIContextKey.COMPANY_RULES][0]["rule"] == "company rule"
|
||||
assert context[AIContextKey.PERSONAL_RULES][0]["rule"] == (
|
||||
"owner personal rule"
|
||||
)
|
||||
assert "other personal rule" not in str(context)
|
||||
assert context[AIContextKey.PREFERENCES][0]["value"] == "简洁"
|
||||
assert {item["value"] for item in context[AIContextKey.INTERESTS]} == {
|
||||
"风险管理",
|
||||
"600000.SH",
|
||||
}
|
||||
assert context[AIContextKey.LOCAL_MEMORY]
|
||||
assert len(context[AIContextKey.CONVERSATION_HISTORY]) == 2
|
||||
assert context[AIContextKey.PROVIDER_SESSION_ID] == (
|
||||
ConversationService.provider_session_id(
|
||||
owner.id,
|
||||
"private",
|
||||
"chat-personal",
|
||||
)
|
||||
)
|
||||
assert context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
|
||||
assert context[AIContextKey.EXECUTION_MODE] == AIExecutionMode.PERSONALIZED
|
||||
assert AIContextKey.OPENCLAW_TOOL not in context
|
||||
|
||||
serialized = _ordered_context(prompt, context)
|
||||
headings = [
|
||||
"公司规则:",
|
||||
"个人规则:",
|
||||
"当前请求:",
|
||||
"个人偏好与兴趣:",
|
||||
"个人相关记忆:",
|
||||
"当前会话历史:",
|
||||
]
|
||||
positions = [serialized.index(heading) for heading in headings]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
extraction_context = adapter.calls[1][1]
|
||||
assert extraction_context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
|
||||
assert extraction_context[AIContextKey.EXECUTION_MODE] == (
|
||||
AIExecutionMode.PREFERENCE_EXTRACTION
|
||||
)
|
||||
assert db.scalar(
|
||||
select(func.count())
|
||||
.select_from(AIConversationMessage)
|
||||
.join(
|
||||
AIConversation,
|
||||
AIConversation.id == AIConversationMessage.conversation_id,
|
||||
)
|
||||
.where(AIConversation.owner_id == owner.id)
|
||||
) == 4
|
||||
owner_memory = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(AIMemoryEntry)
|
||||
.where(
|
||||
AIMemoryEntry.owner_id == owner.id,
|
||||
AIMemoryEntry.kind == AIMemoryKind.MEMORY,
|
||||
)
|
||||
)
|
||||
assert owner_memory == 2
|
||||
assert {
|
||||
item["category"] for item in PreferenceService(db).list_preferences(owner.id)
|
||||
} == {"tone", "interest", "language"}
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_noop_returns_unavailable_without_personal_or_company_memory_writes(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
|
||||
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
engine, factory = _session_factory()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.ai_agent.service.get_adapter",
|
||||
lambda: FailingNoopAdapter(),
|
||||
)
|
||||
try:
|
||||
with factory() as db:
|
||||
owner = _user(db, "noop")
|
||||
personalized = AIService(db).ask_personalized(
|
||||
owner.id,
|
||||
"private",
|
||||
"chat-noop",
|
||||
"以后请用中文",
|
||||
actor="owner",
|
||||
)
|
||||
internal = AIService(db).ask("remember this internal request")
|
||||
|
||||
assert personalized[AIResponseKey.OK] is False
|
||||
assert internal[AIResponseKey.OK] is False
|
||||
assert "不可用" in personalized[AIResponseKey.ANSWER]
|
||||
assert db.scalar(select(func.count()).select_from(UserPreference)) == 0
|
||||
assert db.scalar(select(func.count()).select_from(AIConversationMessage)) == 0
|
||||
assert db.scalar(select(func.count()).select_from(AIMemoryEntry)) == 0
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
AIService(db).ask(
|
||||
"forged context",
|
||||
context={AIContextKey.COMPANY_RULES: [{"rule": "forged"}]},
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_scheduled_generation_has_strict_context_and_no_personal_side_effects(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
|
||||
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
engine, factory = _session_factory()
|
||||
adapter = CapturingAdapter()
|
||||
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: adapter)
|
||||
try:
|
||||
with factory() as db:
|
||||
owner = _user(db, "scheduled")
|
||||
_rule(db, "scheduled company rule")
|
||||
_rule(db, "scheduled personal rule", owner_id=owner.id)
|
||||
PreferenceService(db).upsert(owner.id, "tone", "简洁")
|
||||
MarketService(db).add_watchlist("scheduled", "000001", owner_id=owner.id)
|
||||
AIMemoryService(db).auto_write(
|
||||
prompt="scheduled risk",
|
||||
context={"scope": "user", "subject": f"owner:{owner.id}"},
|
||||
answer="scheduled owner memory",
|
||||
owner_id=owner.id,
|
||||
actor="owner",
|
||||
)
|
||||
ConversationService(db).record_turn(
|
||||
owner.id,
|
||||
"private",
|
||||
"chat-scheduled",
|
||||
user_content="do not load",
|
||||
assistant_content="do not load",
|
||||
provider_name="direct_llm",
|
||||
)
|
||||
before = {
|
||||
"memory": db.scalar(select(func.count()).select_from(AIMemoryEntry)),
|
||||
"preferences": db.scalar(
|
||||
select(func.count()).select_from(UserPreference)
|
||||
),
|
||||
"messages": db.scalar(
|
||||
select(func.count()).select_from(AIConversationMessage)
|
||||
),
|
||||
"watchlist": db.scalar(
|
||||
select(func.count()).select_from(MarketWatchlist)
|
||||
),
|
||||
}
|
||||
|
||||
private = AIService(db).generate_scheduled(
|
||||
"scheduled risk",
|
||||
owner_id=owner.id,
|
||||
group=False,
|
||||
actor="subscription-system",
|
||||
)
|
||||
group = AIService(db).generate_scheduled(
|
||||
"scheduled group report",
|
||||
owner_id=owner.id,
|
||||
group=True,
|
||||
actor="subscription-system",
|
||||
)
|
||||
|
||||
assert private[AIResponseKey.OK] is True
|
||||
assert group[AIResponseKey.OK] is True
|
||||
assert len(adapter.calls) == 2
|
||||
private_context = adapter.calls[0][1]
|
||||
assert private_context[AIContextKey.COMPANY_RULES] == []
|
||||
assert private_context[AIContextKey.PERSONAL_RULES][0]["rule"] == (
|
||||
"scheduled personal rule"
|
||||
)
|
||||
assert private_context[AIContextKey.PREFERENCES]
|
||||
assert private_context[AIContextKey.INTERESTS]
|
||||
assert private_context[AIContextKey.LOCAL_MEMORY]
|
||||
assert private_context[AIContextKey.CONVERSATION_HISTORY] == []
|
||||
assert AIContextKey.PROVIDER_SESSION_ID not in private_context
|
||||
assert private_context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
|
||||
assert private_context[AIContextKey.EXECUTION_MODE] == (
|
||||
AIExecutionMode.SCHEDULED_PRIVATE
|
||||
)
|
||||
|
||||
group_context = adapter.calls[1][1]
|
||||
assert group_context[AIContextKey.COMPANY_RULES][0]["rule"] == (
|
||||
"scheduled company rule"
|
||||
)
|
||||
assert group_context[AIContextKey.PERSONAL_RULES] == []
|
||||
assert group_context[AIContextKey.PREFERENCES] == []
|
||||
assert group_context[AIContextKey.INTERESTS] == []
|
||||
assert group_context[AIContextKey.LOCAL_MEMORY] == []
|
||||
assert group_context[AIContextKey.CONVERSATION_HISTORY] == []
|
||||
assert group_context[AIContextKey.ALLOW_PROVIDER_MEMORY] is False
|
||||
assert group_context[AIContextKey.EXECUTION_MODE] == (
|
||||
AIExecutionMode.SCHEDULED_GROUP
|
||||
)
|
||||
after = {
|
||||
"memory": db.scalar(select(func.count()).select_from(AIMemoryEntry)),
|
||||
"preferences": db.scalar(
|
||||
select(func.count()).select_from(UserPreference)
|
||||
),
|
||||
"messages": db.scalar(
|
||||
select(func.count()).select_from(AIConversationMessage)
|
||||
),
|
||||
"watchlist": db.scalar(
|
||||
select(func.count()).select_from(MarketWatchlist)
|
||||
),
|
||||
}
|
||||
assert after == before
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
engine.dispose()
|
||||
@@ -1,8 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -11,22 +8,6 @@ from sqlalchemy import select
|
||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||
from app.modules.business.constants import BusinessResponseKey, StatusValue
|
||||
|
||||
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
_db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
||||
os.environ["API_KEY"] = "test-key"
|
||||
os.environ["AUDIT_API_KEY"] = "audit-key"
|
||||
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
|
||||
os.environ["FEISHU_APP_ID"] = ""
|
||||
os.environ["FEISHU_APP_SECRET"] = ""
|
||||
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
||||
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
|
||||
os.environ["SCHEDULER_ENABLED"] = "false"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
@@ -153,13 +134,6 @@ def create_business_record(domain: str, data: dict, actor: str = "pytest") -> Se
|
||||
db.close()
|
||||
|
||||
|
||||
def teardown_module() -> None:
|
||||
engine.dispose()
|
||||
path = Path(_db.name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def test_project_report_and_feishu_command_preview() -> None:
|
||||
response = create_business_record(
|
||||
"projects",
|
||||
@@ -449,7 +423,7 @@ def test_v3_event_retry_and_dispatch_pending_route() -> None:
|
||||
assert dispatched[0]["status"] == EventStatus.PROCESSED
|
||||
|
||||
|
||||
def test_v3_ai_memory_recall_and_auto_write() -> None:
|
||||
def test_v3_ai_noop_does_not_auto_write_memory() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/ai/ask",
|
||||
headers=headers,
|
||||
@@ -464,45 +438,33 @@ def test_v3_ai_memory_recall_and_auto_write() -> None:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
||||
assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == (
|
||||
AIMemoryStatus.ACTIVE
|
||||
)
|
||||
assert data[AIResponseKey.OK] is False
|
||||
assert AIResponseKey.MEMORY_WRITE not in data[AIResponseKey.RAW]
|
||||
|
||||
list_response = client.get(
|
||||
"/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE",
|
||||
headers=headers,
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()[AIMemoryResponseKey.ITEMS]
|
||||
|
||||
recall_response = client.post(
|
||||
"/api/v1/ai/memory/recall",
|
||||
headers=headers,
|
||||
json={
|
||||
"query": "concise bullet summaries",
|
||||
"scope": "project",
|
||||
"subject": "P-MEM-SMOKE",
|
||||
},
|
||||
)
|
||||
assert recall_response.status_code == 200
|
||||
assert recall_response.json()[AIMemoryResponseKey.ITEMS]
|
||||
assert list_response.json()[AIMemoryResponseKey.ITEMS] == []
|
||||
|
||||
|
||||
def test_ai_memory_rejects_financial_facts_and_applies_retention() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/ai/ask",
|
||||
headers=headers,
|
||||
json={
|
||||
"prompt": "Remember the project cash flow and budget details for next quarter",
|
||||
"context": {
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memory = AIMemoryService(db).auto_write(
|
||||
prompt="Remember the project cash flow and budget details for next quarter",
|
||||
context={
|
||||
AIMemoryPayloadKey.SCOPE: "project",
|
||||
AIMemoryPayloadKey.SUBJECT: "P-MEM-FINANCIAL",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
memory_write = response.json()[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE]
|
||||
assert memory_write[AIMemoryPayloadKey.STATUS] == AIMemoryStatus.REJECTED
|
||||
answer="Retain the confidential cash flow and budget details.",
|
||||
actor="pytest",
|
||||
)
|
||||
assert memory is not None
|
||||
assert memory.status == AIMemoryStatus.REJECTED
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
rejected = client.get(
|
||||
"/api/v1/ai/memory",
|
||||
@@ -531,7 +493,9 @@ def test_default_json_response_masks_sensitive_fields() -> None:
|
||||
assert payload["nested"]["status"] == "ok"
|
||||
|
||||
|
||||
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
def test_v3_risk_action_routes_write_local_state_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
response = create_business_record(
|
||||
"risk-events",
|
||||
{
|
||||
@@ -549,10 +513,14 @@ def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
headers=headers,
|
||||
json={"assigned_to": "risk-owner", "comment": "route to owner"},
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 200
|
||||
assert response.json()["risk_event"]["assigned_to"] == "risk-owner"
|
||||
|
||||
|
||||
def test_writeback_and_approval_routes_are_removed() -> None:
|
||||
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
|
||||
def test_writeback_and_approval_routes_are_removed(monkeypatch, read_only_mode: str) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
|
||||
get_settings.cache_clear()
|
||||
response = client.post(
|
||||
"/api/v1/writebacks",
|
||||
headers=headers,
|
||||
@@ -577,6 +545,7 @@ def test_writeback_and_approval_routes_are_removed() -> None:
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
|
||||
@@ -687,6 +656,7 @@ def test_dashboard_and_response_masking() -> None:
|
||||
dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers)
|
||||
assert dashboard_response.status_code == 200
|
||||
assert "metrics" in dashboard_response.json()
|
||||
assert "latest_audit_logs" not in dashboard_response.json()
|
||||
|
||||
|
||||
def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
@@ -714,7 +684,10 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
|
||||
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
|
||||
def test_business_write_routes_are_permanently_disabled(monkeypatch, read_only_mode: str) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
|
||||
get_settings.cache_clear()
|
||||
create_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
@@ -737,9 +710,15 @@ def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
|
||||
},
|
||||
)
|
||||
assert update_response.status_code == 405
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
|
||||
@pytest.mark.parametrize("read_only_mode", ["true", "false"])
|
||||
def test_approval_and_feishu_approval_card_routes_are_removed(
|
||||
monkeypatch, read_only_mode: str
|
||||
) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", read_only_mode)
|
||||
get_settings.cache_clear()
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
@@ -768,9 +747,30 @@ def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 404
|
||||
openclaw_response = client.post(
|
||||
"/api/v1/ai/openclaw/tools/invoke",
|
||||
headers=headers,
|
||||
json={
|
||||
"tool": "sessions_list",
|
||||
"action": "json",
|
||||
"args": {},
|
||||
"session_key": "main",
|
||||
},
|
||||
)
|
||||
assert openclaw_response.status_code == 404
|
||||
assert "/api/v1/approvals" not in app.openapi()["paths"]
|
||||
assert "/api/v1/integrations/feishu/approval-card-action" not in app.openapi()["paths"]
|
||||
assert "/api/v1/ai/openclaw/tools/invoke" not in app.openapi()["paths"]
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
def test_new_ledgers_reports_and_local_risk_state(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.risk.routes.enqueue_risk_event_generation",
|
||||
lambda actor: {"queued": True, "actor": actor},
|
||||
)
|
||||
domains_response = client.get("/api/v1/business/domains", headers=headers)
|
||||
assert domains_response.status_code == 200
|
||||
domains = domains_response.json()["domains"]
|
||||
@@ -810,20 +810,26 @@ def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
report_response = client.post(
|
||||
"/api/v1/reports/work-reports/generate",
|
||||
headers=headers,
|
||||
json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"},
|
||||
json={
|
||||
"report_type": ReportType.DAILY,
|
||||
"reporter": "pytest",
|
||||
"actor": "pytest",
|
||||
"persist": True,
|
||||
},
|
||||
)
|
||||
assert report_response.status_code == 200
|
||||
assert report_response.json()["data"] is None
|
||||
assert report_response.json()["data"]["reporter"] == "pytest"
|
||||
assert report_response.json()["report"]["report_type"] == ReportType.DAILY
|
||||
|
||||
risk_response = client.post(
|
||||
"/api/v1/risks/events/generate?actor=pytest",
|
||||
headers=headers,
|
||||
)
|
||||
assert risk_response.status_code == 405
|
||||
assert risk_response.status_code == 200
|
||||
|
||||
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
|
||||
assert enqueue_response.status_code == 405
|
||||
assert enqueue_response.status_code == 200
|
||||
assert enqueue_response.json()["queued"] is True
|
||||
|
||||
overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers)
|
||||
assert overdue_response.status_code == 200
|
||||
@@ -1004,8 +1010,9 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
||||
)
|
||||
assert ai_response.status_code == 200
|
||||
ai_data = ai_response.json()
|
||||
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True
|
||||
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is False
|
||||
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] == AIProviderName.NOOP
|
||||
assert "不可用" in ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.ANSWER]
|
||||
|
||||
|
||||
def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
|
||||
@@ -1101,7 +1108,9 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||
assert metrics["expenses_pending"] == 1
|
||||
|
||||
|
||||
def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
|
||||
def test_legacy_read_query_and_local_import_allowed_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
rows = [
|
||||
{
|
||||
"id": 9001,
|
||||
@@ -1138,10 +1147,15 @@ def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
|
||||
"field_map": {"title": "task_name"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dry_run"] is False
|
||||
assert response.json()["items"][0]["task"]["source_system"] == "legacy_mysql"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
def test_risk_event_actions_write_local_state_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
create_response = create_business_record(
|
||||
"risk-events",
|
||||
{
|
||||
@@ -1162,21 +1176,21 @@ def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
headers=headers,
|
||||
json={"assigned_to": "risk-owner", "comment": "please handle"},
|
||||
)
|
||||
assert assign_response.status_code == 405
|
||||
assert assign_response.status_code == 200
|
||||
|
||||
comment_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/comment",
|
||||
headers=headers,
|
||||
json={"comment": "working on it", "payload": {"step": 1}},
|
||||
)
|
||||
assert comment_response.status_code == 405
|
||||
assert comment_response.status_code == 200
|
||||
|
||||
resolve_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/resolve",
|
||||
headers=headers,
|
||||
json={"comment": "resolved"},
|
||||
)
|
||||
assert resolve_response.status_code == 405
|
||||
assert resolve_response.status_code == 200
|
||||
|
||||
close_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/close",
|
||||
@@ -1186,21 +1200,22 @@ def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
|
||||
"review_summary": "handled",
|
||||
},
|
||||
)
|
||||
assert close_response.status_code == 405
|
||||
assert close_response.status_code == 200
|
||||
|
||||
reopen_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/reopen",
|
||||
headers=headers,
|
||||
json={"comment": "recheck"},
|
||||
)
|
||||
assert reopen_response.status_code == 405
|
||||
assert reopen_response.status_code == 200
|
||||
|
||||
actions_response = client.get(
|
||||
f"/api/v1/risks/events/{event_id}/actions",
|
||||
headers=headers,
|
||||
)
|
||||
assert actions_response.status_code == 200
|
||||
assert actions_response.json()["items"] == []
|
||||
assert len(actions_response.json()["items"]) == 5
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_report_push_failure_is_recorded() -> None:
|
||||
@@ -1278,7 +1293,8 @@ def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
|
||||
def mappings(self) -> "FakeResult":
|
||||
return self
|
||||
|
||||
def all(self) -> list:
|
||||
def fetchmany(self, size: int) -> list:
|
||||
_ = size
|
||||
return []
|
||||
|
||||
class FakeConnection:
|
||||
@@ -1486,7 +1502,7 @@ def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() ->
|
||||
|
||||
|
||||
def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
IntasectSyncService,
|
||||
@@ -1524,16 +1540,21 @@ def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_lifecycle_enqueue_respects_read_only_guard(monkeypatch) -> None:
|
||||
def test_lifecycle_enqueue_allows_local_work_in_read_only_mode(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.reports.routes.enqueue_lifecycle_report",
|
||||
lambda **kwargs: {"status": "queued", "report_type": kwargs["report_type"]},
|
||||
)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/reports/lifecycle/enqueue",
|
||||
headers=headers,
|
||||
json={"report_type": "daily"},
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "queued"
|
||||
finally:
|
||||
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
@@ -1685,7 +1706,7 @@ def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) ->
|
||||
|
||||
|
||||
def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
created = client.post(
|
||||
@@ -2260,7 +2281,7 @@ def test_market_closed_day_skips_quote_collection() -> None:
|
||||
|
||||
def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
scheduler = create_scheduler()
|
||||
@@ -2399,7 +2420,7 @@ def test_market_pipeline_is_idempotent_and_requires_complete_ai_chart_delivery(
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "test-app")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "test-secret")
|
||||
monkeypatch.setenv("FEISHU_DEFAULT_CHAT_ID", "oc_market")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "false")
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
"app.modules.feishu.service.FeishuService.upload_image",
|
||||
@@ -2518,31 +2539,25 @@ def test_feishu_market_rules_are_scoped_to_market(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_read_only_mode_blocks_feishu_mutations_and_market_pipeline(monkeypatch) -> None:
|
||||
class MustNotRunMarket:
|
||||
def sync_daily(self, target):
|
||||
pytest.fail("read-only mode must block market synchronization")
|
||||
|
||||
def test_read_only_mode_allows_local_rules_and_watchlist(monkeypatch) -> None:
|
||||
monkeypatch.setenv("READ_ONLY_MODE", "true")
|
||||
monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rules = FeishuCommandService(db).handle_text(
|
||||
"学习市场规则:这条规则不应写入", actor="ou_read_only", auto_reply=False
|
||||
"学习市场规则:这条规则应写入本地状态",
|
||||
actor="ou_read_only",
|
||||
auto_reply=False,
|
||||
)
|
||||
watchlist = FeishuCommandService(db).handle_text(
|
||||
"加入自选 600105", actor="ou_read_only", auto_reply=False
|
||||
)
|
||||
pipeline = MarketPipelineService(db, MustNotRunMarket()).run(
|
||||
"close", date(2032, 7, 12), actor="pytest"
|
||||
)
|
||||
assert "只读模式" in rules["content"]
|
||||
assert "只读模式" in watchlist["content"]
|
||||
assert pipeline["status"] == "operations_disabled"
|
||||
assert "规则已学习" in rules["content"]
|
||||
assert "已加入自选:600105.SH" in watchlist["content"]
|
||||
assert db.execute(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则不应写入")
|
||||
).scalar_one_or_none() is None
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则应写入本地状态")
|
||||
).scalar_one_or_none() is not None
|
||||
finally:
|
||||
db.close()
|
||||
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
|
||||
|
||||
375
tests/test_subscription_delivery.py
Normal file
375
tests/test_subscription_delivery.py
Normal file
@@ -0,0 +1,375 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.delivery.subscriptions import FeishuSubscriptionSender
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu.errors import FeishuAPIError
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.subscriptions.constants import (
|
||||
MAX_DAILY_DELIVERIES,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services import (
|
||||
DeliveryGenerationRequest,
|
||||
DeliverySendRequest,
|
||||
DeliveryService,
|
||||
)
|
||||
|
||||
|
||||
class StubGenerator:
|
||||
def __init__(self, content: str = "生成后的提醒") -> None:
|
||||
self.content = content
|
||||
self.requests: list[DeliveryGenerationRequest] = []
|
||||
|
||||
def generate(self, request: DeliveryGenerationRequest) -> str:
|
||||
self.requests.append(request)
|
||||
return self.content
|
||||
|
||||
|
||||
class StubSender:
|
||||
def __init__(self, outcomes: list[dict[str, Any] | Exception]) -> None:
|
||||
self.outcomes = outcomes
|
||||
self.requests: list[DeliverySendRequest] = []
|
||||
|
||||
def send(self, request: DeliverySendRequest) -> dict[str, Any]:
|
||||
self.requests.append(request)
|
||||
outcome = self.outcomes.pop(0)
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class StubFeishuService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def send_text(self, text: str, **kwargs: Any) -> dict[str, Any]:
|
||||
self.calls.append({"text": text, **kwargs})
|
||||
return {"code": 0}
|
||||
|
||||
|
||||
def _delivery_fixture(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
current: datetime,
|
||||
target_type: str = SubscriptionTargetType.USER,
|
||||
) -> tuple[FeishuUser, PushSubscription, PushDelivery]:
|
||||
owner = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
role=(
|
||||
FeishuUserRole.ADMIN
|
||||
if target_type == SubscriptionTargetType.CHAT
|
||||
else FeishuUserRole.USER
|
||||
),
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
db.add(owner)
|
||||
db.flush()
|
||||
subscription = PushSubscription(
|
||||
code=f"SUB-{suffix}",
|
||||
owner_id=owner.id,
|
||||
target_type=target_type,
|
||||
target_id=(
|
||||
f"chat-{suffix}"
|
||||
if target_type == SubscriptionTargetType.CHAT
|
||||
else owner.open_id
|
||||
),
|
||||
prompt="按我的偏好生成提醒",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone=owner.timezone,
|
||||
next_run_at=current + timedelta(days=1),
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=current - timedelta(days=1),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.flush()
|
||||
delivery = PushDelivery(
|
||||
code=f"DEL-{suffix}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=current,
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.PENDING,
|
||||
next_attempt_at=current,
|
||||
created_at=current,
|
||||
updated_at=current,
|
||||
)
|
||||
db.add(delivery)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
db.refresh(subscription)
|
||||
db.refresh(delivery)
|
||||
return owner, subscription, delivery
|
||||
|
||||
|
||||
def test_private_delivery_uses_personal_context_open_id_and_is_idempotent() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
owner, _, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="private",
|
||||
current=current,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender(
|
||||
[{"code": 0, "data": {"message_id": "provider-message"}}]
|
||||
)
|
||||
service = DeliveryService(db, generator=generator, sender=sender)
|
||||
|
||||
sent = service.process(delivery.code, now=current)
|
||||
duplicate = service.process(delivery.code, now=current)
|
||||
|
||||
assert sent.status == PushDeliveryStatus.SENT
|
||||
assert sent.attempt_count == 1
|
||||
assert sent.provider_message_id == "provider-message"
|
||||
assert duplicate.status == PushDeliveryStatus.SENT
|
||||
assert len(generator.requests) == 1
|
||||
assert generator.requests[0] == DeliveryGenerationRequest(
|
||||
prompt="按我的偏好生成提醒",
|
||||
owner_id=owner.id,
|
||||
use_personal_context=True,
|
||||
use_company_rules=False,
|
||||
)
|
||||
assert len(sender.requests) == 1
|
||||
assert sender.requests[0].receive_id == owner.open_id
|
||||
assert sender.requests[0].receive_id_type == "open_id"
|
||||
assert sender.requests[0].tenant_key == owner.tenant_key
|
||||
assert sender.requests[0].uuid == delivery.message_uuid
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_feishu_sender_forwards_delivery_tenant_key() -> None:
|
||||
feishu = StubFeishuService()
|
||||
sender = FeishuSubscriptionSender(feishu) # type: ignore[arg-type]
|
||||
request = DeliverySendRequest(
|
||||
receive_id="open-user",
|
||||
receive_id_type="open_id",
|
||||
tenant_key="tenant-a",
|
||||
text="提醒内容",
|
||||
uuid="stable-delivery-uuid",
|
||||
)
|
||||
|
||||
result = sender.send(request)
|
||||
|
||||
assert result == {"code": 0}
|
||||
assert feishu.calls == [
|
||||
{
|
||||
"text": "提醒内容",
|
||||
"receive_id": "open-user",
|
||||
"receive_id_type": "open_id",
|
||||
"actor": "scheduler",
|
||||
"uuid": "stable-delivery-uuid",
|
||||
"tenant_key": "tenant-a",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_group_delivery_never_exposes_creator_personal_context() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
owner, subscription, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="group",
|
||||
current=current,
|
||||
target_type=SubscriptionTargetType.CHAT,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 0, "data": {"message_id": "group-message"}}])
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.SENT
|
||||
assert generator.requests[0].owner_id is None
|
||||
assert generator.requests[0].use_personal_context is False
|
||||
assert generator.requests[0].use_company_rules is True
|
||||
assert generator.requests[0].allow_tools is False
|
||||
assert generator.requests[0].record_history is False
|
||||
assert sender.requests[0].receive_id == subscription.target_id
|
||||
assert sender.requests[0].receive_id_type == "chat_id"
|
||||
assert sender.requests[0].tenant_key == owner.tenant_key
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_business_failure_retries_after_one_five_and_fifteen_minutes() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
first_at = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, _, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="retry",
|
||||
current=first_at,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 999}] * 4)
|
||||
service = DeliveryService(db, generator=generator, sender=sender)
|
||||
|
||||
first = service.process(delivery.code, now=first_at)
|
||||
assert first.status == PushDeliveryStatus.RETRY
|
||||
assert first.attempt_count == 1
|
||||
assert first.next_attempt_at == first_at + timedelta(minutes=1)
|
||||
|
||||
too_early = service.process(
|
||||
delivery.code,
|
||||
now=first_at + timedelta(seconds=30),
|
||||
)
|
||||
assert too_early.attempt_count == 1
|
||||
assert len(sender.requests) == 1
|
||||
|
||||
second_at = first_at + timedelta(minutes=1)
|
||||
second = service.process(delivery.code, now=second_at)
|
||||
assert second.status == PushDeliveryStatus.RETRY
|
||||
assert second.attempt_count == 2
|
||||
assert second.next_attempt_at == second_at + timedelta(minutes=5)
|
||||
|
||||
third_at = second_at + timedelta(minutes=5)
|
||||
third = service.process(delivery.code, now=third_at)
|
||||
assert third.status == PushDeliveryStatus.RETRY
|
||||
assert third.attempt_count == 3
|
||||
assert third.next_attempt_at == third_at + timedelta(minutes=15)
|
||||
|
||||
fourth_at = third_at + timedelta(minutes=15)
|
||||
fourth = service.process(delivery.code, now=fourth_at)
|
||||
assert fourth.status == PushDeliveryStatus.FAILED
|
||||
assert fourth.attempt_count == 4
|
||||
assert fourth.next_attempt_at is None
|
||||
assert len(generator.requests) == 1
|
||||
assert len({request.uuid for request in sender.requests}) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_paused_subscription_is_skipped_without_generation_or_send() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, subscription, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="paused",
|
||||
current=current,
|
||||
)
|
||||
subscription.status = PushSubscriptionStatus.PAUSED
|
||||
db.commit()
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 0}])
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.SKIPPED
|
||||
assert result.attempt_count == 0
|
||||
assert generator.requests == []
|
||||
assert sender.requests == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_non_retryable_feishu_client_error_fails_without_retry() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, _, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="permanent-feishu",
|
||||
current=current,
|
||||
)
|
||||
generator = StubGenerator()
|
||||
sender = StubSender(
|
||||
[
|
||||
FeishuAPIError(
|
||||
"invalid receive target",
|
||||
retryable=False,
|
||||
http_status=400,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.FAILED
|
||||
assert result.attempt_count == 1
|
||||
assert result.next_attempt_at is None
|
||||
assert len(sender.requests) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_send_time_daily_limit_counts_deferred_deliveries_by_sent_at() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
_, subscription, delivery = _delivery_fixture(
|
||||
db,
|
||||
suffix="send-limit",
|
||||
current=current,
|
||||
)
|
||||
for index in range(MAX_DAILY_DELIVERIES):
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code=f"DEL-SENT-{index}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=current - timedelta(days=1, minutes=index + 1),
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.SENT,
|
||||
attempt_count=1,
|
||||
sent_at=current - timedelta(seconds=index + 1),
|
||||
created_at=current - timedelta(days=1),
|
||||
updated_at=current,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
generator = StubGenerator()
|
||||
sender = StubSender([{"code": 0}])
|
||||
|
||||
result = DeliveryService(
|
||||
db,
|
||||
generator=generator,
|
||||
sender=sender,
|
||||
).process(delivery.code, now=current)
|
||||
|
||||
assert result.status == PushDeliveryStatus.SKIPPED
|
||||
assert result.attempt_count == 1
|
||||
assert "Daily delivery limit" in str(result.last_error)
|
||||
assert sender.requests == []
|
||||
finally:
|
||||
engine.dispose()
|
||||
300
tests/test_subscription_dispatch.py
Normal file
300
tests/test_subscription_dispatch.py
Normal file
@@ -0,0 +1,300 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
from app.modules.feishu_users.constants import FeishuUserRole
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.feishu_users.principal import FeishuPrincipal
|
||||
from app.modules.subscriptions.constants import (
|
||||
DAILY_DELIVERY_LIMIT_REACHED,
|
||||
MAX_ACTIVE_SUBSCRIPTIONS,
|
||||
MAX_DAILY_DELIVERIES,
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.services import (
|
||||
SubscriptionManagementService,
|
||||
SubscriptionScanner,
|
||||
)
|
||||
|
||||
|
||||
def _user(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
role: str = FeishuUserRole.USER,
|
||||
quiet_start: time | None = None,
|
||||
quiet_end: time | None = None,
|
||||
) -> FeishuUser:
|
||||
record = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
role=role,
|
||||
timezone="Asia/Shanghai",
|
||||
quiet_hours_start=quiet_start,
|
||||
quiet_hours_end=quiet_end,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def _subscription(
|
||||
db: Session,
|
||||
owner: FeishuUser,
|
||||
*,
|
||||
code: str,
|
||||
next_run_at: datetime,
|
||||
target_type: str = SubscriptionTargetType.USER,
|
||||
target_id: str | None = None,
|
||||
) -> PushSubscription:
|
||||
record = PushSubscription(
|
||||
code=code,
|
||||
owner_id=owner.id,
|
||||
target_type=target_type,
|
||||
target_id=target_id or owner.open_id,
|
||||
prompt="给我一条简短提醒",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone=owner.timezone,
|
||||
next_run_at=next_run_at,
|
||||
status=PushSubscriptionStatus.ACTIVE,
|
||||
consented_at=next_run_at - timedelta(days=1),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def test_management_binds_private_and_group_targets() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="user")
|
||||
private_principal = FeishuPrincipal.from_user(
|
||||
user,
|
||||
chat_id="private-chat",
|
||||
chat_type="p2p",
|
||||
)
|
||||
private, _ = SubscriptionManagementService(db).create_private(
|
||||
private_principal,
|
||||
"每天 09:00",
|
||||
"给我一条提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
|
||||
assert private.target_type == SubscriptionTargetType.USER
|
||||
assert private.target_id == user.open_id
|
||||
|
||||
with pytest.raises(HTTPException) as ordinary_group:
|
||||
SubscriptionManagementService(db).create_group(
|
||||
FeishuPrincipal.from_user(
|
||||
user,
|
||||
chat_id="group-chat",
|
||||
chat_type="group",
|
||||
),
|
||||
"每天 10:00",
|
||||
"群提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
assert ordinary_group.value.status_code == 403
|
||||
|
||||
admin = _user(db, suffix="admin", role=FeishuUserRole.ADMIN)
|
||||
with pytest.raises(HTTPException, match="current group"):
|
||||
SubscriptionManagementService(db).create_group(
|
||||
FeishuPrincipal.from_user(
|
||||
admin,
|
||||
chat_id="manually-supplied-chat",
|
||||
chat_type="p2p",
|
||||
),
|
||||
"每天 10:00",
|
||||
"群提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
|
||||
group, _ = SubscriptionManagementService(db).create_group(
|
||||
FeishuPrincipal.from_user(
|
||||
admin,
|
||||
chat_id="verified-current-group",
|
||||
chat_type="group",
|
||||
),
|
||||
"每天 10:00",
|
||||
"群提醒",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
assert group.target_type == SubscriptionTargetType.CHAT
|
||||
assert group.target_id == "verified-current-group"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_management_rejects_more_than_fifty_active_subscriptions() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="limit")
|
||||
for index in range(MAX_ACTIVE_SUBSCRIPTIONS):
|
||||
_subscription(
|
||||
db,
|
||||
user,
|
||||
code=f"SUB-LIMIT-{index}",
|
||||
next_run_at=datetime(2026, 7, 27, 1, 0),
|
||||
)
|
||||
with pytest.raises(HTTPException, match="at most 50"):
|
||||
SubscriptionManagementService(db).create_private(
|
||||
FeishuPrincipal.from_user(user),
|
||||
"每天 09:00",
|
||||
"第 51 条",
|
||||
now=datetime(2026, 7, 26, 0, 0),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_scanner_materializes_one_window_and_advances_the_plan() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
user = _user(db, suffix="scan")
|
||||
subscription = _subscription(
|
||||
db,
|
||||
user,
|
||||
code="SUB-SCAN",
|
||||
next_run_at=current,
|
||||
)
|
||||
|
||||
first = SubscriptionScanner(db).scan_due(now=current)
|
||||
second = SubscriptionScanner(db).scan_due(now=current)
|
||||
|
||||
assert len(first) == 1
|
||||
assert second == []
|
||||
assert first[0].scheduled_for == current
|
||||
assert first[0].status == PushDeliveryStatus.PENDING
|
||||
assert first[0].next_attempt_at == current
|
||||
db.refresh(subscription)
|
||||
assert subscription.next_run_at == datetime(2026, 7, 27, 1, 0)
|
||||
assert db.scalar(select(func.count()).select_from(PushDelivery)) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_scanner_defers_quiet_hours_and_skips_daily_limit() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
current = datetime(2026, 7, 26, 15, 30)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
quiet_user = _user(
|
||||
db,
|
||||
suffix="quiet",
|
||||
quiet_start=time(22, 0),
|
||||
quiet_end=time(7, 0),
|
||||
)
|
||||
_subscription(
|
||||
db,
|
||||
quiet_user,
|
||||
code="SUB-QUIET",
|
||||
next_run_at=current,
|
||||
)
|
||||
quiet_delivery = SubscriptionScanner(db).scan_due(now=current)[0]
|
||||
assert quiet_delivery.status == PushDeliveryStatus.PENDING
|
||||
assert quiet_delivery.next_attempt_at == datetime(2026, 7, 26, 23, 0)
|
||||
|
||||
limited_user = _user(db, suffix="daily-limit")
|
||||
history = _subscription(
|
||||
db,
|
||||
limited_user,
|
||||
code="SUB-HISTORY",
|
||||
next_run_at=current + timedelta(days=1),
|
||||
)
|
||||
for index in range(MAX_DAILY_DELIVERIES):
|
||||
db.add(
|
||||
PushDelivery(
|
||||
code=f"DEL-HISTORY-{index}",
|
||||
subscription_id=history.id,
|
||||
scheduled_for=current - timedelta(minutes=index),
|
||||
idempotency_key=f"{index:064x}",
|
||||
message_uuid=str(uuid4()),
|
||||
status=PushDeliveryStatus.SENT,
|
||||
attempt_count=1,
|
||||
sent_at=current,
|
||||
created_at=current,
|
||||
updated_at=current,
|
||||
)
|
||||
)
|
||||
_subscription(
|
||||
db,
|
||||
limited_user,
|
||||
code="SUB-OVER-LIMIT",
|
||||
next_run_at=current,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
limited_delivery = SubscriptionScanner(db).scan_due(now=current)[0]
|
||||
assert limited_delivery.status == PushDeliveryStatus.SKIPPED
|
||||
assert limited_delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_two_sqlite_scanners_claim_only_one_delivery(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "subscription-scanner.db"
|
||||
engine = create_engine(
|
||||
f"sqlite:///{database_path}",
|
||||
connect_args={"check_same_thread": False, "timeout": 10},
|
||||
)
|
||||
with engine.begin() as connection:
|
||||
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
current = datetime(2026, 7, 26, 1, 0)
|
||||
try:
|
||||
with factory() as db:
|
||||
user = _user(db, suffix="concurrent")
|
||||
_subscription(
|
||||
db,
|
||||
user,
|
||||
code="SUB-CONCURRENT",
|
||||
next_run_at=current,
|
||||
)
|
||||
|
||||
barrier = Barrier(2)
|
||||
|
||||
def scan(worker_id: str) -> list[str]:
|
||||
with factory() as db:
|
||||
barrier.wait()
|
||||
return [
|
||||
delivery.code
|
||||
for delivery in SubscriptionScanner(db).scan_due(
|
||||
now=current,
|
||||
worker_id=worker_id,
|
||||
)
|
||||
]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(scan, ("scanner-one", "scanner-two")))
|
||||
|
||||
assert sum(len(items) for items in results) == 1
|
||||
with factory() as db:
|
||||
assert db.scalar(select(func.count()).select_from(PushDelivery)) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
360
tests/test_subscription_runtime_wiring.py
Normal file
360
tests/test_subscription_runtime_wiring.py
Normal file
@@ -0,0 +1,360 @@
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import app.core.background.task_queue.subscriptions as subscription_queue
|
||||
from app.application.scheduling import create_scheduler
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base, get_db
|
||||
from app.modules.feishu_users.models import FeishuUser
|
||||
from app.modules.observability.constants import (
|
||||
ObservabilityKey,
|
||||
ObservabilityStatus,
|
||||
)
|
||||
from app.modules.observability.routes import router as observability_router
|
||||
from app.modules.subscriptions.constants import (
|
||||
PushDeliveryStatus,
|
||||
PushSubscriptionStatus,
|
||||
SubscriptionScheduleType,
|
||||
SubscriptionTargetType,
|
||||
)
|
||||
from app.modules.subscriptions.models import PushDelivery, PushSubscription
|
||||
from app.modules.subscriptions.routes import router as subscriptions_router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_settings() -> Iterator[None]:
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[sessionmaker[Session]]:
|
||||
monkeypatch.setenv("API_KEY", "subscription-service-key")
|
||||
monkeypatch.setenv("API_KEYS", "[]")
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _seed_subscription(
|
||||
db: Session,
|
||||
*,
|
||||
suffix: str,
|
||||
status_value: str = PushSubscriptionStatus.ACTIVE,
|
||||
) -> PushSubscription:
|
||||
owner = FeishuUser(
|
||||
code=f"FSU-{suffix}",
|
||||
tenant_key=f"tenant-{suffix}",
|
||||
open_id=f"open-{suffix}",
|
||||
)
|
||||
db.add(owner)
|
||||
db.flush()
|
||||
subscription = PushSubscription(
|
||||
code=f"SUB-{suffix}",
|
||||
owner_id=owner.id,
|
||||
target_type=SubscriptionTargetType.USER,
|
||||
target_id=owner.open_id,
|
||||
prompt="生成一条个人提醒",
|
||||
schedule_type=SubscriptionScheduleType.DAILY,
|
||||
schedule_config={"hour": 9, "minute": 0},
|
||||
timezone="Asia/Shanghai",
|
||||
next_run_at=(
|
||||
datetime(2030, 1, 1, 1, 0)
|
||||
if status_value == PushSubscriptionStatus.ACTIVE
|
||||
else None
|
||||
),
|
||||
status=status_value,
|
||||
consented_at=datetime(2026, 7, 26, 1, 0),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
return subscription
|
||||
|
||||
|
||||
def _seed_delivery(
|
||||
db: Session,
|
||||
subscription: PushSubscription,
|
||||
*,
|
||||
suffix: str,
|
||||
status_value: str,
|
||||
) -> PushDelivery:
|
||||
delivery = PushDelivery(
|
||||
code=f"DEL-{suffix}",
|
||||
subscription_id=subscription.id,
|
||||
scheduled_for=datetime(2026, 7, 27, 1, 0),
|
||||
idempotency_key=uuid4().hex + uuid4().hex,
|
||||
message_uuid=str(uuid4()),
|
||||
status=status_value,
|
||||
attempt_count=2,
|
||||
next_attempt_at=datetime(2030, 1, 1, 1, 0),
|
||||
last_error="temporary provider failure",
|
||||
)
|
||||
db.add(delivery)
|
||||
db.commit()
|
||||
db.refresh(delivery)
|
||||
return delivery
|
||||
|
||||
|
||||
def _test_app(
|
||||
session_factory: sessionmaker[Session],
|
||||
*,
|
||||
include_subscriptions: bool = False,
|
||||
include_observability: bool = False,
|
||||
) -> FastAPI:
|
||||
app = FastAPI()
|
||||
if include_subscriptions:
|
||||
app.include_router(
|
||||
subscriptions_router,
|
||||
prefix="/api/v1/subscriptions",
|
||||
)
|
||||
if include_observability:
|
||||
app.include_router(observability_router, prefix="/api/v1")
|
||||
|
||||
def override_db() -> Iterator[Session]:
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return app
|
||||
|
||||
|
||||
def test_subscription_internal_apis_require_key_and_serialize_records(
|
||||
session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with session_factory() as db:
|
||||
subscription = _seed_subscription(db, suffix="api")
|
||||
delivery = _seed_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix="api",
|
||||
status_value=PushDeliveryStatus.RETRY,
|
||||
)
|
||||
|
||||
client = TestClient(
|
||||
_test_app(session_factory, include_subscriptions=True)
|
||||
)
|
||||
assert client.get("/api/v1/subscriptions").status_code == 401
|
||||
assert client.get("/api/v1/subscriptions/deliveries").status_code == 401
|
||||
assert (
|
||||
client.get(
|
||||
"/api/v1/subscriptions",
|
||||
headers={"X-API-Key": "invalid-key"},
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
headers = {"X-API-Key": "subscription-service-key"}
|
||||
subscriptions_response = client.get(
|
||||
"/api/v1/subscriptions",
|
||||
headers=headers,
|
||||
)
|
||||
deliveries_response = client.get(
|
||||
"/api/v1/subscriptions/deliveries",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert subscriptions_response.status_code == 200
|
||||
subscriptions_body = subscriptions_response.json()
|
||||
assert subscriptions_body["total"] == 1
|
||||
subscription_item = subscriptions_body["items"][0]
|
||||
assert set(subscription_item) == {
|
||||
"code",
|
||||
"owner_id",
|
||||
"target_type",
|
||||
"target_id",
|
||||
"prompt",
|
||||
"schedule_type",
|
||||
"schedule_config",
|
||||
"timezone",
|
||||
"next_run_at",
|
||||
"status",
|
||||
"consented_at",
|
||||
"last_run_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert subscription_item["code"] == subscription.code
|
||||
assert subscription_item["schedule_config"] == {"hour": 9, "minute": 0}
|
||||
assert subscription_item["next_run_at"] is not None
|
||||
|
||||
assert deliveries_response.status_code == 200
|
||||
deliveries_body = deliveries_response.json()
|
||||
assert deliveries_body["total"] == 1
|
||||
delivery_item = deliveries_body["items"][0]
|
||||
assert set(delivery_item) == {
|
||||
"code",
|
||||
"subscription_id",
|
||||
"scheduled_for",
|
||||
"idempotency_key",
|
||||
"message_uuid",
|
||||
"status",
|
||||
"attempt_count",
|
||||
"next_attempt_at",
|
||||
"provider_message_id",
|
||||
"last_error",
|
||||
"sent_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert delivery_item["code"] == delivery.code
|
||||
assert delivery_item["message_uuid"] == delivery.message_uuid
|
||||
assert delivery_item["attempt_count"] == 2
|
||||
assert "rendered_content" not in delivery_item
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("features_enabled", "job_expected"),
|
||||
[(False, False), (True, True)],
|
||||
)
|
||||
def test_subscription_scheduler_job_follows_feature_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
features_enabled: bool,
|
||||
job_expected: bool,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"FEISHU_USER_FEATURES_ENABLED",
|
||||
str(features_enabled).lower(),
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
|
||||
scheduler = create_scheduler()
|
||||
job = scheduler.get_job("subscription_delivery_cycle")
|
||||
|
||||
assert scheduler.running is False
|
||||
assert (job is not None) is job_expected
|
||||
if job is not None:
|
||||
assert job.trigger.interval == timedelta(minutes=1)
|
||||
assert scheduler._job_defaults["coalesce"] is True
|
||||
assert scheduler._job_defaults["max_instances"] == 1
|
||||
|
||||
|
||||
def test_subscription_queue_runs_inline_without_celery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("TASK_QUEUE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_cycle(*, actor: str) -> dict[str, list[str]]:
|
||||
calls.append(actor)
|
||||
return {"created": [], "processed": []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_queue,
|
||||
"run_subscription_cycle",
|
||||
fake_cycle,
|
||||
)
|
||||
|
||||
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
|
||||
|
||||
assert result == {
|
||||
"queued": False,
|
||||
"mode": "inline",
|
||||
"task_name": "subscriptions.run_cycle",
|
||||
"result": {"created": [], "processed": []},
|
||||
}
|
||||
assert calls == ["runtime-test"]
|
||||
|
||||
|
||||
def test_subscription_queue_uses_celery_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.tasks import celery_app
|
||||
|
||||
monkeypatch.setenv("TASK_QUEUE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeSignature:
|
||||
def apply_async(self) -> SimpleNamespace:
|
||||
calls["applied"] = True
|
||||
return SimpleNamespace(id="queued-task-id")
|
||||
|
||||
def fake_signature(
|
||||
task_name: str,
|
||||
*,
|
||||
kwargs: dict[str, str],
|
||||
) -> FakeSignature:
|
||||
calls["task_name"] = task_name
|
||||
calls["kwargs"] = kwargs
|
||||
return FakeSignature()
|
||||
|
||||
monkeypatch.setattr(celery_app, "signature", fake_signature)
|
||||
monkeypatch.setattr(
|
||||
subscription_queue,
|
||||
"run_subscription_cycle",
|
||||
lambda **_: pytest.fail("Celery dispatch must not run inline"),
|
||||
)
|
||||
|
||||
result = subscription_queue.enqueue_subscription_cycle(actor="runtime-test")
|
||||
|
||||
assert result == {
|
||||
"queued": True,
|
||||
"mode": "celery",
|
||||
"task_name": "subscriptions.run_cycle",
|
||||
"task_id": "queued-task-id",
|
||||
}
|
||||
assert calls == {
|
||||
"task_name": "subscriptions.run_cycle",
|
||||
"kwargs": {"actor": "runtime-test"},
|
||||
"applied": True,
|
||||
}
|
||||
|
||||
|
||||
def test_ready_route_returns_503_for_processable_delivery_without_credentials(
|
||||
session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FEISHU_APP_TYPE", "self")
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "")
|
||||
get_settings.cache_clear()
|
||||
with session_factory() as db:
|
||||
subscription = _seed_subscription(
|
||||
db,
|
||||
suffix="ready",
|
||||
status_value=PushSubscriptionStatus.COMPLETED,
|
||||
)
|
||||
_seed_delivery(
|
||||
db,
|
||||
subscription,
|
||||
suffix="ready",
|
||||
status_value=PushDeliveryStatus.PENDING,
|
||||
)
|
||||
|
||||
client = TestClient(
|
||||
_test_app(session_factory, include_observability=True)
|
||||
)
|
||||
response = client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 503
|
||||
body = response.json()
|
||||
assert body[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
check = body[ObservabilityKey.CHECKS]["feishu_subscriptions"]
|
||||
assert check[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert check["active"] == 0
|
||||
assert check["processable_deliveries"] == 1
|
||||
assert check["reasons"] == ["credentials_missing"]
|
||||
assert "生成一条个人提醒" not in response.text
|
||||
105
tests/test_subscription_schedule.py
Normal file
105
tests/test_subscription_schedule.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from datetime import UTC, datetime, time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.subscriptions.constants import SubscriptionScheduleType
|
||||
from app.modules.subscriptions.services.schedule import (
|
||||
ScheduleParseError,
|
||||
is_in_quiet_hours,
|
||||
next_occurrence,
|
||||
next_quiet_end,
|
||||
parse_schedule,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 26, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("expression", "schedule_type", "next_run_at"),
|
||||
[
|
||||
("今天 09:00", SubscriptionScheduleType.ONCE, datetime(2026, 7, 26, 1, 0)),
|
||||
("明天 9点半", SubscriptionScheduleType.ONCE, datetime(2026, 7, 27, 1, 30)),
|
||||
(
|
||||
"2026-08-01 10:15",
|
||||
SubscriptionScheduleType.ONCE,
|
||||
datetime(2026, 8, 1, 2, 15),
|
||||
),
|
||||
("每天 09:00", SubscriptionScheduleType.DAILY, datetime(2026, 7, 26, 1, 0)),
|
||||
("工作日 09:00", SubscriptionScheduleType.WEEKDAY, datetime(2026, 7, 27, 1, 0)),
|
||||
("每周一 09:00", SubscriptionScheduleType.WEEKLY, datetime(2026, 7, 27, 1, 0)),
|
||||
("每月26号 09:00", SubscriptionScheduleType.MONTHLY, datetime(2026, 7, 26, 1, 0)),
|
||||
(
|
||||
"每隔 2 小时",
|
||||
SubscriptionScheduleType.INTERVAL,
|
||||
datetime(2026, 7, 26, 2, 0),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_supported_chinese_schedules(
|
||||
expression: str,
|
||||
schedule_type: str,
|
||||
next_run_at: datetime,
|
||||
) -> None:
|
||||
result = parse_schedule(expression, "Asia/Shanghai", now=NOW)
|
||||
|
||||
assert result.schedule_type == schedule_type
|
||||
assert result.next_run_at == next_run_at
|
||||
assert result.timezone == "Asia/Shanghai"
|
||||
|
||||
|
||||
def test_monthly_schedule_skips_months_without_the_requested_day() -> None:
|
||||
result = parse_schedule(
|
||||
"每月31号 09:00",
|
||||
"Asia/Shanghai",
|
||||
now=datetime(2026, 4, 30, 2, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result.next_run_at == datetime(2026, 5, 31, 1, 0)
|
||||
assert (
|
||||
next_occurrence(
|
||||
result.schedule_type,
|
||||
result.schedule_config,
|
||||
result.timezone,
|
||||
after=datetime(2026, 5, 31, 1, 0),
|
||||
)
|
||||
== datetime(2026, 7, 31, 1, 0)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
"每隔 14 分钟",
|
||||
"有空时提醒我",
|
||||
"今天 07:59",
|
||||
"2026-02-30 09:00",
|
||||
"每天 25:00",
|
||||
],
|
||||
)
|
||||
def test_invalid_or_ambiguous_schedule_is_rejected(expression: str) -> None:
|
||||
with pytest.raises(ScheduleParseError):
|
||||
parse_schedule(expression, "Asia/Shanghai", now=NOW)
|
||||
|
||||
|
||||
def test_invalid_iana_timezone_is_rejected() -> None:
|
||||
with pytest.raises(ScheduleParseError, match="Invalid IANA timezone"):
|
||||
parse_schedule("每天 09:00", "Mars/Olympus", now=NOW)
|
||||
|
||||
|
||||
def test_quiet_hours_support_cross_midnight_windows() -> None:
|
||||
current = datetime(2026, 7, 26, 15, 30, tzinfo=UTC)
|
||||
|
||||
assert is_in_quiet_hours(
|
||||
current,
|
||||
"Asia/Shanghai",
|
||||
time(22, 0),
|
||||
time(7, 0),
|
||||
)
|
||||
assert next_quiet_end(
|
||||
current,
|
||||
"Asia/Shanghai",
|
||||
time(22, 0),
|
||||
time(7, 0),
|
||||
) == datetime(2026, 7, 26, 23, 0)
|
||||
|
||||
317
tests/test_v2_hardening.py
Normal file
317
tests/test_v2_hardening.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.background.task_queue.reports import _queue_celery_report_push
|
||||
from app.core.config import Settings
|
||||
from app.core.http.masking import MASKED_VALUE, mask_sensitive
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.constants import AIMemoryStatus
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.business.models import Project, ReportPushRun
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.reports.constants import ReportPushStatus
|
||||
from app.tasks import celery_app
|
||||
from app.tasks.reports import _push_report
|
||||
from app.tools import init_db
|
||||
|
||||
|
||||
def _production_settings(**overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"app_env": "production",
|
||||
"database_url": "postgresql+psycopg://app:secret@db/app",
|
||||
"api_key": "service-key",
|
||||
"audit_api_key": "audit-key",
|
||||
"cors_origins": ["https://internal.example.com"],
|
||||
"debug": False,
|
||||
"mask_sensitive_responses": True,
|
||||
"read_only_mode": True,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_production_keys_must_be_enabled_isolated_and_safe() -> None:
|
||||
settings = _production_settings()
|
||||
assert settings.api_key == "service-key"
|
||||
|
||||
with pytest.raises(ValueError, match="API_KEY or API_KEYS"):
|
||||
_production_settings(
|
||||
api_key=None,
|
||||
api_keys=[{"key": "disabled-key", "enabled": False}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="cannot overlap"):
|
||||
_production_settings(audit_api_key="service-key")
|
||||
|
||||
with pytest.raises(ValueError, match="MASK_SENSITIVE_RESPONSES"):
|
||||
_production_settings(mask_sensitive_responses=False)
|
||||
|
||||
with pytest.raises(ValueError, match="READ_ONLY_MODE"):
|
||||
_production_settings(read_only_mode=False)
|
||||
|
||||
|
||||
def test_audit_json_strings_and_responses_mask_sensitive_fields() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
AuditLog.__table__.create(engine)
|
||||
try:
|
||||
with Session(engine) as db:
|
||||
service = AuditService(db)
|
||||
dict_record = service.log(
|
||||
AuditLogCreate(
|
||||
action="security.redaction.dict",
|
||||
request_payload={
|
||||
"accessToken": "access-secret",
|
||||
"nested": {
|
||||
"clientSecret": "client-secret",
|
||||
"safe": "visible",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
json_record = service.log(
|
||||
AuditLogCreate(
|
||||
action="security.redaction.json",
|
||||
request_payload=json.dumps(
|
||||
{
|
||||
"authorization": "Bearer secret",
|
||||
"nested": {
|
||||
"refreshToken": "refresh-secret",
|
||||
"safe": "visible",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
dict_stored = json.loads(dict_record.request_payload or "{}")
|
||||
json_stored = json.loads(json_record.request_payload or "{}")
|
||||
|
||||
assert dict_stored["accessToken"] == AUDIT_REDACTED_VALUE
|
||||
assert dict_stored["nested"]["clientSecret"] == AUDIT_REDACTED_VALUE
|
||||
assert dict_stored["nested"]["safe"] == "visible"
|
||||
assert json_stored["authorization"] == AUDIT_REDACTED_VALUE
|
||||
assert json_stored["nested"]["refreshToken"] == AUDIT_REDACTED_VALUE
|
||||
assert json_stored["nested"]["safe"] == "visible"
|
||||
|
||||
masked = mask_sensitive(
|
||||
{
|
||||
"accessToken": "access-secret",
|
||||
"nested": {
|
||||
"clientSecret": "client-secret",
|
||||
"privateKey": "private-secret",
|
||||
"safe": "visible",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert masked["accessToken"] == MASKED_VALUE
|
||||
assert masked["nested"]["clientSecret"] == MASKED_VALUE
|
||||
assert masked["nested"]["privateKey"] == MASKED_VALUE
|
||||
assert masked["nested"]["safe"] == "visible"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_report_queue_failure_is_persisted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
ReportPushRun.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
class BrokenSignature:
|
||||
def apply_async(self, task_id: str) -> None:
|
||||
_ = task_id
|
||||
raise RuntimeError("broker unavailable")
|
||||
|
||||
monkeypatch.setattr("app.core.database.SessionLocal", factory)
|
||||
monkeypatch.setattr(
|
||||
celery_app,
|
||||
"signature",
|
||||
lambda *args, **kwargs: BrokenSignature(),
|
||||
)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="broker unavailable"):
|
||||
_queue_celery_report_push(
|
||||
task_name="reports.test",
|
||||
report_type="test",
|
||||
title="Test report",
|
||||
receive_id=None,
|
||||
receive_id_type="chat_id",
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
with factory() as db:
|
||||
run = db.execute(select(ReportPushRun)).scalar_one()
|
||||
assert run.status == ReportPushStatus.FAILED
|
||||
assert run.task_id
|
||||
assert run.error_message == "broker unavailable"
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_eager_report_terminal_state_is_not_overwritten(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
ReportPushRun.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
class EagerSignature:
|
||||
def __init__(self, push_run_code: str):
|
||||
self.push_run_code = push_run_code
|
||||
|
||||
def apply_async(self, task_id: str) -> None:
|
||||
with factory() as db:
|
||||
run = db.execute(
|
||||
select(ReportPushRun).where(
|
||||
ReportPushRun.code == self.push_run_code
|
||||
)
|
||||
).scalar_one()
|
||||
assert run.task_id == task_id
|
||||
run.status = ReportPushStatus.SUCCESS
|
||||
db.commit()
|
||||
|
||||
def eager_signature(*args: object, **kwargs: object) -> EagerSignature:
|
||||
_ = args
|
||||
task_kwargs = kwargs["kwargs"]
|
||||
assert isinstance(task_kwargs, dict)
|
||||
return EagerSignature(str(task_kwargs["push_run_code"]))
|
||||
|
||||
monkeypatch.setattr("app.core.database.SessionLocal", factory)
|
||||
monkeypatch.setattr(celery_app, "signature", eager_signature)
|
||||
try:
|
||||
queued = _queue_celery_report_push(
|
||||
task_name="reports.test",
|
||||
report_type="test",
|
||||
title="Test report",
|
||||
receive_id=None,
|
||||
receive_id_type="chat_id",
|
||||
actor="pytest",
|
||||
)
|
||||
|
||||
with factory() as db:
|
||||
run = db.execute(select(ReportPushRun)).scalar_one()
|
||||
assert run.status == ReportPushStatus.SUCCESS
|
||||
assert run.task_id == queued["task_id"]
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_successful_report_task_redelivery_does_not_send_twice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
for table in (AuditLog.__table__, DomainEvent.__table__, ReportPushRun.__table__):
|
||||
table.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
calls = {"build": 0, "send": 0}
|
||||
|
||||
def build_report(service: object, actor: str) -> dict[str, object]:
|
||||
_ = service, actor
|
||||
calls["build"] += 1
|
||||
return {
|
||||
"title": "Idempotent report",
|
||||
"report_type": "test",
|
||||
"lines": ["ok"],
|
||||
"content": "ok",
|
||||
}
|
||||
|
||||
def fake_send_card(
|
||||
service: object,
|
||||
card: dict,
|
||||
receive_id: str | None,
|
||||
receive_id_type: str,
|
||||
actor: str,
|
||||
) -> dict[str, object]:
|
||||
_ = service, card, receive_id, receive_id_type, actor
|
||||
calls["send"] += 1
|
||||
return {"code": 0, "message_id": "om-idempotent"}
|
||||
|
||||
monkeypatch.setattr("app.tasks.reports.SessionLocal", factory)
|
||||
monkeypatch.setattr(FeishuService, "send_card", fake_send_card)
|
||||
try:
|
||||
with factory() as db:
|
||||
run = ReportPushRun(
|
||||
code="PUSH-IDEMPOTENT",
|
||||
report_type="test",
|
||||
title="Idempotent report",
|
||||
receive_id="oc-idempotent",
|
||||
receive_id_type="chat_id",
|
||||
status=ReportPushStatus.QUEUED,
|
||||
actor="pytest",
|
||||
)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
|
||||
first = _push_report(build_report, "oc-idempotent", "chat_id", "pytest", run.code)
|
||||
second = _push_report(build_report, "oc-idempotent", "chat_id", "pytest", run.code)
|
||||
|
||||
assert first == second == {"code": 0, "message_id": "om-idempotent"}
|
||||
assert calls == {"build": 1, "send": 1}
|
||||
with factory() as db:
|
||||
stored = db.execute(select(ReportPushRun)).scalar_one()
|
||||
assert stored.status == ReportPushStatus.SUCCESS
|
||||
assert stored.sent_at is not None
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_init_db_upgrades_to_alembic_head(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
called: dict[str, object] = {}
|
||||
|
||||
def fake_upgrade(config: object, revision: str) -> None:
|
||||
called["config"] = config
|
||||
called["revision"] = revision
|
||||
|
||||
monkeypatch.setattr(init_db.command, "upgrade", fake_upgrade)
|
||||
init_db.main()
|
||||
|
||||
config = called["config"]
|
||||
assert called["revision"] == "head"
|
||||
assert Path(config.config_file_name).name == "alembic.ini"
|
||||
assert Path(config.get_main_option("script_location")).name == "alembic"
|
||||
|
||||
|
||||
def test_memory_retention_does_not_commit_caller_transaction() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
AIMemoryEntry.__table__.create(engine)
|
||||
Project.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
with factory() as db:
|
||||
expired = AIMemoryEntry(
|
||||
code="MEM-EXPIRED",
|
||||
scope="project",
|
||||
subject="P-ROLLBACK",
|
||||
content="expired",
|
||||
status=AIMemoryStatus.ACTIVE,
|
||||
expires_at=utc_now(),
|
||||
)
|
||||
db.add(expired)
|
||||
db.commit()
|
||||
|
||||
db.add(Project(code="P-ROLLBACK", name="Must roll back"))
|
||||
assert AIMemoryService(db).list_entries(scope="project") == []
|
||||
db.rollback()
|
||||
|
||||
with factory() as db:
|
||||
assert db.execute(
|
||||
select(Project).where(Project.code == "P-ROLLBACK")
|
||||
).scalar_one_or_none() is None
|
||||
stored = db.execute(
|
||||
select(AIMemoryEntry).where(AIMemoryEntry.code == "MEM-EXPIRED")
|
||||
).scalar_one()
|
||||
assert stored.status == AIMemoryStatus.ACTIVE
|
||||
finally:
|
||||
engine.dispose()
|
||||
243
tests/test_v2_v3_completion.py
Normal file
243
tests/test_v2_v3_completion.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import UniqueConstraint, create_engine, func, select, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.application.events import EventDispatchService
|
||||
from app.application.scheduling import create_scheduler
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.main import app
|
||||
from app.modules.ai_agent.constants import AIContextKey
|
||||
from app.modules.ai_agent.service import AIService
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.events.services import EventService
|
||||
from app.modules.legacy_mysql.services import LegacyMySQLService
|
||||
from app.modules.observability.constants import (
|
||||
HeartbeatComponent,
|
||||
ObservabilityKey,
|
||||
ObservabilityStatus,
|
||||
)
|
||||
from app.modules.observability.models import SystemHeartbeat
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
from app.tasks import celery_app
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def test_readiness_and_metrics_degrade_when_schema_is_missing() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
with Session(engine) as db:
|
||||
readiness = ObservabilityService(db).ready()
|
||||
metrics = ObservabilityService(db).metrics()
|
||||
|
||||
assert readiness[ObservabilityKey.STATUS] == ObservabilityStatus.DEGRADED
|
||||
assert readiness[ObservabilityKey.CHECKS][ObservabilityKey.DATABASE][
|
||||
ObservabilityKey.STATUS
|
||||
] == ObservabilityStatus.OK
|
||||
assert readiness[ObservabilityKey.CHECKS][ObservabilityKey.EVENTS][
|
||||
ObservabilityKey.STATUS
|
||||
] == ObservabilityStatus.ERROR
|
||||
assert metrics[ObservabilityKey.METRICS][ObservabilityKey.EVENTS][
|
||||
ObservabilityKey.STATUS
|
||||
] == ObservabilityStatus.ERROR
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql",
|
||||
[
|
||||
"SELECT 1; DELETE FROM projects",
|
||||
"SELECT * FROM projects /* hidden operation */",
|
||||
"SELECT * FROM projects -- hidden operation",
|
||||
"SELECT * FROM projects FOR UPDATE",
|
||||
"SELECT * INTO OUTFILE '/tmp/data' FROM projects",
|
||||
],
|
||||
)
|
||||
def test_legacy_query_rejects_unsafe_allowlist_sql(sql: str) -> None:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
LegacyMySQLService(None)._ensure_readonly(sql)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_legacy_query_accepts_parameterized_select() -> None:
|
||||
LegacyMySQLService(None)._ensure_readonly(
|
||||
"SELECT code, name FROM projects WHERE owner = :owner LIMIT :limit"
|
||||
)
|
||||
|
||||
|
||||
def test_celery_and_scheduler_have_safe_runtime_defaults() -> None:
|
||||
assert celery_app.conf.task_serializer == "json"
|
||||
assert celery_app.conf.result_serializer == "json"
|
||||
assert tuple(celery_app.conf.accept_content) == ("json",)
|
||||
assert celery_app.conf.task_acks_late is True
|
||||
assert celery_app.conf.worker_prefetch_multiplier == 1
|
||||
|
||||
scheduler = create_scheduler()
|
||||
assert scheduler._job_defaults["coalesce"] is True
|
||||
assert scheduler._job_defaults["max_instances"] == 1
|
||||
|
||||
|
||||
def test_unknown_event_type_fails_instead_of_being_discarded() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
event = EventService(db).emit(
|
||||
event_type="unknown.event",
|
||||
source="pytest",
|
||||
aggregate_type="test",
|
||||
aggregate_id="unknown",
|
||||
idempotency_key="unknown-event",
|
||||
)
|
||||
event.max_attempts = 1
|
||||
db.commit()
|
||||
|
||||
result = EventDispatchService(db).dispatch_event(
|
||||
event.event_id,
|
||||
worker_id="pytest",
|
||||
)
|
||||
|
||||
assert result.status == EventStatus.FAILED
|
||||
assert "Unsupported domain event type" in (result.last_error or "")
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_event_failure_rolls_back_handler_transaction(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
event = EventService(db).emit(
|
||||
event_type="test.database_failure",
|
||||
source="pytest",
|
||||
aggregate_type="test",
|
||||
aggregate_id="database-failure",
|
||||
idempotency_key="database-failure",
|
||||
)
|
||||
event.max_attempts = 1
|
||||
db.commit()
|
||||
dispatcher = EventDispatchService(db)
|
||||
|
||||
def fail_with_database_error(record: DomainEvent) -> None:
|
||||
_ = record
|
||||
db.execute(text("SELECT * FROM missing_handler_table")).all()
|
||||
|
||||
monkeypatch.setattr(dispatcher, "_handle_event", fail_with_database_error)
|
||||
result = dispatcher.dispatch_event(event.event_id, worker_id="pytest")
|
||||
|
||||
assert result.status == EventStatus.FAILED
|
||||
assert result.last_error
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_retry_rejects_an_active_event_lease() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
event = EventService(db).emit(
|
||||
event_type="test.locked",
|
||||
source="pytest",
|
||||
aggregate_type="test",
|
||||
aggregate_id="locked",
|
||||
idempotency_key="locked-event",
|
||||
)
|
||||
event.locked_by = "active-worker"
|
||||
event.locked_until = utc_now() + timedelta(minutes=5)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
EventDispatchService(db).retry_event(event.event_id)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_heartbeat_identity_is_unique_and_expired_instances_are_ignored(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("HEARTBEAT_RETENTION_SECONDS", "3600")
|
||||
get_settings.cache_clear()
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
constraints = SystemHeartbeat.__table__.constraints
|
||||
assert any(
|
||||
isinstance(item, UniqueConstraint)
|
||||
and {column.name for column in item.columns} == {"component", "instance_id"}
|
||||
for item in constraints
|
||||
)
|
||||
|
||||
with factory() as db:
|
||||
service = ObservabilityService(db)
|
||||
service.record_heartbeat(
|
||||
component=HeartbeatComponent.WORKER,
|
||||
instance_id="worker-current",
|
||||
)
|
||||
service.record_heartbeat(
|
||||
component=HeartbeatComponent.WORKER,
|
||||
instance_id="worker-current",
|
||||
)
|
||||
db.add(
|
||||
SystemHeartbeat(
|
||||
component=HeartbeatComponent.WORKER,
|
||||
instance_id="worker-expired",
|
||||
status="ok",
|
||||
last_seen_at=utc_now() - timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
count = db.scalar(select(func.count()).select_from(SystemHeartbeat))
|
||||
summary = service.heartbeat_summary()
|
||||
|
||||
assert count == 2
|
||||
assert summary["total"] == 1
|
||||
assert summary["stale"] == 0
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
(AIContextKey.OPENCLAW_TOOL, "sessions_list"),
|
||||
(AIContextKey.OPENCLAW_ACTION, "json"),
|
||||
(AIContextKey.OPENCLAW_ARGS, {"limit": 1}),
|
||||
(AIContextKey.OPENCLAW_SESSION_KEY, "main"),
|
||||
],
|
||||
)
|
||||
def test_external_ai_cannot_invoke_openclaw_tools(
|
||||
field: AIContextKey,
|
||||
value: object,
|
||||
) -> None:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
AIService(Session()).ask(
|
||||
"run a tool",
|
||||
context={field: value},
|
||||
source=AuditSource.API,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "/api/v1/ai/openclaw/tools/invoke" not in app.openapi()["paths"]
|
||||
|
||||
|
||||
def test_sample_requests_are_read_only() -> None:
|
||||
sample = Path("scripts/sample_requests.http").read_text(encoding="utf-8")
|
||||
|
||||
assert "POST http://127.0.0.1:8010/api/v1/business/" not in sample
|
||||
assert "/sync" not in sample
|
||||
assert "/assign" not in sample
|
||||
199
tests/test_v3_remaining.py
Normal file
199
tests/test_v3_remaining.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.events import EventDispatchService
|
||||
from app.core.database import Base
|
||||
from app.core.utils.time import utc_now
|
||||
from app.modules.ai_memory.constants import (
|
||||
AIMemoryPayloadKey,
|
||||
AIMemoryStatus,
|
||||
)
|
||||
from app.modules.ai_memory.models import AIMemoryEntry
|
||||
from app.modules.ai_memory.service import AIMemoryService
|
||||
from app.modules.business.constants import StatusValue
|
||||
from app.modules.business.models import FundAccount, PerformanceMetric, Project
|
||||
from app.modules.events.constants import (
|
||||
EventAggregateType,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.events.services import EventService
|
||||
from app.modules.reports.constants import EnterpriseAnalyticsKey, MetricKey
|
||||
from app.modules.reports.services import ReportService
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||
|
||||
|
||||
def _session_factory():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine, sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def test_workflow_actions_are_idempotent_per_source_event() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
event = EventService(db).emit(
|
||||
event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED,
|
||||
source=EventSource.ANALYTICS,
|
||||
aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS,
|
||||
aggregate_id="snapshot-1",
|
||||
idempotency_key="analytics:snapshot-1",
|
||||
)
|
||||
|
||||
first = EventDispatchService(db).dispatch_event(
|
||||
event.event_id,
|
||||
worker_id="pytest-first",
|
||||
)
|
||||
assert first.status == EventStatus.PROCESSED
|
||||
|
||||
first.status = EventStatus.PENDING
|
||||
first.processed_at = None
|
||||
first.next_attempt_at = utc_now()
|
||||
db.commit()
|
||||
second = EventDispatchService(db).dispatch_event(
|
||||
event.event_id,
|
||||
worker_id="pytest-second",
|
||||
)
|
||||
|
||||
assert second.status == EventStatus.PROCESSED
|
||||
assert db.scalar(select(func.count()).select_from(WorkflowInstance)) == 1
|
||||
assert db.scalar(select(func.count()).select_from(WorkflowAction)) == 1
|
||||
action = db.execute(select(WorkflowAction)).scalar_one()
|
||||
assert action.source_event_id == event.event_id
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_ai_memory_has_no_unrelated_fallback_and_archives_expired_entries() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
service = AIMemoryService(db)
|
||||
first = service.auto_write(
|
||||
prompt="Remember concise operational summaries",
|
||||
context={
|
||||
AIMemoryPayloadKey.SCOPE: "project",
|
||||
AIMemoryPayloadKey.SUBJECT: "P-MEM-IDEMPOTENT",
|
||||
},
|
||||
answer="Use concise bullet summaries for project updates.",
|
||||
)
|
||||
duplicate = service.auto_write(
|
||||
prompt="Remember concise operational summaries",
|
||||
context={
|
||||
AIMemoryPayloadKey.SCOPE: "project",
|
||||
AIMemoryPayloadKey.SUBJECT: "P-MEM-IDEMPOTENT",
|
||||
},
|
||||
answer="Use concise bullet summaries for project updates.",
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert duplicate is not None
|
||||
assert duplicate.code == first.code
|
||||
assert db.scalar(select(func.count()).select_from(AIMemoryEntry)) == 1
|
||||
assert (
|
||||
service.recall(
|
||||
query="no-such-memory-token",
|
||||
scope="project",
|
||||
subject="P-MEM-IDEMPOTENT",
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
first.expires_at = utc_now() - timedelta(seconds=1)
|
||||
db.commit()
|
||||
assert (
|
||||
service.list_entries(
|
||||
scope="project",
|
||||
subject="P-MEM-IDEMPOTENT",
|
||||
status_filter=AIMemoryStatus.ACTIVE,
|
||||
)
|
||||
== []
|
||||
)
|
||||
db.refresh(first)
|
||||
assert first.status == AIMemoryStatus.ARCHIVED
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_enterprise_analytics_validates_scope_and_reuses_identical_snapshot() -> None:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
with factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Project(
|
||||
code="P-SCOPED",
|
||||
name="Scoped project",
|
||||
owner="owner-a",
|
||||
status=StatusValue.RUNNING_CN,
|
||||
),
|
||||
PerformanceMetric(
|
||||
code="PERF-GLOBAL",
|
||||
name="Global metric",
|
||||
weight=10,
|
||||
auto_score=80,
|
||||
confirmed_score=75,
|
||||
status=StatusValue.REVIEWED,
|
||||
),
|
||||
FundAccount(
|
||||
code="FUND-GLOBAL",
|
||||
name="Global account",
|
||||
current_balance=1000,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
service = ReportService(db)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
service.enterprise_analytics(
|
||||
period_start=date(2030, 2, 2),
|
||||
period_end=date(2030, 2, 1),
|
||||
)
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
first = service.enterprise_analytics(project_code="P-SCOPED")
|
||||
second = service.enterprise_analytics(project_code="P-SCOPED")
|
||||
|
||||
assert first[EnterpriseAnalyticsKey.CODE] == second[EnterpriseAnalyticsKey.CODE]
|
||||
assert first[EnterpriseAnalyticsKey.PERFORMANCE][MetricKey.TOTAL] == 0
|
||||
assert (
|
||||
first[EnterpriseAnalyticsKey.FINANCE][
|
||||
MetricKey.CURRENT_BALANCE_TOTAL
|
||||
]
|
||||
== 0
|
||||
)
|
||||
events = list(
|
||||
db.execute(
|
||||
select(DomainEvent).where(
|
||||
DomainEvent.event_type
|
||||
== EventType.ENTERPRISE_ANALYTICS_GENERATED
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert events[0].aggregate_id == first[EnterpriseAnalyticsKey.CODE]
|
||||
|
||||
dispatched = EventDispatchService(db).dispatch_event(
|
||||
events[0].event_id,
|
||||
worker_id="pytest-enterprise",
|
||||
)
|
||||
assert dispatched.status == EventStatus.PROCESSED
|
||||
workflow = db.execute(
|
||||
select(WorkflowInstance).where(
|
||||
WorkflowInstance.workflow_type
|
||||
== WorkflowType.ENTERPRISE_ANALYTICS
|
||||
)
|
||||
).scalar_one()
|
||||
assert workflow.status == WorkflowStatus.COMPLETED
|
||||
assert workflow.aggregate_id == first[EnterpriseAnalyticsKey.CODE]
|
||||
finally:
|
||||
engine.dispose()
|
||||
Reference in New Issue
Block a user