test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
5da5f910da
commit
2554eb7bc7
97 files changed
+37281
No files matched your search
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models.customer import Customer, LeadStage
|
||||
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||||
from app.services.conversation_token import VERIFIED_SCOPE
|
||||
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
|
||||
from app.services.lead_capture import (
|
||||
has_captured_lead, record_lead_capture, _valid_email,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(db, test_agent):
|
||||
"""A lead-capture config with the standard fields enabled + consent required."""
|
||||
cfg = LeadCaptureConfig(
|
||||
agent_id=test_agent.id,
|
||||
enabled=True,
|
||||
require_consent=True,
|
||||
fields=[
|
||||
{"key": "email", "standard": True, "enabled": True, "required": True},
|
||||
{"key": "name", "standard": True, "enabled": True, "required": False},
|
||||
{"key": "company", "standard": True, "enabled": True, "required": False},
|
||||
],
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
db.refresh(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def _anon(db, org_id, email=None):
|
||||
c = Customer(email=email or f"{uuid4().hex}@noemail.com", organization_id=org_id)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return c
|
||||
|
||||
|
||||
def test_valid_email():
|
||||
assert _valid_email("jane@acme.com")
|
||||
assert not _valid_email("arun.com")
|
||||
assert not _valid_email("")
|
||||
assert not _valid_email(None)
|
||||
assert not _valid_email("no-at-sign")
|
||||
|
||||
|
||||
def test_has_captured_lead(db, test_agent, test_organization_id, config):
|
||||
customer = _anon(db, test_organization_id)
|
||||
assert has_captured_lead(db, customer.id, test_agent.id) is False
|
||||
record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "jane@acme.com"}, summary="s", consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert has_captured_lead(db, customer.id, test_agent.id) is True
|
||||
|
||||
|
||||
def test_no_capture_when_disabled(db, test_agent, test_organization_id, config):
|
||||
config.enabled = False
|
||||
db.commit()
|
||||
customer = _anon(db, test_organization_id)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "jane@acme.com"}, summary="s", consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is None
|
||||
assert has_captured_lead(db, customer.id, test_agent.id) is False
|
||||
|
||||
|
||||
def test_no_capture_for_authenticated_customer(db, test_agent, test_organization_id, config):
|
||||
# Integration-authenticated customer (identified via generate-token) — never a lead.
|
||||
authed = Customer(email="portal@acme.com", organization_id=test_organization_id,
|
||||
is_authenticated=True,
|
||||
meta_data={"student_name": "Sam", "center_name": "MMCA"})
|
||||
db.add(authed); db.commit(); db.refresh(authed)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=authed.id, session_id=None,
|
||||
lead_data={"email": "portal@acme.com"}, summary="s", consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is None
|
||||
assert has_captured_lead(db, authed.id, test_agent.id) is False
|
||||
|
||||
|
||||
def test_capture_for_customer_with_meta_data_but_not_authenticated(db, test_agent, test_organization_id, config):
|
||||
# meta_data present but not authenticated (e.g. UTM data on an organic visitor) —
|
||||
# must still be captured as a lead (regression: meta_data alone must not block).
|
||||
from app.models.customer import Customer as _Customer
|
||||
visitor = _Customer(email="anon@acme.com", organization_id=test_organization_id,
|
||||
meta_data={"utm": "google"})
|
||||
db.add(visitor); db.commit(); db.refresh(visitor)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=visitor.id, session_id=None,
|
||||
lead_data={"email": "lead@acme.com"}, summary="s", consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is not None
|
||||
assert has_captured_lead(db, resp.customer_id, test_agent.id) is True
|
||||
|
||||
|
||||
def test_record_requires_valid_email(db, test_agent, test_organization_id, config):
|
||||
customer = _anon(db, test_organization_id)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "not-an-email"}, summary=None, consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is None
|
||||
|
||||
|
||||
def test_record_requires_consent_when_configured(db, test_agent, test_organization_id, config):
|
||||
customer = _anon(db, test_organization_id)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "jane@acme.com"}, summary=None, consent=False,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is None
|
||||
|
||||
|
||||
def test_record_happy_path_promotes_and_stores(db, test_agent, test_organization_id, config):
|
||||
customer = _anon(db, test_organization_id)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "jane@acme.com", "name": "Jane", "company": "Acme"},
|
||||
summary="Interested in a demo", consent=True, page_url="https://x.com/pricing",
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is not None
|
||||
assert resp.field_values["email"] == "jane@acme.com"
|
||||
assert resp.qualified is True
|
||||
db.refresh(customer)
|
||||
assert customer.lead_stage == LeadStage.LEAD
|
||||
assert customer.lead_qualified_at is not None
|
||||
assert (customer.lead_source or {}).get("page_url") == "https://x.com/pricing"
|
||||
|
||||
|
||||
def test_record_filters_unconfigured_fields(db, test_agent, test_organization_id, config):
|
||||
customer = _anon(db, test_organization_id)
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "jane@acme.com", "ssn": "leak"}, summary=None, consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is not None
|
||||
assert "ssn" not in (resp.field_values or {})
|
||||
|
||||
|
||||
def test_record_no_consent_required(db, test_agent, test_organization_id):
|
||||
cfg = LeadCaptureConfig(
|
||||
agent_id=test_agent.id, enabled=True, require_consent=False,
|
||||
fields=[{"key": "email", "standard": True, "enabled": True, "required": True}],
|
||||
)
|
||||
db.add(cfg); db.commit(); db.refresh(cfg)
|
||||
customer = _anon(db, test_organization_id)
|
||||
resp = record_lead_capture(
|
||||
db, cfg, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=customer.id, session_id=None,
|
||||
lead_data={"email": "bob@corp.com"}, summary=None, consent=False,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is not None # consent not required → records anyway
|
||||
|
||||
|
||||
def test_record_merges_into_existing_customer(db, test_agent, test_organization_id, config):
|
||||
"""A visitor giving an email that already belongs to another customer merges."""
|
||||
existing = Customer(email="dupe@acme.com", organization_id=test_organization_id,
|
||||
full_name="Existing")
|
||||
db.add(existing); db.commit(); db.refresh(existing)
|
||||
anon = _anon(db, test_organization_id)
|
||||
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=anon.id, session_id=None,
|
||||
lead_data={"email": "dupe@acme.com"}, summary=None, consent=True,
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
assert resp is not None
|
||||
assert resp.customer_id == existing.id # lead attached to the existing customer
|
||||
db.refresh(anon)
|
||||
assert anon.merged_into_customer_id == existing.id
|
||||
assert anon.is_active is False
|
||||
|
||||
|
||||
def test_merge_moves_phone_to_the_survivor(db, test_agent, test_organization_id, config):
|
||||
"""Identifiers union on merge — and the phone MOVES rather than copies,
|
||||
because two rows holding one phone would violate the (org, phone) unique
|
||||
index at commit."""
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from app.services.lead_capture import record_lead_capture
|
||||
|
||||
repo = CustomerRepository(db)
|
||||
identified = repo.create_customer(
|
||||
email="priya@example.com", organization_id=test_organization_id,
|
||||
full_name="Priya")
|
||||
anonymous = repo.create_customer(
|
||||
email="911234567890@whatsapp.channel", organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
|
||||
# The WhatsApp person tells the AI an email that already belongs to Priya.
|
||||
record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=anonymous.id, session_id=None,
|
||||
lead_data={"email": "priya@example.com"}, summary=None, consent=True,
|
||||
scope=VERIFIED_SCOPE,)
|
||||
|
||||
db.refresh(identified)
|
||||
db.refresh(anonymous)
|
||||
assert anonymous.merged_into_customer_id == identified.id
|
||||
assert identified.phone == "+911234567890" # carried to the survivor
|
||||
assert anonymous.phone is None # moved, not copied
|
||||
|
||||
|
||||
def test_merge_never_leaves_a_live_number_on_the_tombstone(
|
||||
db, test_agent, test_organization_id, config):
|
||||
"""The branch the move test doesn't reach: when BOTH rows have a phone
|
||||
there is nowhere to move the source's to. It must still be cleared —
|
||||
a merged row is invisible in People, but phone lookup is the first thing
|
||||
every inbound WhatsApp message does, so a number left on it would capture
|
||||
that conversation onto a row nobody can see or edit."""
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from app.services.lead_capture import record_lead_capture
|
||||
|
||||
repo = CustomerRepository(db)
|
||||
identified = repo.create_customer(
|
||||
email="priya@example.com", organization_id=test_organization_id,
|
||||
full_name="Priya", phone="+447700900111") # work mobile
|
||||
anonymous = repo.create_customer(
|
||||
email="911234567890@whatsapp.channel", organization_id=test_organization_id,
|
||||
phone="+911234567890") # personal mobile
|
||||
|
||||
record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=anonymous.id, session_id=None,
|
||||
lead_data={"email": "priya@example.com"}, summary=None, consent=True,
|
||||
scope=VERIFIED_SCOPE,)
|
||||
|
||||
db.refresh(identified)
|
||||
db.refresh(anonymous)
|
||||
assert anonymous.merged_into_customer_id == identified.id
|
||||
assert identified.phone == "+447700900111" # survivor keeps theirs
|
||||
assert anonymous.phone is None # dropped, not left live
|
||||
# And the dropped number resolves to nobody, rather than to a tombstone.
|
||||
assert repo.get_customer_by_phone("+911234567890", test_organization_id) is None
|
||||
|
||||
|
||||
# --- a widget visitor may only merge or edit a customer its own browser created ---
|
||||
|
||||
def _session(db, customer, org_id, key):
|
||||
db.add(SessionToAgent(session_id=uuid4(), customer_id=customer.id, organization_id=org_id,
|
||||
status=SessionStatus.OPEN, conversation_key=key))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _visitor(db, org_id, key):
|
||||
"""An anonymous customer the widget created for the browser holding `key`."""
|
||||
c = Customer(email=f"{uuid4().hex}@noemail.com", organization_id=org_id, conversation_key=key)
|
||||
db.add(c); db.commit(); db.refresh(c)
|
||||
_session(db, c, org_id, key)
|
||||
return c
|
||||
|
||||
|
||||
def test_a_widget_visitor_becomes_a_lead_on_their_own_row(db, test_agent, test_organization_id, config):
|
||||
visitor = _visitor(db, test_organization_id, "browser-a")
|
||||
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=visitor.id, session_id=None,
|
||||
lead_data={"email": "new@acme.com", "name": "New"}, summary=None, consent=True,
|
||||
scope="browser-a",
|
||||
)
|
||||
|
||||
assert resp.customer_id == visitor.id
|
||||
db.refresh(visitor)
|
||||
assert visitor.email == "new@acme.com" and visitor.full_name == "New"
|
||||
assert visitor.lead_stage == LeadStage.LEAD
|
||||
|
||||
|
||||
def test_a_widget_visitor_is_not_merged_into_a_customer_who_already_existed(
|
||||
db, test_agent, test_organization_id, config
|
||||
):
|
||||
"""A typed email is no proof of being that customer, and a merge would hand
|
||||
the visitor's name and phone to the real one."""
|
||||
existing = Customer(email="known@acme.com", organization_id=test_organization_id)
|
||||
db.add(existing); db.commit(); db.refresh(existing)
|
||||
visitor = _visitor(db, test_organization_id, "browser-a")
|
||||
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=visitor.id, session_id=None,
|
||||
lead_data={"email": "known@acme.com", "name": "Visitor"}, summary=None, consent=True,
|
||||
scope="browser-a",
|
||||
)
|
||||
|
||||
assert resp.customer_id == visitor.id
|
||||
db.refresh(visitor); db.refresh(existing)
|
||||
assert visitor.merged_into_customer_id is None
|
||||
assert existing.full_name is None
|
||||
|
||||
|
||||
def test_a_claimed_customer_is_not_merged_away(db, test_agent, test_organization_id, config):
|
||||
"""Bound to a known customer by a typed email, a visitor gives their own
|
||||
address: merging would move the real customer's conversations to them."""
|
||||
victim = Customer(email="victim@acme.com", full_name="Victim", organization_id=test_organization_id)
|
||||
attacker = Customer(email="attacker@evil.com", organization_id=test_organization_id)
|
||||
db.add_all([victim, attacker]); db.commit(); db.refresh(victim); db.refresh(attacker)
|
||||
_session(db, victim, test_organization_id, "attacker-browser")
|
||||
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=victim.id, session_id=None,
|
||||
lead_data={"email": "attacker@evil.com", "name": "Attacker"}, summary=None, consent=True,
|
||||
scope="attacker-browser",
|
||||
)
|
||||
|
||||
assert resp is None
|
||||
assert has_captured_lead(db, victim.id, test_agent.id) is False # the real customer is still asked
|
||||
db.refresh(victim)
|
||||
assert victim.merged_into_customer_id is None
|
||||
assert victim.full_name == "Victim"
|
||||
assert victim.lead_stage != LeadStage.LEAD
|
||||
|
||||
|
||||
def test_a_customer_with_no_conversations_is_not_owned_by_whoever_arrives(
|
||||
db, test_agent, test_organization_id, config
|
||||
):
|
||||
"""A ticket or an import creates customers with no conversations; the first
|
||||
browser to claim one must not get to rename it."""
|
||||
known = Customer(email="ticket@acme.com", full_name="Ticket Person", organization_id=test_organization_id)
|
||||
db.add(known); db.commit(); db.refresh(known)
|
||||
|
||||
record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=known.id, session_id=None,
|
||||
lead_data={"email": "ticket@acme.com", "name": "Someone Else"}, summary=None, consent=True,
|
||||
scope="browser-a",
|
||||
)
|
||||
|
||||
db.refresh(known)
|
||||
assert known.full_name == "Ticket Person"
|
||||
|
||||
|
||||
def test_a_visitor_without_a_scope_changes_nothing(db, test_agent, test_organization_id, config):
|
||||
anon = _anon(db, test_organization_id)
|
||||
|
||||
resp = record_lead_capture(
|
||||
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
|
||||
customer_id=anon.id, session_id=None,
|
||||
lead_data={"email": "someone@acme.com", "name": "Someone"}, summary=None, consent=True,
|
||||
scope=None,
|
||||
)
|
||||
|
||||
assert resp is None
|
||||
db.refresh(anon)
|
||||
assert anon.full_name is None
|
||||
assert anon.email.endswith("@noemail.com")
|
||||
Reference in new issue
Block a user