RentNow / test_flows.py
rairo's picture
Implement renter search, previews and simulated payment flows
be21bae
Raw History Blame Contribute Delete
8.99 kB
"""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()