diff --git a/server/app.py b/server/app.py index 6cded95..4853b24 100644 --- a/server/app.py +++ b/server/app.py @@ -1,7 +1,7 @@ """MusicFetch REST API. Plain HTTP behind an upstream TLS reverse proxy.""" import os -from fastapi import Depends, FastAPI, Header, HTTPException +from fastapi import Depends, FastAPI, Header, HTTPException, Query from fastapi.responses import JSONResponse from . import actions, jobs, mf @@ -28,10 +28,51 @@ def health(): return {"status": "ok"} -# Minimal stub so that auth tests can verify 401 behaviour before the real -# /fetch implementation lands in Task 5. This route MUST carry require_key as -# a dependency so unauthenticated / wrong-key requests are rejected here. +def _hit_public(hit) -> dict: + return {"source": hit.source, "kind": hit.kind, "artist": hit.artist, + "album": hit.album, "title": hit.title, "year": hit.year} + + +def _job_public(job) -> dict: + return {"message": job.message, "job_id": job.id, "status": job.status, + "hit": _hit_public(job.hit) if job.hit is not None else None, + "result": job.result, "error": job.error} + + @app.post("/fetch", dependencies=[Depends(require_key)]) -def fetch_stub(q: str = ""): - # Task 5 will replace this body with the full search-and-download logic. - raise HTTPException(status_code=501, detail="Not yet implemented.") +def fetch(q: str = Query(..., min_length=1), + quality: str = Query("best"), + source: str = Query("auto")): + if quality not in mf.QUALITY_CHOICES: + raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.") + if source not in ("auto", "lidarr", "youtube"): + raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.") + + yt_first = source == "youtube" + hits = mf.build_combined_hits(q, 10, yt_first, + lidarr_only=(source == "lidarr"), + yt_only=(source == "youtube")) + if not hits: + raise HTTPException(status_code=404, detail=f"No results found for '{q}'.") + + chosen = mf.pick(hits, q, True, yt_first) + if chosen is None: + raise HTTPException(status_code=404, detail=f"No results found for '{q}'.") + + job = jobs.create_job(hit=chosen, message=actions.started_message(chosen)) + response = _job_public(job) # snapshot "queued" state before background thread starts + jobs.run_job( + job.id, + lambda: actions.perform_fetch(chosen, hits, quality, ROOT), + done_message=actions.done_message(chosen), + fail_message=actions.failed_message(chosen), + ) + return response + + +@app.get("/jobs/{job_id}", dependencies=[Depends(require_key)]) +def job_status(job_id: str): + job = jobs.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="No such job.") + return _job_public(job) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7560cff --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,81 @@ +import time +import pytest +from server import mf, jobs as jobs_mod + + +@pytest.fixture(autouse=True) +def _clear_jobs(): + jobs_mod.JOBS.clear() + yield + jobs_mod.JOBS.clear() + + +def _yt_hit(): + return mf.Hit(source="youtube", kind="track", title="Together", + artist="Avril Lavigne", album="Under My Skin", year="2004", + payload={"videoId": "abc"}) + + +def test_fetch_returns_job_and_message(client, auth, monkeypatch): + hit = _yt_hit() + monkeypatch.setattr("server.app.mf.build_combined_hits", + lambda q, limit, yt_first, lidarr_only, yt_only: [hit]) + monkeypatch.setattr("server.app.mf.pick", + lambda hits, q, noninteractive, yt_first: hits[0]) + monkeypatch.setattr("server.app.actions.perform_fetch", + lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None}) + + r = client.post("/fetch", params={"q": "Under My Skin"}, headers=auth) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "queued" + assert "Under My Skin" in body["message"] or "Together" in body["message"] + assert body["hit"]["artist"] == "Avril Lavigne" + assert body["job_id"] + + +def test_fetch_no_hits_returns_404(client, auth, monkeypatch): + monkeypatch.setattr("server.app.mf.build_combined_hits", + lambda q, limit, yt_first, lidarr_only, yt_only: []) + r = client.post("/fetch", params={"q": "zzzz"}, headers=auth) + assert r.status_code == 404 + assert "zzzz" in r.json()["message"] + + +def test_fetch_missing_q_returns_422(client, auth): + r = client.post("/fetch", headers=auth) + assert r.status_code == 422 + + +def test_job_lifecycle_done(client, auth, monkeypatch): + hit = _yt_hit() + monkeypatch.setattr("server.app.mf.build_combined_hits", + lambda q, limit, yt_first, lidarr_only, yt_only: [hit]) + monkeypatch.setattr("server.app.mf.pick", + lambda hits, q, noninteractive, yt_first: hits[0]) + monkeypatch.setattr("server.app.actions.perform_fetch", + lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None}) + job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"] + + end = time.time() + 2 + status = None + while time.time() < end: + body = client.get(f"/jobs/{job_id}", headers=auth).json() + status = body["status"] + if status == "done": + break + time.sleep(0.01) + assert status == "done" + assert body["result"]["path"] == "/media/music/x" + assert "Finished" in body["message"] + + +def test_unknown_job_404(client, auth): + r = client.get("/jobs/deadbeef", headers=auth) + assert r.status_code == 404 + assert "message" in r.json() + + +def test_jobs_requires_key(client): + r = client.get("/jobs/whatever") + assert r.status_code == 401