from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest from fastapi.testclient import TestClient from sqlalchemy import select from adcloud_server.eventing.catalog import EVENTS from adcloud_server.eventing.tables import contributions, receipts from adcloud_server.eventing.worker import FeatureWorker, feature_values from adcloud_server.main import create_app from adcloud_server.settings import Settings AUTH = {"Authorization": "Bearer " + "service-" + "x" * 48} CONFIG = { "standard_event": "commerce.cart.add", "description": "加购数码商品", "rules": [ { "conditions": [ { "field": "properties.category_id", "operator": "eq", "value": "digital", } ], "effects": [{"feature": "intent.purchase.digital", "value": 1}], } ], } @pytest.fixture(params=["sqlite", "postgres"]) def client(request, tmp_path): import os url = "sqlite:///" + str(tmp_path / "events.db") if request.param == "postgres": url = os.getenv("ADCLOUD_TEST_POSTGRES_URL") if not url: pytest.skip("No PostgreSQL integration URL configured") settings = Settings( database_url=url, tenant_id="registry-" + uuid4().hex, identity_secret="identity-" + "x" * 48, service_token="service-" + "x" * 48, cookie_secure=False, ) with TestClient(create_app(settings)) as test_client: yield test_client def app(client, app_id="shop"): response = client.post( "/v1/registry/apps", headers=AUTH, json={"app_id": app_id, "name": "测试商店"} ) assert response.status_code == 201, response.text return response.json() def path(application, name="add_cart", version=1): return f"/v1/registry/apps/{application['app_id']}/events/{name}/versions/{version}" def manage(application): return {"X-Adcloud-Management-Key": application["management_key"]} def register(client, application, config=None, name="add_cart"): response = client.put( path(application, name), json=config or CONFIG, headers=manage(application) ) assert response.status_code == 200, response.text response = client.post( path(application, name) + "/publish", headers=manage(application) ) assert response.status_code == 200, response.text return response.json() def event(**kwargs): return { "app_id": "shop", "name": "add_cart", "event_version": 1, "event_id": str(uuid4()), "occurred_at": datetime.now(UTC).isoformat(), "properties": {"product_id": "camera", "quantity": 1, "category_id": "digital"}, **kwargs, } def send(client, application, body=None, server=False): return client.post( "/v1/events", headers={ "X-Adcloud-Key": application["server_key" if server else "collection_key"] }, json=body or event(), ) def worker(client): return FeatureWorker(client.app.state.identity_service) def test_registration_preview_requires_category_and_scope(client): application = app(client) other = app(client, "other") assert ( client.put(path(application), json=CONFIG, headers=manage(other)).status_code == 401 ) saved = client.put( path(application), json=CONFIG, headers=manage(application) ).json() assert saved["draft"]["required_fields"] == ["category_id"] assert send(client, application).status_code == 409 preview = client.post( path(application) + "/preview", headers=manage(application), json={"properties": event()["properties"]}, ) assert preview.json()["effects"][0]["value"] == 1 bad = event() bad["properties"].pop("category_id") assert ( client.post( path(application) + "/preview", headers=manage(application), json={"properties": bad["properties"]}, ).status_code == 422 ) register(client, application) assert send(client, application, bad).status_code == 422 assert send(client, application, event(name="unregistered")).status_code == 404 assert client.post("/v1/events", json={"name": "page.view"}).status_code == 422 def test_receipt_and_worker_are_idempotent_and_exclude_tokens(client): application = app(client) register(client, application) body = event() first = send(client, application, body) assert first.status_code == 200, first.text assert send(client, application, body).json()["duplicate"] is True assert worker(client).flush() == 1 assert worker(client).flush() == 0 profile = client.get("/v1/users/" + first.json()["master_id"], headers=AUTH).json() assert profile["features"] == {"intent.purchase.digital": 1} with client.app.state.identity_service.db.transaction() as conn: rows = ( conn.execute( select(receipts).where( receipts.c.tenant_id == client.app.state.settings.tenant_id ) ) .mappings() .all() ) assert len(rows) == 1 and "anonymous_token" not in rows[0]["payload"] def test_server_only_events_and_business_deduplication(client): application = app(client) register( client, application, { "standard_event": "commerce.payment.success", "description": "支付成功", "rules": [ {"effects": [{"feature": "commerce.purchase.count", "value": 1}]} ], }, "paid", ) body = event( name="paid", properties={ "order_id": "o1", "payment_id": "p1", "amount": 100, "currency": "CNY", }, ) assert send(client, application, body).status_code == 401 assert send(client, application, body, server=True).status_code == 200 retry = {**body, "event_id": str(uuid4())} assert send(client, application, retry, server=True).json()["duplicate"] is True assert worker(client).flush() == 1 retry["properties"] = {**body["properties"], "amount": 200} assert send(client, application, retry, server=True).status_code == 409 def test_frozen_rule_revisions_and_rollback(client): application = app(client) register(client, application) first = send(client, application).json() updated = { **CONFIG, "rules": [ { "conditions": CONFIG["rules"][0]["conditions"], "effects": [{"feature": "intent.purchase.digital", "value": 2}], } ], } assert ( client.put( path(application), json=updated, headers=manage(application) ).status_code == 200 ) assert ( client.post(path(application) + "/publish", headers=manage(application)).json()[ "rule_revision" ] == 2 ) second = send(client, application).json() worker(client).flush() assert ( client.get("/v1/users/" + first["master_id"], headers=AUTH).json()["features"][ "intent.purchase.digital" ] == 3 ) assert first["rule_revision"] == 1 and second["rule_revision"] == 2 assert ( client.post( path(application) + "/rollback", json={"revision": 1}, headers=manage(application), ).status_code == 200 ) assert send(client, application).json()["rule_revision"] == 1 assert ( client.post( path(application) + "/disable", headers=manage(application) ).status_code == 200 ) assert send(client, application).status_code == 409 def test_stitch_sums_features_across_anonymous_nodes(client): application = app(client) register(client, application) first = send(client, application).json() worker(client).flush() client.post( "/v1/identity/stitch", headers=AUTH, json={"identifiers": [{"kind": "email", "value": "alice@example.com"}]}, ) client.post("/v1/identity/anonymous?rotate=true") second = send(client, application).json() worker(client).flush() assert first["anonymous_id"] != second["anonymous_id"] merged = client.post( "/v1/identity/stitch", headers=AUTH, json={"identifiers": [{"kind": "email", "value": "alice@example.com"}]}, ).json() assert client.get("/v1/users/" + merged["master_id"], headers=AUTH).json()[ "features" ] == {"intent.purchase.digital": 2} assert worker(client).flush() == 0 def test_transaction_failure_keeps_task_without_partial_effects(client, monkeypatch): application = app(client) register(client, application) send(client, application) identity = client.app.state.identity_service original = identity._save_profile def fail(*args): raise RuntimeError("simulated failure") monkeypatch.setattr(identity, "_save_profile", fail) with pytest.raises(RuntimeError): worker(client).flush() with identity.db.transaction() as conn: assert ( conn.execute( select(contributions).where( contributions.c.tenant_id == identity.tenant ) ).first() is None ) assert ( conn.execute( select(receipts.c.processed).where( receipts.c.tenant_id == identity.tenant ) ).scalar_one() == 0 ) monkeypatch.setattr(identity, "_save_profile", original) assert worker(client).flush() == 1 def test_feature_window_expiry_and_cap(client): application = app(client) register( client, application, { **CONFIG, "rules": [ {"effects": [{"feature": "intent.purchase.digital", "value": 100}]} ], }, ) result = send(client, application).json() send(client, application) worker(client).flush() identity = client.app.state.identity_service with identity.db.transaction() as conn: assert feature_values(conn, identity.tenant, result["master_id"]) == { "intent.purchase.digital": 100 } assert ( feature_values( conn, identity.tenant, result["master_id"], datetime.now(UTC) + timedelta(days=8), ) == {} ) @pytest.mark.parametrize("standard", list(EVENTS)) def test_catalog_required_fields_are_enforced(standard): from fastapi import HTTPException from adcloud_server.eventing.rules import validate_properties with pytest.raises(HTTPException) as error: validate_properties( {"standard_event": standard, "extra_fields": {}, "required_extra": []}, {} ) assert error.value.status_code == 422 @pytest.mark.parametrize("standard", list(EVENTS)) def test_catalog_accepts_valid_required_and_optional_parameters(standard): from adcloud_server.eventing.rules import validate_properties def sample(spec, name): if name == "currency": return "CNY" if "enum" in spec: return spec["enum"][0] if spec["type"] == "string": return "example" if spec["type"] in ("integer", "number"): return spec.get("minimum", 0) if spec["type"] == "array": return [sample(spec["items"], "item")] if spec["type"] == "object": return {key: sample(value, key) for key, value in spec["fields"].items()} raise AssertionError("Unsupported catalogue field") properties = { key: sample(spec, key) for key, spec in EVENTS[standard]["fields"].items() } validate_properties( {"standard_event": standard, "extra_fields": {}, "required_extra": []}, properties, ) @pytest.mark.parametrize( "operator,value,expected", [ ("gte", 1, 1), ("gt", 0, 1), ("lte", 1, 1), ("lt", 2, 1), ("eq", 2, 0), ("in", [1, 2], 1), ], ) def test_condition_operators(operator, value, expected): from adcloud_server.eventing.rules import evaluate config = { "rules": [ { "conditions": [ { "field": "properties.quantity", "operator": operator, "value": value, } ], "effects": [{"feature": "intent.purchase.digital", "value": 1}], } ] } assert len(evaluate(config, {"quantity": 1})["effects"]) == expected def test_schema_changes_require_new_version_and_retries_use_original_publication( client, ): application = app(client) register(client, application) body = event() assert send(client, application, body).status_code == 200 changed = {**CONFIG, "extra_fields": {"variant_id": "string"}} assert ( client.put( path(application), headers=manage(application), json=changed ).status_code == 409 ) assert ( client.put( path(application, version=2), headers=manage(application), json=changed ).status_code == 200 ) client.post(path(application) + "/disable", headers=manage(application)) assert send(client, application, body).json()["duplicate"] is True bad = event( properties={"product_id": "camera", "quantity": True, "category_id": "digital"} ) client.post( path(application) + "/rollback", headers=manage(application), json={"revision": 1}, ) assert send(client, application, bad).status_code == 422 def test_invalid_mapping_and_unregistered_extensions_are_rejected(client): application = app(client) for config in ( {**CONFIG, "extra_fields": {"email": "string"}}, {**CONFIG, "rules": [{"effects": [{"feature": "unknown", "value": 1}]}]}, { **CONFIG, "rules": [ { "conditions": [ {"field": "properties.missing", "operator": "eq", "value": "x"} ], "effects": CONFIG["rules"][0]["effects"], } ], }, ): assert ( client.put( path(application), headers=manage(application), json=config ).status_code == 422 ) register(client, application) body = event() body["properties"]["unregistered"] = "x" assert send(client, application, body).status_code == 422 with client.app.state.identity_service.db.transaction() as conn: assert ( conn.execute( select(receipts).where( receipts.c.tenant_id == client.app.state.settings.tenant_id ) ).first() is None )