Implement identity graph stitching and configurable profile storage

Issue persistent signed anonymous IDs and accept cookie/token-bound events.
Resolve verified email, phone, OAuth, and namespaced third-party IDs into
transactionally merged master identities with historical event aliases.
Protect shared-device account switching, tenant isolation, and event retries.

Store sparse canonical profiles as PostgreSQL JSONB or local SQLite JSON.
Support an optional versioned Cassandra wide-column projection through a
transactional outbox, retry worker, and immediate canonical read fallback.
Document deployment settings and provide opt-in database integration tests.

Validation: 43 local tests passed; 6 external database tests skipped.
Ruff checks, formatting, and offline uv lock verification passed.
This commit is contained in:
2026-09-15 23:49:43 +08:00
parent c30a136102
commit 0fc6c68a99
27 changed files with 2993 additions and 103 deletions
+87
View File
@@ -0,0 +1,87 @@
"""Optional supplied Cassandra environment; never install/start a server here."""
import os
from uuid import uuid4
import pytest
from adcloud_server.database import Database
from adcloud_server.identity_models import ProfilePatch, StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.profile_store import CassandraProfileStore
from adcloud_server.projection import ProfileProjector
from adcloud_server.settings import Settings
pytestmark = pytest.mark.skipif(
not os.getenv("ADCLOUD_TEST_CASSANDRA_HOSTS"),
reason="No Cassandra integration cluster configured",
)
@pytest.fixture
def components(tmp_path):
settings = Settings(
database_url="sqlite:///" + str(tmp_path / "graph.db"),
profile_backend="cassandra",
tenant_id="test-" + str(uuid4()),
identity_secret="test-cassandra-" + "x" * 48,
cassandra_hosts=tuple(os.environ["ADCLOUD_TEST_CASSANDRA_HOSTS"].split(",")),
cassandra_port=int(os.getenv("ADCLOUD_TEST_CASSANDRA_PORT", "9042")),
cassandra_keyspace=os.environ["ADCLOUD_TEST_CASSANDRA_KEYSPACE"],
cassandra_username=os.getenv("ADCLOUD_TEST_CASSANDRA_USERNAME", ""),
cassandra_password=os.getenv("ADCLOUD_TEST_CASSANDRA_PASSWORD", ""),
cassandra_tls=os.getenv("ADCLOUD_TEST_CASSANDRA_TLS", "true").lower() == "true",
cassandra_ca_file=os.getenv("ADCLOUD_TEST_CASSANDRA_CA_FILE") or None,
)
db = Database(settings.database_url, settings.tenant_id)
db.initialize()
store = CassandraProfileStore(settings)
try:
yield IdentityService(settings, db, store), ProfileProjector(db, store), store
finally:
store.close()
db.close()
def test_cassandra_sparse_snapshot_roundtrip(components):
service, _worker, store = components
master = str(uuid4())
document = {
"attributes": {"nested": {"interest": "running"}, "enabled": True, "score": 0},
"tags": ["runner"],
}
store.put(service.tenant, master, 1, document)
assert store.get(service.tenant, master, 1) == document
assert store.get(service.tenant, master, 2) is None
def test_cassandra_immutable_revision_prevents_stale_attribute_resurrection(components):
service, _worker, store = components
master = str(uuid4())
old = {"attributes": {"remove": "old"}, "tags": []}
new = {"attributes": {}, "tags": []}
store.put(service.tenant, master, 2, new)
store.put(service.tenant, master, 1, old)
assert store.get(service.tenant, master, 2) == new
def test_cassandra_worker_publishes_stitched_profile(components):
service, worker, _store = components
anon = service.anonymous()
stitched = service.stitch(
anon["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}],
attributes={"interest": "running"},
),
)
assert service.profile(stitched["master_id"])["projection_status"] == "pending"
worker.flush()
current = service.profile(stitched["master_id"])
assert current["projection_status"] == "current"
assert current["attributes"] == {"interest": "running"}
service.patch_profile(
stitched["master_id"], ProfilePatch(attributes={"interest": None})
)
worker.flush()
assert service.profile(stitched["master_id"])["attributes"] == {}
+438
View File
@@ -0,0 +1,438 @@
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from adcloud_server.database import Database, identifiers
from adcloud_server.identity_models import StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.main import create_app
from adcloud_server.settings import Settings
SECRET = "identity-test-secret-" + "x" * 32
SERVICE_TOKEN = "trusted-test-token-" + "y" * 32
AUTH = {"Authorization": "Bearer " + SERVICE_TOKEN}
@pytest.fixture
def settings(tmp_path):
return Settings(
database_url="sqlite:///" + str(tmp_path / "identity.db"),
identity_secret=SECRET,
service_token=SERVICE_TOKEN,
cookie_secure=False,
)
@pytest.fixture
def client(settings):
with TestClient(create_app(settings)) as test_client:
yield test_client
def anonymous(client):
response = client.post("/v1/identity/anonymous")
assert response.status_code == 200, response.text
return response.json()
def stitch(client, identity, token=None, **kwargs):
body = {"identifiers": identity, **kwargs}
if token:
body["anonymous_token"] = token
response = client.post("/v1/identity/stitch", json=body, headers=AUTH)
assert response.status_code == 200, response.text
return response.json()
def email(value="alice@example.com"):
return {"kind": "email", "value": value}
def profile(client, master):
response = client.get("/v1/users/" + master, headers=AUTH)
assert response.status_code == 200, response.text
return response.json()
def history(client, master):
response = client.get("/v1/events", params={"master_id": master}, headers=AUTH)
assert response.status_code == 200, response.text
return response.json()
def test_first_event_seeds_persistent_cookie_and_reuses_it(client):
first = client.post(
"/v1/events", json={"name": "page.view", "properties": {"path": "/shoes"}}
)
assert first.status_code == 200
assert "HttpOnly" in first.headers["set-cookie"]
assert "Max-Age=31536000" in first.headers["set-cookie"]
assert "SameSite=lax" in first.headers["set-cookie"]
assert first.headers["cache-control"] == "no-store"
second = anonymous(client)
assert first.json()["anonymous_id"] == second["anonymous_id"]
assert profile(client, second["master_id"])["event_count"] == 1
def test_cross_device_stitch_inherits_events_sparse_traits_and_tags(client):
known = anonymous(client)
stitch(client, [email()], attributes={"preferred_color": "blue"}, tags=["member"])
client.post("/v1/identity/anonymous?rotate=true")
visitor = anonymous(client)
client.patch(
"/v1/users/" + visitor["master_id"],
headers=AUTH,
json={
"attributes": {
"interest": "running",
"missing": None,
"nested": {"present": 1, "empty": None},
"preferred_color": "red",
},
"tags": ["runner"],
},
)
event = client.post(
"/v1/events", json={"name": "product.view", "properties": {"sku": "shoe-1"}}
).json()
result = stitch(client, [email()])
assert result["master_id"] == known["master_id"]
assert visitor["master_id"] in result["merged_master_ids"]
assert result["rotated_anonymous_id"] is False
merged = profile(client, result["master_id"])
assert merged["attributes"] == {
"preferred_color": "blue",
"interest": "running",
"nested": {"present": 1},
}
assert merged["tags"] == ["member", "runner"]
assert merged["event_count"] == 1
assert history(client, visitor["master_id"])["items"][0]["id"] == event["event_id"]
graph = client.get(
"/v1/identity/" + result["master_id"] + "/graph", headers=AUTH
).json()
assert len(graph["member_ids"]) == 2
assert len(graph["edges"]) == 1
assert graph["identifiers"][0]["display"] == "a***@example.com"
def test_transitive_verified_bridge_resolves_every_alias(client):
anonymous(client)
a = stitch(client, [email()])
client.post("/v1/identity/anonymous?rotate=true")
phone = {"kind": "phone", "value": "+1 (415) 555-2671"}
b = stitch(client, [phone])
client.post("/v1/identity/anonymous?rotate=true")
oauth = {
"kind": "oauth",
"namespace": "https://issuer.example/client-a",
"value": "subject-123",
}
c = stitch(client, [oauth])
joined = stitch(client, [email(), phone, oauth])
assert joined["master_id"] == a["master_id"]
for old in [a, b, c]:
assert profile(client, old["master_id"])["master_id"] == a["master_id"]
resolved = client.post(
"/v1/identity/resolve",
headers=AUTH,
json={
"identifiers": [email(), {"kind": "phone", "value": "+14155552671"}, oauth]
},
).json()
assert resolved == {
"master_id": a["master_id"],
"matches": [a["master_id"]],
"ambiguous": False,
}
def test_shared_browser_switches_accounts_without_merging_them(client):
a = stitch_after_anonymous(client, email())
client.post("/v1/events", json={"name": "alice.view"})
b = stitch(client, [email("bob@example.com")])
assert b["rotated_anonymous_id"] is True
assert a["master_id"] != b["master_id"]
assert profile(client, b["master_id"])["event_count"] == 0
assert profile(client, a["master_id"])["event_count"] == 1
def stitch_after_anonymous(client, identifier):
anonymous(client)
return stitch(client, [identifier])
def test_authenticated_backend_can_add_new_identity_to_current_account(client):
a = stitch_after_anonymous(client, email())
result = stitch(
client,
[{"kind": "phone", "value": "+14155552671"}],
account_master_id=a["master_id"],
)
assert result["master_id"] == a["master_id"]
assert result["rotated_anonymous_id"] is False
assert (
len(
client.get(
"/v1/identity/" + a["master_id"] + "/graph", headers=AUTH
).json()["identifiers"]
)
== 2
)
def test_namespace_isolates_third_party_subject_ids(client):
first = {"kind": "oauth", "namespace": "issuer/client-a", "value": "same-subject"}
second = {"kind": "oauth", "namespace": "issuer/client-b", "value": "same-subject"}
a = stitch_after_anonymous(client, first)
client.post("/v1/identity/anonymous?rotate=true")
b = stitch(client, [second])
assert a["master_id"] != b["master_id"]
resolved = client.post(
"/v1/identity/resolve", headers=AUTH, json={"identifiers": [first, second]}
).json()
assert resolved["ambiguous"] is True
assert resolved["master_id"] is None
def test_email_domain_normalization_preserves_case_sensitive_local_parts(client):
first = stitch_after_anonymous(client, email("Alice@EXAMPLE.com"))
resolved = client.post(
"/v1/identity/resolve",
headers=AUTH,
json={"identifiers": [email("Alice@example.com")]},
).json()
assert resolved["master_id"] == first["master_id"]
different = client.post(
"/v1/identity/resolve",
headers=AUTH,
json={"identifiers": [email("alice@example.com")]},
).json()
assert different["master_id"] is None
def test_stitch_requires_trusted_backend_and_signed_anonymous_proof(client):
anon = anonymous(client)
response = client.post("/v1/identity/stitch", json={"identifiers": [email()]})
assert response.status_code == 401
response = client.post(
"/v1/identity/stitch",
headers=AUTH,
json={"identifiers": [email()], "anonymous_token": anon["anonymous_id"]},
)
assert response.status_code == 401
response = client.get("/v1/users/" + anon["master_id"])
assert response.status_code == 401
response = client.post(
"/v1/identity/stitch",
headers=AUTH,
json={"identifiers": [{**email(), "verified": True}]},
)
assert response.status_code == 422
@pytest.mark.parametrize(
"identifier",
[
email("not-an-email"),
{"kind": "phone", "value": "4155552671"},
{"kind": "phone", "value": "+10000000000"},
{"kind": "oauth", "value": "subject"},
{"kind": "maid", "value": "device-id"},
],
)
def test_invalid_or_weak_identifiers_cannot_create_edges(client, identifier):
anon = anonymous(client)
response = client.post(
"/v1/identity/stitch", headers=AUTH, json={"identifiers": [identifier]}
)
assert response.status_code == 422
assert (
client.get("/v1/identity/" + anon["master_id"] + "/graph", headers=AUTH).json()[
"identifiers"
]
== []
)
def test_browser_origin_and_credentials(client):
denied = client.post(
"/v1/events",
headers={"Origin": "https://untrusted.example"},
json={"name": "page.view"},
)
assert denied.status_code == 403
preflight = client.options(
"/v1/events",
headers={
"Origin": "http://localhost:5174",
"Access-Control-Request-Method": "POST",
},
)
assert preflight.status_code == 200
assert preflight.headers["access-control-allow-credentials"] == "true"
assert preflight.headers["access-control-allow-origin"] == "http://localhost:5174"
def test_event_replay_is_idempotent_and_cannot_steal_another_subjects_event(client):
event_id = str(uuid4())
body = {"event_id": event_id, "name": "page.view"}
first = client.post("/v1/events", json=body).json()
replay = client.post("/v1/events", json=body).json()
assert replay["duplicate"] is True
assert profile(client, first["master_id"])["event_count"] == 1
client.post("/v1/identity/anonymous?rotate=true")
assert client.post("/v1/events", json=body).status_code == 409
assert (
client.post(
"/v1/events",
json={"name": "page.view", "occurred_at": "2020-01-01T00:00:00"},
).status_code
== 422
)
def test_profile_patch_omits_nulls_and_deletes_explicitly(client):
anon = anonymous(client)
client.patch(
"/v1/users/" + anon["master_id"],
headers=AUTH,
json={"attributes": {"keep": 1, "remove": "later"}},
)
result = client.patch(
"/v1/users/" + anon["master_id"],
headers=AUTH,
json={"attributes": {"remove": None}},
).json()
assert result["attributes"] == {"keep": 1}
def test_persistence_survives_restart(settings):
with TestClient(create_app(settings)) as first:
anon = anonymous(first)
first.post("/v1/events", json={"name": "page.view"})
stitched = stitch(first, [email()])
with TestClient(create_app(settings)) as second:
recorded = second.post(
"/v1/events",
json={"name": "page.return", "anonymous_token": anon["anonymous_token"]},
).json()
assert recorded["master_id"] == stitched["master_id"]
assert profile(second, stitched["master_id"])["event_count"] == 2
service = second.app.state.identity_service
with service.db.transaction() as conn:
stored = conn.execute(select(identifiers)).mappings().one()
assert "alice" not in stored["digest"]
assert "value" not in stored
def test_tenants_cannot_resolve_or_reuse_each_others_ids(settings):
other = Settings(
database_url=settings.database_url,
tenant_id="other",
identity_secret=SECRET,
service_token=SERVICE_TOKEN,
cookie_secure=False,
)
with (
TestClient(create_app(settings)) as first,
TestClient(create_app(other)) as second,
):
a = stitch_after_anonymous(first, email())
b = stitch_after_anonymous(second, email())
assert a["master_id"] != b["master_id"]
assert (
second.get("/v1/users/" + a["master_id"], headers=AUTH).status_code == 404
)
assert (
second.post(
"/v1/events",
json={"name": "forged", "anonymous_token": a["anonymous_token"]},
).status_code
== 401
)
def test_parallel_workers_stitch_same_email_into_one_master(settings):
databases = [Database(settings.database_url, settings.tenant_id) for _ in range(2)]
for db in databases:
db.initialize()
services = [IdentityService(settings, db) for db in databases]
tokens = [service.anonymous()["anonymous_token"] for service in services]
request = StitchRequest(identifiers=[email()])
try:
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(
pool.map(lambda i: services[i].stitch(tokens[i], request), [0, 1])
)
assert results[0]["master_id"] == results[1]["master_id"]
assert len(services[0].graph(results[0]["master_id"])["identifiers"]) == 1
finally:
for db in databases:
db.close()
def test_merge_limit_failure_rolls_back_all_alias_and_profile_changes(client):
a = stitch_after_anonymous(client, email())
client.patch(
"/v1/users/" + a["master_id"],
headers=AUTH,
json={"attributes": {"a" + str(i): i for i in range(150)}},
)
client.post("/v1/identity/anonymous?rotate=true")
b = anonymous(client)
client.patch(
"/v1/users/" + b["master_id"],
headers=AUTH,
json={"attributes": {"b" + str(i): i for i in range(150)}},
)
result = client.post(
"/v1/identity/stitch", headers=AUTH, json={"identifiers": [email()]}
)
assert result.status_code == 409
assert profile(client, b["master_id"])["master_id"] == b["master_id"]
assert len(profile(client, a["master_id"])["attributes"]) == 150
assert len(profile(client, b["master_id"])["attributes"]) == 150
def test_missing_configuration_disables_identity_but_keeps_gateway_available(tmp_path):
with TestClient(
create_app(Settings(database_url="sqlite:///" + str(tmp_path / "unused.db")))
) as client:
assert client.get("/health").status_code == 200
assert client.post("/v1/identity/anonymous").status_code == 503
assert (
client.post(
"/v1/identity/stitch", json={"identifiers": [email()]}
).status_code
== 503
)
assert not (tmp_path / "unused.db").exists()
def test_secure_cookie_configuration():
with TestClient(
create_app(Settings(database_url="sqlite://", identity_secret=SECRET)),
base_url="https://testserver",
) as client:
result = client.post("/v1/identity/anonymous")
assert "Secure" in result.headers["set-cookie"]
assert anonymous(client)["master_id"] == result.json()["master_id"]
with pytest.raises(ValueError):
Settings(cookie_secure=False, cookie_samesite="none")
with pytest.raises(ValueError):
Settings(profile_backend="unknown")
def test_parallel_workers_initialize_empty_database_safely(settings):
databases = [Database(settings.database_url, settings.tenant_id) for _ in range(2)]
try:
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(lambda db: db.initialize(), databases))
assert IdentityService(settings, databases[0]).anonymous()["master_id"]
finally:
for db in databases:
db.close()
+105
View File
@@ -0,0 +1,105 @@
"""Optional real PostgreSQL integration: set ADCLOUD_TEST_POSTGRES_URL."""
import os
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
from sqlalchemy import text
from adcloud_server.database import Database
from adcloud_server.identity_models import EventRequest, ProfilePatch, StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.settings import Settings
pytestmark = pytest.mark.skipif(
not os.getenv("ADCLOUD_TEST_POSTGRES_URL"),
reason="No PostgreSQL integration URL configured",
)
@pytest.fixture
def service():
settings = Settings(
database_url=os.environ["ADCLOUD_TEST_POSTGRES_URL"],
tenant_id="test-" + str(uuid4()),
identity_secret="postgres-test-" + "x" * 48,
profile_backend="cassandra",
)
db = Database(settings.database_url, settings.tenant_id)
db.initialize()
try:
yield IdentityService(settings, db)
finally:
db.close()
def test_postgres_jsonb_stitch_and_immediate_history(service):
known = service.anonymous()
identity = StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}]
)
service.stitch(known["anonymous_token"], identity)
visitor = service.anonymous(rotate=True)
service.patch_profile(
visitor["master_id"],
ProfilePatch(
attributes={"nested": {"interest": "running"}, "absent": None},
tags=["runner"],
),
)
event = service.record_event(
visitor["anonymous_token"], EventRequest(name="product.view")
)
stitched = service.stitch(visitor["anonymous_token"], identity)
assert stitched["master_id"] == known["master_id"]
assert service.profile(known["master_id"])["attributes"] == {
"nested": {"interest": "running"}
}
assert service.history(visitor["master_id"])["items"][0]["id"] == event["event_id"]
with service.db.transaction() as conn:
result = conn.execute(
text(
"SELECT pg_typeof(document)::text FROM identity_profiles WHERE tenant_id=:tenant LIMIT 1"
),
{"tenant": service.tenant},
).scalar_one()
assert result == "jsonb"
def test_postgres_multiple_process_connections_serialize_identity_merges(service):
second_db = Database(service.settings.database_url, service.tenant)
other = IdentityService(service.settings, second_db)
tokens = [
service.anonymous()["anonymous_token"],
other.anonymous()["anonymous_token"],
]
instances = [service, other]
request = StitchRequest(identifiers=[{"kind": "phone", "value": "+14155552671"}])
try:
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(
pool.map(lambda i: instances[i].stitch(tokens[i], request), [0, 1])
)
assert results[0]["master_id"] == results[1]["master_id"]
assert len(service.graph(results[0]["master_id"])["identifiers"]) == 1
finally:
second_db.close()
def test_postgres_failed_transaction_rolls_back_graph_and_outbox(service, monkeypatch):
visitor = service.anonymous()
def fail(*args):
raise OSError("Simulated outbox failure")
monkeypatch.setattr(service, "_queue", fail)
with pytest.raises(OSError):
service.stitch(
visitor["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}]
),
)
assert service.graph(visitor["master_id"])["identifiers"] == []
assert service.profile(visitor["master_id"])["revision"] == 1
+146
View File
@@ -0,0 +1,146 @@
import copy
from dataclasses import replace
import pytest
from sqlalchemy import select
from sqlalchemy.dialects import postgresql
from sqlalchemy.schema import CreateTable
from adcloud_server.database import Database, outbox, profiles
from adcloud_server.identity_models import ProfilePatch, StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.projection import ProfileProjector
from adcloud_server.settings import Settings
class RecoverableStore:
def __init__(self):
self.snapshots = {}
self.unavailable = False
def put(self, tenant, master, revision, document):
if self.unavailable:
raise OSError("Simulated storage outage")
self.snapshots[tenant, master, revision] = copy.deepcopy(document)
def get(self, tenant, master, revision):
if self.unavailable:
raise OSError("Simulated storage outage")
return self.snapshots.get((tenant, master, revision))
@pytest.fixture
def components(tmp_path):
settings = Settings(
database_url="sqlite:///" + str(tmp_path / "profiles.db"),
identity_secret="x" * 48,
profile_backend="cassandra",
)
db = Database(settings.database_url, settings.tenant_id)
db.initialize()
store = RecoverableStore()
service = IdentityService(settings, db, store)
try:
yield service, ProfileProjector(db, store), store
finally:
db.close()
def test_wide_table_outage_preserves_immediate_stitched_profile_and_retries(components):
service, worker, store = components
anon = service.anonymous()
result = service.stitch(
anon["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}],
attributes={"interest": "running"},
tags=["runner"],
),
)
assert service.profile(result["master_id"])["projection_status"] == "pending"
store.unavailable = True
assert service.profile(result["master_id"])["attributes"] == {"interest": "running"}
with pytest.raises(OSError):
worker.flush()
with service.db.transaction() as conn:
pending = (
conn.execute(select(outbox).where(outbox.c.delivered == 0)).mappings().all()
)
assert any(job["revision"] == result["revision"] for job in pending)
store.unavailable = False
worker.flush()
current = service.profile(result["master_id"])
assert current["projection_status"] == "current"
assert current["tags"] == ["runner"]
assert worker.flush() == 0
def test_out_of_order_projection_cannot_resurrect_deleted_attributes(components):
service, worker, store = components
anon = service.anonymous()
first = service.patch_profile(
anon["master_id"], ProfilePatch(attributes={"remove": "old", "keep": 1})
)
store.put(
service.tenant,
anon["master_id"],
first["revision"],
{"attributes": first["attributes"], "tags": []},
)
last = service.patch_profile(
anon["master_id"], ProfilePatch(attributes={"remove": None})
)
# A stale snapshot never satisfies the latest revision lookup.
assert service.profile(anon["master_id"])["attributes"] == {"keep": 1}
assert service.profile(anon["master_id"])["projection_status"] == "pending"
worker.flush()
assert service.profile(anon["master_id"])["revision"] == last["revision"]
assert service.profile(anon["master_id"])["attributes"] == {"keep": 1}
def test_relational_to_wide_table_switch_backfills_existing_profiles(components):
service, worker, _store = components
relational = IdentityService(
replace(service.settings, profile_backend="relational"), service.db
)
anon = relational.anonymous()
relational.patch_profile(
anon["master_id"], ProfilePatch(attributes={"nested": {"count": 3}})
)
with service.db.transaction() as conn:
assert conn.execute(select(outbox)).first() is None
assert worker.backfill() == 1
worker.flush()
assert service.profile(anon["master_id"])["projection_status"] == "current"
assert service.profile(anon["master_id"])["attributes"] == {"nested": {"count": 3}}
def test_merge_failure_never_leaves_partial_outbox_records(components, monkeypatch):
service, worker, _store = components
a = service.anonymous()
service.stitch(
a["anonymous_token"],
StitchRequest(identifiers=[{"kind": "email", "value": "alice@example.com"}]),
)
b = service.anonymous(rotate=True)
worker.flush()
def fail(*args):
raise OSError("Simulated transaction failure")
monkeypatch.setattr(service, "_queue", fail)
with pytest.raises(OSError):
service.stitch(
b["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}]
),
)
assert service.profile(b["master_id"])["master_id"] == b["master_id"]
assert worker.flush() == 0
assert len(service.graph(a["master_id"])["member_ids"]) == 1
def test_postgres_document_column_is_jsonb():
ddl = str(CreateTable(profiles).compile(dialect=postgresql.dialect()))
assert "document JSONB NOT NULL" in ddl