Skip to content

Commit fadd3b7

Browse files
committed
Setup Githun Action
1 parent f6196c9 commit fadd3b7

File tree

5 files changed

+262
-0
lines changed

5 files changed

+262
-0
lines changed

.github/workflows/ci.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
workflow_dispatch:
6+
7+
jobs:
8+
backend-tests:
9+
runs-on: ubuntu-latest
10+
defaults:
11+
run:
12+
working-directory: backend
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
- name: Setup Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
cache: pip
21+
cache-dependency-path: |
22+
backend/requirements.txt
23+
backend/requirements-dev.txt
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
python -m pip install -r requirements.txt -r requirements-dev.txt
28+
- name: Run tests
29+
env:
30+
DATABASE_URL: sqlite+pysqlite:///:memory:
31+
ENABLE_GEOCODING: "0"
32+
run: python -m pytest -q
33+
34+
frontend-build:
35+
runs-on: ubuntu-latest
36+
defaults:
37+
run:
38+
working-directory: frontend
39+
steps:
40+
- name: Checkout
41+
uses: actions/checkout@v4
42+
- name: Setup Node
43+
uses: actions/setup-node@v4
44+
with:
45+
node-version: "20"
46+
cache: npm
47+
cache-dependency-path: frontend/package-lock.json
48+
- name: Install dependencies
49+
run: npm ci
50+
- name: Build
51+
run: npm run build
52+

backend/tests/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import os
2+
import sys
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
8+
BACKEND_DIR = Path(__file__).resolve().parents[1]
9+
sys.path.insert(0, str(BACKEND_DIR))
10+
11+
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
12+
os.environ.setdefault("ENABLE_GEOCODING", "0")
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def _reset_db() -> None:
17+
from app import models # noqa: F401
18+
from app.db import Base, engine
19+
20+
Base.metadata.drop_all(bind=engine)
21+
Base.metadata.create_all(bind=engine)
22+

backend/tests/test_distance.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from app.distance import haversine_km
2+
3+
4+
def test_haversine_zero_distance() -> None:
5+
assert haversine_km(0.0, 0.0, 0.0, 0.0) == 0.0
6+
7+
8+
def test_haversine_small_delta_lat_reasonable() -> None:
9+
# 0.001 degrees latitude is ~111 meters.
10+
d = haversine_km(37.0, -122.0, 37.001, -122.0)
11+
assert 0.09 < d < 0.14
12+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from app.geocoding import approx_street_from_address, rough_location_from_address
2+
3+
4+
def test_rough_location_city_state() -> None:
5+
assert rough_location_from_address({"city": "Sunnyvale", "state": "CA"}) == "Sunnyvale, CA"
6+
7+
8+
def test_rough_location_country_only() -> None:
9+
assert rough_location_from_address({"country": "US"}) == "US"
10+
11+
12+
def test_approx_street_prefers_road() -> None:
13+
assert approx_street_from_address({"road": "E Middlefield Rd"}) == "E Middlefield Rd"
14+
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from fastapi.testclient import TestClient
2+
3+
import app.main as main
4+
from app.distance import haversine_km
5+
from app.geocoding import GeocodeResult, ReverseGeocodeResult
6+
7+
8+
def test_geocode_endpoint_uses_provider(monkeypatch) -> None:
9+
def fake_geocode_address(query: str, *, limit: int = 5) -> list[GeocodeResult]:
10+
assert query.strip() == "Waymo"
11+
assert limit == 1
12+
return [GeocodeResult(display_name="Waymo HQ", lat=37.416, lng=-122.077)]
13+
14+
monkeypatch.setattr(main, "geocode_address", fake_geocode_address)
15+
16+
with TestClient(main.app) as client:
17+
res = client.get("/api/geocode", params={"query": "Waymo", "limit": 1})
18+
assert res.status_code == 200, res.text
19+
data = res.json()
20+
assert data == [{"display_name": "Waymo HQ", "lat": 37.416, "lng": -122.077}]
21+
22+
23+
def test_reverse_geocode_endpoint_returns_rough_and_street(monkeypatch) -> None:
24+
def fake_reverse_geocode(lat: float, lng: float, *, zoom: int = 10) -> ReverseGeocodeResult:
25+
assert zoom == 18
26+
assert lat == 37.416
27+
assert lng == -122.077
28+
return ReverseGeocodeResult(
29+
display_name="690 E Middlefield Rd, Mountain View, CA 94043, USA",
30+
address={"city": "Mountain View", "state": "CA", "road": "E Middlefield Rd"},
31+
)
32+
33+
monkeypatch.setattr(main, "reverse_geocode", fake_reverse_geocode)
34+
35+
with TestClient(main.app) as client:
36+
res = client.get(
37+
"/api/reverse_geocode", params={"lat": 37.416, "lng": -122.077, "zoom": 18}
38+
)
39+
assert res.status_code == 200, res.text
40+
data = res.json()
41+
assert data["rough_location"] == "Mountain View, CA"
42+
assert data["approx_street"] == "E Middlefield Rd"
43+
44+
45+
def test_upsert_target_address_only_geocodes(monkeypatch) -> None:
46+
def fake_geocode_address(query: str, *, limit: int = 5) -> list[GeocodeResult]:
47+
assert "Middlefield" in query
48+
assert limit == 1
49+
return [GeocodeResult(display_name="Waymo HQ", lat=37.416, lng=-122.077)]
50+
51+
monkeypatch.setattr(main, "geocode_address", fake_geocode_address)
52+
53+
with TestClient(main.app) as client:
54+
res = client.post(
55+
"/api/targets",
56+
json={
57+
"name": "Workplace",
58+
"address": "690 E Middlefield Rd, Mountain View, CA 94043",
59+
},
60+
)
61+
assert res.status_code == 200, res.text
62+
data = res.json()
63+
assert data["name"] == "Workplace"
64+
assert data["address"] == "690 E Middlefield Rd, Mountain View, CA 94043"
65+
assert data["lat"] == 37.416
66+
assert data["lng"] == -122.077
67+
68+
69+
def test_upsert_target_coords_only_reverse_geocodes(monkeypatch) -> None:
70+
def fake_reverse_geocode(lat: float, lng: float, *, zoom: int = 10) -> ReverseGeocodeResult:
71+
assert zoom == 14
72+
return ReverseGeocodeResult(
73+
display_name="Mountain View, CA 94043, USA",
74+
address={"city": "Mountain View", "state": "CA"},
75+
)
76+
77+
monkeypatch.setattr(main, "reverse_geocode", fake_reverse_geocode)
78+
79+
with TestClient(main.app) as client:
80+
res = client.post(
81+
"/api/targets",
82+
json={"name": "Workplace", "lat": 37.416, "lng": -122.077},
83+
)
84+
assert res.status_code == 200, res.text
85+
data = res.json()
86+
assert data["address"] == "Mountain View, CA"
87+
assert data["lat"] == 37.416
88+
assert data["lng"] == -122.077
89+
90+
91+
def test_target_rejects_both_address_and_coords() -> None:
92+
with TestClient(main.app) as client:
93+
res = client.post(
94+
"/api/targets",
95+
json={
96+
"name": "Workplace",
97+
"address": "690 E Middlefield Rd, Mountain View, CA 94043",
98+
"lat": 37.416,
99+
"lng": -122.077,
100+
},
101+
)
102+
assert res.status_code == 422, res.text
103+
104+
105+
def test_compare_computes_distance_and_preserves_listing_order(monkeypatch) -> None:
106+
def fake_reverse_geocode(lat: float, lng: float, *, zoom: int = 10) -> ReverseGeocodeResult:
107+
return ReverseGeocodeResult(display_name="Mountain View, CA", address={"city": "Mountain View"})
108+
109+
monkeypatch.setattr(main, "reverse_geocode", fake_reverse_geocode)
110+
111+
with TestClient(main.app) as client:
112+
t = client.post("/api/targets", json={"name": "Workplace", "lat": 37.416, "lng": -122.077})
113+
assert t.status_code == 200, t.text
114+
target_id = t.json()["id"]
115+
116+
l1 = client.post(
117+
"/api/listings",
118+
json={
119+
"source": "airbnb",
120+
"source_url": "https://www.airbnb.com/rooms/1",
121+
"title": "Newer listing",
122+
"currency": "USD",
123+
"price_period": "unknown",
124+
"price_value": 3000,
125+
"lat": 37.426,
126+
"lng": -122.087,
127+
"location_text": "Mountain View, CA",
128+
"captured_at": "2026-01-30T12:00:00Z",
129+
},
130+
)
131+
assert l1.status_code == 200, l1.text
132+
133+
l2 = client.post(
134+
"/api/listings",
135+
json={
136+
"source": "airbnb",
137+
"source_url": "https://www.airbnb.com/rooms/2",
138+
"title": "Older listing (no coords)",
139+
"currency": "USD",
140+
"price_period": "unknown",
141+
"price_value": 2500,
142+
"captured_at": "2026-01-30T10:00:00Z",
143+
},
144+
)
145+
assert l2.status_code == 200, l2.text
146+
147+
res = client.get("/api/compare", params={"target_id": target_id})
148+
assert res.status_code == 200, res.text
149+
data = res.json()
150+
151+
items = data["items"]
152+
assert len(items) == 2
153+
assert items[0]["listing"]["source_url"] == "https://www.airbnb.com/rooms/1"
154+
assert items[1]["listing"]["source_url"] == "https://www.airbnb.com/rooms/2"
155+
156+
d = items[0]["metrics"]["distance_km"]
157+
assert d is not None
158+
expected = haversine_km(37.426, -122.087, 37.416, -122.077)
159+
assert abs(d - expected) < 1e-6
160+
161+
assert items[1]["metrics"]["distance_km"] is None
162+

0 commit comments

Comments
 (0)