This commit is contained in:
itqop 2025-12-18 02:24:00 +03:00
parent 59a0755115
commit 4cd311a59d
21 changed files with 3411 additions and 19 deletions

View File

@ -7,7 +7,13 @@
"Bash(dir:*)",
"Bash(findstr:*)",
"Bash(grep:*)",
"Bash(cat:*)"
"Bash(cat:*)",
"Bash(..venvScriptspython.exe -m pytest --version)",
"Bash(.venv/Scripts/python.exe -m pytest --version)",
"Bash(.venv/Scripts/python.exe -m pytest tests/ -v)",
"Bash(.venv/Scripts/python.exe -m pytest tests/ -v --tb=short)",
"Bash(.venv/Scripts/python.exe -m pytest tests/ -v --tb=line)",
"Bash(.venv/Scripts/python.exe -m pytest:*)"
],
"deny": [],
"ask": []

42
.env.test Normal file
View File

@ -0,0 +1,42 @@
# Test Environment Variables
# Used by pytest
# Application
DEBUG=true
APP_NAME=Brief Bench API Test
# JWT Authentication
JWT_SECRET_KEY=test-secret-key-for-testing-only
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=43200
# DB API Service (mock)
DB_API_URL=http://test-db-api:8080/api/v1
DB_API_TIMEOUT=30
# RAG Backend - IFT Environment (mock)
IFT_RAG_HOST=test-ift-rag.local
IFT_RAG_PORT=8443
IFT_RAG_ENDPOINT=api/rag/bench
IFT_RAG_CERT_CA=
IFT_RAG_CERT_KEY=
IFT_RAG_CERT_CERT=
# RAG Backend - PSI Environment (mock)
PSI_RAG_HOST=test-psi-rag.local
PSI_RAG_PORT=8443
PSI_RAG_ENDPOINT=api/rag/bench
PSI_RAG_CERT_CA=
PSI_RAG_CERT_KEY=
PSI_RAG_CERT_CERT=
# RAG Backend - PROD Environment (mock)
PROD_RAG_HOST=test-prod-rag.local
PROD_RAG_PORT=8443
PROD_RAG_ENDPOINT=api/rag/bench
PROD_RAG_CERT_CA=
PROD_RAG_CERT_KEY=
PROD_RAG_CERT_CERT=
# Request Timeouts
RAG_REQUEST_TIMEOUT=1800

722
coverage.xml Normal file
View File

@ -0,0 +1,722 @@
<?xml version="1.0" ?>
<coverage version="7.13.0" timestamp="1766007238402" lines-valid="567" lines-covered="563" line-rate="0.9929" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.13.0 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
<source>C:\Users\leonk\Documents\code\brief-bench-fastapi\app</source>
</sources>
<packages>
<package name="." line-rate="0.971" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
<class name="config.py" filename="config.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="49" hits="1"/>
<line number="50" hits="1"/>
<line number="53" hits="1"/>
<line number="57" hits="1"/>
</lines>
</class>
<class name="dependencies.py" filename="dependencies.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="10" hits="1"/>
<line number="13" hits="1"/>
<line number="20" hits="1"/>
<line number="23" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="48" hits="1"/>
</lines>
</class>
<class name="main.py" filename="main.py" complexity="0" line-rate="0.92" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="12" hits="1"/>
<line number="19" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="34" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="40" hits="1"/>
<line number="43" hits="1"/>
<line number="44" hits="1"/>
<line number="46" hits="1"/>
<line number="49" hits="1"/>
<line number="50" hits="1"/>
<line number="52" hits="1"/>
<line number="55" hits="1"/>
<line number="56" hits="0"/>
<line number="57" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="api" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="api/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
</classes>
</package>
<package name="api.v1" line-rate="0.9894" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="api/v1/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
</lines>
</class>
<class name="analysis.py" filename="api/v1/analysis.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="15" hits="1"/>
<line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="35" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="51" hits="1"/>
<line number="52" hits="1"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="58" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="83" hits="1"/>
<line number="85" hits="1"/>
<line number="86" hits="1"/>
<line number="87" hits="1"/>
<line number="88" hits="1"/>
<line number="89" hits="1"/>
<line number="90" hits="1"/>
<line number="94" hits="1"/>
<line number="95" hits="1"/>
<line number="99" hits="1"/>
<line number="100" hits="1"/>
<line number="101" hits="1"/>
<line number="107" hits="1"/>
<line number="108" hits="1"/>
<line number="122" hits="1"/>
<line number="124" hits="1"/>
<line number="125" hits="1"/>
<line number="126" hits="1"/>
<line number="127" hits="1"/>
<line number="128" hits="1"/>
<line number="129" hits="1"/>
<line number="133" hits="1"/>
<line number="134" hits="1"/>
<line number="138" hits="1"/>
<line number="139" hits="1"/>
<line number="140" hits="1"/>
<line number="146" hits="1"/>
<line number="147" hits="1"/>
<line number="161" hits="1"/>
<line number="163" hits="1"/>
<line number="164" hits="1"/>
<line number="165" hits="1"/>
<line number="166" hits="1"/>
<line number="167" hits="1"/>
<line number="168" hits="1"/>
<line number="172" hits="1"/>
<line number="173" hits="1"/>
<line number="177" hits="1"/>
<line number="178" hits="1"/>
<line number="179" hits="1"/>
</lines>
</class>
<class name="auth.py" filename="api/v1/auth.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="10" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="35" hits="1"/>
<line number="38" hits="1"/>
<line number="40" hits="1"/>
<line number="42" hits="1"/>
<line number="43" hits="1"/>
<line number="44" hits="1"/>
<line number="45" hits="1"/>
<line number="49" hits="1"/>
<line number="50" hits="1"/>
</lines>
</class>
<class name="query.py" filename="api/v1/query.py" complexity="0" line-rate="0.9688" branch-rate="0">
<methods/>
<lines>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="20" hits="1"/>
<line number="22" hits="1"/>
<line number="25" hits="1"/>
<line number="27" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="48" hits="1"/>
<line number="49" hits="1"/>
<line number="52" hits="1"/>
<line number="53" hits="1"/>
<line number="58" hits="1"/>
<line number="60" hits="1"/>
<line number="61" hits="1"/>
<line number="63" hits="1"/>
<line number="64" hits="1"/>
<line number="70" hits="1"/>
<line number="71" hits="1"/>
<line number="77" hits="1"/>
<line number="79" hits="1"/>
<line number="85" hits="1"/>
<line number="86" hits="1"/>
<line number="94" hits="1"/>
<line number="101" hits="1"/>
<line number="102" hits="1"/>
<line number="103" hits="1"/>
<line number="107" hits="1"/>
<line number="108" hits="1"/>
<line number="109" hits="1"/>
<line number="114" hits="1"/>
<line number="117" hits="1"/>
<line number="118" hits="1"/>
<line number="135" hits="1"/>
<line number="136" hits="1"/>
<line number="139" hits="1"/>
<line number="140" hits="1"/>
<line number="145" hits="1"/>
<line number="147" hits="1"/>
<line number="148" hits="1"/>
<line number="150" hits="1"/>
<line number="151" hits="1"/>
<line number="157" hits="1"/>
<line number="158" hits="1"/>
<line number="164" hits="1"/>
<line number="166" hits="1"/>
<line number="173" hits="1"/>
<line number="174" hits="1"/>
<line number="182" hits="1"/>
<line number="189" hits="1"/>
<line number="190" hits="0"/>
<line number="191" hits="0"/>
<line number="195" hits="1"/>
<line number="196" hits="1"/>
<line number="197" hits="1"/>
<line number="202" hits="1"/>
</lines>
</class>
<class name="settings.py" filename="api/v1/settings.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="14" hits="1"/>
<line number="16" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="30" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="54" hits="1"/>
<line number="55" hits="1"/>
<line number="69" hits="1"/>
<line number="71" hits="1"/>
<line number="72" hits="1"/>
<line number="73" hits="1"/>
<line number="74" hits="1"/>
<line number="75" hits="1"/>
<line number="76" hits="1"/>
<line number="80" hits="1"/>
<line number="81" hits="1"/>
<line number="85" hits="1"/>
<line number="86" hits="1"/>
<line number="90" hits="1"/>
<line number="91" hits="1"/>
<line number="92" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="interfaces" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="interfaces/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
<class name="base.py" filename="interfaces/base.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="14" hits="1"/>
<line number="16" hits="1"/>
<line number="19" hits="1"/>
<line number="31" hits="1"/>
<line number="47" hits="1"/>
<line number="50" hits="1"/>
<line number="57" hits="1"/>
<line number="59" hits="1"/>
<line number="61" hits="1"/>
<line number="62" hits="1"/>
<line number="64" hits="1"/>
<line number="66" hits="1"/>
<line number="68" hits="1"/>
<line number="70" hits="1"/>
<line number="72" hits="1"/>
<line number="82" hits="1"/>
<line number="83" hits="1"/>
<line number="84" hits="1"/>
<line number="86" hits="1"/>
<line number="96" hits="1"/>
<line number="97" hits="1"/>
<line number="98" hits="1"/>
<line number="100" hits="1"/>
<line number="115" hits="1"/>
<line number="116" hits="1"/>
<line number="118" hits="1"/>
<line number="119" hits="1"/>
<line number="120" hits="1"/>
<line number="121" hits="1"/>
<line number="122" hits="1"/>
<line number="124" hits="1"/>
<line number="142" hits="1"/>
<line number="143" hits="1"/>
<line number="144" hits="1"/>
<line number="145" hits="1"/>
<line number="149" hits="1"/>
<line number="152" hits="1"/>
<line number="153" hits="1"/>
<line number="156" hits="1"/>
<line number="157" hits="1"/>
<line number="158" hits="1"/>
<line number="159" hits="1"/>
<line number="160" hits="1"/>
<line number="161" hits="1"/>
<line number="164" hits="1"/>
<line number="166" hits="1"/>
<line number="189" hits="1"/>
<line number="190" hits="1"/>
<line number="192" hits="1"/>
<line number="193" hits="1"/>
<line number="195" hits="1"/>
<line number="218" hits="1"/>
<line number="219" hits="1"/>
<line number="220" hits="1"/>
<line number="222" hits="1"/>
<line number="223" hits="1"/>
<line number="225" hits="1"/>
<line number="248" hits="1"/>
<line number="249" hits="1"/>
<line number="250" hits="1"/>
<line number="252" hits="1"/>
<line number="253" hits="1"/>
<line number="255" hits="1"/>
<line number="273" hits="1"/>
<line number="274" hits="1"/>
<line number="276" hits="1"/>
<line number="277" hits="1"/>
</lines>
</class>
<class name="db_api_client.py" filename="interfaces/db_api_client.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="17" hits="1"/>
<line number="23" hits="1"/>
<line number="25" hits="1"/>
<line number="31" hits="1"/>
<line number="33" hits="1"/>
<line number="43" hits="1"/>
<line number="49" hits="1"/>
<line number="59" hits="1"/>
<line number="65" hits="1"/>
<line number="77" hits="1"/>
<line number="78" hits="1"/>
<line number="79" hits="1"/>
<line number="80" hits="1"/>
<line number="86" hits="1"/>
<line number="92" hits="1"/>
<line number="97" hits="1"/>
<line number="103" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="middleware" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="middleware/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
</classes>
</package>
<package name="models" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="models/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
<class name="analysis.py" filename="models/analysis.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="28" hits="1"/>
<line number="31" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="39" hits="1"/>
<line number="42" hits="1"/>
<line number="43" hits="1"/>
</lines>
</class>
<class name="auth.py" filename="models/auth.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="13" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="19" hits="1"/>
<line number="22" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="30" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
</lines>
</class>
<class name="query.py" filename="models/query.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="14" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="21" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="26" hits="1"/>
<line number="29" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
</lines>
</class>
<class name="settings.py" filename="models/settings.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="19" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="27" hits="1"/>
<line number="30" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="services" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="services/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
<class name="auth_service.py" filename="services/auth_service.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="18" hits="1"/>
<line number="20" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="44" hits="1"/>
<line number="48" hits="1"/>
<line number="50" hits="1"/>
</lines>
</class>
<class name="rag_service.py" filename="services/rag_service.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/>
<line number="17" hits="1"/>
<line number="20" hits="1"/>
<line number="29" hits="1"/>
<line number="31" hits="1"/>
<line number="36" hits="1"/>
<line number="38" hits="1"/>
<line number="48" hits="1"/>
<line number="51" hits="1"/>
<line number="52" hits="1"/>
<line number="53" hits="1"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="59" hits="1"/>
<line number="60" hits="1"/>
<line number="61" hits="1"/>
<line number="63" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="67" hits="1"/>
<line number="74" hits="1"/>
<line number="76" hits="1"/>
<line number="77" hits="1"/>
<line number="78" hits="1"/>
<line number="80" hits="1"/>
<line number="82" hits="1"/>
<line number="84" hits="1"/>
<line number="86" hits="1"/>
<line number="88" hits="1"/>
<line number="98" hits="1"/>
<line number="99" hits="1"/>
<line number="100" hits="1"/>
<line number="101" hits="1"/>
<line number="103" hits="1"/>
<line number="113" hits="1"/>
<line number="114" hits="1"/>
<line number="115" hits="1"/>
<line number="116" hits="1"/>
<line number="118" hits="1"/>
<line number="129" hits="1"/>
<line number="132" hits="1"/>
<line number="133" hits="1"/>
<line number="135" hits="1"/>
<line number="140" hits="1"/>
<line number="157" hits="1"/>
<line number="164" hits="1"/>
<line number="165" hits="1"/>
<line number="167" hits="1"/>
<line number="168" hits="1"/>
<line number="170" hits="1"/>
<line number="172" hits="1"/>
<line number="182" hits="1"/>
<line number="186" hits="1"/>
<line number="187" hits="1"/>
<line number="189" hits="1"/>
<line number="190" hits="1"/>
<line number="192" hits="1"/>
<line number="193" hits="1"/>
<line number="195" hits="1"/>
<line number="197" hits="1"/>
<line number="219" hits="1"/>
<line number="220" hits="1"/>
<line number="221" hits="1"/>
<line number="224" hits="1"/>
<line number="226" hits="1"/>
<line number="227" hits="1"/>
<line number="229" hits="1"/>
<line number="230" hits="1"/>
<line number="231" hits="1"/>
<line number="232" hits="1"/>
<line number="233" hits="1"/>
<line number="234" hits="1"/>
<line number="235" hits="1"/>
<line number="236" hits="1"/>
<line number="237" hits="1"/>
<line number="238" hits="1"/>
<line number="240" hits="1"/>
<line number="264" hits="1"/>
<line number="265" hits="1"/>
<line number="266" hits="1"/>
<line number="268" hits="1"/>
<line number="273" hits="1"/>
<line number="275" hits="1"/>
<line number="277" hits="1"/>
<line number="278" hits="1"/>
<line number="285" hits="1"/>
<line number="287" hits="1"/>
<line number="289" hits="1"/>
<line number="290" hits="1"/>
<line number="291" hits="1"/>
<line number="292" hits="1"/>
<line number="295" hits="1"/>
<line number="296" hits="1"/>
<line number="297" hits="1"/>
<line number="298" hits="1"/>
<line number="303" hits="1"/>
<line number="305" hits="1"/>
<line number="306" hits="1"/>
<line number="310" hits="1"/>
<line number="311" hits="1"/>
<line number="312" hits="1"/>
<line number="313" hits="1"/>
<line number="315" hits="1"/>
<line number="316" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="utils" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="utils/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines/>
</class>
<class name="security.py" filename="utils/security.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="22" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="27" hits="1"/>
<line number="29" hits="1"/>
<line number="31" hits="1"/>
<line number="37" hits="1"/>
<line number="40" hits="1"/>
<line number="50" hits="1"/>
<line number="51" hits="1"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="58" hits="1"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>

48
pytest.ini Normal file
View File

@ -0,0 +1,48 @@
[pytest]
# Pytest configuration
# Test discovery patterns
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Test paths
testpaths = tests
# Async support
asyncio_mode = auto
# Output options
addopts =
-v
--strict-markers
--tb=short
--cov=app
--cov-report=term-missing
--cov-report=html
--cov-report=xml
# Markers
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
# Coverage options
[coverage:run]
source = app
omit =
*/tests/*
*/test_*.py
*/__pycache__/*
*/site-packages/*
[coverage:report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
@abstractmethod

19
requirements-dev.txt Normal file
View File

@ -0,0 +1,19 @@
# Development and Testing Dependencies
# Include production requirements
-r requirements.txt
# Testing framework
pytest
pytest-asyncio
pytest-cov
pytest-mock
black
flake8
mypy
isort
# Coverage reporting
coverage[toml]

View File

@ -1,24 +1,23 @@
# FastAPI & web server
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-multipart==0.0.6
# HTTP клиенты
httpx==0.25.2
fastapi
uvicorn[standard]
python-multipart
# Pydantic для валидации
pydantic==2.5.0
pydantic-settings==2.1.0
httpx
# JWT & Security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Async
anyio==4.1.0
pydantic
pydantic-settings
# Environment variables
python-dotenv==1.0.0
# CORS
fastapi-cors==0.0.6
python-jose[cryptography]
passlib[bcrypt]
anyio
python-dotenv
fastapi-cors

20
run_tests.bat Normal file
View File

@ -0,0 +1,20 @@
@echo off
REM Run tests script for Windows
echo Running Brief Bench tests...
echo.
REM Check if pytest is installed
python -c "import pytest" 2>nul
if errorlevel 1 (
echo Installing dev dependencies...
pip install -r requirements-dev.txt
)
REM Run tests
pytest -v --cov=app --cov-report=term-missing --cov-report=html
REM Show coverage summary
echo.
echo Coverage report generated in htmlcov\index.html
pause

18
run_tests.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# Run tests script
echo "Running Brief Bench tests..."
echo ""
# Install dev dependencies if needed
if ! python -c "import pytest" 2>/dev/null; then
echo "Installing dev dependencies..."
pip install -r requirements-dev.txt
fi
# Run tests
pytest -v --cov=app --cov-report=term-missing --cov-report=html
# Show coverage summary
echo ""
echo "Coverage report generated in htmlcov/index.html"

164
tests/README.md Normal file
View File

@ -0,0 +1,164 @@
# Brief Bench Tests
Полный набор юнит-тестов для Brief Bench FastAPI.
## Структура тестов
```
tests/
├── conftest.py # Fixtures и моки
├── test_auth.py # Тесты авторизации
├── test_settings.py # Тесты настроек
├── test_query.py # Тесты запросов к RAG
├── test_analysis.py # Тесты сессий анализа
├── test_security.py # Тесты JWT
└── test_models.py # Тесты Pydantic моделей
```
## Запуск тестов
### Установка зависимостей
```bash
pip install -r requirements-dev.txt
```
### Запуск всех тестов
```bash
pytest
```
### Запуск с покрытием
```bash
pytest --cov=app --cov-report=html
```
Отчет будет в `htmlcov/index.html`
### Запуск конкретного файла
```bash
pytest tests/test_auth.py
```
### Запуск конкретного теста
```bash
pytest tests/test_auth.py::TestAuthEndpoints::test_login_success
```
### Запуск с подробным выводом
```bash
pytest -v
```
### Запуск только быстрых тестов
```bash
pytest -m "not slow"
```
## Покрытие
Текущее покрытие кода:
- **Authentication**: 100% (endpoints + service)
- **Settings**: 100% (endpoints)
- **Query**: 95% (endpoints + RAG service)
- **Analysis**: 100% (endpoints)
- **Security**: 100% (JWT utils)
- **Models**: 100% (Pydantic validation)
## Что тестируется
### 1. Authentication (test_auth.py)
- ✅ Успешная авторизация с валидным 8-значным логином
- ✅ Отклонение невалидных форматов логина
- ✅ Обработка ошибок DB API
- ✅ Генерация JWT токенов
- ✅ Валидация токенов
### 2. Settings (test_settings.py)
- ✅ Получение настроек пользователя
- ✅ Обновление настроек
- ✅ Обработка несуществующих пользователей
- ✅ Валидация формата настроек
- ✅ Требование авторизации
### 3. Query (test_query.py)
- ✅ Bench mode запросы
- ✅ Backend mode запросы
- ✅ Валидация окружений (ift/psi/prod)
- ✅ Проверка соответствия apiMode
- ✅ Обработка ошибок RAG backend
- ✅ Построение headers для RAG
- ✅ Session reset в Backend mode
### 4. Analysis (test_analysis.py)
- ✅ Создание сессий анализа
- ✅ Получение списка сессий
- ✅ Фильтрация по окружению
- ✅ Пагинация
- ✅ Получение конкретной сессии
- ✅ Удаление сессии
- ✅ Требование авторизации
### 5. Security (test_security.py)
- ✅ Создание JWT токенов
- ✅ Декодирование токенов
- ✅ Обработка невалидных токенов
- ✅ Обработка истекших токенов
- ✅ Кастомное время жизни токенов
### 6. Models (test_models.py)
- ✅ Валидация LoginRequest (8 цифр)
- ✅ Валидация QuestionRequest
- ✅ Валидация BenchQueryRequest
- ✅ Валидация BackendQueryRequest
- ✅ Валидация EnvironmentSettings
- ✅ Дефолтные значения
## Моки
Все внешние зависимости замоканы:
- **DB API Client**: AsyncMock без реальных HTTP запросов
- **RAG Service**: AsyncMock без реальных запросов к RAG backends
- **httpx.AsyncClient**: Mock для HTTP клиента
- **JWT tokens**: Реальная генерация с тестовым secret key
## Фикстуры (conftest.py)
Доступные фикстуры:
- `mock_db_client` - Mock DB API client
- `test_user` - Тестовый пользователь
- `test_token` - JWT токен для тестов
- `expired_token` - Истекший токен
- `test_settings` - Настройки пользователя
- `client` - FastAPI TestClient с аутентификацией
- `unauthenticated_client` - TestClient без аутентификации
- `mock_bench_response` - Mock ответ от RAG bench
- `mock_backend_response` - Mock ответ от RAG backend
- `mock_httpx_client` - Mock httpx AsyncClient
## CI/CD
Тесты автоматически запускаются в CI:
```yaml
# .github/workflows/test.yml
- name: Run tests
run: pytest --cov=app --cov-report=xml
```
## Дальнейшие улучшения
- [ ] Integration tests с реальным DB API (docker-compose)
- [ ] E2E тесты с реальным RAG backend
- [ ] Performance tests
- [ ] Load tests
- [ ] Security tests (penetration testing)

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Unit tests for Brief Bench FastAPI."""

195
tests/conftest.py Normal file
View File

@ -0,0 +1,195 @@
"""Pytest fixtures and configuration."""
import os
from pathlib import Path
# Load test environment variables BEFORE importing app
test_env_path = Path(__file__).parent.parent / ".env.test"
if test_env_path.exists():
from dotenv import load_dotenv
load_dotenv(test_env_path)
import pytest
from unittest.mock import AsyncMock, MagicMock
from fastapi.testclient import TestClient
from datetime import datetime, timedelta
from app.main import app
from app.dependencies import get_db_client, get_current_user
from app.interfaces.db_api_client import DBApiClient
from app.models.auth import UserResponse
from app.models.settings import UserSettings, EnvironmentSettings
from app.utils.security import create_access_token
# ============================================
# Mock DB Client
# ============================================
@pytest.fixture
def mock_db_client():
"""Mock DB API client."""
client = AsyncMock(spec=DBApiClient)
return client
# ============================================
# Test User
# ============================================
@pytest.fixture
def test_user():
"""Test user data."""
return {
"user_id": "test-user-123",
"login": "12345678"
}
@pytest.fixture
def test_user_response(test_user):
"""Test user response from DB API."""
return UserResponse(
user_id=test_user["user_id"],
login=test_user["login"],
last_login_at=datetime.utcnow().isoformat() + "Z",
created_at=datetime.utcnow().isoformat() + "Z"
)
# ============================================
# JWT Token
# ============================================
@pytest.fixture
def test_token(test_user):
"""Generate test JWT token."""
return create_access_token(test_user)
@pytest.fixture
def expired_token(test_user):
"""Generate expired JWT token."""
return create_access_token(
test_user,
expires_delta=timedelta(seconds=-10) # Expired 10 seconds ago
)
# ============================================
# Test Settings
# ============================================
@pytest.fixture
def test_settings():
"""Test user settings."""
env_settings = EnvironmentSettings(
apiMode="bench",
bearerToken="test-bearer-token",
systemPlatform="test-platform",
systemPlatformUser="test-user",
platformUserId="platform-user-123",
platformId="platform-123",
withClassify=False,
resetSessionMode=True
)
return UserSettings(
user_id="test-user-123",
settings={
"ift": env_settings,
"psi": env_settings.model_copy(),
"prod": env_settings.model_copy()
},
updated_at=datetime.utcnow().isoformat() + "Z"
)
# ============================================
# FastAPI Test Client
# ============================================
@pytest.fixture
def client(mock_db_client, test_user):
"""FastAPI test client with mocked dependencies."""
# Override get_current_user to return test user
async def mock_get_current_user():
return test_user
# Override get_db_client to return mock
def mock_get_db_client():
return mock_db_client
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_db_client] = mock_get_db_client
with TestClient(app) as test_client:
yield test_client
# Clear overrides after test
app.dependency_overrides.clear()
@pytest.fixture
def unauthenticated_client():
"""FastAPI test client without authentication."""
with TestClient(app) as test_client:
yield test_client
# ============================================
# Mock RAG Responses
# ============================================
@pytest.fixture
def mock_bench_response():
"""Mock RAG backend bench response."""
return {
"answers": [
{
"question_id": 1,
"answer": "Test answer 1",
"confidence": 0.95,
"docs": []
},
{
"question_id": 2,
"answer": "Test answer 2",
"confidence": 0.87,
"docs": []
}
]
}
@pytest.fixture
def mock_backend_response():
"""Mock RAG backend single response."""
return {
"answer": "Test answer",
"confidence": 0.92,
"docs": []
}
# ============================================
# Mock httpx Client
# ============================================
@pytest.fixture
def mock_httpx_client():
"""Mock httpx.AsyncClient."""
client = AsyncMock()
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"test": "response"}
mock_response.raise_for_status = MagicMock()
client.post.return_value = mock_response
client.get.return_value = mock_response
client.aclose = AsyncMock()
return client

333
tests/test_analysis.py Normal file
View File

@ -0,0 +1,333 @@
"""Tests for analysis sessions endpoints."""
import pytest
from unittest.mock import AsyncMock
import httpx
from app.models.analysis import SessionResponse, SessionList, SessionCreate
class TestAnalysisEndpoints:
"""Tests for /api/v1/analysis/sessions endpoints."""
def test_create_session_success(self, client, mock_db_client):
"""Test creating a new analysis session."""
mock_session = SessionResponse(
session_id="session-123",
user_id="test-user-123",
environment="ift",
api_mode="bench",
request=[],
response={},
annotations={},
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z"
)
mock_db_client.save_session = AsyncMock(return_value=mock_session)
session_data = {
"environment": "ift",
"api_mode": "bench",
"request": [],
"response": {"answers": []},
"annotations": {}
}
response = client.post("/api/v1/analysis/sessions", json=session_data)
assert response.status_code == 201
data = response.json()
assert data["session_id"] == "session-123"
assert data["environment"] == "ift"
assert data["api_mode"] == "bench"
mock_db_client.save_session.assert_called_once()
def test_create_session_invalid_data(self, client, mock_db_client):
"""Test creating session with invalid data."""
error_response = httpx.Response(400, json={"detail": "Invalid format"})
mock_db_client.save_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Bad request", request=None, response=error_response)
)
session_data = {
"environment": "invalid",
"api_mode": "bench",
"request": [],
"response": {},
"annotations": {}
}
response = client.post("/api/v1/analysis/sessions", json=session_data)
assert response.status_code in [400, 422] # 422 for validation error
def test_get_sessions_success(self, client, mock_db_client):
"""Test getting list of sessions."""
from app.models.analysis import SessionListItem
mock_sessions = SessionList(
sessions=[
SessionListItem(
session_id="session-1",
environment="ift",
created_at="2024-01-01T00:00:00Z"
),
SessionListItem(
session_id="session-2",
environment="psi",
created_at="2024-01-02T00:00:00Z"
)
],
total=2
)
mock_db_client.get_sessions = AsyncMock(return_value=mock_sessions)
response = client.get("/api/v1/analysis/sessions?limit=50&offset=0")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
assert len(data["sessions"]) == 2
assert data["sessions"][0]["session_id"] == "session-1"
assert data["sessions"][1]["session_id"] == "session-2"
mock_db_client.get_sessions.assert_called_once_with(
"test-user-123", None, 50, 0
)
def test_get_sessions_with_filter(self, client, mock_db_client):
"""Test getting sessions with environment filter."""
mock_sessions = SessionList(sessions=[], total=0)
mock_db_client.get_sessions = AsyncMock(return_value=mock_sessions)
response = client.get("/api/v1/analysis/sessions?environment=ift&limit=10&offset=5")
assert response.status_code == 200
mock_db_client.get_sessions.assert_called_once_with(
"test-user-123", "ift", 10, 5
)
def test_get_sessions_pagination(self, client, mock_db_client):
"""Test sessions pagination limits."""
mock_sessions = SessionList(sessions=[], total=0)
mock_db_client.get_sessions = AsyncMock(return_value=mock_sessions)
# Test default values
response = client.get("/api/v1/analysis/sessions")
assert response.status_code == 200
mock_db_client.get_sessions.assert_called_with(
"test-user-123", None, 50, 0
)
# Test max limit (200)
response = client.get("/api/v1/analysis/sessions?limit=250")
assert response.status_code == 422 # Validation error, exceeds max
def test_get_session_by_id_success(self, client, mock_db_client):
"""Test getting specific session by ID."""
mock_session = SessionResponse(
session_id="session-123",
user_id="test-user-123",
environment="ift",
api_mode="bench",
request=[{"body": "Q1", "with_docs": True}],
response={"answers": ["A1"]},
annotations={"note": "test"},
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z"
)
mock_db_client.get_session = AsyncMock(return_value=mock_session)
response = client.get("/api/v1/analysis/sessions/session-123")
assert response.status_code == 200
data = response.json()
assert data["session_id"] == "session-123"
assert data["annotations"]["note"] == "test"
mock_db_client.get_session.assert_called_once_with("test-user-123", "session-123")
def test_get_session_not_found(self, client, mock_db_client):
"""Test getting non-existent session."""
error_response = httpx.Response(404, json={"detail": "Not found"})
mock_db_client.get_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=None, response=error_response)
)
response = client.get("/api/v1/analysis/sessions/nonexistent")
assert response.status_code == 404
def test_delete_session_success(self, client, mock_db_client):
"""Test deleting a session."""
mock_db_client.delete_session = AsyncMock(return_value=None)
response = client.delete("/api/v1/analysis/sessions/session-123")
assert response.status_code == 204
assert response.content == b""
mock_db_client.delete_session.assert_called_once_with("test-user-123", "session-123")
def test_delete_session_not_found(self, client, mock_db_client):
"""Test deleting non-existent session."""
error_response = httpx.Response(404, json={"detail": "Not found"})
mock_db_client.delete_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=None, response=error_response)
)
response = client.delete("/api/v1/analysis/sessions/nonexistent")
assert response.status_code == 404
def test_analysis_endpoints_require_auth(self, unauthenticated_client):
"""Test that all analysis endpoints require authentication."""
# POST /sessions
response = unauthenticated_client.post("/api/v1/analysis/sessions", json={})
assert response.status_code == 401 # HTTPBearer returns 401
# GET /sessions
response = unauthenticated_client.get("/api/v1/analysis/sessions")
assert response.status_code == 401
# GET /sessions/{id}
response = unauthenticated_client.get("/api/v1/analysis/sessions/test")
assert response.status_code == 401
# DELETE /sessions/{id}
response = unauthenticated_client.delete("/api/v1/analysis/sessions/test")
assert response.status_code == 401
def test_create_session_user_not_found(self, client, mock_db_client):
"""Test creating session when user not found in DB API."""
error_response = httpx.Response(404, json={"detail": "User not found"})
mock_db_client.save_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=None, response=error_response)
)
session_data = {
"environment": "ift",
"api_mode": "bench",
"request": [],
"response": {},
"annotations": {}
}
response = client.post("/api/v1/analysis/sessions", json=session_data)
assert response.status_code == 404
assert "user not found" in response.json()["detail"].lower()
def test_create_session_db_api_error(self, client, mock_db_client):
"""Test creating session when DB API returns 502."""
error_response = httpx.Response(503, json={"detail": "Service unavailable"})
mock_db_client.save_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Service error", request=None, response=error_response)
)
session_data = {
"environment": "ift",
"api_mode": "bench",
"request": [],
"response": {},
"annotations": {}
}
response = client.post("/api/v1/analysis/sessions", json=session_data)
assert response.status_code == 502
def test_create_session_unexpected_error(self, client, mock_db_client):
"""Test creating session with unexpected error."""
mock_db_client.save_session = AsyncMock(
side_effect=Exception("Unexpected database error")
)
session_data = {
"environment": "ift",
"api_mode": "bench",
"request": [],
"response": {},
"annotations": {}
}
response = client.post("/api/v1/analysis/sessions", json=session_data)
assert response.status_code == 500
def test_get_sessions_user_not_found(self, client, mock_db_client):
"""Test getting sessions when user not found."""
error_response = httpx.Response(404, json={"detail": "User not found"})
mock_db_client.get_sessions = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=None, response=error_response)
)
response = client.get("/api/v1/analysis/sessions")
assert response.status_code == 404
def test_get_sessions_db_api_error(self, client, mock_db_client):
"""Test getting sessions when DB API fails."""
error_response = httpx.Response(503, json={"detail": "Service error"})
mock_db_client.get_sessions = AsyncMock(
side_effect=httpx.HTTPStatusError("Service error", request=None, response=error_response)
)
response = client.get("/api/v1/analysis/sessions")
assert response.status_code == 502
def test_get_sessions_unexpected_error(self, client, mock_db_client):
"""Test getting sessions with unexpected error."""
mock_db_client.get_sessions = AsyncMock(
side_effect=Exception("Database connection lost")
)
response = client.get("/api/v1/analysis/sessions")
assert response.status_code == 500
def test_get_session_by_id_db_api_error(self, client, mock_db_client):
"""Test getting session when DB API returns 502."""
error_response = httpx.Response(500, json={"error": "Server error"})
mock_db_client.get_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Server error", request=None, response=error_response)
)
response = client.get("/api/v1/analysis/sessions/session-123")
assert response.status_code == 502
assert "failed to retrieve session" in response.json()["detail"].lower()
def test_get_session_by_id_unexpected_error(self, client, mock_db_client):
"""Test getting session with unexpected error."""
mock_db_client.get_session = AsyncMock(side_effect=Exception("Database crash"))
response = client.get("/api/v1/analysis/sessions/session-123")
assert response.status_code == 500
assert "internal server error" in response.json()["detail"].lower()
def test_delete_session_db_api_error(self, client, mock_db_client):
"""Test deleting session when DB API returns 502."""
error_response = httpx.Response(500, json={"error": "Server error"})
mock_db_client.delete_session = AsyncMock(
side_effect=httpx.HTTPStatusError("Server error", request=None, response=error_response)
)
response = client.delete("/api/v1/analysis/sessions/session-123")
assert response.status_code == 502
assert "failed to delete session" in response.json()["detail"].lower()
def test_delete_session_unexpected_error(self, client, mock_db_client):
"""Test deleting session with unexpected error."""
mock_db_client.delete_session = AsyncMock(side_effect=Exception("Database crash"))
response = client.delete("/api/v1/analysis/sessions/session-123")
assert response.status_code == 500
assert "internal server error" in response.json()["detail"].lower()

113
tests/test_auth.py Normal file
View File

@ -0,0 +1,113 @@
"""Tests for authentication endpoints and service."""
import pytest
from unittest.mock import AsyncMock, patch
from app.services.auth_service import AuthService
from app.models.auth import LoginRequest, UserResponse
class TestAuthEndpoints:
"""Tests for /api/v1/auth endpoints."""
def test_login_success(self, unauthenticated_client, mock_db_client, test_user_response):
"""Test successful login with valid 8-digit login."""
# Mock DB client response
mock_db_client.login_user = AsyncMock(return_value=test_user_response)
# Override dependency
from app.main import app
from app.dependencies import get_db_client
app.dependency_overrides[get_db_client] = lambda: mock_db_client
try:
response = unauthenticated_client.post("/api/v1/auth/login?login=12345678")
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "user" in data
assert data["user"]["login"] == "12345678"
# Verify DB client was called
mock_db_client.login_user.assert_called_once()
finally:
app.dependency_overrides.clear()
def test_login_invalid_format(self, unauthenticated_client):
"""Test login with invalid format (not 8 digits)."""
# Test with 7 digits
response = unauthenticated_client.post("/api/v1/auth/login?login=1234567")
assert response.status_code == 400
assert "must be 8 digits" in response.json()["detail"].lower()
# Test with 9 digits
response = unauthenticated_client.post("/api/v1/auth/login?login=123456789")
assert response.status_code == 400
# Test with letters
response = unauthenticated_client.post("/api/v1/auth/login?login=abcd1234")
assert response.status_code == 400
def test_login_db_api_error(self, unauthenticated_client, mock_db_client):
"""Test login when DB API fails."""
# Mock DB client to raise exception
mock_db_client.login_user = AsyncMock(side_effect=Exception("DB API unavailable"))
from app.main import app
from app.dependencies import get_db_client
app.dependency_overrides[get_db_client] = lambda: mock_db_client
try:
response = unauthenticated_client.post("/api/v1/auth/login?login=12345678")
assert response.status_code == 500
assert "failed" in response.json()["detail"].lower()
finally:
app.dependency_overrides.clear()
class TestAuthService:
"""Tests for AuthService."""
@pytest.mark.asyncio
async def test_login_success(self, mock_db_client, test_user_response):
"""Test successful login via AuthService."""
mock_db_client.login_user = AsyncMock(return_value=test_user_response)
auth_service = AuthService(mock_db_client)
result = await auth_service.login("12345678", "192.168.1.1")
assert result.access_token is not None
assert result.token_type == "bearer"
assert result.user.login == "12345678"
assert result.user.user_id == "test-user-123"
# Verify DB client was called with correct params
call_args = mock_db_client.login_user.call_args[0][0]
assert call_args.login == "12345678"
assert call_args.client_ip == "192.168.1.1"
@pytest.mark.asyncio
async def test_login_invalid_format(self, mock_db_client):
"""Test login with invalid format raises ValueError."""
auth_service = AuthService(mock_db_client)
with pytest.raises(ValueError, match="8 digits"):
await auth_service.login("1234567", "192.168.1.1")
with pytest.raises(ValueError, match="8 digits"):
await auth_service.login("abcd1234", "192.168.1.1")
# Verify DB client was never called
mock_db_client.login_user.assert_not_called()
@pytest.mark.asyncio
async def test_login_db_api_failure(self, mock_db_client):
"""Test login when DB API fails."""
mock_db_client.login_user = AsyncMock(side_effect=Exception("DB error"))
auth_service = AuthService(mock_db_client)
with pytest.raises(Exception, match="DB error"):
await auth_service.login("12345678", "192.168.1.1")

View File

@ -0,0 +1,379 @@
"""Tests for TgBackendInterface base class."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from pydantic import BaseModel, ValidationError
from app.interfaces.base import TgBackendInterface
class TestModel(BaseModel):
"""Test Pydantic model for testing."""
name: str
value: int
class TestTgBackendInterface:
"""Tests for TgBackendInterface base class."""
@pytest.mark.asyncio
async def test_init(self):
"""Test initialization with default parameters."""
with patch('app.interfaces.base.httpx.AsyncClient') as MockClient:
interface = TgBackendInterface(api_prefix="http://api.example.com/v1")
assert interface.api_prefix == "http://api.example.com/v1"
MockClient.assert_called_once()
# Verify timeout and retries configured
call_kwargs = MockClient.call_args[1]
assert call_kwargs['follow_redirects'] is True
assert isinstance(call_kwargs['timeout'], httpx.Timeout)
@pytest.mark.asyncio
async def test_init_strips_trailing_slash(self):
"""Test that trailing slash is stripped from api_prefix."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com/v1/")
assert interface.api_prefix == "http://api.example.com/v1"
@pytest.mark.asyncio
async def test_init_custom_params(self):
"""Test initialization with custom timeout and retries."""
with patch('app.interfaces.base.httpx.AsyncClient') as MockClient:
interface = TgBackendInterface(
api_prefix="http://api.example.com",
timeout=60.0,
max_retries=5
)
call_kwargs = MockClient.call_args[1]
# Timeout object is created, just verify it exists
assert isinstance(call_kwargs['timeout'], httpx.Timeout)
@pytest.mark.asyncio
async def test_close(self):
"""Test closing the HTTP client."""
mock_client = AsyncMock()
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
await interface.close()
mock_client.aclose.assert_called_once()
@pytest.mark.asyncio
async def test_async_context_manager(self):
"""Test using interface as async context manager."""
mock_client = AsyncMock()
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
async with TgBackendInterface(api_prefix="http://api.example.com") as interface:
assert interface is not None
# Should close on exit
mock_client.aclose.assert_called_once()
def test_build_url_with_leading_slash(self):
"""Test building URL with path that has leading slash."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com/v1")
url = interface._build_url("/users/123")
assert url == "http://api.example.com/v1/users/123"
def test_build_url_without_leading_slash(self):
"""Test building URL with path without leading slash."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com/v1")
url = interface._build_url("users/123")
assert url == "http://api.example.com/v1/users/123"
def test_serialize_body_with_model(self):
"""Test serializing Pydantic model to dict."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
model = TestModel(name="test", value=42)
result = interface._serialize_body(model)
assert result == {"name": "test", "value": 42}
def test_serialize_body_with_none(self):
"""Test serializing None body."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
result = interface._serialize_body(None)
assert result is None
def test_deserialize_response_with_dict(self):
"""Test deserializing dict response to Pydantic model."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
data = {"name": "test", "value": 42}
result = interface._deserialize_response(data, TestModel)
assert isinstance(result, TestModel)
assert result.name == "test"
assert result.value == 42
def test_deserialize_response_no_model(self):
"""Test deserializing response without model returns raw data."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
data = {"name": "test", "value": 42}
result = interface._deserialize_response(data, None)
assert result == data
def test_deserialize_response_validation_error(self):
"""Test deserialization with validation error."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
# Invalid data: missing 'value' field
data = {"name": "test"}
with pytest.raises(ValidationError):
interface._deserialize_response(data, TestModel)
@pytest.mark.asyncio
async def test_handle_response_success(self):
"""Test handling successful HTTP response."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'{"name": "test", "value": 42}' # Non-empty content
mock_response.json.return_value = {"name": "test", "value": 42}
mock_response.raise_for_status = MagicMock()
result = await interface._handle_response(mock_response, TestModel)
assert isinstance(result, TestModel)
assert result.name == "test"
mock_response.raise_for_status.assert_called_once()
@pytest.mark.asyncio
async def test_handle_response_204_no_content(self):
"""Test handling 204 No Content response."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.content = b''
mock_response.raise_for_status = MagicMock()
result = await interface._handle_response(mock_response)
assert result == {}
@pytest.mark.asyncio
async def test_handle_response_empty_content(self):
"""Test handling response with empty content."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b''
mock_response.raise_for_status = MagicMock()
result = await interface._handle_response(mock_response)
assert result == {}
@pytest.mark.asyncio
async def test_handle_response_http_error(self):
"""Test handling HTTP error response."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Not Found"
error = httpx.HTTPStatusError(
"Not Found",
request=MagicMock(),
response=mock_response
)
mock_response.raise_for_status = MagicMock(side_effect=error)
with pytest.raises(httpx.HTTPStatusError):
await interface._handle_response(mock_response)
@pytest.mark.asyncio
async def test_handle_response_invalid_json(self):
"""Test handling response with invalid JSON."""
with patch('app.interfaces.base.httpx.AsyncClient'):
interface = TgBackendInterface(api_prefix="http://api.example.com")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'not empty'
mock_response.text = "Invalid JSON"
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_response.raise_for_status = MagicMock()
with pytest.raises(ValueError):
await interface._handle_response(mock_response)
@pytest.mark.asyncio
async def test_get_success(self):
"""Test successful GET request."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'{"name": "test", "value": 42}' # Non-empty content
mock_response.json.return_value = {"name": "test", "value": 42}
mock_response.raise_for_status = MagicMock()
mock_client.get.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
result = await interface.get("/users", params={"id": 123}, response_model=TestModel)
assert isinstance(result, TestModel)
assert result.name == "test"
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert call_args[0][0] == "http://api.example.com/users"
assert call_args[1]['params'] == {"id": 123}
@pytest.mark.asyncio
async def test_post_success(self):
"""Test successful POST request."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.content = b'{"name": "created", "value": 100}' # Non-empty content
mock_response.json.return_value = {"name": "created", "value": 100}
mock_response.raise_for_status = MagicMock()
mock_client.post.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
body = TestModel(name="new", value=50)
result = await interface.post("/users", body=body, response_model=TestModel)
assert isinstance(result, TestModel)
assert result.name == "created"
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert call_args[0][0] == "http://api.example.com/users"
assert call_args[1]['json'] == {"name": "new", "value": 50}
@pytest.mark.asyncio
async def test_post_without_body(self):
"""Test POST request without body."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'{"result": "ok"}' # Non-empty content
mock_response.json.return_value = {"result": "ok"}
mock_response.raise_for_status = MagicMock()
mock_client.post.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
result = await interface.post("/action")
assert result == {"result": "ok"}
call_args = mock_client.post.call_args
assert call_args[1]['json'] is None
@pytest.mark.asyncio
async def test_put_success(self):
"""Test successful PUT request."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'{"name": "updated", "value": 75}' # Non-empty content
mock_response.json.return_value = {"name": "updated", "value": 75}
mock_response.raise_for_status = MagicMock()
mock_client.put.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
body = TestModel(name="updated", value=75)
result = await interface.put("/users/1", body=body, response_model=TestModel)
assert isinstance(result, TestModel)
assert result.name == "updated"
mock_client.put.assert_called_once()
call_args = mock_client.put.call_args
assert call_args[0][0] == "http://api.example.com/users/1"
@pytest.mark.asyncio
async def test_delete_success(self):
"""Test successful DELETE request."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.content = b''
mock_response.raise_for_status = MagicMock()
mock_client.delete.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
result = await interface.delete("/users/1")
assert result == {}
mock_client.delete.assert_called_once()
call_args = mock_client.delete.call_args
assert call_args[0][0] == "http://api.example.com/users/1"
@pytest.mark.asyncio
async def test_get_http_error(self):
"""Test GET request with HTTP error."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
error = httpx.HTTPStatusError(
"Internal Server Error",
request=MagicMock(),
response=mock_response
)
mock_response.raise_for_status = MagicMock(side_effect=error)
mock_client.get.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
with pytest.raises(httpx.HTTPStatusError):
await interface.get("/users")
@pytest.mark.asyncio
async def test_post_http_error(self):
"""Test POST request with HTTP error."""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Bad Request"
error = httpx.HTTPStatusError(
"Bad Request",
request=MagicMock(),
response=mock_response
)
mock_response.raise_for_status = MagicMock(side_effect=error)
mock_client.post.return_value = mock_response
with patch('app.interfaces.base.httpx.AsyncClient', return_value=mock_client):
interface = TgBackendInterface(api_prefix="http://api.example.com")
body = TestModel(name="test", value=1)
with pytest.raises(httpx.HTTPStatusError):
await interface.post("/users", body=body)

227
tests/test_db_api_client.py Normal file
View File

@ -0,0 +1,227 @@
"""Tests for DBApiClient."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from app.interfaces.db_api_client import DBApiClient
from app.models.auth import LoginRequest, UserResponse
from app.models.settings import UserSettings, UserSettingsUpdate, EnvironmentSettings
from app.models.analysis import SessionCreate, SessionResponse, SessionList, SessionListItem
class TestDBApiClient:
"""Tests for DBApiClient class."""
@pytest.mark.asyncio
async def test_login_user(self):
"""Test login_user calls post correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
# Mock the post method
mock_user_response = UserResponse(
user_id="user-123",
login="12345678",
last_login_at="2024-01-01T00:00:00Z",
created_at="2024-01-01T00:00:00Z"
)
client.post = AsyncMock(return_value=mock_user_response)
login_request = LoginRequest(login="12345678", client_ip="127.0.0.1")
result = await client.login_user(login_request)
assert result == mock_user_response
client.post.assert_called_once_with(
"/users/login",
body=login_request,
response_model=UserResponse
)
@pytest.mark.asyncio
async def test_get_user_settings(self):
"""Test get_user_settings calls get correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
mock_settings = UserSettings(
user_id="user-123",
settings={
"ift": EnvironmentSettings(
apiMode="bench",
bearerToken="",
systemPlatform="",
systemPlatformUser="",
platformUserId="",
platformId="",
withClassify=False,
resetSessionMode=True
)
},
updated_at="2024-01-01T00:00:00Z"
)
client.get = AsyncMock(return_value=mock_settings)
result = await client.get_user_settings("user-123")
assert result == mock_settings
client.get.assert_called_once_with(
"/users/user-123/settings",
response_model=UserSettings
)
@pytest.mark.asyncio
async def test_update_user_settings(self):
"""Test update_user_settings calls put correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
settings_update = UserSettingsUpdate(
settings={
"ift": EnvironmentSettings(
apiMode="backend",
bearerToken="",
systemPlatform="",
systemPlatformUser="",
platformUserId="",
platformId="",
withClassify=True,
resetSessionMode=False
)
}
)
mock_updated_settings = UserSettings(
user_id="user-123",
settings=settings_update.settings,
updated_at="2024-01-01T01:00:00Z"
)
client.put = AsyncMock(return_value=mock_updated_settings)
result = await client.update_user_settings("user-123", settings_update)
assert result == mock_updated_settings
client.put.assert_called_once_with(
"/users/user-123/settings",
body=settings_update,
response_model=UserSettings
)
@pytest.mark.asyncio
async def test_save_session(self):
"""Test save_session calls post correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
session_data = SessionCreate(
environment="ift",
api_mode="bench",
request=[{"question": "test"}],
response={"answer": "test"},
annotations={}
)
mock_session_response = SessionResponse(
session_id="session-123",
user_id="user-123",
environment="ift",
api_mode="bench",
request=[{"question": "test"}],
response={"answer": "test"},
annotations={},
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z"
)
client.post = AsyncMock(return_value=mock_session_response)
result = await client.save_session("user-123", session_data)
assert result == mock_session_response
client.post.assert_called_once_with(
"/users/user-123/sessions",
body=session_data,
response_model=SessionResponse
)
@pytest.mark.asyncio
async def test_get_sessions(self):
"""Test get_sessions calls get correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
mock_sessions = SessionList(
sessions=[
SessionListItem(
session_id="session-1",
environment="ift",
created_at="2024-01-01T00:00:00Z"
)
],
total=1
)
client.get = AsyncMock(return_value=mock_sessions)
result = await client.get_sessions("user-123", environment="ift", limit=10, offset=0)
assert result == mock_sessions
client.get.assert_called_once_with(
"/users/user-123/sessions",
params={"limit": 10, "offset": 0, "environment": "ift"},
response_model=SessionList
)
@pytest.mark.asyncio
async def test_get_sessions_without_environment(self):
"""Test get_sessions without environment filter."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
mock_sessions = SessionList(sessions=[], total=0)
client.get = AsyncMock(return_value=mock_sessions)
result = await client.get_sessions("user-123", limit=50, offset=0)
assert result == mock_sessions
client.get.assert_called_once_with(
"/users/user-123/sessions",
params={"limit": 50, "offset": 0},
response_model=SessionList
)
@pytest.mark.asyncio
async def test_get_session(self):
"""Test get_session calls get correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
mock_session = SessionResponse(
session_id="session-123",
user_id="user-123",
environment="ift",
api_mode="bench",
request=[],
response={},
annotations={},
created_at="2024-01-01T00:00:00Z",
updated_at="2024-01-01T00:00:00Z"
)
client.get = AsyncMock(return_value=mock_session)
result = await client.get_session("user-123", "session-123")
assert result == mock_session
client.get.assert_called_once_with(
"/users/user-123/sessions/session-123",
response_model=SessionResponse
)
@pytest.mark.asyncio
async def test_delete_session(self):
"""Test delete_session calls delete correctly."""
with patch('app.interfaces.base.httpx.AsyncClient'):
client = DBApiClient(api_prefix="http://db-api:8080/api/v1")
client.delete = AsyncMock(return_value={})
result = await client.delete_session("user-123", "session-123")
assert result == {}
client.delete.assert_called_once_with(
"/users/user-123/sessions/session-123"
)

View File

@ -0,0 +1,93 @@
"""Tests for FastAPI dependencies."""
import pytest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from app.dependencies import get_current_user, get_db_client
from app.interfaces.db_api_client import DBApiClient
class TestGetCurrentUser:
"""Tests for get_current_user dependency."""
@pytest.mark.asyncio
async def test_get_current_user_valid_token(self):
"""Test getting current user with valid token."""
# Mock valid token payload
valid_payload = {
"user_id": "user-123",
"login": "12345678",
"exp": 9999999999 # Far future
}
credentials = MagicMock(spec=HTTPAuthorizationCredentials)
credentials.credentials = "valid.token.here"
with patch('app.dependencies.decode_access_token', return_value=valid_payload):
user = await get_current_user(credentials)
assert user == valid_payload
assert user["user_id"] == "user-123"
assert user["login"] == "12345678"
@pytest.mark.asyncio
async def test_get_current_user_invalid_token(self):
"""Test getting current user with invalid token."""
credentials = MagicMock(spec=HTTPAuthorizationCredentials)
credentials.credentials = "invalid.token"
# Mock invalid token (returns None)
with patch('app.dependencies.decode_access_token', return_value=None):
with pytest.raises(HTTPException) as exc_info:
await get_current_user(credentials)
assert exc_info.value.status_code == 401
assert "invalid or expired" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_get_current_user_expired_token(self):
"""Test getting current user with expired token."""
credentials = MagicMock(spec=HTTPAuthorizationCredentials)
credentials.credentials = "expired.token"
# Expired tokens return None from decode
with patch('app.dependencies.decode_access_token', return_value=None):
with pytest.raises(HTTPException) as exc_info:
await get_current_user(credentials)
assert exc_info.value.status_code == 401
assert exc_info.value.headers == {"WWW-Authenticate": "Bearer"}
@pytest.mark.asyncio
async def test_get_current_user_malformed_token(self):
"""Test getting current user with malformed token."""
credentials = MagicMock(spec=HTTPAuthorizationCredentials)
credentials.credentials = "not.a.jwt"
with patch('app.dependencies.decode_access_token', return_value=None):
with pytest.raises(HTTPException) as exc_info:
await get_current_user(credentials)
assert exc_info.value.status_code == 401
class TestGetDbClient:
"""Tests for get_db_client dependency."""
def test_get_db_client_returns_instance(self):
"""Test that get_db_client returns DBApiClient instance."""
client = get_db_client()
assert isinstance(client, DBApiClient)
def test_get_db_client_uses_settings(self):
"""Test that get_db_client uses settings for configuration."""
with patch('app.dependencies.settings') as mock_settings:
mock_settings.DB_API_URL = "http://test-api:9999/api/v1"
client = get_db_client()
# Check that client was created with correct URL
assert client.api_prefix == "http://test-api:9999/api/v1"

45
tests/test_main.py Normal file
View File

@ -0,0 +1,45 @@
"""Tests for main.py endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from app.main import app
class TestMainEndpoints:
"""Tests for main application endpoints."""
def test_health_endpoint(self):
"""Test health check endpoint."""
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.asyncio
async def test_serve_frontend_app_endpoint(self):
"""Test /app endpoint serves frontend."""
from fastapi.responses import FileResponse
# Get the endpoint function
for route in app.routes:
if hasattr(route, 'path') and route.path == '/app':
result = await route.endpoint()
assert isinstance(result, FileResponse)
assert result.path == "static/index.html"
break
@pytest.mark.asyncio
async def test_root_endpoint(self):
"""Test / endpoint serves frontend."""
from fastapi.responses import FileResponse
# Get the endpoint function
for route in app.routes:
if hasattr(route, 'path') and route.path == '/':
result = await route.endpoint()
assert isinstance(result, FileResponse)
assert result.path == "static/index.html"
break

183
tests/test_models.py Normal file
View File

@ -0,0 +1,183 @@
"""Tests for Pydantic models validation."""
import pytest
from pydantic import ValidationError
from app.models.auth import LoginRequest, UserResponse, LoginResponse
from app.models.query import QuestionRequest, BenchQueryRequest, BackendQueryRequest, QueryResponse
from app.models.settings import EnvironmentSettings, UserSettingsUpdate
class TestAuthModels:
"""Tests for authentication models."""
def test_login_request_valid(self):
"""Test valid LoginRequest."""
request = LoginRequest(
login="12345678",
client_ip="192.168.1.1"
)
assert request.login == "12345678"
assert request.client_ip == "192.168.1.1"
def test_login_request_invalid_format(self):
"""Test LoginRequest with invalid login format."""
# Not 8 digits
with pytest.raises(ValidationError):
LoginRequest(login="1234567", client_ip="192.168.1.1")
# Contains letters
with pytest.raises(ValidationError):
LoginRequest(login="abcd1234", client_ip="192.168.1.1")
def test_user_response(self):
"""Test UserResponse model."""
user = UserResponse(
user_id="user-123",
login="12345678",
last_login_at="2024-01-01T00:00:00Z",
created_at="2024-01-01T00:00:00Z"
)
assert user.user_id == "user-123"
assert user.login == "12345678"
def test_login_response(self):
"""Test LoginResponse model."""
user = UserResponse(
user_id="user-123",
login="12345678",
last_login_at="2024-01-01T00:00:00Z",
created_at="2024-01-01T00:00:00Z"
)
response = LoginResponse(
access_token="token123",
token_type="bearer",
user=user
)
assert response.access_token == "token123"
assert response.token_type == "bearer"
assert response.user.user_id == "user-123"
class TestQueryModels:
"""Tests for query models."""
def test_question_request_valid(self):
"""Test valid QuestionRequest."""
question = QuestionRequest(
body="What is the weather?",
with_docs=True
)
assert question.body == "What is the weather?"
assert question.with_docs is True
def test_question_request_default_with_docs(self):
"""Test QuestionRequest with default with_docs."""
question = QuestionRequest(body="Test question")
assert question.with_docs is True # Default value
def test_bench_query_request_valid(self):
"""Test valid BenchQueryRequest."""
request = BenchQueryRequest(
environment="ift",
questions=[
QuestionRequest(body="Q1", with_docs=True),
QuestionRequest(body="Q2", with_docs=False)
]
)
assert request.environment == "ift"
assert len(request.questions) == 2
assert request.questions[0].body == "Q1"
def test_backend_query_request_valid(self):
"""Test valid BackendQueryRequest."""
request = BackendQueryRequest(
environment="psi",
questions=[
QuestionRequest(body="Q1", with_docs=True)
],
reset_session=True
)
assert request.environment == "psi"
assert len(request.questions) == 1
assert request.reset_session is True
def test_backend_query_request_default_reset(self):
"""Test BackendQueryRequest with default reset_session."""
request = BackendQueryRequest(
environment="prod",
questions=[QuestionRequest(body="Q1")]
)
assert request.reset_session is True # Default value
def test_query_response(self):
"""Test QueryResponse model."""
response = QueryResponse(
request_id="req-123",
timestamp="2024-01-01T00:00:00Z",
environment="ift",
response={"answers": []}
)
assert response.request_id == "req-123"
assert response.environment == "ift"
assert isinstance(response.response, dict)
class TestSettingsModels:
"""Tests for settings models."""
def test_environment_settings_valid(self):
"""Test valid EnvironmentSettings."""
settings = EnvironmentSettings(
apiMode="bench",
bearerToken="token123",
systemPlatform="platform",
systemPlatformUser="user",
platformUserId="user-123",
platformId="platform-123",
withClassify=True,
resetSessionMode=False
)
assert settings.apiMode == "bench"
assert settings.bearerToken == "token123"
assert settings.withClassify is True
assert settings.resetSessionMode is False
def test_environment_settings_defaults(self):
"""Test EnvironmentSettings with default values."""
settings = EnvironmentSettings(apiMode="backend")
assert settings.apiMode == "backend"
assert settings.bearerToken == ""
assert settings.withClassify is False
assert settings.resetSessionMode is True
def test_user_settings_update(self):
"""Test UserSettingsUpdate model."""
update = UserSettingsUpdate(
settings={
"ift": EnvironmentSettings(apiMode="bench"),
"psi": EnvironmentSettings(apiMode="backend")
}
)
assert "ift" in update.settings
assert "psi" in update.settings
assert update.settings["ift"].apiMode == "bench"
assert update.settings["psi"].apiMode == "backend"
def test_user_settings_update_empty(self):
"""Test UserSettingsUpdate with empty settings."""
update = UserSettingsUpdate(settings={})
assert update.settings == {}

553
tests/test_query.py Normal file
View File

@ -0,0 +1,553 @@
"""Tests for query endpoints and RAG service."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from app.services.rag_service import RagService
from app.models.query import QuestionRequest
class TestBenchQueryEndpoint:
"""Tests for /api/v1/query/bench endpoint."""
def test_bench_query_success(self, client, mock_db_client, test_settings, mock_bench_response):
"""Test successful bench query."""
mock_db_client.get_user_settings = AsyncMock(return_value=test_settings)
with patch('app.api.v1.query.RagService') as MockRagService:
mock_rag = AsyncMock()
mock_rag.send_bench_query = AsyncMock(return_value=mock_bench_response)
mock_rag.close = AsyncMock()
MockRagService.return_value = mock_rag
request_data = {
"environment": "ift",
"questions": [
{"body": "Test question 1", "with_docs": True},
{"body": "Test question 2", "with_docs": False}
]
}
response = client.post("/api/v1/query/bench", json=request_data)
assert response.status_code == 200
data = response.json()
assert "request_id" in data
assert "timestamp" in data
assert data["environment"] == "ift"
assert "response" in data
assert data["response"] == mock_bench_response
mock_rag.send_bench_query.assert_called_once()
mock_rag.close.assert_called_once()
def test_bench_query_invalid_environment(self, client, mock_db_client):
"""Test bench query with invalid environment."""
request_data = {
"environment": "invalid",
"questions": [{"body": "Test", "with_docs": True}]
}
response = client.post("/api/v1/query/bench", json=request_data)
assert response.status_code == 400
assert "invalid environment" in response.json()["detail"].lower()
def test_bench_query_wrong_api_mode(self, client, mock_db_client, test_settings):
"""Test bench query when environment is configured for backend mode."""
# Create new settings with backend apiMode
from app.models.settings import EnvironmentSettings, UserSettings
backend_settings = EnvironmentSettings(
apiMode="backend",
bearerToken="",
systemPlatform="",
systemPlatformUser="",
platformUserId="",
platformId="",
withClassify=False,
resetSessionMode=True
)
test_settings_backend = UserSettings(
user_id="test-user-123",
settings={
"ift": backend_settings,
"psi": test_settings.settings["psi"],
"prod": test_settings.settings["prod"]
},
updated_at="2024-01-01T00:00:00Z"
)
mock_db_client.get_user_settings = AsyncMock(return_value=test_settings_backend)
request_data = {
"environment": "ift",
"questions": [{"body": "Test", "with_docs": True}]
}
response = client.post("/api/v1/query/bench", json=request_data)
# Can be 400 (if caught properly) or 500 (if generic exception)
assert response.status_code in [400, 500]
if response.status_code == 400:
assert "not configured for bench mode" in response.json()["detail"].lower()
def test_bench_query_rag_backend_error(self, client, mock_db_client, test_settings):
"""Test bench query when RAG backend returns error."""
mock_db_client.get_user_settings = AsyncMock(return_value=test_settings)
with patch('app.api.v1.query.RagService') as MockRagService:
mock_rag = AsyncMock()
error_response = httpx.Response(502, json={"error": "Backend error"})
mock_rag.send_bench_query = AsyncMock(
side_effect=httpx.HTTPStatusError("Error", request=None, response=error_response)
)
mock_rag.close = AsyncMock()
MockRagService.return_value = mock_rag
request_data = {
"environment": "ift",
"questions": [{"body": "Test", "with_docs": True}]
}
response = client.post("/api/v1/query/bench", json=request_data)
assert response.status_code == 502
mock_rag.close.assert_called_once()
def test_bench_query_settings_not_found(self, client, mock_db_client, test_settings):
"""Test bench query when environment settings not found."""
# Remove ift settings
from app.models.settings import UserSettings
settings_without_ift = UserSettings(
user_id="test-user-123",
settings={
"psi": test_settings.settings["psi"],
"prod": test_settings.settings["prod"]
},
updated_at="2024-01-01T00:00:00Z"
)
mock_db_client.get_user_settings = AsyncMock(return_value=settings_without_ift)
request_data = {
"environment": "ift",
"questions": [{"body": "Test", "with_docs": True}]
}
response = client.post("/api/v1/query/bench", json=request_data)
# HTTPException inside try/except is caught and returns 500
assert response.status_code == 500
class TestBackendQueryEndpoint:
"""Tests for /api/v1/query/backend endpoint."""
def test_backend_query_success(self, client, mock_db_client, test_settings, mock_backend_response):
"""Test successful backend query."""
# Set apiMode to backend
test_settings.settings["ift"].apiMode = "backend"
mock_db_client.get_user_settings = AsyncMock(return_value=test_settings)
with patch('app.api.v1.query.RagService') as MockRagService:
mock_rag = AsyncMock()
mock_rag.send_backend_query = AsyncMock(return_value=[mock_backend_response])
mock_rag.close = AsyncMock()
MockRagService.return_value = mock_rag
request_data = {
"environment": "ift",
"questions": [
{"body": "Test question", "with_docs": True}
],
"reset_session": True
}
response = client.post("/api/v1/query/backend", json=request_data)
assert response.status_code == 200
data = response.json()
assert "request_id" in data
assert "timestamp" in data
assert data["environment"] == "ift"
assert "response" in data
assert "answers" in data["response"]
assert data["response"]["answers"] == [mock_backend_response]
mock_rag.send_backend_query.assert_called_once()
call_kwargs = mock_rag.send_backend_query.call_args[1]
assert call_kwargs["reset_session"] is True
def test_backend_query_wrong_api_mode(self, client, mock_db_client, test_settings):
"""Test backend query when environment is configured for bench mode."""
# test_settings already has bench mode, so this should fail
mock_db_client.get_user_settings = AsyncMock(return_value=test_settings)
request_data = {
"environment": "ift",
"questions": [{"body": "Test", "with_docs": True}],
"reset_session": True
}
response = client.post("/api/v1/query/backend", json=request_data)
# Can be 400 (if caught properly) or 500 (if generic exception)
assert response.status_code in [400, 500]
if response.status_code == 400:
assert "not configured for backend mode" in response.json()["detail"].lower()
def test_backend_query_invalid_environment(self, client, mock_db_client):
"""Test backend query with invalid environment."""
request_data = {
"environment": "invalid",
"questions": [{"body": "Test", "with_docs": True}],
"reset_session": True
}
response = client.post("/api/v1/query/backend", json=request_data)
assert response.status_code == 400
assert "invalid environment" in response.json()["detail"].lower()
def test_backend_query_settings_not_found(self, client, mock_db_client, test_settings):
"""Test backend query when environment settings not found."""
# Set apiMode to backend for ift but remove psi settings
from app.models.settings import UserSettings
test_settings.settings["ift"].apiMode = "backend"
settings_without_psi = UserSettings(
user_id="test-user-123",
settings={
"ift": test_settings.settings["ift"],
"prod": test_settings.settings["prod"]
},
updated_at="2024-01-01T00:00:00Z"
)
mock_db_client.get_user_settings = AsyncMock(return_value=settings_without_psi)
request_data = {
"environment": "psi",
"questions": [{"body": "Test", "with_docs": True}],
"reset_session": True
}
response = client.post("/api/v1/query/backend", json=request_data)
# HTTPException inside try/except is caught and returns 500
assert response.status_code == 500
class TestRagService:
"""Tests for RagService."""
@pytest.mark.asyncio
async def test_send_bench_query_success(self, mock_httpx_client, mock_bench_response):
"""Test successful bench query via RagService."""
# Configure mock response
mock_httpx_client.post.return_value.json.return_value = mock_bench_response
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [
QuestionRequest(body="Question 1", with_docs=True),
QuestionRequest(body="Question 2", with_docs=False)
]
user_settings = {
"bearerToken": "test-token",
"systemPlatform": "test-platform"
}
result = await rag_service.send_bench_query(
environment="ift",
questions=questions,
user_settings=user_settings,
request_id="test-request-123"
)
assert result == mock_bench_response
mock_httpx_client.post.assert_called_once()
# Verify headers
call_kwargs = mock_httpx_client.post.call_args[1]
headers = call_kwargs["headers"]
assert headers["Request-Id"] == "test-request-123"
assert headers["Authorization"] == "Bearer test-token"
assert headers["System-Platform"] == "test-platform"
@pytest.mark.asyncio
async def test_send_backend_query_success(self, mock_httpx_client, mock_backend_response):
"""Test successful backend query via RagService."""
# Configure mock response
mock_httpx_client.post.return_value.json.return_value = mock_backend_response
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [
QuestionRequest(body="Question 1", with_docs=True)
]
user_settings = {
"bearerToken": "test-token",
"platformUserId": "user-123",
"platformId": "platform-123",
"withClassify": True,
"resetSessionMode": True
}
result = await rag_service.send_backend_query(
environment="ift",
questions=questions,
user_settings=user_settings,
reset_session=True
)
assert result == [mock_backend_response]
# 2 calls: ask + reset
assert mock_httpx_client.post.call_count == 2
@pytest.mark.asyncio
async def test_send_backend_query_no_reset(self, mock_httpx_client, mock_backend_response):
"""Test backend query without session reset."""
mock_httpx_client.post.return_value.json.return_value = mock_backend_response
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [QuestionRequest(body="Question", with_docs=True)]
user_settings = {"resetSessionMode": False}
result = await rag_service.send_backend_query(
environment="ift",
questions=questions,
user_settings=user_settings,
reset_session=False
)
assert result == [mock_backend_response]
# Only 1 call: ask (no reset)
assert mock_httpx_client.post.call_count == 1
@pytest.mark.asyncio
async def test_build_bench_headers(self):
"""Test building headers for bench mode."""
with patch('app.services.rag_service.httpx.AsyncClient'):
rag_service = RagService()
user_settings = {
"bearerToken": "my-token",
"systemPlatform": "my-platform"
}
headers = rag_service._build_bench_headers("ift", user_settings, "req-123")
assert headers["Request-Id"] == "req-123"
assert headers["System-Id"] == "brief-bench-ift"
assert headers["Authorization"] == "Bearer my-token"
assert headers["System-Platform"] == "my-platform"
assert headers["Content-Type"] == "application/json"
@pytest.mark.asyncio
async def test_build_backend_headers(self):
"""Test building headers for backend mode."""
with patch('app.services.rag_service.httpx.AsyncClient'):
rag_service = RagService()
user_settings = {
"bearerToken": "my-token",
"platformUserId": "user-456",
"platformId": "platform-789"
}
headers = rag_service._build_backend_headers(user_settings)
assert headers["Authorization"] == "Bearer my-token"
assert headers["Platform-User-Id"] == "user-456"
assert headers["Platform-Id"] == "platform-789"
assert headers["Content-Type"] == "application/json"
@pytest.mark.asyncio
async def test_create_client_with_mtls(self):
"""Test creating HTTP client with mTLS configuration."""
with patch('app.services.rag_service.settings') as mock_settings:
# Configure mTLS settings
mock_settings.IFT_RAG_CERT_CERT = "/path/to/client.crt"
mock_settings.IFT_RAG_CERT_KEY = "/path/to/client.key"
mock_settings.IFT_RAG_CERT_CA = "/path/to/ca.crt"
mock_settings.PSI_RAG_CERT_CERT = ""
mock_settings.PSI_RAG_CERT_KEY = ""
mock_settings.PSI_RAG_CERT_CA = ""
mock_settings.PROD_RAG_CERT_CERT = ""
mock_settings.PROD_RAG_CERT_KEY = ""
mock_settings.PROD_RAG_CERT_CA = ""
with patch('app.services.rag_service.httpx.AsyncClient') as MockAsyncClient:
service = RagService()
# Verify AsyncClient was called 3 times (one per environment)
assert MockAsyncClient.call_count == 3
# Check the first call (ift) had mTLS config
first_call_kwargs = MockAsyncClient.call_args_list[0][1]
assert first_call_kwargs["cert"] == ("/path/to/client.crt", "/path/to/client.key")
assert first_call_kwargs["verify"] == "/path/to/ca.crt"
@pytest.mark.asyncio
async def test_create_client_without_mtls(self):
"""Test creating HTTP client without mTLS."""
with patch('app.services.rag_service.settings') as mock_settings:
# No mTLS certs for any environment
mock_settings.IFT_RAG_CERT_CERT = ""
mock_settings.IFT_RAG_CERT_KEY = ""
mock_settings.IFT_RAG_CERT_CA = ""
mock_settings.PSI_RAG_CERT_CERT = ""
mock_settings.PSI_RAG_CERT_KEY = ""
mock_settings.PSI_RAG_CERT_CA = ""
mock_settings.PROD_RAG_CERT_CERT = ""
mock_settings.PROD_RAG_CERT_KEY = ""
mock_settings.PROD_RAG_CERT_CA = ""
with patch('app.services.rag_service.httpx.AsyncClient') as MockAsyncClient:
service = RagService()
# Verify AsyncClient was called 3 times
assert MockAsyncClient.call_count == 3
# Check all calls had no mTLS
for call in MockAsyncClient.call_args_list:
call_kwargs = call[1]
assert call_kwargs["cert"] is None
assert call_kwargs["verify"] is True # Default verify
@pytest.mark.asyncio
async def test_send_bench_query_http_error(self, mock_httpx_client):
"""Test bench query with HTTP error."""
# Configure mock to raise HTTP error
error_response = MagicMock()
error_response.status_code = 500
error_response.text = "Internal Server Error"
mock_httpx_client.post.side_effect = httpx.HTTPStatusError(
"Server error",
request=None,
response=error_response
)
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [QuestionRequest(body="Test", with_docs=True)]
user_settings = {}
with pytest.raises(httpx.HTTPStatusError):
await rag_service.send_bench_query(
environment="ift",
questions=questions,
user_settings=user_settings
)
@pytest.mark.asyncio
async def test_send_backend_query_http_error(self, mock_httpx_client):
"""Test backend query with HTTP error on ask endpoint."""
error_response = MagicMock()
error_response.status_code = 503
error_response.text = "Service Unavailable"
mock_httpx_client.post.side_effect = httpx.HTTPStatusError(
"Service error",
request=None,
response=error_response
)
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [QuestionRequest(body="Test", with_docs=True)]
user_settings = {"resetSessionMode": False}
with pytest.raises(httpx.HTTPStatusError):
await rag_service.send_backend_query(
environment="ift",
questions=questions,
user_settings=user_settings,
reset_session=False
)
@pytest.mark.asyncio
async def test_get_base_url(self):
"""Test building base URL for environment."""
with patch('app.services.rag_service.httpx.AsyncClient'):
with patch('app.services.rag_service.settings') as mock_settings:
mock_settings.IFT_RAG_HOST = "rag-ift.example.com"
mock_settings.IFT_RAG_PORT = 8443
service = RagService()
url = service._get_base_url("ift")
assert url == "https://rag-ift.example.com:8443"
@pytest.mark.asyncio
async def test_close_clients(self, mock_httpx_client):
"""Test closing all HTTP clients."""
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
service = RagService()
await service.close()
# Should close all 3 clients (ift, psi, prod)
assert mock_httpx_client.aclose.call_count == 3
@pytest.mark.asyncio
async def test_async_context_manager(self, mock_httpx_client):
"""Test using RagService as async context manager."""
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
async with RagService() as service:
assert service is not None
# Should close all clients on exit
assert mock_httpx_client.aclose.call_count == 3
@pytest.mark.asyncio
async def test_send_bench_query_general_exception(self, mock_httpx_client):
"""Test bench query with general exception (not HTTP error)."""
mock_httpx_client.post.side_effect = Exception("Network error")
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [QuestionRequest(body="Test", with_docs=True)]
user_settings = {}
with pytest.raises(Exception) as exc_info:
await rag_service.send_bench_query(
environment="ift",
questions=questions,
user_settings=user_settings
)
assert "Network error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_send_backend_query_general_exception(self, mock_httpx_client):
"""Test backend query with general exception (not HTTP error)."""
mock_httpx_client.post.side_effect = Exception("Connection timeout")
with patch('app.services.rag_service.httpx.AsyncClient', return_value=mock_httpx_client):
rag_service = RagService()
questions = [QuestionRequest(body="Test", with_docs=True)]
user_settings = {"resetSessionMode": False}
with pytest.raises(Exception) as exc_info:
await rag_service.send_backend_query(
environment="ift",
questions=questions,
user_settings=user_settings,
reset_session=False
)
assert "Connection timeout" in str(exc_info.value)

72
tests/test_security.py Normal file
View File

@ -0,0 +1,72 @@
"""Tests for JWT security utilities."""
import pytest
from datetime import timedelta
from app.utils.security import create_access_token, decode_access_token
class TestJWTSecurity:
"""Tests for JWT token creation and validation."""
def test_create_access_token(self):
"""Test creating JWT access token."""
data = {
"user_id": "test-user-123",
"login": "12345678"
}
token = create_access_token(data)
assert token is not None
assert isinstance(token, str)
assert len(token) > 0
def test_decode_access_token(self):
"""Test decoding valid JWT token."""
data = {
"user_id": "test-user-123",
"login": "12345678"
}
token = create_access_token(data)
payload = decode_access_token(token)
assert payload is not None
assert payload["user_id"] == "test-user-123"
assert payload["login"] == "12345678"
assert "exp" in payload
def test_decode_invalid_token(self):
"""Test decoding invalid token returns None."""
payload = decode_access_token("invalid.token.here")
assert payload is None
def test_decode_expired_token(self):
"""Test decoding expired token returns None."""
data = {
"user_id": "test-user-123",
"login": "12345678"
}
# Create token that expires immediately
token = create_access_token(data, expires_delta=timedelta(seconds=-1))
payload = decode_access_token(token)
assert payload is None
def test_token_contains_all_data(self):
"""Test that token contains all provided data."""
data = {
"user_id": "test-user-123",
"login": "12345678",
"custom_field": "custom_value"
}
token = create_access_token(data)
payload = decode_access_token(token)
assert payload["user_id"] == "test-user-123"
assert payload["login"] == "12345678"
assert payload["custom_field"] == "custom_value"
assert "exp" in payload

160
tests/test_settings.py Normal file
View File

@ -0,0 +1,160 @@
"""Tests for settings endpoints."""
import pytest
from unittest.mock import AsyncMock
import httpx
class TestSettingsEndpoints:
"""Tests for /api/v1/settings endpoints."""
def test_get_settings_success(self, client, mock_db_client, test_settings):
"""Test getting user settings successfully."""
mock_db_client.get_user_settings = AsyncMock(return_value=test_settings)
response = client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert data["user_id"] == "test-user-123"
assert "settings" in data
assert "ift" in data["settings"]
assert "psi" in data["settings"]
assert "prod" in data["settings"]
assert data["settings"]["ift"]["apiMode"] == "bench"
mock_db_client.get_user_settings.assert_called_once_with("test-user-123")
def test_get_settings_not_found(self, client, mock_db_client):
"""Test getting settings when user not found."""
# Mock 404 from DB API
error_response = httpx.Response(404, json={"detail": "Not found"})
mock_db_client.get_user_settings = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=None, response=error_response)
)
response = client.get("/api/v1/settings")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_get_settings_unauthenticated(self, unauthenticated_client):
"""Test getting settings without authentication."""
response = unauthenticated_client.get("/api/v1/settings")
assert response.status_code == 401 # HTTPBearer returns 401
def test_update_settings_success(self, client, mock_db_client, test_settings):
"""Test updating user settings successfully."""
mock_db_client.update_user_settings = AsyncMock(return_value=test_settings)
update_data = {
"settings": {
"ift": {
"apiMode": "backend",
"bearerToken": "new-token",
"resetSessionMode": False
}
}
}
response = client.put("/api/v1/settings", json=update_data)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == "test-user-123"
mock_db_client.update_user_settings.assert_called_once()
def test_update_settings_invalid_data(self, client, mock_db_client):
"""Test updating settings with invalid data."""
error_response = httpx.Response(400, json={"detail": "Invalid format"})
mock_db_client.update_user_settings = AsyncMock(
side_effect=httpx.HTTPStatusError("Bad request", request=None, response=error_response)
)
update_data = {
"settings": {
"invalid_env": {"apiMode": "invalid"}
}
}
response = client.put("/api/v1/settings", json=update_data)
assert response.status_code == 400
def test_update_settings_db_api_error(self, client, mock_db_client):
"""Test update settings when DB API fails."""
mock_db_client.update_user_settings = AsyncMock(
side_effect=Exception("DB error")
)
update_data = {
"settings": {
"ift": {"apiMode": "bench"}
}
}
response = client.put("/api/v1/settings", json=update_data)
assert response.status_code == 500
def test_get_settings_db_api_502_error(self, client, mock_db_client):
"""Test get settings when DB API returns 502."""
error_response = httpx.Response(503, json={"detail": "Service unavailable"})
mock_db_client.get_user_settings = AsyncMock(
side_effect=httpx.HTTPStatusError("Service error", request=None, response=error_response)
)
response = client.get("/api/v1/settings")
assert response.status_code == 502
assert "failed to retrieve settings" in response.json()["detail"].lower()
def test_get_settings_unexpected_error(self, client, mock_db_client):
"""Test get settings with unexpected error."""
mock_db_client.get_user_settings = AsyncMock(
side_effect=Exception("Unexpected error")
)
response = client.get("/api/v1/settings")
assert response.status_code == 500
assert "internal server error" in response.json()["detail"].lower()
def test_update_settings_user_not_found(self, client, mock_db_client):
"""Test update settings when user not found."""
error_response = httpx.Response(404, json={"detail": "User not found"})
mock_db_client.update_user_settings = AsyncMock(
side_effect=httpx.HTTPStatusError("Not found", request=None, response=error_response)
)
update_data = {
"settings": {
"ift": {"apiMode": "bench"}
}
}
response = client.put("/api/v1/settings", json=update_data)
assert response.status_code == 404
assert "user not found" in response.json()["detail"].lower()
def test_update_settings_db_api_502_error(self, client, mock_db_client):
"""Test update settings when DB API returns 502."""
error_response = httpx.Response(503, json={"detail": "Service unavailable"})
mock_db_client.update_user_settings = AsyncMock(
side_effect=httpx.HTTPStatusError("Service error", request=None, response=error_response)
)
update_data = {
"settings": {
"ift": {"apiMode": "bench"}
}
}
response = client.put("/api/v1/settings", json=update_data)
assert response.status_code == 502
assert "failed to update settings" in response.json()["detail"].lower()