Skip to content

Commit 2dfad2c

Browse files
Add comprehensive unit tests for all endpoints: info, TVM (present-value, annuity-payment), mortgage/summary, bond/price, xirr/explain
1 parent f90b062 commit 2dfad2c

5 files changed

Lines changed: 457 additions & 0 deletions

File tree

tests/test_bond.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,86 @@ def test_bond_yield_validation():
7272
assert response.status_code == 422
7373
data = response.json()
7474
assert data["ok"] is False
75+
76+
77+
def test_bond_price_basic():
78+
"""Test basic bond price calculation."""
79+
payload = {
80+
"face_value": 1000,
81+
"coupon_rate": 0.05,
82+
"years_to_maturity": 10,
83+
"yield_to_maturity": 0.055,
84+
"payments_per_year": 2
85+
}
86+
response = client.post("/v1/bond/price", json=payload)
87+
assert response.status_code == 200
88+
data = response.json()
89+
assert data["ok"] is True
90+
assert "price" in data
91+
assert data["price"] > 0
92+
# Bond trading at discount (YTM > coupon) should have price < face value
93+
assert data["price"] < payload["face_value"]
94+
95+
96+
def test_bond_price_premium():
97+
"""Test bond price for premium bond (YTM < coupon rate)."""
98+
payload = {
99+
"face_value": 1000,
100+
"coupon_rate": 0.05,
101+
"years_to_maturity": 10,
102+
"yield_to_maturity": 0.04, # Lower yield = premium
103+
"payments_per_year": 2
104+
}
105+
response = client.post("/v1/bond/price", json=payload)
106+
assert response.status_code == 200
107+
data = response.json()
108+
assert data["ok"] is True
109+
# Premium bond should have price > face value
110+
assert data["price"] > payload["face_value"]
111+
112+
113+
def test_bond_price_par():
114+
"""Test bond price when YTM equals coupon rate (at par)."""
115+
payload = {
116+
"face_value": 1000,
117+
"coupon_rate": 0.05,
118+
"years_to_maturity": 10,
119+
"yield_to_maturity": 0.05, # Same as coupon = at par
120+
"payments_per_year": 2
121+
}
122+
response = client.post("/v1/bond/price", json=payload)
123+
assert response.status_code == 200
124+
data = response.json()
125+
assert data["ok"] is True
126+
# At par, price should be close to face value
127+
assert abs(data["price"] - payload["face_value"]) < 10
128+
129+
130+
def test_bond_price_validation_negative_face_value():
131+
"""Test that negative face value is rejected."""
132+
payload = {
133+
"face_value": -1000,
134+
"coupon_rate": 0.05,
135+
"years_to_maturity": 10,
136+
"yield_to_maturity": 0.055,
137+
"payments_per_year": 2
138+
}
139+
response = client.post("/v1/bond/price", json=payload)
140+
assert response.status_code == 422
141+
data = response.json()
142+
assert data["ok"] is False
143+
144+
145+
def test_bond_price_validation_invalid_yield():
146+
"""Test that invalid yield (> 100%) is rejected."""
147+
payload = {
148+
"face_value": 1000,
149+
"coupon_rate": 0.05,
150+
"years_to_maturity": 10,
151+
"yield_to_maturity": 1.5, # > 100%
152+
"payments_per_year": 2
153+
}
154+
response = client.post("/v1/bond/price", json=payload)
155+
assert response.status_code == 422
156+
data = response.json()
157+
assert data["ok"] is False

tests/test_info.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Tests for the /v1/info endpoint."""
2+
from fastapi.testclient import TestClient
3+
from app.main import app
4+
5+
client = TestClient(app)
6+
7+
8+
def test_info_endpoint():
9+
"""Test that the info endpoint returns correct metadata."""
10+
response = client.get("/v1/info")
11+
assert response.status_code == 200
12+
data = response.json()
13+
assert data["ok"] is True
14+
assert data["service"] == "finance-api"
15+
assert "version" in data
16+
assert "environment" in data
17+
assert "build_timestamp" in data
18+
assert "git_sha" in data
19+
20+
21+
def test_info_response_structure():
22+
"""Test that the info response has the expected structure."""
23+
response = client.get("/v1/info")
24+
assert response.status_code == 200
25+
data = response.json()
26+
27+
# Check all required fields
28+
required_fields = ["ok", "service", "version", "environment", "build_timestamp", "git_sha"]
29+
for field in required_fields:
30+
assert field in data, f"Missing field: {field}"
31+
32+
# Check data types
33+
assert isinstance(data["ok"], bool)
34+
assert isinstance(data["service"], str)
35+
assert isinstance(data["version"], str)
36+
assert isinstance(data["environment"], str)
37+
assert isinstance(data["build_timestamp"], str)
38+
assert isinstance(data["git_sha"], str)
39+
40+
# Check version format
41+
assert data["version"] == "1.0.0"

tests/test_mortgage.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,72 @@ def test_mortgage_with_extra_payments_structure():
276276
# Check date format (YYYY-MM)
277277
assert len(data["new_payoff_date"]) == 7
278278
assert data["new_payoff_date"][4] == "-"
279+
280+
281+
def test_mortgage_summary_basic():
282+
"""Test basic mortgage summary calculation."""
283+
payload = {
284+
"principal": 300000,
285+
"annual_rate": 0.04,
286+
"years": 30
287+
}
288+
response = client.post("/v1/mortgage/summary", json=payload)
289+
assert response.status_code == 200
290+
data = response.json()
291+
assert data["ok"] is True
292+
assert "monthly_payment" in data
293+
assert "total_paid" in data
294+
assert "total_interest" in data
295+
assert "payoff_months" in data
296+
assert "payoff_date" in data
297+
298+
# Verify calculations
299+
assert data["monthly_payment"] > 0
300+
assert data["total_paid"] > data["principal"]
301+
assert data["total_interest"] > 0
302+
assert data["payoff_months"] == 360
303+
assert data["total_paid"] == data["monthly_payment"] * data["payoff_months"]
304+
305+
306+
def test_mortgage_summary_structure():
307+
"""Test that mortgage summary response has all required fields."""
308+
payload = {
309+
"principal": 200000,
310+
"annual_rate": 0.035,
311+
"years": 15
312+
}
313+
response = client.post("/v1/mortgage/summary", json=payload)
314+
assert response.status_code == 200
315+
data = response.json()
316+
317+
required_fields = [
318+
"ok",
319+
"monthly_payment",
320+
"total_paid",
321+
"total_interest",
322+
"payoff_months",
323+
"payoff_date"
324+
]
325+
for field in required_fields:
326+
assert field in data, f"Missing field: {field}"
327+
328+
# Check date format
329+
assert len(data["payoff_date"]) == 7
330+
assert data["payoff_date"][4] == "-"
331+
332+
333+
def test_mortgage_summary_zero_interest():
334+
"""Test mortgage summary with zero interest."""
335+
payload = {
336+
"principal": 100000,
337+
"annual_rate": 0.0,
338+
"years": 10
339+
}
340+
response = client.post("/v1/mortgage/summary", json=payload)
341+
assert response.status_code == 200
342+
data = response.json()
343+
344+
# With zero interest, total_interest should be 0
345+
assert data["total_interest"] == 0
346+
assert data["total_paid"] == payload["principal"]
347+
assert data["monthly_payment"] == payload["principal"] / (payload["years"] * 12)

tests/test_tvm.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Tests for TVM (Time Value of Money) endpoints."""
2+
from fastapi.testclient import TestClient
3+
from app.main import app
4+
5+
client = TestClient(app)
6+
7+
8+
def test_present_value_basic():
9+
"""Test basic present value calculation."""
10+
payload = {
11+
"future_value": 10000,
12+
"annual_rate": 0.07,
13+
"years": 10,
14+
"compounds_per_year": 12
15+
}
16+
response = client.post("/v1/tvm/present-value", json=payload)
17+
assert response.status_code == 200
18+
data = response.json()
19+
assert data["ok"] is True
20+
assert "present_value" in data
21+
assert data["present_value"] > 0
22+
# Future value should be greater than present value
23+
assert data["present_value"] < payload["future_value"]
24+
25+
26+
def test_present_value_zero_rate():
27+
"""Test present value with zero interest rate."""
28+
payload = {
29+
"future_value": 10000,
30+
"annual_rate": 0.0,
31+
"years": 10,
32+
"compounds_per_year": 12
33+
}
34+
response = client.post("/v1/tvm/present-value", json=payload)
35+
assert response.status_code == 200
36+
data = response.json()
37+
assert data["ok"] is True
38+
# With zero rate, PV should equal FV
39+
assert abs(data["present_value"] - 10000) < 0.01
40+
41+
42+
def test_present_value_validation_negative_future_value():
43+
"""Test that negative future value is rejected."""
44+
payload = {
45+
"future_value": -10000,
46+
"annual_rate": 0.07,
47+
"years": 10,
48+
"compounds_per_year": 12
49+
}
50+
response = client.post("/v1/tvm/present-value", json=payload)
51+
assert response.status_code == 422
52+
data = response.json()
53+
assert data["ok"] is False
54+
55+
56+
def test_present_value_validation_zero_years():
57+
"""Test that zero years is rejected."""
58+
payload = {
59+
"future_value": 10000,
60+
"annual_rate": 0.07,
61+
"years": 0,
62+
"compounds_per_year": 12
63+
}
64+
response = client.post("/v1/tvm/present-value", json=payload)
65+
assert response.status_code == 422
66+
data = response.json()
67+
assert data["ok"] is False
68+
69+
70+
def test_annuity_payment_basic():
71+
"""Test basic annuity payment calculation."""
72+
payload = {
73+
"present_value": 10000,
74+
"annual_rate": 0.05,
75+
"years": 5,
76+
"payments_per_year": 12
77+
}
78+
response = client.post("/v1/tvm/annuity-payment", json=payload)
79+
assert response.status_code == 200
80+
data = response.json()
81+
assert data["ok"] is True
82+
assert "payment" in data
83+
assert data["payment"] > 0
84+
# Payment should be reasonable (less than PV per month)
85+
assert data["payment"] < payload["present_value"]
86+
87+
88+
def test_annuity_payment_zero_rate():
89+
"""Test annuity payment with zero interest rate."""
90+
payload = {
91+
"present_value": 10000,
92+
"annual_rate": 0.0,
93+
"years": 5,
94+
"payments_per_year": 12
95+
}
96+
response = client.post("/v1/tvm/annuity-payment", json=payload)
97+
assert response.status_code == 200
98+
data = response.json()
99+
assert data["ok"] is True
100+
# With zero rate, payment should be PV / total_payments
101+
expected = 10000 / (5 * 12)
102+
assert abs(data["payment"] - expected) < 0.01
103+
104+
105+
def test_annuity_payment_validation_negative_present_value():
106+
"""Test that negative present value is rejected."""
107+
payload = {
108+
"present_value": -10000,
109+
"annual_rate": 0.05,
110+
"years": 5,
111+
"payments_per_year": 12
112+
}
113+
response = client.post("/v1/tvm/annuity-payment", json=payload)
114+
assert response.status_code == 422
115+
data = response.json()
116+
assert data["ok"] is False
117+
118+
119+
def test_annuity_payment_validation_zero_payments():
120+
"""Test that zero payments_per_year is rejected."""
121+
payload = {
122+
"present_value": 10000,
123+
"annual_rate": 0.05,
124+
"years": 5,
125+
"payments_per_year": 0
126+
}
127+
response = client.post("/v1/tvm/annuity-payment", json=payload)
128+
assert response.status_code == 422
129+
data = response.json()
130+
assert data["ok"] is False
131+
132+
133+
def test_present_value_response_structure():
134+
"""Test that present value response has correct structure."""
135+
payload = {
136+
"future_value": 5000,
137+
"annual_rate": 0.06,
138+
"years": 5,
139+
"compounds_per_year": 4
140+
}
141+
response = client.post("/v1/tvm/present-value", json=payload)
142+
assert response.status_code == 200
143+
data = response.json()
144+
assert "ok" in data
145+
assert "present_value" in data
146+
assert isinstance(data["ok"], bool)
147+
assert isinstance(data["present_value"], (int, float))
148+
149+
150+
def test_annuity_payment_response_structure():
151+
"""Test that annuity payment response has correct structure."""
152+
payload = {
153+
"present_value": 5000,
154+
"annual_rate": 0.06,
155+
"years": 5,
156+
"payments_per_year": 4
157+
}
158+
response = client.post("/v1/tvm/annuity-payment", json=payload)
159+
assert response.status_code == 200
160+
data = response.json()
161+
assert "ok" in data
162+
assert "payment" in data
163+
assert isinstance(data["ok"], bool)
164+
assert isinstance(data["payment"], (int, float))

0 commit comments

Comments
 (0)