| import copy |
|
|
| from fastapi.testclient import TestClient |
|
|
| from app.api import routes |
| from app.core.errors import ROADMAP_GENERATION_FAILED, ServiceError |
| from app.main import app |
| from app.schemas.requests import GenerateRoadmapRequest |
| from app.schemas.roadmap import Roadmap |
| from app.services.id_generator import assign_backend_ids |
|
|
|
|
| def sample_roadmap(cached: bool = False) -> Roadmap: |
| resource_a = { |
| "title": "Python for Beginners", |
| "url": "https://www.youtube.com/watch?v=kqtD5dpn9C8", |
| "type": "youtube_video", |
| "source": "YouTube", |
| "is_free": True, |
| } |
| resource_b = { |
| "title": "Python Tutorial", |
| "url": "https://www.w3schools.com/python/", |
| "type": "article", |
| "source": "Tavily", |
| "is_free": True, |
| } |
| payload = { |
| "skill": "Python for Beginners", |
| "overview": "A practical beginner roadmap for learning Python with short projects and steady practice.", |
| "estimated_total_duration": "6-8 weeks", |
| "phases": [ |
| { |
| "title": f"Phase {phase}", |
| "level": "beginner", |
| "goal": "Learn useful basics and apply them.", |
| "estimated_duration": "2 weeks", |
| "nodes": [ |
| { |
| "title": f"Node {node}", |
| "description": "Practice a focused beginner topic with examples and a small exercise.", |
| "estimated_completion_time": "2-3 hours", |
| "resources": [copy.deepcopy(resource_a), copy.deepcopy(resource_b)], |
| } |
| for node in range(1, 4) |
| ], |
| "project": { |
| "title": f"Project {phase}", |
| "brief": "Build a small project that applies the lessons from this phase.", |
| "tools_needed": ["Browser"], |
| "resources": [copy.deepcopy(resource_a), copy.deepcopy(resource_b)], |
| }, |
| } |
| for phase in range(1, 4) |
| ], |
| "metadata": {"model_used": "mock", "resource_sources": ["mock"], "cached": cached}, |
| } |
| return Roadmap.model_validate(assign_backend_ids(payload, "python", suffix="abc123")) |
|
|
|
|
| class FakeGenerator: |
| def __init__(self, *, fail: bool = False, service_fail: bool = False, cached: bool = False): |
| self.fail = fail |
| self.service_fail = service_fail |
| self.cached = cached |
|
|
| async def generate(self, request: GenerateRoadmapRequest): |
| if self.service_fail: |
| raise ServiceError( |
| code=ROADMAP_GENERATION_FAILED, |
| message="generation failed after retries", |
| stage="roadmap_generation", |
| ) |
| if self.fail: |
| raise RuntimeError("model unavailable") |
| return sample_roadmap(cached=self.cached) |
|
|
|
|
| def test_post_generate_returns_success_with_mocked_generator(monkeypatch): |
| monkeypatch.setattr(routes, "generator", FakeGenerator()) |
| client = TestClient(app) |
| response = client.post("/api/v1/roadmaps/generate", json={"skill": "Learn Python"}) |
| body = response.json() |
| assert response.status_code == 200 |
| assert body["success"] is True |
| assert len(body["data"]["phases"]) == 3 |
|
|
|
|
| def test_invalid_model_response_triggers_repair_path(): |
| from app.core.config import Settings |
| from app.services.roadmap_generator import RoadmapGenerator |
| from app.services.resource_filter import ResourceCandidate |
| from app.schemas.roadmap import ResourceType |
|
|
| class Search: |
| async def search(self, skill): |
| return [ |
| ResourceCandidate("Video", "https://www.youtube.com/watch?v=kqtD5dpn9C8", ResourceType.youtube_video, "YouTube"), |
| ResourceCandidate("Article", "https://www.w3schools.com/python/", ResourceType.article, "Tavily"), |
| ] |
|
|
| class DeepSeek: |
| def __init__(self): |
| self.calls = 0 |
|
|
| async def generate_json(self, prompt, model): |
| self.calls += 1 |
| if self.calls == 1: |
| return "{bad json" |
| return sample_roadmap().model_dump_json() |
|
|
| class Cache: |
| async def get(self, *args): |
| return None |
|
|
| async def set(self, *args): |
| return None |
|
|
| deepseek = DeepSeek() |
| generator = RoadmapGenerator( |
| settings=Settings(enable_link_validation=False, enable_cache=False, deepseek_api_key="x"), |
| search_service=Search(), |
| deepseek_client=deepseek, |
| cache=Cache(), |
| ) |
| import anyio |
|
|
| roadmap = anyio.run(generator.generate, GenerateRoadmapRequest(skill="Learn Python")) |
| assert roadmap.metadata.model_used == "deepseek-v4-flash" |
| assert deepseek.calls == 2 |
|
|
|
|
| def test_service_generation_failure_returns_503_with_request_id(monkeypatch): |
| monkeypatch.setattr(routes, "generator", FakeGenerator(service_fail=True)) |
| client = TestClient(app) |
| response = client.post("/api/v1/roadmaps/generate", json={"skill": "Unknown"}) |
| body = response.json() |
| assert response.status_code == 503 |
| assert body["success"] is False |
| assert body["error"]["code"] == "ROADMAP_GENERATION_FAILED" |
| assert body["error"]["request_id"] |
|
|
|
|
| def test_unexpected_generation_error_returns_500_with_request_id(monkeypatch): |
| monkeypatch.setattr(routes, "generator", FakeGenerator(fail=True)) |
| client = TestClient(app) |
| response = client.post("/api/v1/roadmaps/generate", json={"skill": "Unknown"}) |
| body = response.json() |
| assert response.status_code == 500 |
| assert body["success"] is False |
| assert body["error"]["code"] == "ROADMAP_GENERATION_FAILED" |
| assert body["error"]["request_id"] |
|
|
|
|
| def test_failed_generation_hides_debug_in_production(monkeypatch): |
| from app.core.config import get_settings |
|
|
| get_settings.cache_clear() |
| monkeypatch.setenv("ENVIRONMENT", "production") |
| monkeypatch.setattr(routes, "generator", FakeGenerator(service_fail=True)) |
| try: |
| client = TestClient(app) |
| response = client.post("/api/v1/roadmaps/generate", json={"skill": "Unknown"}) |
| body = response.json() |
| assert response.status_code == 503 |
| assert body["success"] is False |
| assert "debug" not in body["error"] |
| assert body["error"]["request_id"] |
| finally: |
| get_settings.cache_clear() |
|
|
|
|
| def test_cached_request_returns_cached_metadata(monkeypatch): |
| monkeypatch.setattr(routes, "generator", FakeGenerator(cached=True)) |
| client = TestClient(app) |
| response = client.post("/api/v1/roadmaps/generate", json={"skill": "Learn Python"}) |
| body = response.json() |
| assert body["success"] is True |
| assert body["data"]["metadata"]["cached"] is True |
|
|
|
|
| def test_openapi_request_schema_shows_all_default_resource_types(): |
| schema = app.openapi()["components"]["schemas"]["GenerateRoadmapRequest"] |
| assert schema["properties"]["preferred_resource_types"]["default"] == [ |
| "youtube_video", |
| "article", |
| "free_course", |
| "documentation", |
| "free_book", |
| "interactive_practice", |
| ] |
|
|
|
|
| def test_custom_api_docs_page_is_available(): |
| client = TestClient(app) |
| response = client.get("/api-docs") |
| assert response.status_code == 200 |
| assert "LockedIn AI Service API Docs" in response.text |
| assert "/api/v1/roadmaps/generate" in response.text |
|
|