From 5752c2cc8cdc94fa1baa3cbf3221805feace9542 Mon Sep 17 00:00:00 2001 From: PyDevDeep Date: Sat, 20 Jun 2026 10:25:54 +0300 Subject: [PATCH] feat(core): implement mkdocs, fix linting, and achieve 100% test coverage This commit adds the mkdocs documentation structure, updates core logic for WooCommerce and Telegram integration, and ensures 100% test coverage across all related services. --- README.md | 322 +++++++++++++++++++++ app/main.py | 12 +- app/schemas/chat.py | 24 ++ docs/explanation/rag-architecture.md | 20 ++ docs/how-to/configure-pinecone.md | 27 ++ docs/index.md | 12 + docs/reference/api.md | 20 ++ docs/tutorials/getting-started.md | 60 ++++ mkdocs.yml | 34 +++ poetry.lock | 402 ++++++++++++++++++++++++++- pyproject.toml | 2 + tests/test_core_db.py | 85 ++++++ tests/test_guardrails.py | 7 + tests/test_intent_handlers.py | 35 +++ tests/test_main.py | 82 ++++++ tests/test_openai_service.py | 65 +++++ tests/test_rag_engine.py | 185 +++++++++++- tests/test_schemas_order.py | 100 +++++++ tests/test_statistics_service.py | 138 +++++++++ tests/test_telegram_service.py | 118 +++++++- tests/test_woo_service.py | 156 ++++++++++- tests/test_woo_smart_parser.py | 68 +++++ 22 files changed, 1959 insertions(+), 15 deletions(-) create mode 100644 docs/explanation/rag-architecture.md create mode 100644 docs/how-to/configure-pinecone.md create mode 100644 docs/index.md create mode 100644 docs/reference/api.md create mode 100644 docs/tutorials/getting-started.md create mode 100644 mkdocs.yml create mode 100644 tests/test_core_db.py create mode 100644 tests/test_schemas_order.py create mode 100644 tests/test_statistics_service.py diff --git a/README.md b/README.md index e69de29..74eb65a 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,322 @@ +# 🧠 BubbleBrain + +> **AI-powered chatbot backend for e-commerce** — RAG pipeline, price comparison, lead generation, and Flowise widget integration in a single production-ready FastAPI service. + +[![Python](https://img.shields.io/badge/Python-3.13+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-009688?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/) +[![OpenAI](https://img.shields.io/badge/OpenAI-GPT--3.5%2F4-412991?logo=openai&logoColor=white)](https://openai.com/) +[![Pinecone](https://img.shields.io/badge/Pinecone-Vector%20DB-00B5AD)](https://www.pinecone.io/) +[![Poetry](https://img.shields.io/badge/Poetry-dependency%20manager-60A5FA)](https://python-poetry.org/) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) +[![Coverage](https://img.shields.io/badge/Coverage-88%25-brightgreen)](https://pytest-cov.readthedocs.io/) +[![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE) + +--- + +## šŸ“Œ Why BubbleBrain Exists + +Modern e-commerce stores lose customers due to slow or absent support. BubbleBrain automates this entirely: + +- Answers product questions **instantly** using your store's own data (RAG, no hallucinations) +- **Compares prices** between your store and suppliers in real-time +- **Captures leads** and routes hot prospects directly to Telegram +- Embeds into any frontend via **Flowise Chat Widget** — no custom UI required +- Syncs with **WooCommerce** via webhooks to stay up-to-date on orders and inventory + +--- + +## šŸš€ Features + +- **RAG Engine** — retrieves accurate answers from your product catalog using OpenAI Embeddings + Pinecone vector search +- **Price Comparator** — scrapes supplier sites and compares against WooCommerce prices on demand +- **Lead Pipeline** — classifies intent, captures contact info, and routes hot leads to dedicated Telegram topics +- **Document Ingestion** — uploads and indexes PDF/DOCX files into the vector store via `/api/v1/ingest` +- **WooCommerce Webhook** — receives real-time order/product events and updates internal state +- **Telegram Integration** — broadcasts lead alerts, price updates, bot stats, and errors across topic-organized groups +- **API Key Auth** — static secret key validation on all `/api/v1/*` endpoints +- **Rate Limiting** — 20 requests/min per IP via `slowapi` +- **Structured Logging** — `structlog` + Sentry error tracking +- **Prometheus Metrics** — built-in `/metrics` endpoint, Prometheus container included in Compose +- **Conversation Memory** — per-session chat history stored in SQLite via `aiosqlite` +- **88% Test Coverage** — pytest suite with async support and remote integration tests + +--- + +## šŸ›  Tech Stack + +| Layer | Technology | +|---|---| +| Runtime | Python 3.13 | +| Web Framework | FastAPI + Uvicorn | +| AI / LLM | OpenAI GPT-3.5/4, `text-embedding-3-small` | +| Vector DB | Pinecone | +| Chat Widget | Flowise Embed | +| WooCommerce | REST API + Webhooks | +| Scheduling | APScheduler | +| HTTP Client | httpx | +| Scraping | BeautifulSoup4, requests | +| Data Validation | Pydantic v2, pydantic-settings | +| Database | SQLite (aiosqlite) + SQLAlchemy | +| Monitoring | Prometheus, Sentry SDK | +| Logging | structlog | +| Rate Limiting | slowapi | +| Containerization | Docker, Docker Compose | +| Dependency Manager | Poetry | +| Linter / Formatter | Ruff | +| Type Checker | mypy (strict), pyright | +| Testing | pytest, pytest-asyncio, pytest-cov | +| Docs | MkDocs Material | + +--- + +## šŸ“¦ Quick Start + +### Prerequisites + +- [ ] Python 3.13+ +- [ ] [Poetry](https://python-poetry.org/docs/#installation) +- [ ] Docker + Docker Compose +- [ ] OpenAI API key +- [ ] Pinecone API key (free tier works) + +### 1. Clone the repository + +```bash +git clone https://github.com/PyDevDeep/BubbleBrain.git +cd BubbleBrain +``` + +### 2. Configure environment variables + +```bash +cp .env.example .env +``` + +Open `.env` and fill in the required values: + +```env +# Required +OPENAI_API_KEY=sk-... +PINECONE_API_KEY=pc-... +PINECONE_INDEX_NAME=chatbot-index +API_KEY_SECRET=your_static_secret_key + +# WooCommerce (if using webhook integration) +WOO_CK=your_consumer_key +WOO_CS=your_consumer_secret +WOO_URL=https://your-shop-domain.com + +# Telegram (for lead alerts) +TELEGRAM_CONTACT_URL=https://t.me/your_bot +``` + +See [`.env.example`](.env.example) for the full list of available variables. + +### 3. Start infrastructure + +```bash +docker-compose up -d +``` + +This launches: +- `bubblebrain-app` on port **8200** (maps to internal 8000) +- `bubblebrain-prometheus` on port **9290** + +### 4. Install dependencies and start the dev server + +```bash +poetry install +poetry run uvicorn app.main:app --reload +``` + +API is now available at `http://localhost:8000`. +Interactive Swagger UI: [`http://localhost:8000/docs`](http://localhost:8000/docs) +ReDoc: [`http://localhost:8000/redoc`](http://localhost:8000/redoc) + +--- + +## šŸ”Œ API Overview + +All endpoints are prefixed with `/api/v1/` and require Bearer token authentication. + +**Header:** +``` +Authorization: Bearer YOUR_API_KEY +``` + +### Core Endpoints + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/api/v1/chat` | Send a message and receive an AI response | +| `POST` | `/api/v1/ingest` | Upload PDF/DOCX for RAG indexing | +| `POST` | `/api/v1/leads` | Submit a lead capture form | +| `POST` | `/api/v1/telegram` | Telegram webhook receiver | +| `POST` | `/api/v1/woo-webhook` | WooCommerce event receiver | +| `GET` | `/api/v1/health` | Health check | +| `GET` | `/metrics` | Prometheus metrics | + +### Example: Chat Request + +```bash +curl -X POST "http://localhost:8000/api/v1/chat" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{"question": "What is the price of product X?"}' +``` + +**Response:** +```json +{ + "answer": "Product X costs $49.99. Our supplier price is $42.00, giving you a margin of 16%.", + "sources": ["catalog/product-x.pdf"], + "session_id": "abc123" +} +``` + +> Full endpoint reference with schemas and error codes: [`docs/reference/api.md`](docs/reference/api.md) +> After starting the app, also see: [`http://localhost:8000/docs`](http://localhost:8000/docs) + +--- + +## 🧠 RAG Architecture + +BubbleBrain uses the **Retrieval-Augmented Generation** pattern to eliminate AI hallucinations: + +``` +User Question + │ + ā–¼ +[Embedding Model] ←── text-embedding-3-small + │ + ā–¼ +[Pinecone Search] ←── cosine similarity, top-k retrieval + │ + ā–¼ +[Context Assembly] ←── retrieved chunks + chat history + │ + ā–¼ +[OpenAI LLM] ←── GPT-4 + │ + ā–¼ +Grounded Answer +``` + +1. **Ingestion** — documents are chunked, embedded, and stored in Pinecone +2. **Retrieval** — query is embedded; nearest vectors are fetched +3. **Generation** — LLM generates an answer strictly from retrieved context + +See [`docs/explanation/rag-architecture.md`](docs/explanation/rag-architecture.md) for full details. + +--- + +## āš™ļø Configuration Reference + +| Variable | Required | Description | +|---|---|---| +| `OPENAI_API_KEY` | āœ… | OpenAI API key | +| `OPENAI_MODEL` | āœ… | LLM model (e.g. `gpt-3.5-turbo`) | +| `EMBEDDING_MODEL` | āœ… | Embedding model (e.g. `text-embedding-3-small`) | +| `PINECONE_API_KEY` | āœ… | Pinecone API key | +| `PINECONE_INDEX_NAME` | āœ… | Name of your Pinecone index | +| `PINECONE_ENVIRONMENT` | āœ… | e.g. `gcp-starter` | +| `API_KEY_SECRET` | āœ… | Static secret for client authentication | +| `WOO_CK` / `WOO_CS` | āš ļø | WooCommerce consumer key/secret | +| `WOO_URL` | āš ļø | WooCommerce store URL | +| `SUPPLIER_URL` | āš ļø | Supplier site URL for price comparison | +| `SENTRY_DSN` | āŒ | Sentry error tracking DSN | +| `PROMETHEUS_EXTERNAL_URL` | āŒ | External URL for Prometheus | +| `ALLOWED_ORIGINS` | āŒ | CORS origins (default: `*`) | +| `TELEGRAM_CONTACT_URL` | āŒ | Telegram bot deep link | + +--- + +## 🧪 Testing + +```bash +# Run all tests with coverage report +poetry run pytest --cov=app --cov-report=term-missing + +# Run only remote integration tests (requires running server) +poetry run pytest -m remote +``` + +**Current coverage: 88%** across 2,553 statements. + +Key modules with full coverage: `main.py`, `health`, `security`, `metrics`, `woo_service`, `telegram_service`, `statistics_service`. + +--- + +## šŸ“Š Monitoring + +BubbleBrain exposes Prometheus metrics at `/metrics` and includes a pre-configured Prometheus container. + +| Service | Port | URL | +|---|---|---| +| BubbleBrain API | 8200 | `http://localhost:8200` | +| Prometheus | 9290 | `http://localhost:9290` | +| Swagger UI | 8200 | `http://localhost:8200/docs` | + +Sentry integration is enabled when `SENTRY_DSN` is set in `.env`. + +--- + +## šŸ“ Project Structure + +``` +BubbleBrain/ +ā”œā”€ā”€ app/ +│ ā”œā”€ā”€ api/v1/endpoints/ # chat, ingest, leads, telegram, woo_webhook +│ ā”œā”€ā”€ core/ # config, db, security, logging, metrics +│ ā”œā”€ā”€ middleware/ # rate limiter, request logging +│ ā”œā”€ā”€ models/ # SQLAlchemy models +│ ā”œā”€ā”€ schemas/ # Pydantic schemas +│ ā”œā”€ā”€ services/ # RAG engine, OpenAI, vector, scraper, price comparator... +│ └── utils/ # helpers, prompts, URL utils +ā”œā”€ā”€ tests/ +ā”œā”€ā”€ docs/ # MkDocs documentation +ā”œā”€ā”€ prometheus/ +ā”œā”€ā”€ docker-compose.yml +ā”œā”€ā”€ pyproject.toml +└── .env.example +``` + +--- + +## šŸ“š Documentation + +Full documentation is available via MkDocs: + +```bash +poetry run mkdocs serve +``` + +Then open [`http://localhost:8001`](http://localhost:8001). + +| Section | Description | +|---|---| +| [Getting Started](docs/tutorials/getting-started.md) | Run the stack locally in 10 minutes | +| [Configure Pinecone](docs/how-to/configure-pinecone.md) | Set up vector index for RAG | +| [API Reference](docs/reference/api.md) | Endpoint schemas and auth details | +| [RAG Architecture](docs/explanation/rag-architecture.md) | How the retrieval pipeline works | + +--- + +## šŸ¤ Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feat/your-feature` +3. Commit using [Conventional Commits](https://www.conventionalcommits.org/): `git commit -m "feat: add X"` +4. Push and open a Pull Request + +Code quality is enforced via pre-commit hooks (Ruff, mypy, pyright): + +```bash +pre-commit install +``` + +--- + +## šŸ“„ License + +[MIT](LICENSE) Ā© [PyDevDeep](https://github.com/PyDevDeep) diff --git a/app/main.py b/app/main.py index 6ae47da..cfc47c2 100644 --- a/app/main.py +++ b/app/main.py @@ -117,7 +117,17 @@ def create_application() -> FastAPI: app = FastAPI( title="Chatbot AI Backend", version="0.1.0", - description="Backend for Chat Embed with RAG", + description=""" +Backend for Chat Embed with RAG. + +## Authentication +All API requests to `/api/v1/*` endpoints require a Bearer token in the `Authorization` header. +Example: `Authorization: Bearer YOUR_API_KEY` + +## Rate Limiting +Endpoints are protected by rate limiting. +For chat endpoints, the limit is **20 requests per minute** per IP. +""", lifespan=lifespan, ) diff --git a/app/schemas/chat.py b/app/schemas/chat.py index e3afc8c..e8197fa 100644 --- a/app/schemas/chat.py +++ b/app/schemas/chat.py @@ -11,6 +11,14 @@ class ChatRequest(BaseModel): question: str = Field(..., min_length=1, max_length=4000) session_id: str | None = None + model_config = { + "json_schema_extra": { + "examples": [ + {"question": "What is the price of product X?", "session_id": "session_12345"} + ] + } + } + class LeadData(BaseModel): name: str | None = Field(default=None, description="Client name") @@ -47,6 +55,22 @@ class RAGResponse(BaseModel): class ChatResponse(RAGResponse): session_id: str | None = None + model_config = { + "json_schema_extra": { + "examples": [ + { + "answer": "The price for product X is $100.", + "sources": ["product_db_1"], + "has_context": True, + "links": [], + "requires_lead": False, + "lead_form_type": None, + "session_id": "session_12345", + } + ] + } + } + class StreamEvent(BaseModel): event: str diff --git a/docs/explanation/rag-architecture.md b/docs/explanation/rag-architecture.md new file mode 100644 index 0000000..bd65af4 --- /dev/null +++ b/docs/explanation/rag-architecture.md @@ -0,0 +1,20 @@ +# RAG (Retrieval-Augmented Generation) Architecture + +BubbleBrain uses the RAG pattern to provide accurate answers based on the store's own data. + +## How does it work? + +1. **Ingestion**: + - Product data is loaded into the system. + - The text is converted into vectors using OpenAI Embeddings. + - Vectors are stored in a vector database (Pinecone). + +2. **Retrieval**: + - When a user asks a question, their query is also converted into a vector. + - Pinecone finds the most relevant pieces of information (nearest vectors). + +3. **Generation**: + - The retrieved context is appended to the user's initial query. + - The augmented prompt is sent to the language model (LLM), which generates an accurate response based on the provided facts. + +This approach ensures no "hallucinations" from the AI, as it always relies on strict factual context. diff --git a/docs/how-to/configure-pinecone.md b/docs/how-to/configure-pinecone.md new file mode 100644 index 0000000..c291f30 --- /dev/null +++ b/docs/how-to/configure-pinecone.md @@ -0,0 +1,27 @@ +# How to Configure Pinecone for Vector Search + +This guide will help you properly initialize a Pinecone index for use in the RAG module. + +## Creating an Index +1. Go to your Pinecone dashboard. +2. Create a new index with the following parameters: + - **Dimensions**: `1536` (if using the `text-embedding-3-small` model from OpenAI). + - **Metric**: `cosine`. +3. Wait until the index status changes to "Ready". + +## Project Configuration +Open the `.env` file and specify the name of the created index: + +```env +PINECONE_INDEX_NAME="your-index-name" +PINECONE_ENVIRONMENT="gcp-starter" # or another region according to your account +``` + +## Testing the Connection +To verify that everything is configured correctly, run the initialization script: + +```bash +poetry run python -m app.services.vector_service +``` + +If you see `Connected to Pinecone successfully`, the setup is complete. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d77bdb0 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +# BubbleBrain Documentation + +Welcome to the BubbleBrain project documentation. This documentation is structured according to the **Divio System** architecture, meaning information is clearly categorized by experience type. + +## Documentation Structure + +* **[Tutorials](tutorials/getting-started.md)**: Step-by-step learning guides for beginners. +* **[How-to guides](how-to/configure-pinecone.md)**: Instructions for solving specific tasks and problems. +* **[Reference](reference/api.md)**: Technical references for API and code. +* **[Explanation](explanation/rag-architecture.md)**: In-depth explanations of architectural decisions and concepts (e.g., RAG). + +Choose the relevant section from the sidebar to get started! diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 0000000..a613b05 --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,20 @@ +# API Reference + +This is the technical reference for the available endpoints in BubbleBrain. The core documentation is generated automatically via Swagger UI. + +## Swagger UI +You will find all details regarding requests, their types, and responses in the Swagger UI: +- **Local environment**: [http://localhost:8000/docs](http://localhost:8000/docs) +- **Alternative documentation (Redoc)**: [http://localhost:8000/redoc](http://localhost:8000/redoc) + +## Authentication +All requests to `/api/v1/*` require the use of an API key: +- **Header**: `Authorization` +- **Value**: `Bearer YOUR_API_KEY` + +If the key is missing or invalid, you will receive a `401 Unauthorized` error. + +## Rate Limiting +We use `slowapi` to limit the number of requests: +- Chat endpoints: **20 requests per minute** per IP address. +- If the limit is exceeded, the API will return a `429 Too Many Requests` error. diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md new file mode 100644 index 0000000..1bc3793 --- /dev/null +++ b/docs/tutorials/getting-started.md @@ -0,0 +1,60 @@ +# Tutorial: Running BubbleBrain in 10 Minutes + +**What you will build**: A working local AI server with a connected RAG engine and OpenAPI Swagger UI, capable of answering product queries. + +**What you will learn**: +- How to initialize the Poetry environment +- How to start necessary Docker containers +- How to make your first API request + +**Prerequisites**: +- [ ] Python 3.13+ installed +- [ ] Docker and Docker Compose installed +- [ ] OpenAI and Pinecone API keys + +--- + +## Step 1: Environment Setup +First, you will configure environment variables so the application can communicate with external services. + +Copy the `.env.example` file: + +```bash +cp .env.example .env +``` + +> **Tip**: If an error occurs, make sure the `.env.example` file exists in the root directory of your project. + +Open the `.env` file and insert your keys: +```env +OPENAI_API_KEY="sk-..." +PINECONE_API_KEY="pc-..." +``` + +--- + +## Step 2: Starting Containers +To allow the application to cache data, we will start Redis. + +```bash +docker-compose up -d +``` + +You will see the following output: +``` +[+] Running 1/1 + āœ” Container redis-stack Started +``` + +--- + +## Step 3: Starting the Server +Finally, we will start the FastAPI development server. + +```bash +poetry install +poetry run uvicorn app.main:app --reload +``` + +After this, you will have a working API accessible at `http://localhost:8000`. +To see the generated Swagger UI, navigate to `http://localhost:8000/docs` in your browser. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ea776aa --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,34 @@ +site_name: BubbleBrain Documentation +site_description: Technical documentation for BubbleBrain Chatbot AI Backend. +theme: + name: material + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + +nav: + - Home: index.md + - Tutorials: + - Getting Started: tutorials/getting-started.md + - How-to Guides: + - Configure Pinecone: how-to/configure-pinecone.md + - Reference: + - API: reference/api.md + - Explanation: + - RAG Architecture: explanation/rag-architecture.md + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.tabbed diff --git a/poetry.lock b/poetry.lock index 155e03e..50f2518 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -129,6 +129,40 @@ files = [ {file = "ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6"}, ] +[[package]] +name = "babel" +version = "2.18.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, + {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + +[[package]] +name = "backrefs" +version = "7.0" +description = "A wrapper around re and regex that adds additional back references." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9"}, + {file = "backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475"}, + {file = "backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12"}, + {file = "backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a"}, + {file = "backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9"}, + {file = "backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82"}, +] + +[package.extras] +extras = ["regex"] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -158,7 +192,7 @@ version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, @@ -280,7 +314,7 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -419,7 +453,7 @@ version = "8.4.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"}, {file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"}, @@ -439,7 +473,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -700,6 +734,24 @@ files = [ {file = "filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a"}, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + [[package]] name = "greenlet" version = "3.5.2" @@ -935,7 +987,7 @@ version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, @@ -956,6 +1008,24 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "jiter" version = "0.15.0" @@ -1354,6 +1424,223 @@ html-clean = ["lxml_html_clean"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] +[[package]] +name = "markdown" +version = "3.10.2" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, +] + +[package.extras] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for šŸ." +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +description = "An extra command for MkDocs that infers required PyPI packages from `plugins` in mkdocs.yml" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650"}, + {file = "mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba"}, + {file = "mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69"}, +] + +[package.dependencies] +babel = ">=2.10" +backrefs = ">=5.7.post1" +colorama = ">=0.4" +jinja2 = ">=3.1" +markdown = ">=3.2" +mkdocs = ">=1.6,<2" +mkdocs-material-extensions = ">=1.3" +paginate = ">=0.5" +pygments = ">=2.16" +pymdown-extensions = ">=10.2" +requests = ">=2.30" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4)"] +imaging = ["cairosvg (>=2.6)", "pillow (>=10.2)"] +recommended = ["mkdocs-minify-plugin (>=0.7)", "mkdocs-redirects (>=1.2)", "mkdocs-rss-plugin (>=1.6)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + [[package]] name = "mypy" version = "2.1.0" @@ -1489,6 +1776,22 @@ files = [ {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] +[[package]] +name = "paginate" +version = "0.5.7" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, + {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, +] + +[package.extras] +dev = ["pytest", "tox"] +lint = ["black"] + [[package]] name = "pathspec" version = "1.1.1" @@ -1973,6 +2276,25 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6"}, + {file = "pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.19.1)"] + [[package]] name = "pypdfium2" version = "5.10.1" @@ -2093,7 +2415,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2248,13 +2570,28 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +description = "A custom YAML tag for referencing environment variables in YAML files." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, + {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, +] + +[package.dependencies] +pyyaml = "*" + [[package]] name = "requests" version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, @@ -2369,7 +2706,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2651,7 +2988,7 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -2772,6 +3109,49 @@ filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" python-discovery = ">=1.4.2" +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + [[package]] name = "watchfiles" version = "1.2.0" @@ -3069,4 +3449,4 @@ dev = ["pytest", "setuptools"] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "8423480783a6fcb0fffe3ed02c755904c1d785e26592eee45a5660a32c278a90" +content-hash = "644aa8b88483b4ac90af7dc200338fe5c477c8ef9cc8dadcd6e838ea661a5d46" diff --git a/pyproject.toml b/pyproject.toml index 42fa1b2..2e6e5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ ruff = ">=0.9.0" mypy = ">=1.11.2,<3.0.0" pre-commit = "^4.0.0" pyright = "^1.1.410" +mkdocs = "^1.6.1" +mkdocs-material = "^9.5.34" [build-system] requires = ["poetry-core"] diff --git a/tests/test_core_db.py b/tests/test_core_db.py new file mode 100644 index 0000000..41fb517 --- /dev/null +++ b/tests/test_core_db.py @@ -0,0 +1,85 @@ +import sqlite3 +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy.exc import OperationalError + +from app.core.db import commit_with_retry, init_db, set_sqlite_pragma + + +def test_set_sqlite_pragma(): + # Arrange + mock_conn = MagicMock(spec=sqlite3.Connection) + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + + # Act + set_sqlite_pragma(mock_conn, None) + + # Assert + mock_conn.cursor.assert_called_once() + mock_cursor.execute.assert_any_call("PRAGMA journal_mode=WAL;") + mock_cursor.execute.assert_any_call("PRAGMA busy_timeout=5000;") + mock_cursor.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_commit_with_retry_success(): + # Arrange + mock_session = AsyncMock() + + # Act + await commit_with_retry(mock_session) + + # Assert + mock_session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_commit_with_retry_retries_on_operational_error(): + # Arrange + mock_session = AsyncMock() + # Let it fail twice then succeed + mock_session.commit.side_effect = [ + OperationalError("db locked", None, Exception("db locked")), + OperationalError("db locked", None, Exception("db locked")), + None, + ] + + # Act + await commit_with_retry(mock_session) + + # Assert + assert mock_session.commit.await_count == 3 + + +@pytest.mark.asyncio +async def test_commit_with_retry_fails_after_max_retries(): + # Arrange + mock_session = AsyncMock() + # Let it always fail + mock_session.commit.side_effect = OperationalError("db locked", None, Exception("db locked")) + + # Act & Assert + from tenacity import RetryError + + with pytest.raises(RetryError): + await commit_with_retry(mock_session) + + assert mock_session.commit.await_count == 5 + + +@pytest.mark.asyncio +@patch("app.core.db.engine") +@patch("app.core.db.Base.metadata.create_all") +async def test_init_db(mock_create_all, mock_engine): + # Arrange + mock_conn = AsyncMock() + mock_engine.begin.return_value.__aenter__.return_value = mock_conn + + # Act + await init_db() + + # Assert + mock_engine.begin.assert_called_once() + mock_conn.run_sync.assert_awaited_once_with(mock_create_all) diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py index 5c24137..f7fbb4a 100644 --- a/tests/test_guardrails.py +++ b/tests/test_guardrails.py @@ -117,3 +117,10 @@ def test_logging_contains_client_ip( assert "Prompt Injection detected by heuristic" in output assert ip_address in output + + +def test_payload_size_limit(guardrails_service: GuardrailsService) -> None: + from app.core.constants import MAX_PAYLOAD_SIZE + + huge_text = "A" * (MAX_PAYLOAD_SIZE + 1) + assert guardrails_service.validate_input(huge_text) is False diff --git a/tests/test_intent_handlers.py b/tests/test_intent_handlers.py index 1cea6cf..72e22b8 100644 --- a/tests/test_intent_handlers.py +++ b/tests/test_intent_handlers.py @@ -194,3 +194,38 @@ async def test_search_intent_nothing_found(mock_price_comparator): # Assert assert res.requires_lead is True assert len(res.system_instructions) > 0 + + +@pytest.mark.asyncio +async def test_order_status_intent_handler(): + from app.services.intent_handlers import OrderStatusIntentHandler + + woo_service = AsyncMock() + handler = OrderStatusIntentHandler(woo_service) + + # Test valid id + woo_service.get_order_async.return_value = { + "id": 1234, + "status": "processing", + "total": "500", + "currency": "UAH", + } + + res = await handler.handle({"strict_query": "1234"}, [], []) + assert res.requires_lead is False + assert "Š—Š°Š¼Š¾Š²Š»ŠµŠ½Š½Ń #1234" in res.product_facts[0] + + # Test invalid id + res_no_id = await handler.handle({"strict_query": None}, [], []) + assert res_no_id.requires_lead is False + assert "ŠŠµ Š²Š“Š°Š»Š¾ŃŃ визначити номер Š·Š°Š¼Š¾Š²Š»ŠµŠ½Š½Ń" in res_no_id.system_instructions[0] + + # Test not found + woo_service.get_order_async.return_value = None + res_not_found = await handler.handle({"strict_query": "999"}, [], []) + assert "не знайГено" in res_not_found.system_instructions[0] + + # Test API error + woo_service.get_order_async.side_effect = Exception("API") + res_err = await handler.handle({"strict_query": "777"}, [], []) + assert "не знайГено" in res_err.system_instructions[0] diff --git a/tests/test_main.py b/tests/test_main.py index 6336485..5ef974c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,8 @@ +from unittest.mock import AsyncMock, MagicMock, patch + import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient from app.main import app @@ -18,3 +22,81 @@ async def test_debug_fs(async_client): assert "exists" in data assert "files" in data assert "path" in data + + +def test_create_application(): + from app.main import create_application + + app = create_application() + assert isinstance(app, FastAPI) + assert app.title == "Chatbot AI Backend" + + # Check if static is mounted + assert any(getattr(route, "name", None) == "frontend" for route in app.routes) + + # Check if debug-fs works + client = TestClient(app) + response = client.get("/debug-fs") + assert response.status_code == 200 + + +@pytest.mark.asyncio +@patch("app.core.db.init_db", new_callable=AsyncMock) +@patch("app.services.cache_service.CacheService") +@patch("app.services.vector_service.VectorService") +@patch("app.main.AsyncIOScheduler") +@patch("apscheduler.jobstores.sqlalchemy.SQLAlchemyJobStore") +@patch("app.api.v1.endpoints.chat.get_cached_rag_engine") +@patch("app.services.woo_service.close_woo_client", new_callable=AsyncMock) +@patch("app.main.sentry_sdk") +@patch("app.main.get_settings") +async def test_lifespan( + mock_get_settings, + mock_sentry, + mock_close_woo, + mock_get_rag, + mock_jobstore, + mock_scheduler, + mock_vector_service, + mock_cache_service, + mock_init_db, +): + from app.main import lifespan + + # Arrange + mock_settings = MagicMock() + mock_settings.sentry_dsn = "http://test@localhost/1" + mock_settings.pinecone_environment = "test-env" + mock_settings.log_level = "INFO" + mock_get_settings.return_value = mock_settings + + mock_cache_instance = AsyncMock() + mock_cache_service.return_value = mock_cache_instance + + mock_vector_instance = AsyncMock() + mock_vector_service.return_value = mock_vector_instance + + mock_sched_instance = MagicMock() + mock_scheduler.return_value = mock_sched_instance + + mock_rag = MagicMock() + mock_rag.price_comparator.scraper_service.close = AsyncMock() + mock_get_rag.return_value = mock_rag + + app = FastAPI() + + # Act + async with lifespan(app): + # Assert startup + mock_init_db.assert_awaited_once() + mock_cache_instance.initialize.assert_awaited_once() + mock_sentry.init.assert_called_once() + mock_vector_instance.initialize.assert_awaited_once() + mock_sched_instance.add_job.assert_called() + mock_sched_instance.start.assert_called_once() + + # Assert shutdown + mock_sched_instance.shutdown.assert_called_once_with(wait=False) + mock_rag.price_comparator.scraper_service.close.assert_awaited_once() + mock_close_woo.assert_awaited_once() + mock_sentry.flush.assert_called_once() diff --git a/tests/test_openai_service.py b/tests/test_openai_service.py index f30e5cf..46b249c 100644 --- a/tests/test_openai_service.py +++ b/tests/test_openai_service.py @@ -131,3 +131,68 @@ async def test_get_chat_completion_rate_limit(mock_async_openai_class, mock_sett # Act & Assert with pytest.raises(tenacity.RetryError): await service.get_chat_completion("sys", "user", []) + + +@pytest.mark.asyncio +@patch("app.services.openai_service.AsyncOpenAI") +async def test_generate_embeddings_batch(mock_async_openai_class, mock_settings): + mock_client = AsyncMock() + mock_async_openai_class.return_value = mock_client + service = OpenAIService(mock_settings) + service.client = mock_client + + mock_response = MagicMock() + mock_response.usage.prompt_tokens = 5 + m1 = MagicMock(index=1, embedding=[0.2]) + m0 = MagicMock(index=0, embedding=[0.1]) + mock_response.data = [m1, m0] + mock_client.embeddings.create.return_value = mock_response + + result = await service.generate_embeddings_batch(["t1", "t2"]) + + assert result == [[0.1], [0.2]] + + +@pytest.mark.asyncio +@patch("app.services.openai_service.AsyncOpenAI") +async def test_generate_embeddings_batch_empty(mock_async_openai_class, mock_settings): + service = OpenAIService(mock_settings) + with pytest.raises(ValueError, match="cannot be empty"): + await service.generate_embeddings_batch([]) + + +@pytest.mark.asyncio +@patch("app.services.openai_service.AsyncOpenAI") +async def test_generate_embeddings_batch_error(mock_async_openai_class, mock_settings): + mock_client = AsyncMock() + mock_async_openai_class.return_value = mock_client + service = OpenAIService(mock_settings) + service.client = mock_client + + from openai import OpenAIError + + mock_client.embeddings.create.side_effect = OpenAIError("API error") + import tenacity + + with pytest.raises(tenacity.RetryError): + await service.generate_embeddings_batch(["t1"]) + + +@pytest.mark.asyncio +@patch("app.services.openai_service.AsyncOpenAI") +async def test_stream_chat_completion_error(mock_async_openai_class, mock_settings): + mock_client = AsyncMock() + mock_async_openai_class.return_value = mock_client + service = OpenAIService(mock_settings) + service.client = mock_client + + from openai import OpenAIError + + mock_client.chat.completions.create.side_effect = OpenAIError("API error") + + import openai + import tenacity + + with pytest.raises((tenacity.RetryError, openai.OpenAIError)): + async for _ in service.stream_chat_completion("sys", "user", []): + pass diff --git a/tests/test_rag_engine.py b/tests/test_rag_engine.py index 2855f08..aa358e1 100644 --- a/tests/test_rag_engine.py +++ b/tests/test_rag_engine.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -157,3 +157,186 @@ async def test_detect_intent_fallback_override(mock_rag_engine): assert result["strict_query"] == "90AR00R2-M00090" assert result["broad_query"] == query mock_rag_engine.openai_service.get_chat_completion.assert_called_once() + + +@pytest.mark.asyncio +async def test_detect_intent_json_error(mock_rag_engine): + mock_rag_engine.openai_service.get_chat_completion.return_value = "invalid json" + + from app.services.rag_engine import INTENT_FAQ + + result = await mock_rag_engine.detect_intent("help", "") + assert result["intent"] == INTENT_FAQ + + +@pytest.mark.asyncio +async def test_detect_intent_generic_error(mock_rag_engine): + mock_rag_engine.openai_service.get_chat_completion.side_effect = Exception("API error") + + from app.services.rag_engine import INTENT_FAQ + + result = await mock_rag_engine.detect_intent("help", "") + assert result["intent"] == INTENT_FAQ + + +@pytest.mark.asyncio +async def test_retrieve_context(mock_rag_engine): + mock_rag_engine.vector_service.query_similar = AsyncMock( + return_value=[ + {"metadata": {"text": "Text snippet", "source": "src1"}}, + {"metadata": {"questions": "Q1", "answer": "A1", "source": "src2"}}, + ] + ) + + chunks, sources, timing = await mock_rag_engine._retrieve_context( + "query", precomputed_vector=[0.1, 0.2] + ) + + assert len(chunks) == 2 + assert "Text snippet" in chunks[0] + assert "ŠŸŠøŃ‚Š°Š½Š½Ń: Q1\nŠ’Ń–Š“ŠæŠ¾Š²Ń–Š“ŃŒ: A1" in chunks[1] + assert "src1" in sources + assert "src2" in sources + assert "embedding_ms" in timing + assert "retrieval_ms" in timing + + +@pytest.mark.asyncio +async def test_get_intent_context_search(mock_rag_engine): + from app.services.rag_engine import INTENT_SEARCH + + # Arrange + intent_data = {"intent": INTENT_SEARCH, "strict_query": "laptop"} + history = [] + + # Mock search intent handler + mock_res = MagicMock( + product_facts=["Fact 1"], + system_instructions=["Inst 1"], + extracted_links=[], + requires_lead=False, + lead_form_type=None, + ) + mock_rag_engine.search_intent_handler.handle = AsyncMock(return_value=mock_res) + + # Act + ctx = await mock_rag_engine._get_intent_context(intent_data, history, "session_1") + + # Assert + assert ctx.product_facts == ["Fact 1"] + assert ctx.system_instructions == ["Inst 1"] + + +@pytest.mark.asyncio +async def test_get_intent_context_contact(mock_rag_engine): + from app.services.rag_engine import INTENT_CONTACT + + # Arrange + intent_data = {"intent": INTENT_CONTACT} + history = [] + mock_rag_engine.settings.telegram_contact_url = "http://tg" + mock_rag_engine.settings.viber_contact_url = "http://vb" + + # Act + ctx = await mock_rag_engine._get_intent_context(intent_data, history, "session_1") + + # Assert + assert ctx.requires_lead is True + assert ctx.lead_form_type == "contact" + assert len(ctx.extracted_links) == 2 # Telegram & Viber + + +@pytest.mark.asyncio +@patch("app.services.rag_engine.AsyncSessionLocal") +async def test_try_capture_lead_success(mock_session_local, mock_rag_engine): + # Arrange + from unittest.mock import MagicMock + + mock_session = MagicMock() + mock_session_local.return_value.__aenter__.return_value = mock_session + + mock_rag_engine.chat_memory_service.get_history.return_value = [ + {"role": "bot", "content": "Here is a link https://example.com"} + ] + mock_rag_engine.telegram_service.send_alert.return_value = True + + # Act + is_lead, ctx = await mock_rag_engine._try_capture_lead("call me +380501234567", "sess") + + # Assert + assert is_lead is True + assert ctx is not None + assert ctx.is_valid is False # Lead triggers short-circuit + + +@pytest.mark.asyncio +@patch("app.services.rag_engine.AsyncSessionLocal") +async def test_try_capture_lead_exception(mock_session_local, mock_rag_engine): + from app.services.rag_engine import MSG_LEAD_FAILED + + # Arrange + mock_session_local.side_effect = Exception("DB error") + + # Act + is_lead, ctx = await mock_rag_engine._try_capture_lead("call me +380501234567", "sess") + + # Assert + assert is_lead is True + assert ctx.fallback_response == MSG_LEAD_FAILED + + +@pytest.mark.asyncio +async def test_process_query_sync_exception(mock_rag_engine): + from app.schemas.chat import PipelineContext + + with patch( + "app.services.rag_engine.RAGEngine._prepare_rag_pipeline", + return_value=PipelineContext( + is_valid=True, + fallback_response=None, + final_context=["Fact"], + sources=["doc1"], + extracted_links=[], + requires_lead=False, + lead_form_type=None, + extended_user_message="Msg", + ), + ): + mock_rag_engine.openai_service.get_chat_completion.side_effect = Exception("API") + + resp = await mock_rag_engine.process_query("Q") + from app.core.constants import MSG_SYSTEM_ERROR + + assert resp.answer == MSG_SYSTEM_ERROR + + +@pytest.mark.asyncio +async def test_process_query_stream_success(mock_rag_engine): + from app.schemas.chat import PipelineContext + + with patch( + "app.services.rag_engine.RAGEngine._prepare_rag_pipeline", + return_value=PipelineContext( + is_valid=True, + fallback_response=None, + final_context=["Fact"], + sources=["doc1"], + extracted_links=[], + requires_lead=False, + lead_form_type=None, + extended_user_message="Msg", + ), + ): + + async def mock_stream(*args, **kwargs): + yield "Hello " + yield "World" + + mock_rag_engine.openai_service.stream_chat_completion = mock_stream + + tokens = [] + async for token in mock_rag_engine.process_query_stream("Q"): + tokens.append(token) + + assert any("Hello " in t for t in tokens) + assert any("World" in t for t in tokens) diff --git a/tests/test_schemas_order.py b/tests/test_schemas_order.py new file mode 100644 index 0000000..2aa6e13 --- /dev/null +++ b/tests/test_schemas_order.py @@ -0,0 +1,100 @@ +import pytest +from pydantic import ValidationError + +from app.schemas.order import ( + WooOrder, + WooOrderBilling, + WooOrderLineItem, + WooOrderShipping, +) + + +def test_woo_order_line_item_default(): + item = WooOrderLineItem() + assert item.name == "" + assert item.quantity == 0 + assert item.price == 0.0 + assert item.total == 0.0 + assert item.sku == "" + + +def test_woo_order_line_item_custom(): + item = WooOrderLineItem(name="Test Product", quantity=2, price=10.5, total=21.0, sku="SKU123") + assert item.name == "Test Product" + assert item.quantity == 2 + assert item.price == 10.5 + assert item.total == 21.0 + assert item.sku == "SKU123" + + +def test_woo_order_shipping_default(): + shipping = WooOrderShipping() + assert shipping.method_title == "" + assert shipping.meta_data == [] + + +def test_woo_order_shipping_custom(): + shipping = WooOrderShipping( + method_title="Flat Rate", meta_data=[{"key": "cost", "value": "10.0"}] + ) + assert shipping.method_title == "Flat Rate" + assert shipping.meta_data == [{"key": "cost", "value": "10.0"}] + + +def test_woo_order_billing_default(): + billing = WooOrderBilling() + assert billing.first_name == "" + assert billing.last_name == "" + assert billing.phone == "" + + +def test_woo_order_billing_custom(): + billing = WooOrderBilling(first_name="John", last_name="Doe", phone="+380991234567") + assert billing.first_name == "John" + assert billing.last_name == "Doe" + assert billing.phone == "+380991234567" + + +def test_woo_order_required_fields(): + # id is required + with pytest.raises(ValidationError): + WooOrder() # type: ignore[call-arg] + + +def test_woo_order_default_values(): + order = WooOrder(id=123) + assert order.id == 123 + assert order.status == "" + assert order.total == 0.0 + assert order.currency == "UAH" + assert order.date_created == "" + assert order.billing.first_name == "" + assert order.payment_method_title == "" + assert order.shipping_lines == [] + assert order.line_items == [] + + +def test_woo_order_custom_values(): + order = WooOrder( + id=1001, + status="processing", + total=500.5, + currency="USD", + date_created="2023-01-01T12:00:00", + billing=WooOrderBilling(first_name="Alice", last_name="Smith"), + payment_method_title="Credit Card", + shipping_lines=[WooOrderShipping(method_title="Nova Poshta")], + line_items=[WooOrderLineItem(name="Laptop", quantity=1)], + ) + + assert order.id == 1001 + assert order.status == "processing" + assert order.total == 500.5 + assert order.currency == "USD" + assert order.date_created == "2023-01-01T12:00:00" + assert order.billing.first_name == "Alice" + assert order.payment_method_title == "Credit Card" + assert len(order.shipping_lines) == 1 + assert order.shipping_lines[0].method_title == "Nova Poshta" + assert len(order.line_items) == 1 + assert order.line_items[0].name == "Laptop" diff --git a/tests/test_statistics_service.py b/tests/test_statistics_service.py new file mode 100644 index 0000000..9ce85bb --- /dev/null +++ b/tests/test_statistics_service.py @@ -0,0 +1,138 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from app.services.statistics_service import ( + fetch_prometheus_data, + fetch_unique_users_24h, + gather_and_send_daily_report_job, +) + + +@pytest.mark.asyncio +@patch("app.services.statistics_service.httpx.AsyncClient.get") +async def test_fetch_prometheus_data_success(mock_get): + # Arrange + settings = MagicMock() + settings.prometheus_url = "http://test-prom" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"result": [{"value": [1600000000, "42.5"]}]}} + mock_get.return_value = mock_response + + # Act + metrics = await fetch_prometheus_data(settings) + + # Assert + assert metrics["tokens"] == 42.5 + assert metrics["latency_avg"] == 42.5 + assert mock_get.call_count > 0 + + +@pytest.mark.asyncio +@patch("app.services.statistics_service.httpx.AsyncClient.get") +async def test_fetch_prometheus_data_error(mock_get): + # Arrange + settings = MagicMock() + settings.prometheus_url = "http://test-prom" + + mock_get.side_effect = httpx.RequestError("Network error") + + # Act + metrics = await fetch_prometheus_data(settings) + + # Assert + assert metrics["tokens"] == 0.0 + assert metrics["latency_avg"] == 0.0 + + +@pytest.mark.asyncio +@patch("app.services.statistics_service.AsyncSessionLocal") +async def test_fetch_unique_users_24h_success(mock_session_local): + # Arrange + mock_session = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_session + mock_result = MagicMock() + mock_result.scalar.return_value = 15 + mock_session.execute.return_value = mock_result + + # Act + users_count = await fetch_unique_users_24h() + + # Assert + assert users_count == 15 + mock_session.execute.assert_called_once() + + +@pytest.mark.asyncio +@patch("app.services.statistics_service.AsyncSessionLocal") +async def test_fetch_unique_users_24h_exception(mock_session_local): + # Arrange + mock_session = AsyncMock() + mock_session_local.return_value.__aenter__.return_value = mock_session + mock_session.execute.side_effect = Exception("DB Error") + + # Act + users_count = await fetch_unique_users_24h() + + # Assert + assert users_count == 0 + + +@pytest.mark.asyncio +@patch("app.services.statistics_service.get_settings") +@patch("app.services.statistics_service.TelegramService") +@patch("app.services.statistics_service.WooService") +@patch("app.services.statistics_service.fetch_prometheus_data") +@patch("app.services.statistics_service.fetch_unique_users_24h") +@patch( + "app.services.statistics_service.STATISTICS_TEMPLATE", + ["Stats: {unique_users} users, {woo_total} orders"], +) +async def test_gather_and_send_daily_report_job( + mock_fetch_users, + mock_fetch_prom, + mock_woo_service_class, + mock_tg_service_class, + mock_get_settings, +): + # Arrange + mock_fetch_users.return_value = 100 + mock_fetch_prom.return_value = { + "tokens": 1000, + "latency_avg": 0.5, + "errors": 2, + "price_alerts": 5, + "leads_contact": 10, + "leads_hot": 20, + "conversions": 5, + } + + mock_woo_service = AsyncMock() + mock_woo_service.get_daily_orders_stats.return_value = { + "total": 50, + "processing": 10, + "on-hold": 5, + "paid": 35, + "tags": {"organic": 30, "cpc": 20}, + } + mock_woo_service_class.return_value = mock_woo_service + + mock_tg_service = AsyncMock() + mock_tg_service_class.return_value = mock_tg_service + + # Act + await gather_and_send_daily_report_job() + + # Assert + mock_woo_service.get_daily_orders_stats.assert_awaited_once() + mock_tg_service.send_alert.assert_awaited_once() + + # Check that message formatting worked + call_args = mock_tg_service.send_alert.call_args[0] + message = call_args[0] + assert "Stats: 100 users, 50 orders" in message + assert "organic: 30 (60.0%)" in message + assert "cpc: 20 (40.0%)" in message diff --git a/tests/test_telegram_service.py b/tests/test_telegram_service.py index 965c7fe..71bb5f9 100644 --- a/tests/test_telegram_service.py +++ b/tests/test_telegram_service.py @@ -1,3 +1,4 @@ +# pyright: reportPrivateUsage=false from unittest.mock import AsyncMock, Mock, patch import pytest @@ -61,7 +62,7 @@ async def test_send_alert_retries(mock_client_class, mock_settings): with patch("app.services.telegram_service.asyncio.sleep") as mock_sleep: # Act - res = await service.send_alert("Test Alert") + res = await service.send_alert("Test Alert", reply_markup={"inline_keyboard": []}) # Assert assert res is True @@ -90,6 +91,7 @@ async def test_send_lead_success(mock_client_class, mock_settings): lead = LeadData(name="Test & Co", phone="+380000000000") # Act + service.fallback_topic = None res = await service.send_lead(lead, context_info="Some context ") # Assert @@ -117,3 +119,117 @@ async def test_send_lead_error(mock_client_class, mock_settings): assert res is False assert mock_client.post.call_count == 2 + + # Missing credentials + service.api_base = None + res_no_creds = await service.send_lead(LeadData(phone="+380123456789")) + assert res_no_creds is False + + +@pytest.mark.asyncio +async def test_close(mock_settings): + service = TelegramService(mock_settings) + service.client.aclose = AsyncMock() + await service.close() + service.client.aclose.assert_called_once() + + +@pytest.mark.asyncio +@patch("app.services.telegram_service.httpx.AsyncClient") +async def test_make_request_with_files(mock_client_class, mock_settings): + mock_client = AsyncMock() + mock_client_class.return_value = mock_client + service = TelegramService(mock_settings) + mock_client.post.return_value = Mock(status_code=200) + await service._make_request("test", data={"a": 1}, files={"f": "file"}) + mock_client.post.assert_called_once_with( + f"{service.api_base}/test", data={"a": 1}, files={"f": "file"}, timeout=60.0 + ) + + # test missing api_base + service.api_base = None + res = await service._make_request("test") + assert res is None + + +@pytest.mark.asyncio +@patch("app.services.telegram_service.httpx.AsyncClient") +async def test_send_alert_with_history(mock_client_class, mock_settings): + mock_client = AsyncMock() + mock_client_class.return_value = mock_client + service = TelegramService(mock_settings) + service.send_document = AsyncMock(return_value=True) + mock_client.post.return_value = Mock(status_code=200) + # With history and session_id + await service.send_alert("msg", history=[{"role": "user", "content": "hi"}], session_id="123") + service.send_document.assert_called_once() + + +@pytest.mark.asyncio +@patch("app.services.telegram_service.httpx.AsyncClient") +async def test_update_message_reply_markup(mock_client_class, mock_settings): + mock_client = AsyncMock() + mock_client_class.return_value = mock_client + mock_client.post.return_value = Mock(status_code=200) + service = TelegramService(mock_settings) + res = await service.update_message_reply_markup(123, {"inline_keyboard": []}) + assert res is True + # Test without api base + service.api_base = None + res = await service.update_message_reply_markup(123) + assert res is False + + +@pytest.mark.asyncio +@patch("app.services.telegram_service.httpx.AsyncClient") +async def test_edit_message_text(mock_client_class, mock_settings): + mock_client = AsyncMock() + mock_client_class.return_value = mock_client + mock_client.post.return_value = Mock(status_code=200) + service = TelegramService(mock_settings) + res = await service.edit_message_text(123, "text", {"kb": 1}) + assert res is True + service.api_base = None + res = await service.edit_message_text(123, "text") + assert res is False + + +@pytest.mark.asyncio +@patch("app.services.telegram_service.httpx.AsyncClient") +async def test_send_document(mock_client_class, mock_settings): + mock_client = AsyncMock() + mock_client_class.return_value = mock_client + mock_client.post.return_value = Mock(status_code=200) + service = TelegramService(mock_settings) + service.fallback_topic = None + res = await service.send_document("doc.txt", "content", "cap", "general") + assert res is True + + # Missing credentials + service.api_base = None + res2 = await service.send_document("doc.txt", "content", "cap", "general") + assert res2 is False + + +@pytest.mark.asyncio +@patch("app.services.telegram_service.httpx.AsyncClient") +async def test_topics_present(mock_client_class, mock_settings): + mock_client = AsyncMock() + mock_client_class.return_value = mock_client + mock_settings.telegram_topic_general = 12345 + service = TelegramService(mock_settings) + + mock_client.post.return_value = Mock(status_code=200) + + await service.send_alert("msg") + await service.send_lead(LeadData(phone="+380000000000")) + await service.send_document("doc.txt", "content") + + assert mock_client.post.call_count == 3 + + # Assert that message_thread_id was sent in the payload + for call in mock_client.post.call_args_list: + if "data" in call.kwargs: + assert call.kwargs["data"]["message_thread_id"] == 12345 + else: + assert call.kwargs["json"]["message_thread_id"] == 12345 diff --git a/tests/test_woo_service.py b/tests/test_woo_service.py index 516165a..cbaf375 100644 --- a/tests/test_woo_service.py +++ b/tests/test_woo_service.py @@ -1,3 +1,4 @@ +# pyright: reportPrivateUsage=false from unittest.mock import AsyncMock, Mock, patch import httpx @@ -71,7 +72,7 @@ async def test_search_products_async_success(mock_get_client, mock_settings): mock_client.get = AsyncMock(return_value=mock_response) service = WooService(mock_settings) - res = await service.search_products_async("Test") + res = await service.search_products_async("Test", category_id=1) assert len(res) == 2 @@ -109,3 +110,156 @@ async def test_woo_service_timeout(mock_get_client, mock_settings): res3 = await service.search_products_by_category_async(1) assert res3 == [] + + +@pytest.mark.asyncio +async def test_close_woo_client(): + import app.services.woo_service as ws + from app.services.woo_service import WooService, close_woo_client + + ws._global_client = None + service = WooService(Mock()) + + # Test _get_client branch + c1 = service._get_client() + c2 = service._get_client() + assert c1 is c2 + + ws._global_client = AsyncMock() + await close_woo_client() + assert ws._global_client is None + + +@pytest.mark.asyncio +@patch("app.services.woo_service.WooService._get_client") +async def test_fetch_and_parse_single_exceptions(mock_get_client, mock_settings): + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + mock_client.get = AsyncMock(side_effect=Exception("API Error")) + + service = WooService(mock_settings) + res = await service._fetch_and_parse_single({"search": "test"}) + assert res is None + + +@pytest.mark.asyncio +@patch("app.services.woo_service.WooService._get_client") +async def test_fetch_products_list_exceptions(mock_get_client, mock_settings): + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + service = WooService(mock_settings) + + mock_client.get = AsyncMock( + side_effect=httpx.HTTPStatusError("Err", request=Mock(), response=Mock()) + ) + res = await service._fetch_products_list({}, "Ctx") + assert res == [] + + mock_client.get = AsyncMock(side_effect=httpx.RequestError("Err", request=Mock())) + res = await service._fetch_products_list({}, "Ctx") + assert res == [] + + mock_client.get = AsyncMock(side_effect=Exception("Err")) + res = await service._fetch_products_list({}, "Ctx") + assert res == [] + + +@pytest.mark.asyncio +@patch("app.services.woo_service.WooService._get_client") +async def test_get_daily_orders_stats(mock_get_client, mock_settings): + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"status": "processing", "meta_data": [{"key": "utm_source", "value": "fb"}]}, + {"status": "on-hold", "meta_data": [{"key": "bot_tag", "value": "TEST"}]}, + { + "status": "completed", + "meta_data": [{"key": "_wc_order_attribution_utm_campaign", "value": "camp"}], + }, + { + "status": "completed", + "meta_data": [{"key": "_wc_order_attribution_utm_medium", "value": "cpc"}], + }, + { + "status": "completed", + "meta_data": [ + {"key": "_wc_order_attribution_referrer", "value": "https://google.com/path"} + ], + }, + {"status": "other", "meta_data": "invalid"}, + {"status": "completed", "meta_data": [{"key": "bot_tag", "value": ""}]}, + ] + mock_client.get = AsyncMock(return_value=mock_response) + + service = WooService(mock_settings) + stats = await service.get_daily_orders_stats() + + assert stats["total"] == 7 + assert stats["processing"] == 1 + assert stats["on-hold"] == 1 + assert stats["completed"] == 4 + assert stats["paid"] == 4 + assert "Джерело: fb" in stats["tags"] + assert "Дтворено: test" in stats["tags"] + assert "ŠšŠ°Š¼ŠæŠ°Š½Ń–Ń: camp" in stats["tags"] + assert "Канал: cpc" in stats["tags"] + assert "Реферер: google.com" in stats["tags"] + + +@pytest.mark.asyncio +@patch("app.services.woo_service.WooService._get_client") +async def test_get_daily_orders_stats_exception(mock_get_client, mock_settings): + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + mock_client.get = AsyncMock(side_effect=Exception("API Error")) + + service = WooService(mock_settings) + stats = await service.get_daily_orders_stats() + assert stats["total"] == 0 + + +@pytest.mark.asyncio +@patch("app.services.woo_service.WooService._get_client") +async def test_get_order_async(mock_get_client, mock_settings): + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": 123, "status": "processing"} + mock_client.get = AsyncMock(return_value=mock_response) + + service = WooService(mock_settings) + order = await service.get_order_async(123) + assert order is not None + assert order["id"] == 123 + + # Exception paths + mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) + order = await service.get_order_async(123) + assert order is None + + mock_resp_404 = Mock() + mock_resp_404.status_code = 404 + mock_client.get = AsyncMock( + side_effect=httpx.HTTPStatusError("404", request=Mock(), response=mock_resp_404) + ) + order = await service.get_order_async(123) + assert order is None + + mock_resp_500 = Mock() + mock_resp_500.status_code = 500 + mock_client.get = AsyncMock( + side_effect=httpx.HTTPStatusError("500", request=Mock(), response=mock_resp_500) + ) + order = await service.get_order_async(123) + assert order is None + + mock_client.get = AsyncMock(side_effect=httpx.RequestError("Err", request=Mock())) + order = await service.get_order_async(123) + assert order is None + + mock_client.get = AsyncMock(side_effect=Exception("Err")) + order = await service.get_order_async(123) + assert order is None diff --git a/tests/test_woo_smart_parser.py b/tests/test_woo_smart_parser.py index 7f43d4c..431a96d 100644 --- a/tests/test_woo_smart_parser.py +++ b/tests/test_woo_smart_parser.py @@ -41,3 +41,71 @@ def test_parse_product_short_description_truncation(): res = parse_product(raw, max_desc_length=100) assert len(res["short_description"]) <= 103 # including ... assert res["short_description"].endswith("...") + + +def test_parse_product_invalid_input(): + from app.services.woo_smart_parser import parse_product + + res = parse_product(None) + assert res["id"] is None + res2 = parse_product("not_a_dict") # type: ignore[arg-type] + assert res2["id"] is None + + +def test_parse_product_validation_error(): + from app.services.woo_smart_parser import parse_product + + # pass bad types to trigger ValidationError + res = parse_product({"id": "valid", "attributes": "not_a_list"}) + assert res["id"] is None + + +def test_parse_product_exception(): + from unittest.mock import patch + + from app.services.woo_smart_parser import parse_product + + with patch("app.services.woo_smart_parser.BeautifulSoup", side_effect=Exception("BS Error")): + res = parse_product({"id": 1, "short_description": "
"}) + assert res["id"] == 1 + + +def test_parse_order(): + from app.services.woo_smart_parser import parse_order + + res = parse_order(None) + assert res == {} + + res = parse_order("not a dict") # type: ignore[arg-type] + assert res == {} + + raw_order = { + "id": 123, + "status": "processing", + "total": "500.0", + "currency": "UAH", + "date_created": "2023-01-01T10:00:00", + "billing": {"first_name": "Ivan", "last_name": "Ivanov", "phone": "380991234567"}, + "shipping_lines": [{"method_title": "Nova Poshta", "meta_data": []}], + "line_items": [ + {"name": "Product 1", "quantity": 2, "price": "250.0", "total": "500.0", "sku": "SKU-1"} + ], + "payment_method_title": "Card", + } + + parsed = parse_order(raw_order) + assert parsed["id"] == 123 + assert parsed["status"] == "processing" + assert parsed["billing"]["first_name"] == "Ivan" + assert parsed["shipping_lines"][0]["method_title"] == "Nova Poshta" + assert parsed["line_items"][0]["sku"] == "SKU-1" + + +def test_parse_order_exception(): + from unittest.mock import patch + + from app.services.woo_smart_parser import parse_order + + with patch("app.schemas.order.WooOrder", side_effect=Exception("Model Error")): + res = parse_order({"id": 1}) + assert res == {}