"""tests/test_api.py — pytest suite (no GROQ_API_KEY required to run)""" import os, math, pytest from unittest.mock import MagicMock os.environ.pop("GROQ_API_KEY", None) # ensure clean state from fastapi.testclient import TestClient @pytest.fixture(scope="session") def client(): from app import app, DB, STAGE_MAP stub = [ {"i":"1","p":"ACARBOSE","s":"ACA_SEQ_01","d":"2025-01-03","sc":"RM", "it":"Unknown","st":"Finished","sw":501.0,"t":"ASSAY","b":"BATCH001", "ar":"AR001","in":"INSTR-01","co":"COL-01","d1":25.0,"wl":"210 nm", "ft":"Isocratic","th":25.0,"ct":35.0,"rp":106.0,"pp":102.0, "pa":"311000.0","pr":"17.6","pas":"1.19","pp2":"3900","cn":"Acarbose","sw1":"502.41","pn":"Acarbose"}, {"i":"2","p":"METFORMIN","s":"MET_SEQ_01","d":"2025-03-15","sc":"FP", "it":"CalibrationStandard","st":"Finished","sw":200.0,"t":"DISSOLUTION", "b":"BATCH002","ar":"AR002","in":"INSTR-02","co":"COL-02","d1":100.0, "wl":"254 nm","ft":"Gradient","th":37.0,"ct":40.0,"rp":200.0,"pp":198.0, "pa":"150000.0","pr":"5.3","pas":"1.05","pp2":"5200","cn":"Metformin HCl","sw1":"100.0","pn":"Metformin"}, {"i":"3","p":"ACARBOSE","s":"ACA_SEQ_01","d":"2025-06-20","sc":"RM", "it":"Blank","st":"Finished","sw":None,"t":"ASSAY","b":"BATCH001", "ar":"AR001","in":"INSTR-01","co":"COL-01","d1":1.0,"wl":"210 nm", "ft":"Isocratic","th":25.0,"ct":35.0,"rp":104.0,"pp":101.0, "pa":None,"pr":None,"pas":None,"pp2":None,"cn":None,"sw1":None,"pn":None}, ] areas=[float(r["pa"]) for r in stub if r.get("pa")] pv=[float(r["rp"]) for r in stub if r.get("rp")] cv=[float(r["ct"]) for r in stub if r.get("ct") is not None] def avg(a): return sum(a)/len(a) if a else 0.0 def std(a): if len(a)<2: return 0.0 m=avg(a); return math.sqrt(sum((x-m)**2 for x in a)/len(a)) DB.records=stub DB.agg={"total":3,"finished":3, "area_mean":avg(areas),"area_std":std(areas),"area_n":len(areas), "pres_mean":avg(pv),"pres_std":std(pv),"pres_n":len(pv), "ct_mean":avg(cv),"ct_std":std(cv),"ct_n":len(cv), "stage_cnt":{"Raw Material":2,"Finished Product":1}, "it_cnt":{"Unknown":1,"CalibrationStandard":1,"Blank":1}, "instr_cnt":{"INSTR-01":2,"INSTR-02":1},"wl_cnt":{"210 nm":2,"254 nm":1}, "prod_cnt":{"ACARBOSE":2,"METFORMIN":1},"ft_cnt":{"Isocratic":2,"Gradient":1}, "thermo_cnt":{"25.0":2,"37.0":1},"ct_cnt":{"35.0":2,"40.0":1}, "daily":[ {"d":"2025-01-03","n":1,"a":311000.0,"p":106.0,"c":35.0,"r":17.6,"as":1.19}, {"d":"2025-03-15","n":1,"a":150000.0,"p":200.0,"c":40.0,"r":5.3,"as":1.05}, {"d":"2025-06-20","n":1,"a":None,"p":104.0,"c":35.0,"r":None,"as":None}, ], "area_sample":areas,"sa_stats":{"Raw Material":{"mean":311000.0,"std":0.0,"n":1}},"it_stats":{}} DB.meta={"total":3,"sequences":["ACA_SEQ_01","MET_SEQ_01"],"products":["ACARBOSE","METFORMIN"], "instruments":["INSTR-01","INSTR-02"],"columns":["COL-01","COL-02"], "inj_types":["Blank","CalibrationStandard","Unknown"],"stage_cats":list(STAGE_MAP.values()), "tests":["ASSAY","DISSOLUTION"],"wavelengths":["210 nm","254 nm"],"flow_types":["Gradient","Isocratic"]} DB.loaded=True return TestClient(app) class TestHealth: def test_ok(self, client): r=client.get("/api/health"); assert r.status_code==200 b=r.json(); assert b["status"]=="ok"; assert b["records"]==3 assert b["port"]==7860; assert "ai_enabled" in b def test_meta(self, client): r=client.get("/api/meta"); assert r.status_code==200 assert "ACARBOSE" in r.json()["products"] def test_agg(self, client): r=client.get("/api/aggregate"); assert r.status_code==200; assert r.json()["total"]==3 class TestInjections: def test_list(self, client): assert client.get("/api/injections").json()["total"]==3 def test_page(self, client): r=client.get("/api/injections?per_page=2"); assert len(r.json()["data"])==2 def test_product(self, client): assert client.get("/api/injections?product=ACARBOSE").json()["total"]==2 def test_inj_type(self, client): assert client.get("/api/injections?inj_type=Blank").json()["total"]==1 def test_stage(self, client): assert client.get("/api/injections?stage=Finished+Product").json()["total"]==1 def test_dates(self, client): assert client.get("/api/injections?date_from=2025-02-01").json()["total"]==2 def test_pressure(self, client): assert client.get("/api/injections?pres_min=150&pres_max=300").json()["total"]==1 def test_instrument(self, client): assert client.get("/api/injections?instrument=INSTR-02").json()["total"]==1 def test_wavelength(self, client): assert client.get("/api/injections?wavelength=254+nm").json()["total"]==1 def test_fulltext(self, client): assert client.get("/api/injections?q=BATCH002").json()["total"]==1 def test_stage_label(self, client): for rec in client.get("/api/injections").json()["data"]: assert "stage_label" in rec class TestSingle: def test_found(self, client): r=client.get("/api/injections/1"); assert r.status_code==200 assert r.json()["stage_label"]=="Raw Material" def test_missing(self, client): assert client.get("/api/injections/99999").status_code==404 class TestTrends: def test_count(self, client): assert client.get("/api/trends/daily?metric=n").json()["values"]==[1,1,1] def test_area(self, client): assert client.get("/api/trends/daily?metric=a").json()["values"][0]==311000.0 def test_date_filter(self, client): assert len(client.get("/api/trends/daily?date_from=2025-03-01").json()["labels"])==2 class TestStats: def test_summary(self, client): b=client.get("/api/stats/summary").json() assert b["total"]==3; assert b["finish_rate_pct"]==100.0 assert b["area"]["p10"]<=b["area"]["median"]<=b["area"]["p90"] class TestExport: def test_csv(self, client): r=client.get("/api/injections/export"); assert "text/csv" in r.headers["content-type"] lines=r.text.strip().split("\n"); assert lines[0].startswith("Product,Batch"); assert len(lines)==4 def test_filtered(self, client): lines=client.get("/api/injections/export?product=ACARBOSE").text.strip().split("\n"); assert len(lines)==3 class TestCompare: def test_ok(self, client): r=client.post("/api/compare",json={"by":"product","groups":["ACARBOSE","METFORMIN"]}) assert r.status_code==200; assert r.json()["groups"]["ACARBOSE"]["count"]==2 def test_too_few(self, client): assert client.post("/api/compare",json={"by":"product","groups":["A"]}).status_code==400 def test_too_many(self, client): assert client.post("/api/compare",json={"by":"product","groups":["A","B","C","D"]}).status_code==400 def test_bad_by(self, client): assert client.post("/api/compare",json={"by":"bad","groups":["A","B"]}).status_code==400 class TestAI: def test_models_no_key(self, client): # Works without any key — just lists models r=client.get("/api/ai/models"); assert r.status_code==200 b=r.json(); assert b["provider"]=="GROQ" assert any(m["id"]=="llama-3.3-70b-versatile" for m in b["models"]) assert b["enabled"]==False # no key in test env assert "note" in b def test_chat_no_key_503(self, client): # Without key → 503 with clear message r=client.post("/api/ai/chat",json={"messages":[{"role":"user","content":"hi"}]}) assert r.status_code==503 assert "GROQ_API_KEY" in r.json()["detail"] assert "console.groq.com" in r.json()["detail"] def test_chat_with_mock(self, client): import app as app_mod mock_resp=MagicMock() mock_resp.choices=[MagicMock()] mock_resp.choices[0].message.content="RSD 12% is within acceptable limits." mock_resp.choices[0].finish_reason="stop" mock_resp.model="llama-3.3-70b-versatile" mock_resp.usage.prompt_tokens=50; mock_resp.usage.completion_tokens=30; mock_resp.usage.total_tokens=80 mock_client=MagicMock() mock_client.chat.completions.create.return_value=mock_resp original=app_mod._get_groq_client app_mod._get_groq_client=lambda: mock_client r=client.post("/api/ai/chat",json={"messages":[{"role":"user","content":"Is RSD ok?"}]}) app_mod._get_groq_client=original assert r.status_code==200 assert "RSD" in r.json()["reply"] assert r.json()["model"]=="llama-3.3-70b-versatile"