""" 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 uuid import uuid4 from app.models.customer import Customer, LeadStage from app.models.lead_capture import LeadCaptureResponse from app.models.chat_history import ChatHistory from app.repositories.people import PeopleRepository @pytest.fixture def repo(db): return PeopleRepository(db) def _customer(db, org_id, chatted=True, **kw): """Create a customer. By default also adds a chat message so it counts as 'engaged' (People only shows people who actually chatted). Pass chatted=False to simulate an empty widget-load.""" c = Customer(email=kw.pop("email", f"{uuid4().hex}@noemail.com"), organization_id=org_id, **kw) db.add(c); db.commit(); db.refresh(c) if chatted: db.add(ChatHistory(organization_id=org_id, customer_id=c.id, message="hi", message_type="user")) db.commit() return c def _response(db, org_id, customer_id, agent_id, field_values): r = LeadCaptureResponse( organization_id=org_id, agent_id=agent_id, customer_id=customer_id, field_values=field_values, summary="qualified", consent=True, qualified=True, ) db.add(r); db.commit(); db.refresh(r) return r def test_list_and_stats(repo, db, test_organization_id, test_agent): lead = _customer(db, test_organization_id, email="lead@acme.com", full_name="Lead One", lead_stage=LeadStage.LEAD) _response(db, test_organization_id, lead.id, test_agent.id, {"email": "lead@acme.com"}) _customer(db, test_organization_id) # anonymous visitor # Identity split: the default view holds identified people only; the # anonymous browser session lives behind view="anonymous". items, total = repo.list_people(test_organization_id) assert total == 1 assert items[0]["email"] == "lead@acme.com" anon_items, anon_total = repo.list_people(test_organization_id, view="anonymous") assert anon_total == 1 assert anon_items[0]["is_anonymous"] is True # stage filter leads, lead_total = repo.list_people(test_organization_id, stage="lead") assert lead_total == 1 assert leads[0]["qualified"] is True # search found, found_total = repo.list_people(test_organization_id, search="Lead One") assert found_total == 1 stats = repo.get_stats(test_organization_id) assert stats["total_people"] == 1 assert stats["anonymous"] == 1 def test_anonymous_falls_back_to_captured_email(repo, db, test_organization_id, test_agent): """A captured lead whose email couldn't be written to the customer row still shows the captured email (not 'anonymous').""" anon = _customer(db, test_organization_id, lead_stage=LeadStage.LEAD) _response(db, test_organization_id, anon.id, test_agent.id, {"email": "hidden@acme.com"}) items, _ = repo.list_people(test_organization_id, stage="lead") row = next(i for i in items if i["id"] == anon.id) assert row["email"] == "hidden@acme.com" assert row["is_anonymous"] is False def test_get_detail_and_mark_customer(repo, db, test_organization_id, test_agent): person = _customer(db, test_organization_id, email="d@acme.com", full_name="Detail", lead_stage=LeadStage.LEAD) _response(db, test_organization_id, person.id, test_agent.id, {"email": "d@acme.com", "company": "Acme"}) detail = repo.get_detail(test_organization_id, person.id) assert detail is not None assert detail["email"] == "d@acme.com" assert detail["captured_attributes"].get("company") == "Acme" assert detail["summary"] == "qualified" updated = repo.mark_customer(test_organization_id, person.id) assert updated.lead_stage == LeadStage.CUSTOMER def test_get_detail_missing_returns_none(repo, test_organization_id): assert repo.get_detail(test_organization_id, uuid4()) is None def test_unengaged_widget_loads_excluded(repo, db, test_organization_id): """A customer that never chatted (empty widget-load) is excluded from People.""" _customer(db, test_organization_id, email="chatted@acme.com", chatted=True, lead_stage=LeadStage.LEAD) _customer(db, test_organization_id, chatted=False) # loaded widget, never messaged items, total = repo.list_people(test_organization_id) assert total == 1 assert items[0]["email"] == "chatted@acme.com" assert repo.get_stats(test_organization_id)["total_people"] == 1 def test_authenticated_customers_excluded(repo, db, test_organization_id): """Integration-authenticated people (identified via generate-token) are the business's existing customers, not leads — excluded from People + stats. Having meta_data must NOT by itself exclude a customer: meta_data is orthogonal to authentication (and is stored as JSON null when empty, so the old `meta_data IS NULL` test hid every organic visitor).""" # Organic lead that happens to carry meta_data (e.g. UTM) — must still show. _customer(db, test_organization_id, email="organic@acme.com", full_name="Organic", lead_stage=LeadStage.LEAD, meta_data={"utm": "google"}) # Integration-authenticated customer — excluded. _customer(db, test_organization_id, email="portal@acme.com", full_name="Portal User", is_authenticated=True, meta_data={"student_name": "Sam", "center_name": "MMCA"}) items, total = repo.list_people(test_organization_id) emails = {i["email"] for i in items} assert "organic@acme.com" in emails # meta_data alone no longer excludes assert "portal@acme.com" not in emails # authenticated flag excludes assert total == 1 stats = repo.get_stats(test_organization_id) assert stats["total_people"] == 1 # the authenticated customer is not counted def test_merged_rows_hidden_and_resolved(repo, db, test_organization_id): target = _customer(db, test_organization_id, email="t@acme.com", full_name="Target", lead_stage=LeadStage.LEAD) merged = _customer(db, test_organization_id, lead_stage=LeadStage.LEAD) merged.merged_into_customer_id = target.id db.commit() # Merged row is excluded from the list. items, total = repo.list_people(test_organization_id) ids = {i["id"] for i in items} assert merged.id not in ids assert target.id in ids # Requesting the merged row resolves to the surviving target. detail = repo.get_detail(test_organization_id, merged.id) assert detail["id"] == target.id def test_phone_identifies_and_is_searchable(repo, db, test_organization_id): """A phone alone identifies a person (WhatsApp/SMS contacts have no real email), and decorated search terms match on digits.""" person = _customer(db, test_organization_id, email="911234567890@whatsapp.channel", full_name="Whatsapp user 91123456", phone="+911234567890") items, total = repo.list_people(test_organization_id) assert total == 1 and items[0]["id"] == person.id assert items[0]["phone"] == "+911234567890" for term in ["+91 12345 67890", "9112345", "12345 67890"]: _, found = repo.list_people(test_organization_id, search=term) assert found == 1, term # A non-numeric search must not explode on the phone clause _, none_found = repo.list_people(test_organization_id, search="nobody") assert none_found == 0 def test_update_person_edits_and_guards(repo, db, test_organization_id): person = _customer(db, test_organization_id) other = _customer(db, test_organization_id, phone="+15550001111") # Set + correct (overwrite is allowed here — it's the explicit human path) updated, error = repo.update_person(test_organization_id, person.id, full_name="Priya", phone="+91 12345 67890") assert error is None assert updated.full_name == "Priya" and updated.phone == "+911234567890" updated, error = repo.update_person(test_organization_id, person.id, phone="+919999999999") assert error is None and updated.phone == "+919999999999" # Someone else's number is refused, not reassigned _, error = repo.update_person(test_organization_id, person.id, phone="+15550001111") assert "another person" in error db.refresh(person) assert person.phone == "+919999999999" # National-format digits are refused, not guessed at _, error = repo.update_person(test_organization_id, person.id, phone="1234567890") assert "international format" in error # Clearing via empty string updated, error = repo.update_person(test_organization_id, person.id, phone="") assert error is None and updated.phone is None missing, _ = repo.update_person(test_organization_id, uuid4(), full_name="x") assert missing is None def test_mark_customer_is_gated_on_identity(repo, db, test_organization_id): anon = _customer(db, test_organization_id) assert repo.is_identified(anon) is False # Identify via phone → the gate opens repo.update_person(test_organization_id, anon.id, phone="+911234567890") db.refresh(anon) assert repo.is_identified(anon) is True def _whatsapp_conversation(db, org_id, customer, wa_id="919999900001"): """A WhatsApp conversation — what makes a person reachable BY PHONE, and so what makes their phone load-bearing for inbound routing.""" from app.models.channels.channel_conversation import ChannelConversation conv = ChannelConversation( channel_account_id=uuid4(), channel_type="whatsapp", external_conversation_id=wa_id, external_user_id=wa_id, session_id=uuid4(), organization_id=org_id, customer_id=customer.id) db.add(conv); db.commit() return conv class TestPhoneAsSoleIdentityKey: """A WhatsApp person starts keyed by both their @whatsapp.channel address and their phone. Once a capture upgrades that address to a real email, the phone is all that's left — nothing about a WhatsApp message carries email.""" def test_editing_the_only_key_is_refused(self, repo, db, test_organization_id): person = _customer(db, test_organization_id, email="w@corp.com", phone="+919999900001") _whatsapp_conversation(db, test_organization_id, person) updated, error = repo.update_person( test_organization_id, person.id, phone="+919999900002") assert error is not None and "split them into a second person" in error db.refresh(person) assert person.phone == "+919999900001" # unchanged def test_clearing_the_only_key_is_refused(self, repo, db, test_organization_id): person = _customer(db, test_organization_id, email="w@corp.com", phone="+919999900001") _whatsapp_conversation(db, test_organization_id, person) _, error = repo.update_person(test_organization_id, person.id, phone="") assert error is not None db.refresh(person) assert person.phone == "+919999900001" def test_still_editable_while_the_channel_address_survives( self, repo, db, test_organization_id): """Not yet captured: they're still findable by 919…@whatsapp.channel, so the phone is a correctable detail, not their only identity.""" person = _customer(db, test_organization_id, email="919999900001@whatsapp.channel", phone="+919999900001") _whatsapp_conversation(db, test_organization_id, person) _, error = repo.update_person( test_organization_id, person.id, phone="+919999900002") assert error is None db.refresh(person) assert person.phone == "+919999900002" def test_widget_person_phone_stays_freely_editable( self, repo, db, test_organization_id): """No phone-keyed channel: a typo'd number is just a typo. This is the common case and must not be caught by the guard.""" person = _customer(db, test_organization_id, email="w@corp.com", phone="+919999900001") _, error = repo.update_person( test_organization_id, person.id, phone="+919999900002") assert error is None db.refresh(person) assert person.phone == "+919999900002"