test: 补充知识处理、检索与 Agent 回归用例
覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
1 parent
2dd062787d
commit
d9fe727c3f
123 files changed
+29005
No files matched your search
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 app.models.customer import Customer
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@pytest.fixture
|
||||
def customer_repo(db):
|
||||
"""Create a customer repository instance"""
|
||||
return CustomerRepository(db)
|
||||
|
||||
def test_get_or_create_customer_new(customer_repo, test_organization_id):
|
||||
"""Test creating a new customer when one doesn't exist"""
|
||||
email = "newcustomer@example.com"
|
||||
full_name = "New Customer"
|
||||
|
||||
customer = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id,
|
||||
full_name=full_name
|
||||
)
|
||||
|
||||
assert customer is not None
|
||||
assert customer.email == email
|
||||
assert customer.full_name == full_name
|
||||
assert customer.organization_id == test_organization_id
|
||||
|
||||
def test_get_or_create_customer_existing(customer_repo, test_organization_id):
|
||||
"""Test retrieving an existing customer"""
|
||||
email = "existing@example.com"
|
||||
full_name = "Existing Customer"
|
||||
|
||||
# Create initial customer
|
||||
customer1 = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id,
|
||||
full_name=full_name
|
||||
)
|
||||
|
||||
# Try to create/get the same customer again
|
||||
customer2 = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id,
|
||||
full_name="Different Name" # This should be ignored as customer exists
|
||||
)
|
||||
|
||||
assert customer2 is not None
|
||||
assert customer2.id == customer1.id
|
||||
assert customer2.email == email
|
||||
assert customer2.full_name == full_name # Should keep original name
|
||||
assert customer2.organization_id == test_organization_id
|
||||
|
||||
def test_get_or_create_customer_different_org(customer_repo, test_organization_id, db):
|
||||
"""Test creating customers with same email but different organizations"""
|
||||
email = "customer@example.com"
|
||||
|
||||
# Create second test organization
|
||||
from app.models.organization import Organization
|
||||
other_org = Organization(
|
||||
id=uuid4(),
|
||||
name="Other Test Org",
|
||||
domain="other-test-org.com",
|
||||
timezone="UTC"
|
||||
)
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
|
||||
# Create customer in first organization
|
||||
customer1 = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id,
|
||||
full_name="Customer One"
|
||||
)
|
||||
|
||||
# Create customer with same email in different organization
|
||||
customer2 = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=other_org.id,
|
||||
full_name="Customer Two"
|
||||
)
|
||||
|
||||
assert customer1.id != customer2.id
|
||||
assert customer1.email == customer2.email
|
||||
assert customer1.organization_id != customer2.organization_id
|
||||
|
||||
def test_get_by_id(customer_repo, test_organization_id):
|
||||
"""Test retrieving a customer by ID"""
|
||||
# Create test customer
|
||||
customer = customer_repo.get_or_create_customer(
|
||||
email="test@example.com",
|
||||
organization_id=test_organization_id,
|
||||
full_name="Test Customer"
|
||||
)
|
||||
|
||||
# Retrieve customer by ID
|
||||
retrieved_customer = customer_repo.get_by_id(customer.id)
|
||||
assert retrieved_customer is not None
|
||||
assert retrieved_customer.id == customer.id
|
||||
assert retrieved_customer.email == customer.email
|
||||
|
||||
def test_get_by_id_nonexistent(customer_repo):
|
||||
"""Test retrieving a non-existent customer by ID"""
|
||||
non_existent_id = uuid4()
|
||||
customer = customer_repo.get_by_id(non_existent_id)
|
||||
assert customer is None
|
||||
|
||||
def test_get_customer_email(customer_repo, test_organization_id):
|
||||
"""Test retrieving a customer's email by ID"""
|
||||
email = "test@example.com"
|
||||
|
||||
# Create test customer
|
||||
customer = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id,
|
||||
full_name="Test Customer"
|
||||
)
|
||||
|
||||
# Get customer email
|
||||
retrieved_email = customer_repo.get_customer_email(customer.id)
|
||||
assert retrieved_email == email
|
||||
|
||||
def test_get_customer_email_nonexistent(customer_repo):
|
||||
"""Test retrieving email for a non-existent customer"""
|
||||
non_existent_id = uuid4()
|
||||
email = customer_repo.get_customer_email(non_existent_id)
|
||||
assert email is None
|
||||
|
||||
def test_get_or_create_customer_no_name(customer_repo, test_organization_id):
|
||||
"""Test creating a customer without a full name"""
|
||||
email = "noname@example.com"
|
||||
|
||||
customer = customer_repo.get_or_create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id
|
||||
)
|
||||
|
||||
assert customer is not None
|
||||
assert customer.email == email
|
||||
assert customer.full_name is None
|
||||
assert customer.organization_id == test_organization_id
|
||||
|
||||
def test_create_customer_with_meta_data(customer_repo, test_organization_id):
|
||||
"""Test creating a customer with integrator-supplied meta_data"""
|
||||
email = "student@example.com"
|
||||
meta_data = {"student_name": "Aarav Krishnan", "center_name": "Special Academy U12"}
|
||||
|
||||
customer = customer_repo.create_customer(
|
||||
email=email,
|
||||
organization_id=test_organization_id,
|
||||
full_name="Parent Name",
|
||||
meta_data=meta_data
|
||||
)
|
||||
|
||||
assert customer is not None
|
||||
assert customer.meta_data == meta_data
|
||||
|
||||
def test_create_customer_without_meta_data(customer_repo, test_organization_id):
|
||||
"""Test creating a customer with no meta_data leaves the column null"""
|
||||
customer = customer_repo.create_customer(
|
||||
email="nometa@example.com",
|
||||
organization_id=test_organization_id
|
||||
)
|
||||
|
||||
assert customer.meta_data is None
|
||||
|
||||
def test_update_meta_data_merges_new_keys(customer_repo, test_organization_id):
|
||||
"""A second call adds new keys and overwrites changed ones, keeping the rest"""
|
||||
customer = customer_repo.create_customer(
|
||||
email="merge@example.com",
|
||||
organization_id=test_organization_id,
|
||||
meta_data={"student_name": "Aarav", "grade": "U12"}
|
||||
)
|
||||
|
||||
updated = customer_repo.update_meta_data(
|
||||
customer.id,
|
||||
{"student_name": "Aarav Krishnan", "center_name": "Special Academy U12"}
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated.meta_data == {
|
||||
"student_name": "Aarav Krishnan", # overwritten
|
||||
"grade": "U12", # preserved
|
||||
"center_name": "Special Academy U12" # added
|
||||
}
|
||||
|
||||
def test_update_meta_data_on_customer_with_no_existing_data(customer_repo, test_organization_id):
|
||||
"""Merging onto a customer created without meta_data starts from an empty dict"""
|
||||
customer = customer_repo.create_customer(
|
||||
email="firstmeta@example.com",
|
||||
organization_id=test_organization_id
|
||||
)
|
||||
|
||||
updated = customer_repo.update_meta_data(customer.id, {"plan": "premium"})
|
||||
|
||||
assert updated.meta_data == {"plan": "premium"}
|
||||
|
||||
def test_update_meta_data_empty_input_is_noop(customer_repo, test_organization_id):
|
||||
"""Calling with no data returns None and leaves the customer untouched"""
|
||||
customer = customer_repo.create_customer(
|
||||
email="noop@example.com",
|
||||
organization_id=test_organization_id,
|
||||
meta_data={"a": "b"}
|
||||
)
|
||||
|
||||
result = customer_repo.update_meta_data(customer.id, {})
|
||||
|
||||
assert result is None
|
||||
assert customer_repo.get_by_id(customer.id).meta_data == {"a": "b"}
|
||||
|
||||
def test_update_meta_data_nonexistent_customer(customer_repo):
|
||||
"""Updating meta_data for a customer that doesn't exist returns None"""
|
||||
result = customer_repo.update_meta_data(uuid4(), {"a": "b"})
|
||||
assert result is None
|
||||
|
||||
class TestPhoneIdentity:
|
||||
"""customers.phone is the second identity key: phone-first resolution,
|
||||
organic backfill, and the never-silently-merge conflict rule."""
|
||||
|
||||
def test_phone_lookup_beats_email_creation(self, customer_repo, test_organization_id):
|
||||
existing = customer_repo.create_customer(
|
||||
email="priya@example.com", organization_id=test_organization_id,
|
||||
full_name="Priya", phone="+911234567890")
|
||||
|
||||
# Same human arrives via WhatsApp: synthesized email, same phone.
|
||||
resolved = customer_repo.get_or_create_customer(
|
||||
email="911234567890@whatsapp.channel",
|
||||
organization_id=test_organization_id,
|
||||
full_name="Whatsapp user 91123456",
|
||||
phone="+911234567890")
|
||||
|
||||
assert resolved.id == existing.id
|
||||
assert resolved.full_name == "Priya" # nothing overwritten
|
||||
assert resolved.email == "priya@example.com" # no junk row minted
|
||||
|
||||
def test_backfills_phone_when_found_by_email(self, customer_repo, test_organization_id):
|
||||
existing = customer_repo.create_customer(
|
||||
email="911234567890@whatsapp.channel", organization_id=test_organization_id)
|
||||
assert existing.phone is None
|
||||
|
||||
resolved = customer_repo.get_or_create_customer(
|
||||
email="911234567890@whatsapp.channel",
|
||||
organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
|
||||
assert resolved.id == existing.id
|
||||
assert resolved.phone == "+911234567890"
|
||||
|
||||
def test_creates_with_phone_when_nobody_matches(self, customer_repo, test_organization_id):
|
||||
customer = customer_repo.get_or_create_customer(
|
||||
email="447700900123@whatsapp.channel",
|
||||
organization_id=test_organization_id,
|
||||
phone="+447700900123")
|
||||
assert customer.phone == "+447700900123"
|
||||
|
||||
def test_conflict_uses_phone_match_and_keeps_both_rows(self, customer_repo, test_organization_id, db):
|
||||
"""The Chatwoot trap: phone says A, email says B -> A wins the
|
||||
conversation, nobody gets merged or overwritten."""
|
||||
by_phone = customer_repo.create_customer(
|
||||
email="a@example.com", organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
by_email = customer_repo.create_customer(
|
||||
email="911234567890@whatsapp.channel", organization_id=test_organization_id)
|
||||
|
||||
resolved = customer_repo.get_or_create_customer(
|
||||
email="911234567890@whatsapp.channel",
|
||||
organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
|
||||
assert resolved.id == by_phone.id
|
||||
db.refresh(by_email)
|
||||
assert by_email.merged_into_customer_id is None # intact
|
||||
assert by_email.phone is None # not clobbered
|
||||
|
||||
def test_without_phone_behaviour_is_unchanged(self, customer_repo, test_organization_id):
|
||||
"""Channels that declare no phone must take the exact old path."""
|
||||
first = customer_repo.get_or_create_customer(
|
||||
email="D7@telegram.channel", organization_id=test_organization_id,
|
||||
full_name="Telegram user D7")
|
||||
again = customer_repo.get_or_create_customer(
|
||||
email="D7@telegram.channel", organization_id=test_organization_id)
|
||||
assert again.id == first.id
|
||||
assert again.phone is None
|
||||
|
||||
|
||||
class TestUpdateContactPhone:
|
||||
"""Set-if-absent with the same conflict-skip rule as email."""
|
||||
|
||||
def test_sets_phone_when_absent(self, customer_repo, test_organization_id):
|
||||
customer = customer_repo.create_customer(
|
||||
email="lead@example.com", organization_id=test_organization_id)
|
||||
result = customer_repo.update_contact(customer.id, phone="+91 12345 67890")
|
||||
assert result['phone_updated'] is True
|
||||
assert customer_repo.get_by_id(customer.id).phone == "+911234567890"
|
||||
|
||||
def test_never_overwrites_an_existing_phone(self, customer_repo, test_organization_id):
|
||||
customer = customer_repo.create_customer(
|
||||
email="lead@example.com", organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
result = customer_repo.update_contact(customer.id, phone="+15550001111")
|
||||
assert result['phone_updated'] is False
|
||||
assert customer_repo.get_by_id(customer.id).phone == "+911234567890"
|
||||
|
||||
def test_skips_a_phone_owned_by_someone_else(self, customer_repo, test_organization_id):
|
||||
customer_repo.create_customer(
|
||||
email="owner@example.com", organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
other = customer_repo.create_customer(
|
||||
email="other@example.com", organization_id=test_organization_id)
|
||||
|
||||
result = customer_repo.update_contact(other.id, phone="+911234567890")
|
||||
|
||||
assert result['phone_updated'] is False
|
||||
assert customer_repo.get_by_id(other.id).phone is None
|
||||
|
||||
def test_skips_an_unresolvable_number_rather_than_guessing(self, customer_repo, test_organization_id):
|
||||
customer = customer_repo.create_customer(
|
||||
email="lead@example.com", organization_id=test_organization_id)
|
||||
# Bare national digits: normalize_phone (strict) rejects them.
|
||||
result = customer_repo.update_contact(customer.id, phone="1234567890")
|
||||
assert result['phone_updated'] is False
|
||||
assert customer_repo.get_by_id(customer.id).phone is None
|
||||
|
||||
def test_phone_update_composes_with_email_and_name(self, customer_repo, test_organization_id):
|
||||
customer = customer_repo.create_customer(
|
||||
email="1712345@noemail.com", organization_id=test_organization_id)
|
||||
result = customer_repo.update_contact(
|
||||
customer.id, email="real@example.com", full_name="Priya",
|
||||
phone="+911234567890")
|
||||
assert result == {'email_updated': True, 'name_updated': True,
|
||||
'phone_updated': True, 'email': 'real@example.com'}
|
||||
|
||||
|
||||
class TestPlaceholderEmails:
|
||||
"""A `{id}@{channel}.channel` address is a synthesized identity key, not an
|
||||
address — but it is still the ONLY key a phone-less channel has."""
|
||||
|
||||
def test_channel_addresses_are_placeholders(self, customer_repo):
|
||||
assert CustomerRepository.is_placeholder_email("911234567890@whatsapp.channel") is True
|
||||
assert CustomerRepository.is_placeholder_email("555@telegram.channel") is True
|
||||
assert CustomerRepository.is_placeholder_email("1712345@noemail.com") is True
|
||||
assert CustomerRepository.is_placeholder_email(None) is True
|
||||
assert CustomerRepository.is_placeholder_email("priya@example.com") is False
|
||||
|
||||
def test_display_email_hides_stand_ins(self, customer_repo):
|
||||
assert CustomerRepository.display_email("911234567890@whatsapp.channel") is None
|
||||
assert CustomerRepository.display_email("1712345@noemail.com") is None
|
||||
assert CustomerRepository.display_email("priya@example.com") == "priya@example.com"
|
||||
|
||||
def test_captured_email_lands_when_a_phone_holds_the_identity(self, customer_repo, test_organization_id):
|
||||
"""WhatsApp/SMS: the phone is the key, so the email is free to become real."""
|
||||
customer = customer_repo.create_customer(
|
||||
email="911234567890@whatsapp.channel", organization_id=test_organization_id,
|
||||
phone="+911234567890")
|
||||
|
||||
result = customer_repo.update_contact(customer.id, email="priya@example.com")
|
||||
|
||||
assert result['email_updated'] is True
|
||||
assert customer_repo.get_by_id(customer.id).email == "priya@example.com"
|
||||
|
||||
def test_phoneless_channel_keeps_its_key_so_no_duplicate_is_minted(self, customer_repo, test_organization_id):
|
||||
"""Telegram has no phone: the .channel address is how the next inbound
|
||||
message finds this person. Overwriting it would create a second row."""
|
||||
customer = customer_repo.create_customer(
|
||||
email="555@telegram.channel", organization_id=test_organization_id)
|
||||
|
||||
result = customer_repo.update_contact(customer.id, email="ada@example.com")
|
||||
|
||||
assert result['email_updated'] is False
|
||||
assert customer_repo.get_by_id(customer.id).email == "555@telegram.channel"
|
||||
# And the lookup that every inbound message does still resolves.
|
||||
assert customer_repo.get_customer_by_email(
|
||||
"555@telegram.channel", test_organization_id).id == customer.id
|
||||
|
||||
def test_telegram_keeps_its_key_even_after_sharing_a_phone(self, customer_repo, test_organization_id):
|
||||
"""The trap: Telegram CAN learn a phone (share-contact button), but its
|
||||
inbound messages never carry one — so the address is still the lookup
|
||||
key. Having a phone must not be mistaken for being found by it."""
|
||||
customer = customer_repo.create_customer(
|
||||
email="555@telegram.channel", organization_id=test_organization_id)
|
||||
# They tapped "Share my phone number" during a handover.
|
||||
customer_repo.update_contact(customer.id, phone="+447700900123")
|
||||
db_customer = customer_repo.get_by_id(customer.id)
|
||||
assert db_customer.phone == "+447700900123"
|
||||
|
||||
# Now the handoff form captures their real email.
|
||||
result = customer_repo.update_contact(customer.id, email="ada@example.com")
|
||||
|
||||
assert result['email_updated'] is False
|
||||
# The key survives, so their next Telegram message still finds them.
|
||||
assert customer_repo.get_customer_by_email(
|
||||
"555@telegram.channel", test_organization_id).id == customer.id
|
||||
|
||||
def test_widget_placeholder_still_replaceable(self, customer_repo, test_organization_id):
|
||||
"""Unchanged behaviour: @noemail.com was never an identity key."""
|
||||
customer = customer_repo.create_customer(
|
||||
email="1712345@noemail.com", organization_id=test_organization_id)
|
||||
result = customer_repo.update_contact(customer.id, email="real@example.com")
|
||||
assert result['email_updated'] is True
|
||||
|
||||
|
||||
class TestPhoneIdentityHazards:
|
||||
"""The ways a phone key can attach a conversation to the wrong row."""
|
||||
|
||||
def test_merged_row_never_owns_a_live_number(self, customer_repo, test_organization_id):
|
||||
"""A merged-away row is invisible in People and uneditable — so if it
|
||||
kept a phone, the phone lookup (the FIRST thing every inbound WhatsApp
|
||||
message does) would attach that conversation to a row nobody can see."""
|
||||
survivor = customer_repo.create_customer(
|
||||
email="real@example.com", organization_id=test_organization_id,
|
||||
phone="+447700900111")
|
||||
tombstone = customer_repo.create_customer(
|
||||
email="old@example.com", organization_id=test_organization_id,
|
||||
phone="+447700900222")
|
||||
tombstone.merged_into_customer_id = survivor.id
|
||||
customer_repo.db.commit()
|
||||
|
||||
# The number the tombstone still holds must not resolve to it.
|
||||
assert customer_repo.get_customer_by_phone(
|
||||
"+447700900222", test_organization_id) is None
|
||||
|
||||
def test_inbound_wins_a_race_instead_of_being_dropped(
|
||||
self, customer_repo, test_organization_id, monkeypatch):
|
||||
"""A concurrent insert on the phone index must not surface as an
|
||||
exception: process_channel_message swallows it and the customer's
|
||||
message is never answered. The winner's row is the right answer."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
winner = customer_repo.create_customer(
|
||||
email="919999900001@whatsapp.channel",
|
||||
organization_id=test_organization_id, phone="+919999900001")
|
||||
|
||||
# Simulate losing the race: our lookup saw nothing, the insert collides.
|
||||
calls = {"n": 0}
|
||||
real_create = customer_repo.create_customer
|
||||
|
||||
def racing_create(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
raise IntegrityError("duplicate key", None, Exception("conflict"))
|
||||
|
||||
monkeypatch.setattr(customer_repo, "create_customer", racing_create)
|
||||
monkeypatch.setattr(customer_repo, "_resolve_existing",
|
||||
lambda e, o, p: None if calls["n"] == 0 else winner)
|
||||
|
||||
resolved = customer_repo.get_or_create_customer(
|
||||
email="919999900001@whatsapp.channel",
|
||||
organization_id=test_organization_id, phone="+919999900001")
|
||||
|
||||
assert resolved.id == winner.id
|
||||
assert calls["n"] == 1
|
||||
|
||||
def test_race_with_no_winner_still_raises(
|
||||
self, customer_repo, test_organization_id, monkeypatch):
|
||||
"""The retry must not swallow a genuine IntegrityError — if re-resolving
|
||||
finds nobody, the constraint fired for a reason we don't understand."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
def always_conflicts(*args, **kwargs):
|
||||
raise IntegrityError("duplicate key", None, Exception("conflict"))
|
||||
|
||||
monkeypatch.setattr(customer_repo, "create_customer", always_conflicts)
|
||||
monkeypatch.setattr(customer_repo, "_resolve_existing", lambda e, o, p: None)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
customer_repo.get_or_create_customer(
|
||||
email="x@whatsapp.channel", organization_id=test_organization_id,
|
||||
phone="+919999900009")
|
||||
Reference in new issue
Block a user