"""Conversation regressions. No credentials, network calls or real payments.""" import os import unittest from unittest.mock import patch os.environ.pop('FIREBASE', None) os.environ.pop('GOOGLE_API_KEY', None) with patch('dotenv.load_dotenv'): import main as bot import utility as U import whatsapp_client as wa class RentalFlows(unittest.TestCase): def setUp(self): self.phone = '+263770000099' U.db = None U._mem.clear() self.messages = [] self.images = [] self.patches = [ patch.object(wa, 'send_text_message', side_effect=lambda m, text: self.messages.append(text) or True), patch.object(wa, 'send_image_message', side_effect=lambda m, url, caption='': self.images.append((url, caption)) or True), patch.object(wa, 'send_document_message', return_value=True), patch.object(U, 'gemini_ack_search', return_value='Matching homes'), patch.object(U, 'gemini_detect_intent', return_value={'intent': 'SEARCH', 'criteria': {}}), ] bot.logger.disabled = True for p in self.patches: p.start() self.addCleanup(p.stop) def say(self, text): bot._process_text_inner(text, self.phone) return bot.get_s(self.phone) def browse(self): U.save_profile(self.phone, {'name': 'Test Renter'}) bot.show_results(self.phone, {}) return bot.get_s(self.phone) def test_all_text_menu_options_visible(self): wa.send_buttons_as_text(self.phone, 'Features', bot.FIND_AMENS) self.assertIn('8. Other (type)', self.messages[-1]) def test_guided_any_choices_reach_summary(self): self.say('menu') self.say('1') self.assertEqual(self.say('1')['step'], 'type') self.assertEqual(self.say('2')['step'], 'suburb') self.assertEqual(self.say('8')['step'], 'budget') self.assertEqual(self.say('6')['step'], 'amen') state = self.say('skip') self.assertEqual(state['step'], 'summary') self.assertEqual(state['data']['type'], 'cottage') self.assertEqual(self.say('1')['menu'], 'browse') def test_budget_range_preserves_lower_bound(self): bot.start_find(self.phone, {'type': 'cottage', 'suburb': 'Avondale'}) self.say('2') state = self.say('7') self.assertEqual(state['data']['min_budget'], 200) self.assertEqual(state['data']['max_budget'], 400) def test_over_800_has_no_upper_bound(self): bot.start_find(self.phone, {'type': 'house', 'suburb': 'Harare'}) self.assertEqual(self.say('5')['step'], 'amen') state = self.say('7') self.assertGreater(state['data']['min_budget'], 800) self.assertEqual(state['data']['max_budget'], 0) def test_compact_features(self): self.assertEqual(bot._pick_multi('123', bot.FIND_AMENS), ['Borehole', 'Solar', 'Parking']) self.assertEqual(bot._pick_multi('7', bot.FIND_AMENS), []) def test_natural_input_from_feature_step(self): bot.start_find(self.phone, {'type': 'cottage', 'suburb': 'Westgate', 'max_budget': 400}) state = self.say('1 bed cottage in Mount Pleasant with borehole solar parking under $400') self.assertEqual(state['step'], 'summary') self.assertEqual(state['data']['type'], 'cottage') self.assertEqual(state['data']['suburb'], 'Mount Pleasant') self.assertEqual(len(state['data']['amenities']), 3) def test_edit_budget_returns_to_summary(self): bot._finish_find(self.phone, {'type': 'cottage', 'suburb': 'Avondale', 'max_budget': 400, 'features_chosen': True}) self.say('2') self.assertEqual(self.say('3')['step'], 'budget') state = self.say('350') self.assertEqual(state['step'], 'summary') self.assertEqual(state['data']['max_budget'], 350) def test_first_result_is_free_preview(self): self.browse() state = self.say('1') self.assertEqual(state['screen'], 'preview') self.assertFalse(any(k.startswith('pay:') for k in U._mem)) self.assertTrue(self.images) def test_payment_then_selected_unlock_and_repeat_preview(self): original = self.browse() lid = original['results'][0] self.say('1') state = self.say('1') self.assertEqual(state['menu'], 'await_pay') self.assertEqual(state['results'], original['results']) state = self.say('1') self.assertEqual(state['screen'], 'pass_ready') self.assertEqual(U.active_pass(self.phone)['credits_used'], 0) state = self.say('1') self.assertEqual(state['screen'], 'full') self.assertTrue(U.is_unlocked(self.phone, lid)) self.assertEqual(U.active_pass(self.phone)['credits_used'], 1) self.say('back to results') self.say('1') self.assertEqual(U.active_pass(self.phone)['credits_used'], 1) def test_account_capture_retains_selected_property(self): bot.show_results(self.phone, {}) original = bot.get_s(self.phone) self.assertEqual(self.say('unlock 2')['menu'], 'need_name') state = self.say('Test Renter') self.assertEqual(state['menu'], 'await_pay') self.assertEqual(state['results'], original['results']) self.assertEqual(state['pending_unlock'], original['results'][1]) def test_cancel_preserves_results_without_pass(self): original = self.browse() self.say('unlock 2') state = self.say('2') self.assertEqual(state['menu'], 'browse') self.assertEqual(state['results'], original['results']) self.assertIsNone(U.active_pass(self.phone)) def test_continue_after_pass_does_not_consume_credit(self): self.browse() self.say('unlock 2') self.say('1') state = self.say('2') self.assertEqual(state['screen'], 'results') self.assertEqual(U.active_pass(self.phone)['credits_used'], 0) def test_full_gallery_and_viewing_request(self): original = self.browse() lid = original['results'][0] self.say('unlock 1') self.say('1') self.say('1') state = self.say('1') self.assertEqual(state['kind'], 'viewing') self.assertEqual(U._mem['pay:' + state['payment_id']]['amount_cents'], 500) self.assertIn('$2 goes to the landlord', '\n'.join(self.messages)) state = self.say('1') self.assertEqual(state['menu'], 'browse') viewings = [v for k, v in U._mem.items() if k.startswith('view:')] self.assertEqual(len(viewings), 1) self.assertEqual(viewings[0]['status'], 'paid_appointment_pending') self.assertEqual(viewings[0]['listing_id'], lid) def test_locked_property_cannot_buy_viewing(self): self.browse() self.say('1') self.say('request viewing') self.assertFalse(any(k.startswith('pay:') for k in U._mem)) def test_more_handles_listing_without_ranking(self): self.browse() state = self.say('more') self.assertEqual(state['shown'], 6) self.say('back') self.assertIn('NEW', self.messages[-1]) def test_constraints_filter_all_required_features_and_minimum(self): fixture = [dict(id='a', type='cottage', suburb='Mt Pleasant', rent_cents=35000, amenities=['solar', 'parking']), dict(id='b', type='cottage', suburb='Mount Pleasant', rent_cents=15000, amenities=['solar']), dict(id='c', type='cottage', suburb='Mount Pleasant', rent_cents=85000, amenities=['solar', 'parking'])] with patch.object(U, 'get_listings', return_value=fixture): rows = U.search_listings({'suburb': 'Mount Pleasant', 'min_budget': 200, 'max_budget': 400, 'amenities': ['solar', 'parking']}) self.assertEqual([r['id'] for r in rows], ['a']) def test_unknown_suburb_and_other_features(self): bot.start_find(self.phone, {'type': 'cottage'}) self.say('7') self.say('Chishawasha') self.say('400') self.say('8') state = self.say('swimming pool') self.assertEqual(state['step'], 'summary') self.assertEqual(state['data']['amenities'], ['swimming pool']) def test_pass_expires_and_exhausts(self): from datetime import datetime, timezone, timedelta p = U.issue_pass(self.phone, 'test-payment') p['expires_at'] = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() self.assertIsNone(U.active_pass(self.phone)) p['expires_at'] = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() for lid in ['a', 'b', 'c']: self.assertTrue(U.consume_unlock(self.phone, lid)[0]) self.assertIsNone(U.active_pass(self.phone)) self.assertFalse(U.consume_unlock(self.phone, 'd')[0]) self.assertTrue(U.consume_unlock(self.phone, 'a')[0]) if __name__ == '__main__': unittest.main()