2026-09-05 14:05:11 +02:00
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import tempfile
|
|
|
|
|
import threading
|
2026-09-05 15:47:02 +02:00
|
|
|
import time
|
2026-09-05 14:05:11 +02:00
|
|
|
import unittest
|
|
|
|
|
from http.client import HTTPConnection
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
from api import server
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BrowserOnlyAppTests(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.tmp = tempfile.NamedTemporaryFile(delete=False)
|
|
|
|
|
self.tmp.close()
|
|
|
|
|
server.DB_PATH = self.tmp.name
|
2026-09-05 17:06:17 +02:00
|
|
|
self.scan_patcher = patch("api.server.scan_website", return_value={"website_status": "healthy", "website_http_status": 200, "website_final_url": "", "website_latency_ms": 50, "website_https": 1, "website_title": "", "website_technology": "", "website_has_viewport": 1, "website_tls_expires_at": "", "website_checked_at": server.now(), "website_error": "", "website_emails": [], "email_evidence": []})
|
|
|
|
|
self.scan_patcher.start()
|
2026-09-05 14:05:11 +02:00
|
|
|
self.http = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
|
|
|
|
|
self.thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
|
|
|
|
self.thread.start()
|
|
|
|
|
self.conn = HTTPConnection("127.0.0.1", self.http.server_port)
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
2026-09-05 17:06:17 +02:00
|
|
|
self.scan_patcher.stop()
|
2026-09-05 14:05:11 +02:00
|
|
|
self.http.shutdown(); self.http.server_close(); self.thread.join(1)
|
|
|
|
|
try: os.unlink(self.tmp.name)
|
|
|
|
|
except FileNotFoundError: pass
|
|
|
|
|
|
|
|
|
|
def request(self, method, path, payload=None):
|
|
|
|
|
body = json.dumps(payload).encode() if payload is not None else None
|
|
|
|
|
self.conn.request(method, path, body, {"Content-Type": "application/json"} if body else {})
|
|
|
|
|
response = self.conn.getresponse()
|
|
|
|
|
return response.status, json.loads(response.read() or b"{}")
|
|
|
|
|
|
2026-09-05 15:47:02 +02:00
|
|
|
def wait_for_run(self, run_id):
|
2026-09-05 17:06:17 +02:00
|
|
|
for _ in range(300):
|
2026-09-05 15:47:02 +02:00
|
|
|
status, body = self.request("GET", f"/api/runs/{run_id}")
|
|
|
|
|
self.assertEqual(status, 200)
|
|
|
|
|
if body["status"] not in {"queued", "running"}:
|
|
|
|
|
return body
|
2026-09-05 17:06:17 +02:00
|
|
|
time.sleep(0.02)
|
2026-09-05 15:47:02 +02:00
|
|
|
self.fail("run did not finish")
|
|
|
|
|
|
2026-09-05 14:59:28 +02:00
|
|
|
def test_search_redirect_links_are_unwrapped(self):
|
|
|
|
|
wrapped = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.example.co.za%2Fservices"
|
|
|
|
|
self.assertEqual(server.normalize_result_href(wrapped), "https://www.example.co.za/services")
|
|
|
|
|
self.assertEqual(server.normalize_result_href("//example.co.za/about"), "https://example.co.za/about")
|
|
|
|
|
|
2026-09-05 15:47:02 +02:00
|
|
|
@patch.dict(os.environ, {"DISCOVERY_BACKEND": "browser"})
|
2026-09-05 14:05:11 +02:00
|
|
|
@patch("api.server.google_search", return_value=[{"name": "Acme Plumbing", "website": "https://www.acme.co.za", "domain": "acme.co.za"}, {"name": "Other", "website": "https://other.co.za", "domain": "other.co.za"}])
|
|
|
|
|
def test_discovery_filters_catalogued_root_domains(self, search):
|
|
|
|
|
status, first = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town", "max_results": 5})
|
2026-09-05 15:47:02 +02:00
|
|
|
self.assertEqual(status, 202)
|
|
|
|
|
first = self.wait_for_run(first["id"])
|
|
|
|
|
self.assertEqual(first["added"], 2)
|
2026-09-05 14:05:11 +02:00
|
|
|
status, second = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "Cape Town", "max_results": 5})
|
2026-09-05 15:47:02 +02:00
|
|
|
self.assertEqual(status, 202)
|
|
|
|
|
second = self.wait_for_run(second["id"])
|
|
|
|
|
self.assertEqual(second["found"], 2); self.assertEqual(second["added"], 0)
|
2026-09-05 14:05:11 +02:00
|
|
|
self.assertEqual(self.request("GET", "/api/prospects")[1]["total"], 2)
|
|
|
|
|
|
2026-09-05 15:47:02 +02:00
|
|
|
@patch.dict(os.environ, {"DISCOVERY_BACKEND": "browser"})
|
|
|
|
|
def test_map_without_website_gets_stable_catalogue_key(self):
|
|
|
|
|
mapped = server.map_gmaps_result({"title": "A Local Plumber", "place_id": "abc123", "address": "Cape Town", "phone": "+27 10", "review_rating": 4.5, "review_count": 12}, "plumbers Cape Town")
|
|
|
|
|
self.assertEqual(mapped["domain"], "maps:abc123")
|
2026-09-05 17:06:17 +02:00
|
|
|
self.assertEqual(mapped["website_status"], "no_detected_website")
|
2026-09-05 15:47:02 +02:00
|
|
|
self.assertGreaterEqual(mapped["opportunity_score"], 70)
|
|
|
|
|
|
|
|
|
|
@patch.dict(os.environ, {"DISCOVERY_BACKEND": "browser"})
|
2026-09-05 14:05:11 +02:00
|
|
|
def test_review_updates_only_selected_prospect(self):
|
|
|
|
|
with patch("api.server.google_search", return_value=[{"name": "Acme", "website": "https://acme.co.za", "domain": "acme.co.za"}]):
|
2026-09-05 15:47:02 +02:00
|
|
|
_, run = self.request("POST", "/api/discover", {"category": "plumbers", "keywords": "plumbing", "location": "South Africa"})
|
|
|
|
|
self.wait_for_run(run["id"])
|
2026-09-05 14:05:11 +02:00
|
|
|
status, item = self.request("PATCH", "/api/prospects/1/review", {"status": "approved", "notes": "Looks relevant"})
|
|
|
|
|
self.assertEqual(status, 200); self.assertEqual(item["review_status"], "approved")
|
|
|
|
|
|
2026-09-05 16:15:03 +02:00
|
|
|
def test_standard_maps_worker_uses_jobs_api_without_key(self):
|
|
|
|
|
csv_body = b"title,address,website,phone,latitude,longitude,review_rating,review_count,link\nAcme Plumbing,Cape Town,https://acme.co.za,+2710, -33.9,18.4,4.5,12,https://maps.google.com/?cid=1\n"
|
|
|
|
|
with patch.object(server, "GMAPS_URL", "http://gmaps:8080"), \
|
|
|
|
|
patch.object(server, "GMAPS_API_MODE", "standard"), \
|
|
|
|
|
patch.object(server, "GMAPS_API_KEY", ""), \
|
2026-09-05 16:29:59 +02:00
|
|
|
patch("api.server.http_json", side_effect=[{"id": "job-1"}, {"ID": "job-1", "Status": "pending"}, {"ID": "job-1", "Status": "working"}, {"ID": "job-1", "Status": "ok"}]) as http_json, \
|
2026-09-05 16:15:03 +02:00
|
|
|
patch("api.server.http_bytes", return_value=csv_body) as http_bytes:
|
|
|
|
|
job_id = server.submit_gmaps_job("plumbers Cape Town")
|
|
|
|
|
results = server.poll_gmaps_job(job_id)
|
|
|
|
|
self.assertEqual(job_id, "job-1")
|
|
|
|
|
self.assertEqual(results[0]["title"], "Acme Plumbing")
|
|
|
|
|
first_call = http_json.call_args_list[0]
|
|
|
|
|
self.assertEqual(first_call.args[0:2], ("POST", "http://gmaps:8080/api/v1/jobs"))
|
|
|
|
|
self.assertNotIn("X-API-Key", first_call.args[3])
|
2026-09-05 17:06:17 +02:00
|
|
|
self.assertTrue(first_call.args[2]["email"])
|
2026-09-05 16:15:03 +02:00
|
|
|
self.assertEqual(http_bytes.call_args.args[1], "http://gmaps:8080/api/v1/jobs/job-1/download")
|
|
|
|
|
|
2026-09-05 14:05:11 +02:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|