One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
482 lines
21 KiB
Python
482 lines
21 KiB
Python
"""
|
|
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")
|