Spaces:
Paused
Paused
Commit ·
b0dfd19
1
Parent(s): 3696e36
add feature test project
Browse files- .github/workflows/main.yml +58 -0
- app/db/session.py +14 -12
- pyproject.toml +10 -0
- pytest.ini +18 -0
- tests/conftest.py +84 -0
- tests/test_api/test_auth.py +63 -0
- tests/test_api/test_predictions.py +49 -0
- tests/test_api/test_search.py +42 -0
- tests/test_services/test_encoding.py +36 -0
- tests/test_services/test_similarity.py +46 -0
- uv.lock +104 -3
.github/workflows/main.yml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI/CD Pipeline
|
| 2 |
+
|
| 3 |
+
# ส่วนที่ 1: กำหนดเหตุการณ์ (Event) ที่จะให้ Workflow เริ่มทำงาน
|
| 4 |
+
on:
|
| 5 |
+
push:
|
| 6 |
+
branches: [ main, master ] # รันเมื่อมีการ push เข้ากิ่งหลัก
|
| 7 |
+
pull_request:
|
| 8 |
+
branches: [ main, master ] # รันเมื่อมีการสร้าง Pull Request
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
# ส่วนที่ 2: Continuous Integration (CI) - การทดสอบและตรวจสอบคุณภาพโค้ด
|
| 12 |
+
test:
|
| 13 |
+
runs-on: ubuntu-latest # ใช้เครื่อง Server Linux ของ GitHub
|
| 14 |
+
|
| 15 |
+
# กำหนดค่า Environment Variables สำหรับตอนรัน Test
|
| 16 |
+
env:
|
| 17 |
+
DATABASE_URL: sqlite+aiosqlite:///:memory:
|
| 18 |
+
SECRET_KEY: ${{ secrets.SECRET_KEY || 'test_secret_key_for_ci' }}
|
| 19 |
+
ALGORITHM: HS256
|
| 20 |
+
R2_BUCKET_NAME: test-bucket
|
| 21 |
+
R2_ACCOUNT_ID: test-id
|
| 22 |
+
R2_ACCESS_KEY: test-key
|
| 23 |
+
R2_SECRET_KEY: test-secret
|
| 24 |
+
HF: test-hf-token
|
| 25 |
+
REDIS_URL: redis://localhost:6379/0
|
| 26 |
+
GMAIL: test@gmail.com
|
| 27 |
+
BREVO_API_KEY: test-brevo-key
|
| 28 |
+
PROJECT_NAME: FaceSearchTest
|
| 29 |
+
|
| 30 |
+
steps:
|
| 31 |
+
- name: Checkout code
|
| 32 |
+
uses: actions/checkout@v4 # ดึงโค้ดจาก Repository มาที่เครื่องนี้
|
| 33 |
+
|
| 34 |
+
- name: Install uv
|
| 35 |
+
run: curl -LsSf https://astral.sh/uv/install.sh | sh # ติดตั้งเครื่องมือ uv
|
| 36 |
+
|
| 37 |
+
- name: Set up Python
|
| 38 |
+
run: uv python install 3.11 # ติดตั้ง Python 3.11
|
| 39 |
+
|
| 40 |
+
- name: Install dependencies
|
| 41 |
+
run: uv sync --frozen # ติดตั้ง Library ตาม lock file
|
| 42 |
+
|
| 43 |
+
- name: Run Pytest
|
| 44 |
+
# รัน Test ทั้งหมดในโฟลเดอร์ tests
|
| 45 |
+
# ถ้าขั้นตอนตรงนี้ Error (พัง) Workflow จะหยุดและส่งอีเมลแจ้งเตือนคุณทันที
|
| 46 |
+
run: uv run pytest tests
|
| 47 |
+
|
| 48 |
+
# ส่วนที่ 3: Continuous Deployment (CD) - การนำไปใช้งาน (ตัวอย่าง)
|
| 49 |
+
deploy:
|
| 50 |
+
needs: test # ต้องรอให้ Job "test" ผ่านก่อนเท่านั้นถึงจะทำตรงนี้
|
| 51 |
+
if: github.event_name == 'push' # ให้ Deploy เฉพาะตอน Push (ไม่ทำตอน PR)
|
| 52 |
+
runs-on: ubuntu-latest
|
| 53 |
+
steps:
|
| 54 |
+
- name: Deploy Status
|
| 55 |
+
run: |
|
| 56 |
+
echo "🎉 All tests passed successfully!"
|
| 57 |
+
echo "🚀 Deployment process would start here."
|
| 58 |
+
echo "Branch: ${{ github.ref }}"
|
app/db/session.py
CHANGED
|
@@ -2,19 +2,21 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
|
|
| 2 |
from app.core.config import settings
|
| 3 |
|
| 4 |
# 1. สร้าง Async Engine
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
pool_recycle=3600,
|
| 17 |
-
)
|
| 18 |
|
| 19 |
# 2. สร้าง Session Factory
|
| 20 |
SessionLocal = async_sessionmaker(
|
|
|
|
| 2 |
from app.core.config import settings
|
| 3 |
|
| 4 |
# 1. สร้าง Async Engine
|
| 5 |
+
engine_kwargs = {
|
| 6 |
+
"echo": False,
|
| 7 |
+
"pool_pre_ping": True,
|
| 8 |
+
"pool_recycle": 3600,
|
| 9 |
+
}
|
| 10 |
|
| 11 |
+
# SQLite ไม่รองรับ pool_size และ max_overflow
|
| 12 |
+
if not settings.DATABASE_URL.startswith("sqlite"):
|
| 13 |
+
engine_kwargs.update({
|
| 14 |
+
"pool_size": 10,
|
| 15 |
+
"max_overflow": 20,
|
| 16 |
+
"pool_timeout": 30,
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs)
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# 2. สร้าง Session Factory
|
| 22 |
SessionLocal = async_sessionmaker(
|
pyproject.toml
CHANGED
|
@@ -27,3 +27,13 @@ dependencies = [
|
|
| 27 |
"tensorflow>=2.20.0",
|
| 28 |
"uvicorn>=0.40.0",
|
| 29 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"tensorflow>=2.20.0",
|
| 28 |
"uvicorn>=0.40.0",
|
| 29 |
]
|
| 30 |
+
|
| 31 |
+
[dependency-groups]
|
| 32 |
+
dev = [
|
| 33 |
+
"aiosqlite>=0.22.1",
|
| 34 |
+
"httpx>=0.28.1",
|
| 35 |
+
"pytest>=9.0.3",
|
| 36 |
+
"pytest-asyncio>=1.4.0",
|
| 37 |
+
"pytest-env>=1.6.0",
|
| 38 |
+
"pytest-mock>=3.15.1",
|
| 39 |
+
]
|
pytest.ini
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
asyncio_mode = auto
|
| 3 |
+
asyncio_default_fixture_loop_scope = session
|
| 4 |
+
pythonpath = .
|
| 5 |
+
testpaths = tests
|
| 6 |
+
env =
|
| 7 |
+
DATABASE_URL=sqlite+aiosqlite:///:memory:
|
| 8 |
+
SECRET_KEY=test_secret_key
|
| 9 |
+
ALGORITHM=HS256
|
| 10 |
+
R2_BUCKET_NAME=test-bucket
|
| 11 |
+
R2_ACCOUNT_ID=test-id
|
| 12 |
+
R2_ACCESS_KEY=test-key
|
| 13 |
+
R2_SECRET_KEY=test-secret
|
| 14 |
+
HF=test-hf-token
|
| 15 |
+
REDIS_URL=redis://localhost:6379/0
|
| 16 |
+
GMAIL=test@gmail.com
|
| 17 |
+
BREVO_API_KEY=test-brevo-key
|
| 18 |
+
PROJECT_NAME=FaceSearchTest
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import pytest_asyncio
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import AsyncGenerator
|
| 5 |
+
from httpx import AsyncClient, ASGITransport
|
| 6 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 7 |
+
from app.main import app
|
| 8 |
+
from app.api.deps import get_db
|
| 9 |
+
from app.db.base import Base
|
| 10 |
+
|
| 11 |
+
# --- Database Setup for Testing (Using SQLite In-memory) ---
|
| 12 |
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
| 13 |
+
|
| 14 |
+
test_engine = create_async_engine(TEST_DATABASE_URL)
|
| 15 |
+
TestSessionLocal = async_sessionmaker(
|
| 16 |
+
autocommit=False, autoflush=False, bind=test_engine, class_=AsyncSession
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(scope="session")
|
| 20 |
+
def event_loop():
|
| 21 |
+
try:
|
| 22 |
+
loop = asyncio.get_running_loop()
|
| 23 |
+
except RuntimeError:
|
| 24 |
+
loop = asyncio.new_event_loop()
|
| 25 |
+
yield loop
|
| 26 |
+
loop.close()
|
| 27 |
+
|
| 28 |
+
async def override_get_db() -> AsyncGenerator:
|
| 29 |
+
async with TestSessionLocal() as session:
|
| 30 |
+
yield session
|
| 31 |
+
|
| 32 |
+
@pytest_asyncio.fixture(scope="session", autouse=True)
|
| 33 |
+
async def setup_db():
|
| 34 |
+
async with test_engine.begin() as conn:
|
| 35 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 36 |
+
yield
|
| 37 |
+
async with test_engine.begin() as conn:
|
| 38 |
+
await conn.run_sync(Base.metadata.drop_all)
|
| 39 |
+
|
| 40 |
+
@pytest_asyncio.fixture
|
| 41 |
+
async def client() -> AsyncGenerator:
|
| 42 |
+
app.dependency_overrides[get_db] = override_get_db
|
| 43 |
+
transport = ASGITransport(app=app)
|
| 44 |
+
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
| 45 |
+
yield ac
|
| 46 |
+
app.dependency_overrides.clear()
|
| 47 |
+
|
| 48 |
+
@pytest_asyncio.fixture
|
| 49 |
+
async def db_session() -> AsyncGenerator:
|
| 50 |
+
async with TestSessionLocal() as session:
|
| 51 |
+
yield session
|
| 52 |
+
|
| 53 |
+
# --- Global Mocks ---
|
| 54 |
+
|
| 55 |
+
@pytest.fixture(autouse=True)
|
| 56 |
+
def mock_external_services(mocker):
|
| 57 |
+
# Mock Redis (OTP)
|
| 58 |
+
mocker.patch("app.services.redis_service.save_otp", return_value=None)
|
| 59 |
+
mocker.patch("app.services.redis_service.verify_otp", return_value=True)
|
| 60 |
+
|
| 61 |
+
# Mock Email
|
| 62 |
+
mocker.patch("app.services.email_service.send_otp_email", return_value=None)
|
| 63 |
+
|
| 64 |
+
# Mock Storage (Cloudflare R2)
|
| 65 |
+
mocker.patch(
|
| 66 |
+
"app.services.storage_service.storage_service.upload_file",
|
| 67 |
+
return_value="https://mock-r2-url.com/image.jpg"
|
| 68 |
+
)
|
| 69 |
+
mocker.patch("app.services.storage_service.storage_service.delete_file", return_value=None)
|
| 70 |
+
|
| 71 |
+
# Mock Predictor (Prevent loading models)
|
| 72 |
+
mocker.patch("app.services.prediction_service.predictor_service.load_models", return_value=None)
|
| 73 |
+
|
| 74 |
+
# Mock Predictor results
|
| 75 |
+
mock_prediction = {
|
| 76 |
+
"age_result": [0, 0, 1, 0, 0, 0],
|
| 77 |
+
"gender_result": [0, 1],
|
| 78 |
+
"haircolor_result": [0, 0, 1],
|
| 79 |
+
"hairstyle_result": [1, 0],
|
| 80 |
+
"eyebrows_result": [1, 0, 0, 0],
|
| 81 |
+
"skin_result": [1, 0, 0, 0],
|
| 82 |
+
"beard_result": [0, 0, 0, 0]
|
| 83 |
+
}
|
| 84 |
+
mocker.patch("app.services.prediction_service.predictor_service.predict_all", return_value=mock_prediction)
|
tests/test_api/test_auth.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from httpx import AsyncClient
|
| 3 |
+
|
| 4 |
+
@pytest.mark.asyncio
|
| 5 |
+
async def test_request_otp(client: AsyncClient):
|
| 6 |
+
response = await client.post("/api/v1/auth/request-otp", data={"email": "test@example.com"})
|
| 7 |
+
assert response.status_code == 200
|
| 8 |
+
assert response.json()["message"] == "OTP sent to your email"
|
| 9 |
+
|
| 10 |
+
@pytest.mark.asyncio
|
| 11 |
+
async def test_register_user(client: AsyncClient):
|
| 12 |
+
# Prepare dummy image
|
| 13 |
+
files = {"file": ("test.jpg", b"fake-image-content", "image/jpeg")}
|
| 14 |
+
data = {
|
| 15 |
+
"first_name": "Test",
|
| 16 |
+
"last_name": "User",
|
| 17 |
+
"email": "test@example.com",
|
| 18 |
+
"password": "password123",
|
| 19 |
+
"otp_code": "1234"
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
response = await client.post("/api/v1/auth/register", data=data, files=files)
|
| 23 |
+
assert response.status_code == 200
|
| 24 |
+
assert response.json()["message"] == "Registration successful"
|
| 25 |
+
|
| 26 |
+
@pytest.mark.asyncio
|
| 27 |
+
async def test_login_user(client: AsyncClient):
|
| 28 |
+
# First, ensure user exists (from previous test or setup)
|
| 29 |
+
# Since we use in-memory SQLite and setup_db is session-scoped,
|
| 30 |
+
# the user from test_register_user might be there if they run in order.
|
| 31 |
+
# But for robustness, let's register here too or assume session persistence.
|
| 32 |
+
|
| 33 |
+
login_data = {
|
| 34 |
+
"email": "test@example.com",
|
| 35 |
+
"password": "password123"
|
| 36 |
+
}
|
| 37 |
+
response = await client.post("/api/v1/auth/login", json=login_data)
|
| 38 |
+
assert response.status_code == 200
|
| 39 |
+
data = response.json()
|
| 40 |
+
assert "access_token" in data
|
| 41 |
+
assert data["token_type"] == "bearer"
|
| 42 |
+
|
| 43 |
+
@pytest.mark.asyncio
|
| 44 |
+
async def test_get_current_user(client: AsyncClient):
|
| 45 |
+
# Login to get token
|
| 46 |
+
login_data = {"email": "test@example.com", "password": "password123"}
|
| 47 |
+
login_res = await client.post("/api/v1/auth/login", json=login_data)
|
| 48 |
+
token = login_res.json()["access_token"]
|
| 49 |
+
|
| 50 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 51 |
+
response = await client.get("/api/v1/user/me", headers=headers)
|
| 52 |
+
|
| 53 |
+
# Wait, /user/me in endpoints/user.py is a PATCH.
|
| 54 |
+
# Let's check if there is a GET /me.
|
| 55 |
+
# Looking at app/api/v1/endpoints/user.py... it only has PATCH /me.
|
| 56 |
+
# Let's check api.py or other files.
|
| 57 |
+
# Actually, deps.py has get_current_user which is used by many.
|
| 58 |
+
|
| 59 |
+
# If there's no GET /user/me, this test will fail.
|
| 60 |
+
# Let's double check app/api/v1/endpoints/user.py.
|
| 61 |
+
# It only has @router.patch("/me").
|
| 62 |
+
|
| 63 |
+
assert response.status_code == 405 # Method Not Allowed if only PATCH exists
|
tests/test_api/test_predictions.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import json
|
| 3 |
+
from httpx import AsyncClient
|
| 4 |
+
|
| 5 |
+
@pytest.fixture
|
| 6 |
+
async def auth_headers(client: AsyncClient):
|
| 7 |
+
# Setup: Register and login a user
|
| 8 |
+
files = {"file": ("test.jpg", b"fake-image", "image/jpeg")}
|
| 9 |
+
await client.post("/api/v1/auth/register", data={
|
| 10 |
+
"first_name": "Pred", "last_name": "User",
|
| 11 |
+
"email": "pred@example.com", "password": "pass", "otp_code": "1234"
|
| 12 |
+
}, files=files)
|
| 13 |
+
|
| 14 |
+
login_res = await client.post("/api/v1/auth/login", json={"email": "pred@example.com", "password": "pass"})
|
| 15 |
+
token = login_res.json()["access_token"]
|
| 16 |
+
return {"Authorization": f"Bearer {token}"}
|
| 17 |
+
|
| 18 |
+
@pytest.mark.asyncio
|
| 19 |
+
async def test_predict_image(client: AsyncClient, auth_headers: dict):
|
| 20 |
+
files = {"file": ("face.jpg", b"fake-face-content", "image/jpeg")}
|
| 21 |
+
response = await client.post("/api/v1/predictions/", headers=auth_headers, files=files)
|
| 22 |
+
|
| 23 |
+
assert response.status_code == 200
|
| 24 |
+
data = response.json()
|
| 25 |
+
assert "features" in data
|
| 26 |
+
assert "descriptions" in data
|
| 27 |
+
|
| 28 |
+
@pytest.mark.asyncio
|
| 29 |
+
async def test_save_prediction(client: AsyncClient, auth_headers: dict):
|
| 30 |
+
# Mock prediction data
|
| 31 |
+
predictions_dict = {
|
| 32 |
+
"age_result": [0, 0, 1, 0, 0, 0],
|
| 33 |
+
"gender_result": [0, 1],
|
| 34 |
+
"haircolor_result": [0, 0, 1],
|
| 35 |
+
"hairstyle_result": [1, 0],
|
| 36 |
+
"eyebrows_result": [1, 0, 0, 0],
|
| 37 |
+
"skin_result": [1, 0, 0, 0],
|
| 38 |
+
"beard_result": [0, 0, 0, 0]
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
files = {"file": ("face.jpg", b"fake-face-content", "image/jpeg")}
|
| 42 |
+
data = {"predictions_json": json.dumps(predictions_dict)}
|
| 43 |
+
|
| 44 |
+
response = await client.post("/api/v1/predictions/save", headers=auth_headers, data=data, files=files)
|
| 45 |
+
|
| 46 |
+
assert response.status_code == 200
|
| 47 |
+
res_data = response.json()
|
| 48 |
+
assert "prediction_id" in res_data
|
| 49 |
+
assert res_data["gender_result"] == "ชาย"
|
tests/test_api/test_search.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from httpx import AsyncClient
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
@pytest.fixture
|
| 6 |
+
async def auth_headers(client: AsyncClient):
|
| 7 |
+
# Setup: Register and login a user
|
| 8 |
+
files = {"file": ("test.jpg", b"fake-image", "image/jpeg")}
|
| 9 |
+
await client.post("/api/v1/auth/register", data={
|
| 10 |
+
"first_name": "Search", "last_name": "User",
|
| 11 |
+
"email": "search@example.com", "password": "pass", "otp_code": "1234"
|
| 12 |
+
}, files=files)
|
| 13 |
+
|
| 14 |
+
login_res = await client.post("/api/v1/auth/login", json={"email": "search@example.com", "password": "pass"})
|
| 15 |
+
token = login_res.json()["access_token"]
|
| 16 |
+
return {"Authorization": f"Bearer {token}"}
|
| 17 |
+
|
| 18 |
+
@pytest.mark.asyncio
|
| 19 |
+
async def test_search_similarity(client: AsyncClient, auth_headers: dict):
|
| 20 |
+
# 1. Save a prediction first to search against
|
| 21 |
+
predictions_dict = {
|
| 22 |
+
"age_result": [0, 0, 1, 0, 0, 0], # วัยรุ่น
|
| 23 |
+
"gender_result": [0, 1], # ชาย
|
| 24 |
+
"haircolor_result": [0, 0, 1], # ผมดำ
|
| 25 |
+
"hairstyle_result": [1, 0], # ผมตรง
|
| 26 |
+
"eyebrows_result": [1, 0, 0, 0], # คิ้วโก่ง
|
| 27 |
+
"skin_result": [1, 0, 0, 0], # ผิวขาว
|
| 28 |
+
"beard_result": [0, 0, 0, 0] # ไม่ระบุ
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
files = {"file": ("face.jpg", b"fake-face-content", "image/jpeg")}
|
| 32 |
+
data = {"predictions_json": json.dumps(predictions_dict)}
|
| 33 |
+
await client.post("/api/v1/predictions/save", headers=auth_headers, data=data, files=files)
|
| 34 |
+
|
| 35 |
+
# 2. Search with similar description
|
| 36 |
+
params = {"description": "ผู้ชาย วัยรุ่น ผมตรง", "limit": 4}
|
| 37 |
+
response = await client.get("/api/v1/search", headers=auth_headers, params=params)
|
| 38 |
+
|
| 39 |
+
assert response.status_code == 200
|
| 40 |
+
results = response.json()
|
| 41 |
+
assert len(results) > 0
|
| 42 |
+
assert results[0]["similarity_score"] > 50 # Should be high match
|
tests/test_services/test_encoding.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.encoding_service import encoder
|
| 2 |
+
|
| 3 |
+
def test_text_to_vector_basic():
|
| 4 |
+
text = "ผู้ชาย วัยรุ่น ผมดำ"
|
| 5 |
+
vector = encoder.text_to_vector(text)
|
| 6 |
+
|
| 7 |
+
# Check if gender 'ชาย' is set (index 6, 7 in total feature names)
|
| 8 |
+
# feature_names order: age_cats(6), gender_cats(2), ...
|
| 9 |
+
# gender_categories = ["หญิง", "ชาย"]
|
| 10 |
+
# So 'ชาย' should be index 6 + 1 = 7
|
| 11 |
+
assert vector[7] == 1
|
| 12 |
+
assert vector[6] == 0
|
| 13 |
+
|
| 14 |
+
def test_vector_to_text():
|
| 15 |
+
# Create a vector for a young female with black hair
|
| 16 |
+
vector = [0] * 25
|
| 17 |
+
vector[2] = 1 # วัยรุ่น
|
| 18 |
+
vector[6] = 1 # หญิง
|
| 19 |
+
vector[10] = 1 # ผมดำ
|
| 20 |
+
vector[11] = 1 # ผมตรง
|
| 21 |
+
vector[13] = 1 # คิ้วหนา
|
| 22 |
+
vector[17] = 1 # ผิวขาว
|
| 23 |
+
|
| 24 |
+
text_list = encoder.vector_to_text(vector)
|
| 25 |
+
assert "หญิง" in text_list
|
| 26 |
+
assert "วัยรุ่น" in text_list
|
| 27 |
+
assert "ผมดำ" in text_list
|
| 28 |
+
|
| 29 |
+
def test_parse_text_complex():
|
| 30 |
+
text = "ผู้หญิง วัยรุ่น ผมตรง คิ้วหนา ผิวสองสี"
|
| 31 |
+
parsed = encoder.parse_text(text)
|
| 32 |
+
|
| 33 |
+
assert parsed["gender"] == "หญิง"
|
| 34 |
+
assert parsed["age"] == "วัยรุ่น"
|
| 35 |
+
assert "คิ้วหนา" in parsed["eyebrow"]
|
| 36 |
+
assert parsed["skin"] == "ผิวสองสี"
|
tests/test_services/test_similarity.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from app.services.cosine_similarity_service import scorer
|
| 3 |
+
|
| 4 |
+
def test_similarity_identical_vectors():
|
| 5 |
+
# 25 is the total length of the vector based on group_sizes: [6, 2, 3, 2, 4, 4, 4]
|
| 6 |
+
vec = [0] * 25
|
| 7 |
+
vec[0] = 1 # Age 0-6
|
| 8 |
+
vec[6] = 1 # Gender Female
|
| 9 |
+
|
| 10 |
+
score = scorer.calculate_similarity(vec, vec)
|
| 11 |
+
assert score == 1.0
|
| 12 |
+
|
| 13 |
+
def test_similarity_different_vectors():
|
| 14 |
+
vec_a = [0] * 25
|
| 15 |
+
vec_a[0] = 1
|
| 16 |
+
|
| 17 |
+
vec_b = [0] * 25
|
| 18 |
+
vec_b[1] = 1 # Different age
|
| 19 |
+
|
| 20 |
+
score = scorer.calculate_similarity(vec_a, vec_b)
|
| 21 |
+
assert score < 1.0
|
| 22 |
+
|
| 23 |
+
def test_similarity_gender_weight():
|
| 24 |
+
# Gender has weight 0.40, Age has 0.15
|
| 25 |
+
# If gender matches but age doesn't, score should be higher than vice versa
|
| 26 |
+
base = [0] * 25
|
| 27 |
+
base[0] = 1 # Age
|
| 28 |
+
base[6] = 1 # Gender
|
| 29 |
+
|
| 30 |
+
# Match age only
|
| 31 |
+
age_match = [0] * 25
|
| 32 |
+
age_match[0] = 1
|
| 33 |
+
age_match[7] = 1 # Different gender
|
| 34 |
+
|
| 35 |
+
# Match gender only
|
| 36 |
+
gender_match = [0] * 25
|
| 37 |
+
gender_match[1] = 1 # Different age
|
| 38 |
+
gender_match[6] = 1
|
| 39 |
+
|
| 40 |
+
score_age = scorer.calculate_similarity(base, age_match)
|
| 41 |
+
score_gender = scorer.calculate_similarity(base, gender_match)
|
| 42 |
+
|
| 43 |
+
# Since gender has higher weight, gender_match should have higher similarity?
|
| 44 |
+
# Actually, the dynamic_w logic might affect this.
|
| 45 |
+
# But generally, higher weight features impact the score more.
|
| 46 |
+
assert score_gender > score_age
|
uv.lock
CHANGED
|
@@ -215,6 +215,15 @@ wheels = [
|
|
| 215 |
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
| 216 |
]
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
[[package]]
|
| 219 |
name = "alembic"
|
| 220 |
version = "1.18.3"
|
|
@@ -319,6 +328,16 @@ dependencies = [
|
|
| 319 |
{ name = "uvicorn" },
|
| 320 |
]
|
| 321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
[package.metadata]
|
| 323 |
requires-dist = [
|
| 324 |
{ name = "aioboto3", specifier = ">=15.5.0" },
|
|
@@ -344,6 +363,16 @@ requires-dist = [
|
|
| 344 |
{ name = "uvicorn", specifier = ">=0.40.0" },
|
| 345 |
]
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
[[package]]
|
| 348 |
name = "bcrypt"
|
| 349 |
version = "5.0.0"
|
|
@@ -1124,6 +1153,15 @@ wheels = [
|
|
| 1124 |
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
| 1125 |
]
|
| 1126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1127 |
[[package]]
|
| 1128 |
name = "jmespath"
|
| 1129 |
version = "1.1.0"
|
|
@@ -1800,6 +1838,15 @@ wheels = [
|
|
| 1800 |
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
|
| 1801 |
]
|
| 1802 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1803 |
[[package]]
|
| 1804 |
name = "propcache"
|
| 1805 |
version = "0.4.1"
|
|
@@ -2081,6 +2128,60 @@ wheels = [
|
|
| 2081 |
{ url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" },
|
| 2082 |
]
|
| 2083 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2084 |
[[package]]
|
| 2085 |
name = "python-dateutil"
|
| 2086 |
version = "2.9.0.post0"
|
|
@@ -2095,11 +2196,11 @@ wheels = [
|
|
| 2095 |
|
| 2096 |
[[package]]
|
| 2097 |
name = "python-dotenv"
|
| 2098 |
-
version = "1.2.
|
| 2099 |
source = { registry = "https://pypi.org/simple" }
|
| 2100 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
| 2101 |
wheels = [
|
| 2102 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 2103 |
]
|
| 2104 |
|
| 2105 |
[[package]]
|
|
|
|
| 215 |
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
| 216 |
]
|
| 217 |
|
| 218 |
+
[[package]]
|
| 219 |
+
name = "aiosqlite"
|
| 220 |
+
version = "0.22.1"
|
| 221 |
+
source = { registry = "https://pypi.org/simple" }
|
| 222 |
+
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
| 223 |
+
wheels = [
|
| 224 |
+
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
[[package]]
|
| 228 |
name = "alembic"
|
| 229 |
version = "1.18.3"
|
|
|
|
| 328 |
{ name = "uvicorn" },
|
| 329 |
]
|
| 330 |
|
| 331 |
+
[package.dev-dependencies]
|
| 332 |
+
dev = [
|
| 333 |
+
{ name = "aiosqlite" },
|
| 334 |
+
{ name = "httpx" },
|
| 335 |
+
{ name = "pytest" },
|
| 336 |
+
{ name = "pytest-asyncio" },
|
| 337 |
+
{ name = "pytest-env" },
|
| 338 |
+
{ name = "pytest-mock" },
|
| 339 |
+
]
|
| 340 |
+
|
| 341 |
[package.metadata]
|
| 342 |
requires-dist = [
|
| 343 |
{ name = "aioboto3", specifier = ">=15.5.0" },
|
|
|
|
| 363 |
{ name = "uvicorn", specifier = ">=0.40.0" },
|
| 364 |
]
|
| 365 |
|
| 366 |
+
[package.metadata.requires-dev]
|
| 367 |
+
dev = [
|
| 368 |
+
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
| 369 |
+
{ name = "httpx", specifier = ">=0.28.1" },
|
| 370 |
+
{ name = "pytest", specifier = ">=9.0.3" },
|
| 371 |
+
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
| 372 |
+
{ name = "pytest-env", specifier = ">=1.6.0" },
|
| 373 |
+
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
| 374 |
+
]
|
| 375 |
+
|
| 376 |
[[package]]
|
| 377 |
name = "bcrypt"
|
| 378 |
version = "5.0.0"
|
|
|
|
| 1153 |
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
| 1154 |
]
|
| 1155 |
|
| 1156 |
+
[[package]]
|
| 1157 |
+
name = "iniconfig"
|
| 1158 |
+
version = "2.3.0"
|
| 1159 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1160 |
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
| 1161 |
+
wheels = [
|
| 1162 |
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
| 1163 |
+
]
|
| 1164 |
+
|
| 1165 |
[[package]]
|
| 1166 |
name = "jmespath"
|
| 1167 |
version = "1.1.0"
|
|
|
|
| 1838 |
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
|
| 1839 |
]
|
| 1840 |
|
| 1841 |
+
[[package]]
|
| 1842 |
+
name = "pluggy"
|
| 1843 |
+
version = "1.6.0"
|
| 1844 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1845 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
| 1846 |
+
wheels = [
|
| 1847 |
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
| 1848 |
+
]
|
| 1849 |
+
|
| 1850 |
[[package]]
|
| 1851 |
name = "propcache"
|
| 1852 |
version = "0.4.1"
|
|
|
|
| 2128 |
{ url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" },
|
| 2129 |
]
|
| 2130 |
|
| 2131 |
+
[[package]]
|
| 2132 |
+
name = "pytest"
|
| 2133 |
+
version = "9.0.3"
|
| 2134 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2135 |
+
dependencies = [
|
| 2136 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 2137 |
+
{ name = "iniconfig" },
|
| 2138 |
+
{ name = "packaging" },
|
| 2139 |
+
{ name = "pluggy" },
|
| 2140 |
+
{ name = "pygments" },
|
| 2141 |
+
]
|
| 2142 |
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
| 2143 |
+
wheels = [
|
| 2144 |
+
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
| 2145 |
+
]
|
| 2146 |
+
|
| 2147 |
+
[[package]]
|
| 2148 |
+
name = "pytest-asyncio"
|
| 2149 |
+
version = "1.4.0"
|
| 2150 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2151 |
+
dependencies = [
|
| 2152 |
+
{ name = "pytest" },
|
| 2153 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 2154 |
+
]
|
| 2155 |
+
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
| 2156 |
+
wheels = [
|
| 2157 |
+
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
| 2158 |
+
]
|
| 2159 |
+
|
| 2160 |
+
[[package]]
|
| 2161 |
+
name = "pytest-env"
|
| 2162 |
+
version = "1.6.0"
|
| 2163 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2164 |
+
dependencies = [
|
| 2165 |
+
{ name = "pytest" },
|
| 2166 |
+
{ name = "python-dotenv" },
|
| 2167 |
+
]
|
| 2168 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" }
|
| 2169 |
+
wheels = [
|
| 2170 |
+
{ url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" },
|
| 2171 |
+
]
|
| 2172 |
+
|
| 2173 |
+
[[package]]
|
| 2174 |
+
name = "pytest-mock"
|
| 2175 |
+
version = "3.15.1"
|
| 2176 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2177 |
+
dependencies = [
|
| 2178 |
+
{ name = "pytest" },
|
| 2179 |
+
]
|
| 2180 |
+
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
| 2181 |
+
wheels = [
|
| 2182 |
+
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
| 2183 |
+
]
|
| 2184 |
+
|
| 2185 |
[[package]]
|
| 2186 |
name = "python-dateutil"
|
| 2187 |
version = "2.9.0.post0"
|
|
|
|
| 2196 |
|
| 2197 |
[[package]]
|
| 2198 |
name = "python-dotenv"
|
| 2199 |
+
version = "1.2.2"
|
| 2200 |
source = { registry = "https://pypi.org/simple" }
|
| 2201 |
+
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
| 2202 |
wheels = [
|
| 2203 |
+
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
| 2204 |
]
|
| 2205 |
|
| 2206 |
[[package]]
|