""" FastAPI 엔드포인트 통합 테스트. 외부 API (NEIS, 행안부) 호출을 mock 하여 실제 인터넷 연결 없이 실행합니다. """ from __future__ import annotations from unittest.mock import AsyncMock, patch import pandas as pd from fastapi.testclient import TestClient from src.analytics.diagnostics import DiagnosticsResult from src.analytics.predictor import ForecastResult from src.analytics.scorer import SustainabilityScore from src.analytics.xai import ShapResult from src.api.main import app client = TestClient(app) # ── 공통 mock 데이터 ───────────────────────────────────────────────────────── MOCK_SCHOOL_INFO_ROWS = [ { "ATPT_OFCDC_SC_CODE": "P10", "ATPT_OFCDC_SC_NM": "경상남도교육청", "SD_SCHUL_CODE": "9290079", "SCHUL_NM": "곤명초등학교", "SCHUL_KND_SC_NM": "초등학교", "LCTN_SC_NM": "농촌형", "FOND_SC_NM": "공립", "COEDU_SC_NM": "남여공학", "XTLN_YN": "N", "SCHUL_FOND_YMD": "19620301", } ] MOCK_STUDENT_ROWS = [ {"SD_SCHUL_CODE": "9290079", "SCHUL_NM": "곤명초등학교", "AY": "2023", "STUDENT_COUNT": "8", "SE_COUNT": "6"}, ] MOCK_TEACHER_ROWS = [ {"SD_SCHUL_CODE": "9290079", "SCHUL_NM": "곤명초등학교", "AY": "2023", "TEACHER_COUNT": "4", "TEMP_TEACHER_COUNT": "2"}, ] MOCK_YEARLY_STUDENT_ROWS = [ [{"SD_SCHUL_CODE": "9290079", "AY": "2017", "STUDENT_COUNT": "20", "SE_COUNT": "6"}], [{"SD_SCHUL_CODE": "9290079", "AY": "2018", "STUDENT_COUNT": "18", "SE_COUNT": "6"}], [{"SD_SCHUL_CODE": "9290079", "AY": "2019", "STUDENT_COUNT": "16", "SE_COUNT": "6"}], [{"SD_SCHUL_CODE": "9290079", "AY": "2020", "STUDENT_COUNT": "14", "SE_COUNT": "6"}], [{"SD_SCHUL_CODE": "9290079", "AY": "2021", "STUDENT_COUNT": "12", "SE_COUNT": "6"}], [{"SD_SCHUL_CODE": "9290079", "AY": "2022", "STUDENT_COUNT": "10", "SE_COUNT": "6"}], [{"SD_SCHUL_CODE": "9290079", "AY": "2023", "STUDENT_COUNT": "8", "SE_COUNT": "6"}], ] class TestHealthEndpoint: def test_health_returns_ok(self) -> None: response = client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" assert "env" in data assert "granite_model" in data class TestSchoolInfoEndpoint: def test_school_not_found_returns_404(self) -> None: with patch( "src.api.routes.schools.DataGoKrClient.fetch_school_info", new_callable=AsyncMock, return_value=[], ): response = client.get("/schools/NOTEXIST") assert response.status_code == 404 def test_school_found_returns_info(self) -> None: with patch( "src.api.routes.schools.DataGoKrClient.fetch_school_info", new_callable=AsyncMock, return_value=MOCK_SCHOOL_INFO_ROWS, ): response = client.get("/schools/9290079") assert response.status_code == 200 data = response.json() assert data["sd_schul_code"] == "9290079" assert data["school_name"] == "곤명초등학교" class TestSchoolStatsEndpoint: def test_stats_no_data_returns_404(self) -> None: with ( patch( "src.api.routes.schools.DataGoKrClient.fetch_student_count", new_callable=AsyncMock, return_value=[], ), patch( "src.api.routes.schools.DataGoKrClient.fetch_teacher_count", new_callable=AsyncMock, return_value=[], ), ): response = client.get("/schools/NOTEXIST/stats?year=2023") assert response.status_code == 404 def test_stats_returns_data(self) -> None: with ( patch( "src.api.routes.schools.DataGoKrClient.fetch_student_count", new_callable=AsyncMock, return_value=MOCK_STUDENT_ROWS, ), patch( "src.api.routes.schools.DataGoKrClient.fetch_teacher_count", new_callable=AsyncMock, return_value=MOCK_TEACHER_ROWS, ), ): response = client.get("/schools/9290079/stats?year=2023") assert response.status_code == 200 data = response.json() assert data["year"] == 2023 assert float(data["student_count"]) == 8.0 class TestDemoEndpoint: """데모 엔드포인트 테스트 (mock 데이터 반환).""" def test_demo_diagnose_returns_200(self) -> None: response = client.get("/demo/diagnose/9290079") assert response.status_code == 200 def test_demo_diagnose_has_required_fields(self) -> None: response = client.get("/demo/diagnose/9290079") data = response.json() required = {"schul_code", "school_name", "status_label", "sustainability_score", "forecast"} assert required.issubset(data.keys()) def test_demo_forecast_years_sequential(self) -> None: response = client.get("/demo/diagnose/9290079") forecast = response.json()["forecast"] years = forecast["forecast_years"] for i in range(1, len(years)): assert years[i] == years[i - 1] + 1 def test_demo_shap_has_top_factors(self) -> None: response = client.get("/demo/diagnose/9290079") shap = response.json()["shap_result"] assert len(shap["top_factors"]) > 0 def test_demo_score_in_range(self) -> None: response = client.get("/demo/diagnose/9290079") score = response.json()["sustainability_score"] assert 0 <= score <= 100 def test_demo_simulations_present(self) -> None: response = client.get("/demo/diagnose/9290079") sims = response.json()["simulations"] assert len(sims) >= 1 class TestPredictionEndpoint: def test_predict_returns_forecast(self) -> None: fake_forecast = ForecastResult( schul_code="9290079", target_col="student_count", forecast_years=[2024, 2025, 2026], point_forecast=[7.0, 6.0, 5.0], lower_bound=[6.0, 5.0, 4.0], upper_bound=[8.0, 7.0, 6.0], model_version="arima-fallback", context_years=[2017, 2018, 2019, 2020, 2021, 2022, 2023], context_values=[20.0, 18.0, 16.0, 14.0, 12.0, 10.0, 8.0], ) with ( patch( "src.api.routes.predictions.DataGoKrClient.fetch_student_count", new_callable=AsyncMock, side_effect=MOCK_YEARLY_STUDENT_ROWS, ), patch( "src.api.routes.predictions._predictor.predict", return_value=fake_forecast, ), ): response = client.get("/schools/9290079/predict?horizon_years=3&end_year=2023") assert response.status_code == 200 data = response.json() assert data["schul_code"] == "9290079" assert len(data["forecast_years"]) == 3 class TestDiagnosticsAndSimulationEndpoint: def test_diagnose_returns_integrated_payload(self) -> None: fake_result = DiagnosticsResult( schul_code="9290079", school_name="곤명초등학교", status_label="위험", status_code=2, sustainability_score=38.5, forecast=ForecastResult( schul_code="9290079", target_col="student_count", forecast_years=[2024, 2025], point_forecast=[7.0, 6.0], lower_bound=[6.0, 5.0], upper_bound=[8.0, 7.0], model_version="arima-fallback", context_years=[2022, 2023], context_values=[10.0, 8.0], ), shap_result=ShapResult( schul_code="9290079", status_label="위험", status_code=2, shap_values={"student_count_latest": 1.0}, top_factors=[ { "feature": "student_count_latest", "feature_kr": "현재 학생 수", "shap_value": 1.0, "feature_value": 8.0, } ], base_value=0.0, model_accuracy=None, ), score_detail=SustainabilityScore( schul_code="9290079", total_score=38.5, curriculum_score=20.0, personnel_score=30.0, facility_score=40.0, community_score=60.0, sub_scores={}, warnings=["학생 수가 매우 적습니다."], ), simulations={ "improve_teacher_ratio": { "label": "교사 1인당 학생 수 개선", "description": "교원 증원", "base_total_score": 38.5, "sim_total_score": 41.0, "delta_score": 2.5, } }, metadata={"diagnosed_at": "2026-01-01T00:00:00", "model_version": "arima-fallback"}, ) with ( patch( "src.api.routes.diagnostics._collect_school_data", new_callable=AsyncMock, return_value=( {"sd_schul_code": "9290079", "school_name": "곤명초등학교"}, pd.Series({2022: 10.0, 2023: 8.0}), ), ), patch( "src.api.routes.diagnostics._engine.diagnose", return_value=fake_result, ), ): response = client.get("/schools/9290079/diagnose?horizon_years=2&end_year=2023") assert response.status_code == 200 data = response.json() assert data["schul_code"] == "9290079" assert "forecast" in data assert "shap_result" in data assert "score_detail" in data def test_simulate_returns_score(self) -> None: with patch( "src.api.routes.diagnostics._collect_school_data", new_callable=AsyncMock, return_value=( {"sd_schul_code": "9290079", "teacher_count": 4, "student_count": 8}, pd.Series({2023: 8.0}), ), ): response = client.post( "/schools/9290079/simulate?end_year=2023", json={"school_data_overrides": {"teacher_count": 6}}, ) assert response.status_code == 200 data = response.json() assert data["schul_code"] == "9290079" assert "total_score" in data class TestRegionRiskEndpoint: def test_region_risk_returns_summary(self) -> None: with ( patch( "src.api.routes.regions.DataGoKrClient.fetch_school_info", new_callable=AsyncMock, return_value=MOCK_SCHOOL_INFO_ROWS, ), patch( "src.api.routes.regions.DataGoKrClient.fetch_student_count", new_callable=AsyncMock, return_value=MOCK_STUDENT_ROWS, ), patch( "src.api.routes.regions.DataGoKrClient.fetch_teacher_count", new_callable=AsyncMock, return_value=MOCK_TEACHER_ROWS, ), ): response = client.get("/regions/P10/risk?year=2023") assert response.status_code == 200 data = response.json() assert data["region_code"] == "P10" assert "schools" in data assert "summary" in data