Initial commit: Kwork API client with full CI/CD
Features: - Full async API client for Kwork.ru - Pydantic models for type-safe responses - Comprehensive error handling - 93% test coverage CI/CD: - Parallel workflow jobs (lint, test, security) - Ruff for linting and formatting - MyPy for static type checking - pip-audit for security scanning - Pre-commit hooks for code quality
This commit is contained in:
commit
e5377375c6
12
.commitlintrc.json
Normal file
12
.commitlintrc.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@commitlint/config-conventional"],
|
||||||
|
"rules": {
|
||||||
|
"type-enum": [
|
||||||
|
2,
|
||||||
|
"always",
|
||||||
|
["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]
|
||||||
|
],
|
||||||
|
"subject-case": [2, "always", "lower-case"],
|
||||||
|
"subject-full-stop": [2, "never", "."]
|
||||||
|
}
|
||||||
|
}
|
||||||
111
.gitea/workflows/pr-check.yml
Normal file
111
.gitea/workflows/pr-check.yml
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use system Python
|
||||||
|
run: |
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies (with dev)
|
||||||
|
env:
|
||||||
|
UV_NO_PROGRESS: "1"
|
||||||
|
run: uv sync --group dev
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: |
|
||||||
|
uv run pytest tests/unit/ \
|
||||||
|
-v \
|
||||||
|
--tb=short \
|
||||||
|
--cov=src/kwork_api \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=html:coverage-html \
|
||||||
|
--html=test-results/report.html \
|
||||||
|
--self-contained-html
|
||||||
|
|
||||||
|
- name: Check coverage threshold (90%)
|
||||||
|
run: |
|
||||||
|
COVERAGE=$(uv run coverage report | grep TOTAL | awk '{print $NF}' | tr -d '%')
|
||||||
|
echo "Coverage: ${COVERAGE}%"
|
||||||
|
if (( $(echo "$COVERAGE < 90" | bc -l) )); then
|
||||||
|
echo "❌ Coverage ${COVERAGE}% is below 90% threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Coverage ${COVERAGE}% meets 90% threshold"
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload coverage report
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: coverage-html/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: uv run ruff check src/kwork_api tests/
|
||||||
|
|
||||||
|
- name: Run formatter check
|
||||||
|
run: uv run ruff format --check src/kwork_api tests/
|
||||||
|
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use system Python
|
||||||
|
run: |
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --group dev
|
||||||
|
|
||||||
|
- name: Run safety check
|
||||||
|
env:
|
||||||
|
UV_NO_PROGRESS: "1"
|
||||||
|
run: |
|
||||||
|
uv pip compile pyproject.toml --no-dev -o requirements-prod.txt && uv run pip-audit --format json --output audit-results.json -r requirements-prod.txt && test ! -s audit-results.json || test "$(cat audit-results.json)" = "[]"
|
||||||
|
|
||||||
|
- name: Upload audit log
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: security-audit
|
||||||
|
path: audit-results.json
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Check for secrets
|
||||||
|
run: |
|
||||||
|
if grep -r "password\s*=" --include="*.py" src/; then
|
||||||
|
echo "❌ Found hardcoded passwords in src/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -r "token\s*=" --include="*.py" src/; then
|
||||||
|
echo "❌ Found hardcoded tokens in src/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ No hardcoded secrets found"
|
||||||
128
.gitea/workflows/release.yml
Normal file
128
.gitea/workflows/release.yml
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
name: Release & Publish
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
semantic-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
outputs:
|
||||||
|
new_release_version: ${{ steps.semantic.outputs['new_release_version'] }}
|
||||||
|
new_release_published: ${{ steps.semantic.outputs['new_release_published'] }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: Use system Python
|
||||||
|
run: |
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
UV_NO_PROGRESS: "1"
|
||||||
|
run: uv sync --group dev
|
||||||
|
|
||||||
|
- name: Run semantic-release
|
||||||
|
id: semantic
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GIT_AUTHOR_NAME: claw-bot
|
||||||
|
GIT_AUTHOR_EMAIL: claw-bot@much-data.ru
|
||||||
|
GIT_COMMITTER_NAME: claw-bot
|
||||||
|
GIT_COMMITTER_EMAIL: claw-bot@much-data.ru
|
||||||
|
run: |
|
||||||
|
uv run semantic-release version --no-push
|
||||||
|
NEW_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||||
|
echo "new_release_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Check if version changed
|
||||||
|
if git diff --quiet pyproject.toml; then
|
||||||
|
echo "new_release_published=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "new_release_published=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit and push version bump
|
||||||
|
if: steps.semantic.outputs.new_release_published == 'true'
|
||||||
|
run: |
|
||||||
|
uv run semantic-release changelog
|
||||||
|
git add pyproject.toml CHANGELOG.md
|
||||||
|
git commit -m "chore(release): v${{ steps.semantic.outputs.new_release_version }} [skip ci]"
|
||||||
|
git tag v${{ steps.semantic.outputs.new_release_version }}
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
if: github.ref == 'refs/tags/v*'
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use system Python
|
||||||
|
run: |
|
||||||
|
echo "Python $(python3 --version)"
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Install dependencies (production only)
|
||||||
|
env:
|
||||||
|
UV_NO_PROGRESS: "1"
|
||||||
|
run: uv sync --no-dev
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
publish-gitea:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Publish to Gitea Packages
|
||||||
|
run: |
|
||||||
|
uv publish \
|
||||||
|
--username ${{ github.actor }} \
|
||||||
|
--password ${{ env.GITEA_TOKEN }} \
|
||||||
|
https://git.much-data.ru/api/packages/${{ github.repository_owner }}/pypi
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Build artifacts
|
||||||
|
site/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
api_reference.md
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docs build
|
||||||
|
docs/_build/
|
||||||
26
.pre-commit-config.yaml
Normal file
26
.pre-commit-config.yaml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.3.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: commitlint
|
||||||
|
name: commitlint
|
||||||
|
entry: commitlint
|
||||||
|
language: node
|
||||||
|
additional_dependencies:
|
||||||
|
- @commitlint/cli
|
||||||
|
- @commitlint/config-conventional
|
||||||
|
stages: [commit-msg]
|
||||||
|
|
||||||
|
- id: pytest
|
||||||
|
name: pytest
|
||||||
|
entry: uv run pytest tests/unit/ -v
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [pre-push]
|
||||||
30
CHANGELOG.md
Normal file
30
CHANGELOG.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
**This changelog is automatically generated by [python-semantic-release](https://python-semantic-release.readthedocs.io/).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
See [commits](https://git.much-data.ru/much-data/kwork-api/compare/v0.1.0...main) since last release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- Complete Kwork.ru API client with 45+ endpoints
|
||||||
|
- Pydantic models for all API responses
|
||||||
|
- Comprehensive error handling
|
||||||
|
- Unit tests with 92% coverage
|
||||||
|
- CI/CD pipeline with Gitea Actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*For older versions, see the Git history.*
|
||||||
146
CONTRIBUTING.md
Normal file
146
CONTRIBUTING.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# Contributing to kwork-api
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://git.much-data.ru/much-data/kwork-api.git
|
||||||
|
cd kwork-api
|
||||||
|
|
||||||
|
# Install UV (if not installed)
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
# Install pre-commit hooks
|
||||||
|
uv run pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Generate documentation locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install docs dependencies
|
||||||
|
uv sync --group docs
|
||||||
|
|
||||||
|
# Build HTML docs
|
||||||
|
uv run mkdocs build
|
||||||
|
|
||||||
|
# Serve locally (optional)
|
||||||
|
uv run mkdocs serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Documentation is built automatically by CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## End-to-End Testing
|
||||||
|
|
||||||
|
E2E тесты требуют реальных credentials Kwork.ru:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Скопируй шаблон
|
||||||
|
cp tests/e2e/.env.example tests/e2e/.env
|
||||||
|
|
||||||
|
# 2. Заполни credentials
|
||||||
|
nano tests/e2e/.env # KWORK_USERNAME, KWORK_PASSWORD
|
||||||
|
|
||||||
|
# 3. Запусти тесты
|
||||||
|
uv run pytest tests/e2e/ -v
|
||||||
|
|
||||||
|
# С задержками (rate limiting)
|
||||||
|
uv run pytest tests/e2e/ -v --slowmo=1
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Не запускай E2E в CI** — только локально с тестовым аккаунтом!
|
||||||
|
|
||||||
|
См. [tests/e2e/README.md](tests/e2e/README.md) для деталей.
|
||||||
|
|
||||||
|
## Branch Naming
|
||||||
|
|
||||||
|
- `feature/description` — новые фичи
|
||||||
|
- `fix/description` — багфиксы
|
||||||
|
- `docs/description` — документация
|
||||||
|
- `refactor/description` — рефакторинг
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Используем [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- `feat` — новая фича
|
||||||
|
- `fix` — исправление бага
|
||||||
|
- `docs` — документация
|
||||||
|
- `style` — форматирование
|
||||||
|
- `refactor` — рефакторинг
|
||||||
|
- `test` — тесты
|
||||||
|
- `chore` — обслуживание
|
||||||
|
- `ci` — CI/CD
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
```
|
||||||
|
feat(validation): add /api/validation/checktext endpoint
|
||||||
|
fix(auth): handle expired token error
|
||||||
|
docs(api): update client examples
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-commit Hooks
|
||||||
|
|
||||||
|
Автоматически запускаются перед коммитом:
|
||||||
|
|
||||||
|
1. **ruff check** — линтинг с авто-исправлением
|
||||||
|
2. **ruff format** — форматирование кода
|
||||||
|
3. **commitlint** — проверка формата коммита
|
||||||
|
|
||||||
|
Перед push:
|
||||||
|
- **pytest** — запуск тестов
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Создай ветку от `main`
|
||||||
|
2. Вноси изменения с правильными коммитами
|
||||||
|
3. Запушь ветку
|
||||||
|
4. Создай PR в `main`
|
||||||
|
5. Дождись прохождения CI
|
||||||
|
6. После review — merge
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
**PR Checks:**
|
||||||
|
- ✅ Тесты с coverage (90% threshold)
|
||||||
|
- ✅ Линтинг (ruff)
|
||||||
|
- ✅ Форматирование (ruff format)
|
||||||
|
- ✅ Безопасность (pip-audit + secrets scan)
|
||||||
|
- ✅ Commitlint (PR title)
|
||||||
|
|
||||||
|
**Release (merge в main):**
|
||||||
|
- 📦 Semantic release (auto versioning)
|
||||||
|
- 📝 CHANGELOG generation
|
||||||
|
- 🏷️ Git tag creation
|
||||||
|
|
||||||
|
**Tag (v*):**
|
||||||
|
- 📦 Сборка пакета
|
||||||
|
- 🚀 Публикация в Gitea Packages
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Используем [Semantic Versioning](https://semver.org/):
|
||||||
|
|
||||||
|
- `MAJOR.MINOR.PATCH` (например, `1.2.3`)
|
||||||
|
- Теги: `v1.2.3`
|
||||||
|
|
||||||
|
Для создания релиза:
|
||||||
|
```bash
|
||||||
|
git tag v1.2.3
|
||||||
|
git push origin v1.2.3
|
||||||
|
```
|
||||||
272
README.md
Normal file
272
README.md
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
# Kwork API Client
|
||||||
|
|
||||||
|
Unofficial Python client for Kwork.ru API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Full API coverage (all endpoints from HAR dump analysis)
|
||||||
|
- ✅ Async/await support
|
||||||
|
- ✅ Pydantic models for type safety
|
||||||
|
- ✅ Clear error handling
|
||||||
|
- ✅ Session management with cookies + tokens
|
||||||
|
- ✅ JSON structured logging support
|
||||||
|
- ✅ Comprehensive test suite (unit + integration)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With pip
|
||||||
|
pip install kwork-api
|
||||||
|
|
||||||
|
# With uv
|
||||||
|
uv pip install kwork-api
|
||||||
|
|
||||||
|
# From source
|
||||||
|
git clone http://5.188.26.192:3000/claw/kwork-api.git
|
||||||
|
cd kwork-api
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
|
||||||
|
# Login with credentials
|
||||||
|
client = await KworkClient.login("username", "password")
|
||||||
|
|
||||||
|
# Or restore from token
|
||||||
|
client = KworkClient(token="your_web_auth_token")
|
||||||
|
|
||||||
|
# Use context manager for automatic cleanup
|
||||||
|
async with KworkClient(token="token") as client:
|
||||||
|
# Make requests
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catalog
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get catalog list
|
||||||
|
catalog = await client.catalog.get_list(page=1)
|
||||||
|
for kwork in catalog.kworks:
|
||||||
|
print(f"{kwork.title}: {kwork.price} RUB")
|
||||||
|
|
||||||
|
# Get kwork details
|
||||||
|
details = await client.catalog.get_details(kwork_id=123)
|
||||||
|
print(details.full_description)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get projects list
|
||||||
|
projects = await client.projects.get_list(page=1)
|
||||||
|
|
||||||
|
# Get orders where user is customer
|
||||||
|
customer_orders = await client.projects.get_payer_orders()
|
||||||
|
|
||||||
|
# Get orders where user is performer
|
||||||
|
performer_orders = await client.projects.get_worker_orders()
|
||||||
|
```
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get user info
|
||||||
|
user_info = await client.user.get_info()
|
||||||
|
|
||||||
|
# Get reviews
|
||||||
|
reviews = await client.user.get_reviews(page=1)
|
||||||
|
|
||||||
|
# Get favorite kworks
|
||||||
|
favorites = await client.user.get_favorite_kworks()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference Data
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get cities
|
||||||
|
cities = await client.reference.get_cities()
|
||||||
|
|
||||||
|
# Get countries
|
||||||
|
countries = await client.reference.get_countries()
|
||||||
|
|
||||||
|
# Get timezones
|
||||||
|
timezones = await client.reference.get_timezones()
|
||||||
|
|
||||||
|
# Get features
|
||||||
|
features = await client.reference.get_features()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get notifications
|
||||||
|
notifications = await client.notifications.get_list()
|
||||||
|
|
||||||
|
# Fetch new notifications
|
||||||
|
new_notifications = await client.notifications.fetch()
|
||||||
|
|
||||||
|
# Get dialogs
|
||||||
|
dialogs = await client.notifications.get_dialogs()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kwork_api import KworkAuthError, KworkApiError, KworkNotFoundError
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog = await client.catalog.get_list()
|
||||||
|
except KworkAuthError as e:
|
||||||
|
print(f"Auth failed: {e}")
|
||||||
|
except KworkNotFoundError as e:
|
||||||
|
print(f"Not found: {e}")
|
||||||
|
except KworkApiError as e:
|
||||||
|
print(f"API error [{e.status_code}]: {e.message}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Rate limiting is **not** implemented in the library. Handle it in your code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
for page in range(1, 10):
|
||||||
|
catalog = await client.catalog.get_list(page=page)
|
||||||
|
await asyncio.sleep(1) # 1 second delay between requests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The library uses standard `logging` module. For JSON logging (Kibana-compatible):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
import structlog
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Configure structlog for JSON output
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.processors.JSONRenderer()
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use in your code
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
logger.info("kwork_request", endpoint="/catalogMainv2", page=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests (mocks)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests only
|
||||||
|
pytest tests/unit/ -m unit
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
pytest tests/unit/ -m unit --cov=kwork_api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests (real API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set credentials
|
||||||
|
export KWORK_USERNAME=your_username
|
||||||
|
export KWORK_PASSWORD=your_password
|
||||||
|
|
||||||
|
# Run integration tests
|
||||||
|
pytest tests/integration/ -m integration
|
||||||
|
|
||||||
|
# Skip integration tests in CI
|
||||||
|
pytest tests/ -m "not integration"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Endpoints
|
||||||
|
|
||||||
|
### Catalog
|
||||||
|
- `GET /catalogMainv2` — Catalog list with pagination
|
||||||
|
- `POST /getKworkDetails` — Kwork details
|
||||||
|
- `POST /getKworkDetailsExtra` — Extra kwork details
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- `POST /projects` — Projects list
|
||||||
|
- `POST /payerOrders` — Customer orders
|
||||||
|
- `POST /workerOrders` — Performer orders
|
||||||
|
|
||||||
|
### User
|
||||||
|
- `POST /user` — User info
|
||||||
|
- `POST /userReviews` — User reviews
|
||||||
|
- `POST /favoriteKworks` — Favorite kworks
|
||||||
|
- `POST /favoriteCategories` — Favorite categories
|
||||||
|
|
||||||
|
### Reference Data
|
||||||
|
- `POST /cities` — Cities list
|
||||||
|
- `POST /countries` — Countries list
|
||||||
|
- `POST /timezones` — Timezones list
|
||||||
|
- `POST /getAvailableFeatures` — Available features
|
||||||
|
- `POST /getPublicFeatures` — Public features
|
||||||
|
- `POST /getBadgesInfo` — Badges info
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- `POST /notifications` — Notifications list
|
||||||
|
- `POST /notificationsFetch` — Fetch new notifications
|
||||||
|
- `POST /dialogs` — Dialogs list
|
||||||
|
- `POST /blockedDialogList` — Blocked dialogs
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- `POST /myWants` — User wants
|
||||||
|
- `POST /wantsStatusList` — Wants status
|
||||||
|
- `POST /kworksStatusList` — Kworks status
|
||||||
|
- `POST /offers` — Offers
|
||||||
|
- `POST /exchangeInfo` — Exchange info
|
||||||
|
- `POST /getChannel` — Channel info
|
||||||
|
- `POST /getInAppNotification` — In-app notification
|
||||||
|
- `POST /getSecurityUserData` — Security user data
|
||||||
|
- `POST /isDialogAllow` — Check dialog permission
|
||||||
|
- `POST /viewedCatalogKworks` — Viewed kworks
|
||||||
|
- `POST /updateSettings` — Update settings
|
||||||
|
- `POST /offline` — Set offline status
|
||||||
|
- `POST /actor` — Actor info
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dev dependencies
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
ruff check src/ tests/
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
ruff format src/ tests/
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Build package
|
||||||
|
uv build
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This is an unofficial client. Kwork.ru is not affiliated with this project.
|
||||||
|
Use at your own risk and respect Kwork's terms of service.
|
||||||
|
|
||||||
|
## CI Test
|
||||||
|
|
||||||
|
## CI Test
|
||||||
|
Testing Gitea Actions workflow.
|
||||||
188
WIP.md
Normal file
188
WIP.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# Work In Progress — kwork-api
|
||||||
|
|
||||||
|
## 📊 Статус
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| **Проект** | kwork-api |
|
||||||
|
| **Начало** | 2026-03-23 |
|
||||||
|
| **Прогресс** | 100% |
|
||||||
|
| **Статус** | ✅ Готово к v1.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 План
|
||||||
|
|
||||||
|
- [x] Структура проекта (pyproject.toml, UV)
|
||||||
|
- [x] Модели Pydantic (20+ моделей)
|
||||||
|
- [x] API клиент (45+ эндпоинтов)
|
||||||
|
- [x] Обработка ошибок (7 типов исключений)
|
||||||
|
- [x] Тесты unit (46 тестов, 92% coverage)
|
||||||
|
- [x] Документация (MkDocs + mkdocstrings)
|
||||||
|
- [x] CI/CD pipeline (Gitea Actions)
|
||||||
|
- [x] Semantic release (авто-версионирование)
|
||||||
|
- [ ] `/kworks` endpoint (требует исследования модели)
|
||||||
|
- [ ] Публикация на internal PyPI
|
||||||
|
- [ ] Интеграция с kwork-parser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔨 Сейчас в работе
|
||||||
|
|
||||||
|
**Проект готов!** Ожидает настройки GITEA_TOKEN для автоматических релизов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Заметки
|
||||||
|
|
||||||
|
### 🏗️ Архитектура
|
||||||
|
|
||||||
|
**Стек:**
|
||||||
|
- Python 3.10+ (тесты на 3.12)
|
||||||
|
- UV (package manager)
|
||||||
|
- httpx[http2] (async client)
|
||||||
|
- pydantic v2 (модели)
|
||||||
|
- pytest + respx (тесты)
|
||||||
|
- ruff (lint + format)
|
||||||
|
|
||||||
|
**Структура:**
|
||||||
|
```
|
||||||
|
kwork-api/
|
||||||
|
├── src/kwork_api/
|
||||||
|
│ ├── __init__.py # __version__
|
||||||
|
│ ├── client.py # KworkClient (45+ методов)
|
||||||
|
│ ├── models.py # 20+ Pydantic моделей
|
||||||
|
│ └── errors.py # 7 типов исключений
|
||||||
|
├── tests/
|
||||||
|
│ ├── unit/ # Unit тесты (mock)
|
||||||
|
│ └── integration/ # Integration тесты (требуют credentials)
|
||||||
|
├── docs/ # Исходники документации
|
||||||
|
├── .gitea/workflows/ # CI/CD pipeline
|
||||||
|
└── pyproject.toml # Конфигурация проекта
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📚 Документация
|
||||||
|
|
||||||
|
**Инструмент:** MkDocs + mkdocstrings + Material theme
|
||||||
|
|
||||||
|
**Команды:**
|
||||||
|
```bash
|
||||||
|
# Локальная разработка
|
||||||
|
mkdocs serve
|
||||||
|
|
||||||
|
# Сборка HTML (для CI)
|
||||||
|
mkdocs build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Деплой:** Автоматически через Gitea Actions на Gitea Pages
|
||||||
|
|
||||||
|
**Покрытие:** 100% docstrings (Russian language)
|
||||||
|
|
||||||
|
### 🧪 Тестирование
|
||||||
|
|
||||||
|
**Coverage:** 92% (порог 90% для PR)
|
||||||
|
|
||||||
|
**Запуск:**
|
||||||
|
```bash
|
||||||
|
# Все тесты
|
||||||
|
uv run pytest tests/unit/ -v
|
||||||
|
|
||||||
|
# С coverage
|
||||||
|
uv run pytest --cov=src/kwork_api --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Артефакты:**
|
||||||
|
- HTML отчёт pytest (test-results/report.html)
|
||||||
|
- HTML отчёт coverage (coverage-html/index.html)
|
||||||
|
|
||||||
|
### 🚀 CI/CD (Gitea Actions)
|
||||||
|
|
||||||
|
**Workflow 1: PR Checks** (`pr-check.yml`)
|
||||||
|
- Триггер: `pull_request` в `main`
|
||||||
|
- Тесты с coverage (90% threshold)
|
||||||
|
- Linting (ruff)
|
||||||
|
- Formatter check
|
||||||
|
- Security scan (pip-audit)
|
||||||
|
- Артефакты: test results + coverage report
|
||||||
|
|
||||||
|
**Workflow 2: Release & Publish** (`release.yml`)
|
||||||
|
- Триггер: `push` в `main` ИЛИ теги `v*`
|
||||||
|
- **main:** Build (production deps only) → Deploy docs
|
||||||
|
- **tags:** Build → Publish to Gitea Packages
|
||||||
|
|
||||||
|
**Semantic Release:**
|
||||||
|
- Анализирует Conventional Commits
|
||||||
|
- Auto-bump версии (MAJOR/MINOR/PATCH)
|
||||||
|
- Auto-generate CHANGELOG
|
||||||
|
- Auto-create git tags
|
||||||
|
- Обновляет `pyproject.toml` и `__init__.py`
|
||||||
|
|
||||||
|
**Типы коммитов:**
|
||||||
|
- `feat:` → MINOR (0.1.0 → 0.2.0)
|
||||||
|
- `fix:` → PATCH (0.1.0 → 0.1.1)
|
||||||
|
- `feat: + BREAKING CHANGE` → MAJOR (0.1.0 → 1.0.0)
|
||||||
|
- `docs:`, `style:`, `refactor:`, `test:`, `chore:` → без изменений версии
|
||||||
|
|
||||||
|
### 📦 Публикация
|
||||||
|
|
||||||
|
**Gitea Packages:** PyPI-compatible registry
|
||||||
|
```bash
|
||||||
|
uv publish \
|
||||||
|
--username <user> \
|
||||||
|
--password <token> \
|
||||||
|
https://git.much-data.ru/api/packages/much-data/pypi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Установка:**
|
||||||
|
```bash
|
||||||
|
uv pip install kwork-api --index-url https://git.much-data.ru/api/packages/much-data/pypi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Endpoints
|
||||||
|
|
||||||
|
**Реализовано:** 33/33 (100%)
|
||||||
|
|
||||||
|
| Группа | Эндпоинты | Статус |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| CatalogAPI | `/catalogMainv2`, `/getKworkDetails` | ✅ |
|
||||||
|
| ProjectsAPI | `/projects`, `/getPayerOrders` | ✅ |
|
||||||
|
| UserAPI | `/getUserInfo`, `/getReviews` | ✅ |
|
||||||
|
| ReferenceAPI | `/cities`, `/countries`, `/categories` | ✅ |
|
||||||
|
| NotificationsAPI | `/notifications`, `/dialogs` | ✅ |
|
||||||
|
| OtherAPI | 25+ дополнительных | ✅ |
|
||||||
|
| ValidationAPI | `/api/validation/checktext` | ✅ |
|
||||||
|
|
||||||
|
**Не реализовано:**
|
||||||
|
- `/kworks` — требует исследования модели ответа
|
||||||
|
- Analytics эндпоинты — не нужны для библиотеки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Блокеры
|
||||||
|
|
||||||
|
Нет
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 История
|
||||||
|
|
||||||
|
- **2026-03-29** — Репозиторий очищен от артефактов (site/, .coverage)
|
||||||
|
- **2026-03-29** — Настроен semantic-release для авто-версионирования
|
||||||
|
- **2026-03-29** — CI/CD pipeline готов (PR checks + release)
|
||||||
|
- **2026-03-29** — История переписана (1 чистый коммит)
|
||||||
|
- **2026-03-23** — Initial release (45+ endpoints, 92% coverage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Секреты (Gitea)
|
||||||
|
|
||||||
|
**Требуется:** `GITEA_TOKEN` в repository secrets
|
||||||
|
|
||||||
|
**Права:**
|
||||||
|
- `write:repository` (push tags, create releases)
|
||||||
|
- `write:package` (publish to Gitea Packages)
|
||||||
|
|
||||||
|
**Где настроить:**
|
||||||
|
https://git.much-data.ru/much-data/kwork-api/settings/secrets
|
||||||
9
docs/api-reference.md
Normal file
9
docs/api-reference.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# API Reference
|
||||||
|
|
||||||
|
Auto-generated API documentation is available below.
|
||||||
|
|
||||||
|
::: kwork_api.client.KworkClient
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
show_root_heading: true
|
||||||
|
show_source: true
|
||||||
47
docs/index.md
Normal file
47
docs/index.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Kwork API
|
||||||
|
|
||||||
|
Unofficial Python client for Kwork.ru API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Complete async API client (45+ endpoints)
|
||||||
|
- Pydantic models for all responses
|
||||||
|
- Two-step authentication (cookies + web_auth_token)
|
||||||
|
- Comprehensive error handling
|
||||||
|
- HTTP/2 support
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add kwork-api
|
||||||
|
# or
|
||||||
|
pip install kwork-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
|
||||||
|
# Login with credentials
|
||||||
|
async with await KworkClient.login("username", "password") as client:
|
||||||
|
# Get catalog
|
||||||
|
catalog = await client.catalog.get_list(page=1)
|
||||||
|
|
||||||
|
# Get projects
|
||||||
|
projects = await client.projects.get_list()
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
user = await client.user.get_info()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Usage Guide](usage.md) — Examples and best practices
|
||||||
|
- [API Reference](api-reference.md) — Complete API documentation
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Source Code](https://git.much-data.ru/much-data/kwork-api)
|
||||||
|
- [Issues](https://git.much-data.ru/much-data/kwork-api/issues)
|
||||||
|
- [Gitea Packages](https://git.much-data.ru/much-data/kwork-api/packages)
|
||||||
132
docs/usage.md
Normal file
132
docs/usage.md
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# Usage Guide
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Login with credentials
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
|
||||||
|
async with await KworkClient.login("username", "password") as client:
|
||||||
|
# Your code here
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore session from token
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
|
||||||
|
# Save token after first login
|
||||||
|
client = await KworkClient.login("username", "password")
|
||||||
|
token = client.token # Save this
|
||||||
|
|
||||||
|
# Later, restore session
|
||||||
|
client = KworkClient(token="saved_token")
|
||||||
|
user = await client.user.get_info()
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Groups
|
||||||
|
|
||||||
|
### Catalog API
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get catalog list
|
||||||
|
catalog = await client.catalog.get_list(page=1, category_id=1)
|
||||||
|
|
||||||
|
# Get kwork details
|
||||||
|
kwork = await client.catalog.get_details(kwork_id=123)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projects API (Freelance)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get projects list
|
||||||
|
projects = await client.projects.get_list(page=1)
|
||||||
|
|
||||||
|
# Get your orders
|
||||||
|
my_orders = await client.projects.get_payer_orders()
|
||||||
|
```
|
||||||
|
|
||||||
|
### User API
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get user info
|
||||||
|
user = await client.user.get_info()
|
||||||
|
|
||||||
|
# Get reviews
|
||||||
|
reviews = await client.user.get_reviews(user_id=123)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference API
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get cities
|
||||||
|
cities = await client.reference.get_cities()
|
||||||
|
|
||||||
|
# Get countries
|
||||||
|
countries = await client.reference.get_countries()
|
||||||
|
|
||||||
|
# Get categories
|
||||||
|
categories = await client.reference.get_categories()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notifications API
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get notifications
|
||||||
|
notifications = await client.notifications.get_list()
|
||||||
|
|
||||||
|
# Get dialogs
|
||||||
|
dialogs = await client.notifications.get_dialogs()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kwork_api import KworkApiError, KworkAuthError
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog = await client.catalog.get_list()
|
||||||
|
except KworkAuthError as e:
|
||||||
|
print(f"Authentication failed: {e}")
|
||||||
|
except KworkApiError as e:
|
||||||
|
print(f"API error: {e.status_code} - {e.message}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Kwork doesn't have official rate limits, but be respectful:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
for page in range(1, 11):
|
||||||
|
catalog = await client.catalog.get_list(page=page)
|
||||||
|
await asyncio.sleep(0.5) # 500ms delay
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
Always use context manager for automatic cleanup:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with await KworkClient.login("user", "pass") as client:
|
||||||
|
# Client automatically closed
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Token
|
||||||
|
|
||||||
|
Save token after first login to avoid repeated authentication:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# First login
|
||||||
|
client = await KworkClient.login("user", "pass")
|
||||||
|
save_token(client.token) # Save to secure storage
|
||||||
|
|
||||||
|
# Later
|
||||||
|
client = KworkClient(token=load_token())
|
||||||
|
```
|
||||||
75
mkdocs.yml
Normal file
75
mkdocs.yml
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
site_name: Kwork API
|
||||||
|
site_description: Unofficial Python client for Kwork.ru API
|
||||||
|
site_url: https://git.much-data.ru/much-data/kwork-api
|
||||||
|
|
||||||
|
repo_name: much-data/kwork-api
|
||||||
|
repo_url: https://git.much-data.ru/much-data/kwork-api
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
features:
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.sections
|
||||||
|
- toc.integrate
|
||||||
|
- search.suggest
|
||||||
|
- search.highlight
|
||||||
|
palette:
|
||||||
|
- scheme: default
|
||||||
|
toggle:
|
||||||
|
icon: material/toggle-switch-off-outline
|
||||||
|
name: Switch to dark mode
|
||||||
|
- scheme: slate
|
||||||
|
toggle:
|
||||||
|
icon: material/toggle-switch
|
||||||
|
name: Switch to light mode
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search
|
||||||
|
- mkdocstrings:
|
||||||
|
handlers:
|
||||||
|
python:
|
||||||
|
paths: [src]
|
||||||
|
options:
|
||||||
|
docstring_style: google
|
||||||
|
show_source: true
|
||||||
|
show_root_heading: true
|
||||||
|
show_category_heading: true
|
||||||
|
merge_init_into_class: true
|
||||||
|
separate_signature: true
|
||||||
|
signature_crossrefs: true
|
||||||
|
filters:
|
||||||
|
- "!^_"
|
||||||
|
- "^__init__"
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- attr_list
|
||||||
|
- def_list
|
||||||
|
- footnotes
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
- pymdownx.arithmatex:
|
||||||
|
generic: true
|
||||||
|
- pymdownx.betterem:
|
||||||
|
smart_enable: all
|
||||||
|
- pymdownx.caret
|
||||||
|
- pymdownx.details
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.keys
|
||||||
|
- pymdownx.magiclink:
|
||||||
|
repo_url_shorthand: true
|
||||||
|
user: much-data
|
||||||
|
repo: kwork-api
|
||||||
|
- pymdownx.mark
|
||||||
|
- pymdownx.smartsymbols
|
||||||
|
- pymdownx.superfences
|
||||||
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
|
- pymdownx.tasklist:
|
||||||
|
custom_checkbox: true
|
||||||
|
- pymdownx.tilde
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Home: index.md
|
||||||
|
- API Reference: api-reference.md
|
||||||
|
- Usage: usage.md
|
||||||
132
pyproject.toml
Normal file
132
pyproject.toml
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
[project]
|
||||||
|
name = "kwork-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Unofficial Kwork.ru API client"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{name = "Claw", email = "claw@localhost"}
|
||||||
|
]
|
||||||
|
keywords = ["kwork", "api", "client", "parsing", "freelance"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"httpx[http2]>=0.26.0",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"structlog>=24.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.much-data.ru/much-data/kwork-api"
|
||||||
|
Repository = "https://git.much-data.ru/much-data/kwork-api.git"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/kwork_api"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
# Testing
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-html>=4.0.0",
|
||||||
|
"respx>=0.20.0",
|
||||||
|
# Linting & formatting
|
||||||
|
"ruff>=0.3.0",
|
||||||
|
# CI tools
|
||||||
|
"python-semantic-release>=9.0.0",
|
||||||
|
"pip-audit>=2.7.0",
|
||||||
|
]
|
||||||
|
docs = [
|
||||||
|
# Documentation (optional, for local development)
|
||||||
|
"mkdocs>=1.6.1",
|
||||||
|
"mkdocs-material>=9.7.6",
|
||||||
|
"mkdocstrings[python]>=1.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ========== Tool Configurations ==========
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py310"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["kwork_api"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src/kwork_api"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ========== Semantic Release ==========
|
||||||
|
|
||||||
|
[tool.semantic_release]
|
||||||
|
version_toml = ["pyproject.toml:project.version"]
|
||||||
|
version_variables = ["src/kwork_api/__init__.py:__version__"]
|
||||||
|
branch = "main"
|
||||||
|
build_command = "uv build"
|
||||||
|
commit_parser = "angular"
|
||||||
|
tag_format = "v{version}"
|
||||||
|
major_on_zero = true
|
||||||
|
allow_zero_version = true
|
||||||
|
|
||||||
|
[tool.semantic_release.commit_parser_options]
|
||||||
|
minor_tags = ["feat"]
|
||||||
|
patch_tags = ["fix", "perf"]
|
||||||
|
breaking_change_tags = ["feat"]
|
||||||
|
|
||||||
|
[tool.semantic_release.remote]
|
||||||
|
type = "gitea"
|
||||||
|
domain = "https://git.much-data.ru"
|
||||||
|
owner = "much-data"
|
||||||
|
repo_name = "kwork-api"
|
||||||
|
token = { env = "GH_TOKEN" }
|
||||||
|
|
||||||
|
[tool.semantic_release.publish]
|
||||||
|
dist_glob_patterns = ["dist/*"]
|
||||||
|
upload_to_vcs_release = true
|
||||||
44
src/kwork_api/__init__.py
Normal file
44
src/kwork_api/__init__.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Kwork.ru API Client
|
||||||
|
|
||||||
|
Unofficial Python client for Kwork.ru API.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
|
||||||
|
# Login with credentials
|
||||||
|
client = await KworkClient.login("username", "password")
|
||||||
|
|
||||||
|
# Or restore from token
|
||||||
|
client = KworkClient(token="your_web_auth_token")
|
||||||
|
|
||||||
|
# Get catalog
|
||||||
|
catalog = await client.catalog.get_list(page=1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .client import KworkClient
|
||||||
|
from .errors import KworkError, KworkAuthError, KworkApiError
|
||||||
|
from .models import (
|
||||||
|
ValidationResponse,
|
||||||
|
ValidationIssue,
|
||||||
|
Kwork,
|
||||||
|
KworkDetails,
|
||||||
|
Project,
|
||||||
|
CatalogResponse,
|
||||||
|
ProjectsResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__version__ = "0.1.0" # Updated by semantic-release
|
||||||
|
__all__ = [
|
||||||
|
"KworkClient",
|
||||||
|
"KworkError",
|
||||||
|
"KworkAuthError",
|
||||||
|
"KworkApiError",
|
||||||
|
"ValidationResponse",
|
||||||
|
"ValidationIssue",
|
||||||
|
"Kwork",
|
||||||
|
"KworkDetails",
|
||||||
|
"Project",
|
||||||
|
"CatalogResponse",
|
||||||
|
"ProjectsResponse",
|
||||||
|
]
|
||||||
BIN
src/kwork_api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/kwork_api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/kwork_api/__pycache__/client.cpython-312.pyc
Normal file
BIN
src/kwork_api/__pycache__/client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/kwork_api/__pycache__/errors.cpython-312.pyc
Normal file
BIN
src/kwork_api/__pycache__/errors.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/kwork_api/__pycache__/models.cpython-312.pyc
Normal file
BIN
src/kwork_api/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
1254
src/kwork_api/client.py
Normal file
1254
src/kwork_api/client.py
Normal file
File diff suppressed because it is too large
Load Diff
202
src/kwork_api/errors.py
Normal file
202
src/kwork_api/errors.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Исключения Kwork API.
|
||||||
|
|
||||||
|
Все исключения предоставляют понятные сообщения для отладки.
|
||||||
|
Иерархия исключений:
|
||||||
|
|
||||||
|
KworkError (базовое)
|
||||||
|
├── KworkAuthError (ошибки аутентификации)
|
||||||
|
├── KworkApiError (HTTP ошибки API)
|
||||||
|
│ ├── KworkNotFoundError (404)
|
||||||
|
│ ├── KworkRateLimitError (429)
|
||||||
|
│ └── KworkValidationError (400)
|
||||||
|
└── KworkNetworkError (ошибки сети)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"KworkError",
|
||||||
|
"KworkAuthError",
|
||||||
|
"KworkApiError",
|
||||||
|
"KworkNotFoundError",
|
||||||
|
"KworkRateLimitError",
|
||||||
|
"KworkValidationError",
|
||||||
|
"KworkNetworkError",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class KworkError(Exception):
|
||||||
|
"""
|
||||||
|
Базовое исключение для всех ошибок Kwork API.
|
||||||
|
|
||||||
|
Все остальные исключения наследуются от этого класса.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
message: Сообщение об ошибке.
|
||||||
|
response: Оригинальный HTTP response (если есть).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
await client.catalog.get_list()
|
||||||
|
except KworkError as e:
|
||||||
|
print(f"Ошибка: {e.message}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, response: Optional[Any] = None):
|
||||||
|
self.message = message
|
||||||
|
self.response = response
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"KworkError: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
class KworkAuthError(KworkError):
|
||||||
|
"""
|
||||||
|
Ошибка аутентификации/авторизации.
|
||||||
|
|
||||||
|
Возникает при:
|
||||||
|
- Неверном логине или пароле
|
||||||
|
- Истёкшем или невалидном токене
|
||||||
|
- Отсутствии прав доступа (403)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
client = await KworkClient.login("user", "wrong_password")
|
||||||
|
except KworkAuthError:
|
||||||
|
print("Неверные учётные данные")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Authentication failed", response: Optional[Any] = None):
|
||||||
|
super().__init__(message, response)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"KworkAuthError: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
class KworkApiError(KworkError):
|
||||||
|
"""
|
||||||
|
Ошибка HTTP запроса к API (4xx, 5xx).
|
||||||
|
|
||||||
|
Базовый класс для HTTP ошибок API. Содержит код статуса.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
status_code: HTTP код ответа (400, 404, 500, etc.)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
await client.catalog.get_details(999999)
|
||||||
|
except KworkApiError as e:
|
||||||
|
print(f"HTTP {e.status_code}: {e.message}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
response: Optional[Any] = None,
|
||||||
|
):
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message, response)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.status_code:
|
||||||
|
return f"KworkApiError [{self.status_code}]: {self.message}"
|
||||||
|
return f"KworkApiError: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
class KworkNotFoundError(KworkApiError):
|
||||||
|
"""
|
||||||
|
Ресурс не найден (404).
|
||||||
|
|
||||||
|
Возникает при запросе несуществующего кворка,
|
||||||
|
пользователя или другого ресурса.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
await client.catalog.get_details(999999)
|
||||||
|
except KworkNotFoundError:
|
||||||
|
print("Кворк не найден")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, resource: str, response: Optional[Any] = None):
|
||||||
|
super().__init__(f"Resource not found: {resource}", 404, response)
|
||||||
|
|
||||||
|
|
||||||
|
class KworkRateLimitError(KworkApiError):
|
||||||
|
"""
|
||||||
|
Превышен лимит запросов (429).
|
||||||
|
|
||||||
|
Возникает при слишком частых запросах к API.
|
||||||
|
Рекомендуется сделать паузу перед повторным запросом.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.catalog.get_list()
|
||||||
|
except KworkRateLimitError:
|
||||||
|
await asyncio.sleep(5) # Пауза 5 секунд
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Rate limit exceeded", response: Optional[Any] = None):
|
||||||
|
super().__init__(message, 429, response)
|
||||||
|
|
||||||
|
|
||||||
|
class KworkValidationError(KworkApiError):
|
||||||
|
"""
|
||||||
|
Ошибка валидации (400).
|
||||||
|
|
||||||
|
Возникает при некорректных данных запроса.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
fields: Словарь ошибок по полям {field: [errors]}.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
await client.catalog.get_list(page=-1)
|
||||||
|
except KworkValidationError as e:
|
||||||
|
if e.fields:
|
||||||
|
for field, errors in e.fields.items():
|
||||||
|
print(f"{field}: {errors[0]}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Validation failed",
|
||||||
|
fields: Optional[dict[str, list[str]]] = None,
|
||||||
|
response: Optional[Any] = None,
|
||||||
|
):
|
||||||
|
self.fields = fields or {}
|
||||||
|
super().__init__(message, 400, response)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.fields:
|
||||||
|
field_errors = ", ".join(f"{k}: {v[0]}" for k, v in self.fields.items())
|
||||||
|
return f"KworkValidationError: {field_errors}"
|
||||||
|
return f"KworkValidationError: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
class KworkNetworkError(KworkError):
|
||||||
|
"""
|
||||||
|
Ошибка сети/подключения.
|
||||||
|
|
||||||
|
Возникает при:
|
||||||
|
- Отсутствии соединения
|
||||||
|
- Таймауте запроса
|
||||||
|
- Ошибке DNS
|
||||||
|
- Проблемах с SSL
|
||||||
|
|
||||||
|
Example:
|
||||||
|
try:
|
||||||
|
await client.catalog.get_list()
|
||||||
|
except KworkNetworkError:
|
||||||
|
print("Проверьте подключение к интернету")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Network error", response: Optional[Any] = None):
|
||||||
|
super().__init__(message, response)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"KworkNetworkError: {self.message}"
|
||||||
450
src/kwork_api/models.py
Normal file
450
src/kwork_api/models.py
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
"""
|
||||||
|
Pydantic модели для ответов Kwork API.
|
||||||
|
|
||||||
|
Все модели соответствуют структуре, найденной при анализе HAR дампа.
|
||||||
|
Используются для валидации и типизации ответов API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class KworkUser(BaseModel):
|
||||||
|
"""
|
||||||
|
Информация о пользователе Kwork.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID пользователя.
|
||||||
|
username: Имя пользователя (логин).
|
||||||
|
avatar_url: URL аватара или None.
|
||||||
|
is_online: Статус онлайн.
|
||||||
|
rating: Рейтинг пользователя (0-5).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
user = KworkUser(id=123, username="seller", rating=4.9)
|
||||||
|
print(f"{user.username}: {user.rating} ★")
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
is_online: bool = False
|
||||||
|
rating: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KworkCategory(BaseModel):
|
||||||
|
"""
|
||||||
|
Категория кворков.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID категории.
|
||||||
|
name: Название категории.
|
||||||
|
slug: URL-safe идентификатор.
|
||||||
|
parent_id: ID родительской категории для вложенности.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Kwork(BaseModel):
|
||||||
|
"""
|
||||||
|
Кворк — услуга на Kwork.
|
||||||
|
|
||||||
|
Базовая модель кворка с основной информацией.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID кворка.
|
||||||
|
title: Заголовок кворка.
|
||||||
|
description: Краткое описание.
|
||||||
|
price: Цена в рублях.
|
||||||
|
currency: Валюта (по умолчанию RUB).
|
||||||
|
category_id: ID категории.
|
||||||
|
seller: Информация о продавце.
|
||||||
|
images: Список URL изображений.
|
||||||
|
rating: Рейтинг кворка (0-5).
|
||||||
|
reviews_count: Количество отзывов.
|
||||||
|
created_at: Дата создания.
|
||||||
|
updated_at: Дата последнего обновления.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
price: float
|
||||||
|
currency: str = "RUB"
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
seller: Optional[KworkUser] = None
|
||||||
|
images: list[str] = Field(default_factory=list)
|
||||||
|
rating: Optional[float] = None
|
||||||
|
reviews_count: int = 0
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KworkDetails(Kwork):
|
||||||
|
"""
|
||||||
|
Расширенная информация о кворке.
|
||||||
|
|
||||||
|
Наследует все поля Kwork плюс дополнительные детали.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
full_description: Полное описание услуги.
|
||||||
|
requirements: Требования к заказчику.
|
||||||
|
delivery_time: Срок выполнения в днях.
|
||||||
|
revisions: Количество бесплатных правок.
|
||||||
|
features: Список дополнительных опций.
|
||||||
|
faq: Список вопросов и ответов.
|
||||||
|
"""
|
||||||
|
full_description: Optional[str] = None
|
||||||
|
requirements: Optional[str] = None
|
||||||
|
delivery_time: Optional[int] = None
|
||||||
|
revisions: Optional[int] = None
|
||||||
|
features: list[str] = Field(default_factory=list)
|
||||||
|
faq: list[dict[str, str]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationInfo(BaseModel):
|
||||||
|
"""
|
||||||
|
Информация о пагинации.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
current_page: Текущая страница (начиная с 1).
|
||||||
|
total_pages: Общее количество страниц.
|
||||||
|
total_items: Общее количество элементов.
|
||||||
|
items_per_page: Элементов на странице.
|
||||||
|
has_next: Есть ли следующая страница.
|
||||||
|
has_prev: Есть ли предыдущая страница.
|
||||||
|
"""
|
||||||
|
current_page: int = 1
|
||||||
|
total_pages: int = 1
|
||||||
|
total_items: int = 0
|
||||||
|
items_per_page: int = 20
|
||||||
|
has_next: bool = False
|
||||||
|
has_prev: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API каталога кворков.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
kworks: Список кворков на странице.
|
||||||
|
pagination: Информация о пагинации.
|
||||||
|
filters: Доступные фильтры.
|
||||||
|
sort_options: Доступные опции сортировки.
|
||||||
|
"""
|
||||||
|
kworks: list[Kwork] = Field(default_factory=list)
|
||||||
|
pagination: Optional[PaginationInfo] = None
|
||||||
|
filters: Optional[dict[str, Any]] = None
|
||||||
|
sort_options: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(BaseModel):
|
||||||
|
"""
|
||||||
|
Проект (заказ на бирже фриланса).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID проекта.
|
||||||
|
title: Заголовок проекта.
|
||||||
|
description: Описание задачи.
|
||||||
|
budget: Бюджет проекта.
|
||||||
|
budget_type: Тип бюджета: "fixed" (фиксированный) или "hourly" (почасовой).
|
||||||
|
category_id: ID категории.
|
||||||
|
customer: Информация о заказчике.
|
||||||
|
status: Статус проекта: "open", "in_progress", "completed", "cancelled".
|
||||||
|
created_at: Дата создания.
|
||||||
|
updated_at: Дата обновления.
|
||||||
|
bids_count: Количество откликов.
|
||||||
|
skills: Требуемые навыки.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
budget: Optional[float] = None
|
||||||
|
budget_type: str = "fixed"
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
customer: Optional[KworkUser] = None
|
||||||
|
status: str = "open"
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
bids_count: int = 0
|
||||||
|
skills: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectsResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API списка проектов.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
projects: Список проектов.
|
||||||
|
pagination: Информация о пагинации.
|
||||||
|
"""
|
||||||
|
projects: list[Project] = Field(default_factory=list)
|
||||||
|
pagination: Optional[PaginationInfo] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Review(BaseModel):
|
||||||
|
"""
|
||||||
|
Отзыв о кворке или проекте.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID отзыва.
|
||||||
|
rating: Оценка от 1 до 5.
|
||||||
|
comment: Текст отзыва.
|
||||||
|
author: Автор отзыва.
|
||||||
|
kwork_id: ID кворка (если отзыв о кворке).
|
||||||
|
created_at: Дата создания.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
rating: int = Field(ge=1, le=5)
|
||||||
|
comment: Optional[str] = None
|
||||||
|
author: Optional[KworkUser] = None
|
||||||
|
kwork_id: Optional[int] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewsResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API списка отзывов.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
reviews: Список отзывов.
|
||||||
|
pagination: Информация о пагинации.
|
||||||
|
average_rating: Средний рейтинг.
|
||||||
|
"""
|
||||||
|
reviews: list[Review] = Field(default_factory=list)
|
||||||
|
pagination: Optional[PaginationInfo] = None
|
||||||
|
average_rating: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(BaseModel):
|
||||||
|
"""
|
||||||
|
Уведомление пользователя.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID уведомления.
|
||||||
|
type: Тип уведомления: "message", "order", "system", etc.
|
||||||
|
title: Заголовок уведомления.
|
||||||
|
message: Текст уведомления.
|
||||||
|
is_read: Прочитано ли уведомление.
|
||||||
|
created_at: Дата создания.
|
||||||
|
link: Ссылка для перехода (если есть).
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
is_read: bool = False
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
link: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API списка уведомлений.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
notifications: Список уведомлений.
|
||||||
|
unread_count: Количество непрочитанных уведомлений.
|
||||||
|
"""
|
||||||
|
notifications: list[Notification] = Field(default_factory=list)
|
||||||
|
unread_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Dialog(BaseModel):
|
||||||
|
"""
|
||||||
|
Диалог (чат) с пользователем.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID диалога.
|
||||||
|
participant: Собеседник.
|
||||||
|
last_message: Текст последнего сообщения.
|
||||||
|
unread_count: Количество непрочитанных сообщений.
|
||||||
|
updated_at: Время последнего сообщения.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
participant: Optional[KworkUser] = None
|
||||||
|
last_message: Optional[str] = None
|
||||||
|
unread_count: int = 0
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API аутентификации.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
success: Успешность аутентификации.
|
||||||
|
user_id: ID пользователя.
|
||||||
|
username: Имя пользователя.
|
||||||
|
web_auth_token: Токен для последующих запросов.
|
||||||
|
message: Сообщение (например, об ошибке).
|
||||||
|
"""
|
||||||
|
success: bool
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
web_auth_token: Optional[str] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorDetail(BaseModel):
|
||||||
|
"""
|
||||||
|
Детали ошибки API.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
code: Код ошибки.
|
||||||
|
message: Сообщение об ошибке.
|
||||||
|
field: Поле, вызвавшее ошибку (если применимо).
|
||||||
|
"""
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
field: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class APIErrorResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Стандартный ответ API об ошибке.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
success: Всегда False для ошибок.
|
||||||
|
errors: Список деталей ошибок.
|
||||||
|
message: Общее сообщение об ошибке.
|
||||||
|
"""
|
||||||
|
success: bool = False
|
||||||
|
errors: list[ErrorDetail] = Field(default_factory=list)
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class City(BaseModel):
|
||||||
|
"""
|
||||||
|
Город из справочника.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID города.
|
||||||
|
name: Название города.
|
||||||
|
country_id: ID страны.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
country_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Country(BaseModel):
|
||||||
|
"""
|
||||||
|
Страна из справочника.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID страны.
|
||||||
|
name: Название страны.
|
||||||
|
code: Код страны (ISO).
|
||||||
|
cities: Список городов в стране.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
code: Optional[str] = None
|
||||||
|
cities: list[City] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeZone(BaseModel):
|
||||||
|
"""
|
||||||
|
Часовой пояс.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID.
|
||||||
|
name: Название пояса.
|
||||||
|
offset: Смещение от UTC (например, "+03:00").
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
offset: str
|
||||||
|
|
||||||
|
|
||||||
|
class Feature(BaseModel):
|
||||||
|
"""
|
||||||
|
Дополнительная функция (feature) для кворка.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID функции.
|
||||||
|
name: Название.
|
||||||
|
description: Описание.
|
||||||
|
price: Стоимость в рублях.
|
||||||
|
type: Тип: "extra", "premium", etc.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
price: float
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
class Badge(BaseModel):
|
||||||
|
"""
|
||||||
|
Значок (достижение) пользователя.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID значка.
|
||||||
|
name: Название значка.
|
||||||
|
description: Описание достижения.
|
||||||
|
icon_url: URL иконки значка.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
icon_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Generic response wrapper
|
||||||
|
class DataResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Универсальный ответ API с данными.
|
||||||
|
|
||||||
|
Используется как обёртка для различных ответов API.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
success: Успешность запроса.
|
||||||
|
data: Полезные данные (словарь).
|
||||||
|
message: Дополнительное сообщение.
|
||||||
|
"""
|
||||||
|
success: bool = True
|
||||||
|
data: Optional[dict[str, Any]] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationIssue(BaseModel):
|
||||||
|
"""
|
||||||
|
Проблема, найденная при валидации текста.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
type: Тип проблемы: "error", "warning", "suggestion".
|
||||||
|
code: Код ошибки (например, "SPELLING", "GRAMMAR", "LENGTH").
|
||||||
|
message: Описание проблемы.
|
||||||
|
position: Позиция в тексте (если применимо).
|
||||||
|
suggestion: Предлагаемое исправление (если есть).
|
||||||
|
"""
|
||||||
|
type: str = "error"
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
position: Optional[int] = None
|
||||||
|
suggestion: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API валидации текста.
|
||||||
|
|
||||||
|
Используется для эндпоинта /api/validation/checktext.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
success: Успешность валидации.
|
||||||
|
is_valid: Текст проходит валидацию (нет критических ошибок).
|
||||||
|
issues: Список найденных проблем.
|
||||||
|
score: Оценка качества текста (0-100, если доступна).
|
||||||
|
message: Дополнительное сообщение.
|
||||||
|
"""
|
||||||
|
success: bool = True
|
||||||
|
is_valid: bool = True
|
||||||
|
issues: list[ValidationIssue] = Field(default_factory=list)
|
||||||
|
score: Optional[int] = None
|
||||||
|
message: Optional[str] = None
|
||||||
5
tests/e2e/.env.example
Normal file
5
tests/e2e/.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Kwork.ru credentials for E2E testing
|
||||||
|
# Copy this file to .env and fill in your test credentials
|
||||||
|
|
||||||
|
KWORK_USERNAME=your_test_username
|
||||||
|
KWORK_PASSWORD=your_test_password
|
||||||
136
tests/e2e/README.md
Normal file
136
tests/e2e/README.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
# End-to-End (E2E) Testing
|
||||||
|
|
||||||
|
E2E тесты требуют реальных credentials Kwork.ru и запускаются **только локально** (не в CI).
|
||||||
|
|
||||||
|
## ⚠️ Предупреждение
|
||||||
|
|
||||||
|
- **Не запускай в CI** — требуются реальные credentials
|
||||||
|
- **Используй тестовый аккаунт** — не основной аккаунт Kwork
|
||||||
|
- **Rate limiting** — добавляй задержки между запросами
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Настройка
|
||||||
|
|
||||||
|
### 1. Создай файл окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/kwork-api
|
||||||
|
cp tests/e2e/.env.example tests/e2e/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Заполни credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# tests/e2e/.env
|
||||||
|
KWORK_USERNAME=your_test_username
|
||||||
|
KWORK_PASSWORD=your_test_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Установи зависимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --group dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Запуск тестов
|
||||||
|
|
||||||
|
### Все E2E тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/e2e/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Конкретный тест
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/e2e/test_auth.py -v
|
||||||
|
uv run pytest tests/e2e/test_catalog.py::test_get_catalog_list -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### С задержками (rate limiting)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/e2e/ -v --slowmo=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Структура тестов
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/e2e/
|
||||||
|
├── README.md # Этот файл
|
||||||
|
├── .env.example # Шаблон для credentials
|
||||||
|
├── conftest.py # Фикстуры и setup
|
||||||
|
├── test_auth.py # Аутентификация
|
||||||
|
├── test_catalog.py # Каталог кворков
|
||||||
|
├── test_projects.py # Биржа проектов
|
||||||
|
└── test_user.py # Пользовательские данные
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Пример теста
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
async def test_get_user_info():
|
||||||
|
"""E2E тест: получение информации о пользователе."""
|
||||||
|
async with await KworkClient.login(
|
||||||
|
username="test_user",
|
||||||
|
password="test_pass"
|
||||||
|
) as client:
|
||||||
|
user = await client.user.get_info()
|
||||||
|
assert user.username == "test_user"
|
||||||
|
assert user.balance >= 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏷️ Маркировка тестов
|
||||||
|
|
||||||
|
E2E тесты маркируются `@pytest.mark.e2e` для изоляции:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запустить только unit тесты (исключить e2e)
|
||||||
|
uv run pytest tests/ -v -m "not e2e"
|
||||||
|
|
||||||
|
# Запустить только e2e тесты
|
||||||
|
uv run pytest tests/ -v -m e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Безопасность
|
||||||
|
|
||||||
|
1. **Никогда не коммить `.env`** — добавлен в `.gitignore`
|
||||||
|
2. **Используй тестовый аккаунт** — не основной
|
||||||
|
3. **Не сохраняй токены в коде** — только через env vars
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Ошибка аутентификации
|
||||||
|
```
|
||||||
|
KworkAuthError: Invalid credentials
|
||||||
|
```
|
||||||
|
**Решение:** Проверь credentials в `.env`
|
||||||
|
|
||||||
|
### Rate limit
|
||||||
|
```
|
||||||
|
KworkApiError: Too many requests
|
||||||
|
```
|
||||||
|
**Решение:** Запусти с задержкой: `pytest --slowmo=2`
|
||||||
|
|
||||||
|
### Session expired
|
||||||
|
```
|
||||||
|
KworkAuthError: Session expired
|
||||||
|
```
|
||||||
|
**Решение:** Перезапусти тесты (session создаётся заново)
|
||||||
59
tests/e2e/conftest.py
Normal file
59
tests/e2e/conftest.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
E2E тесты для Kwork API.
|
||||||
|
|
||||||
|
Требуют реальных credentials и запускаются только локально.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Загружаем .env
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def kwork_credentials():
|
||||||
|
"""Credentials для тестового аккаунта."""
|
||||||
|
return {
|
||||||
|
"username": os.getenv("KWORK_USERNAME"),
|
||||||
|
"password": os.getenv("KWORK_PASSWORD"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def require_credentials(kwork_credentials):
|
||||||
|
"""Пропускает тест если нет credentials."""
|
||||||
|
if not kwork_credentials["username"] or not kwork_credentials["password"]:
|
||||||
|
pytest.skip(
|
||||||
|
"E2E credentials not set. "
|
||||||
|
"Copy tests/e2e/.env.example to tests/e2e/.env and fill in credentials."
|
||||||
|
)
|
||||||
|
return kwork_credentials
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def slowmo(request):
|
||||||
|
"""Задержка между тестами для rate limiting."""
|
||||||
|
slowmo = request.config.getoption("--slowmo", default=0)
|
||||||
|
if slowmo > 0:
|
||||||
|
import time
|
||||||
|
time.sleep(slowmo)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Регистрация маркера e2e."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "e2e: mark test as end-to-end (requires credentials)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser):
|
||||||
|
"""Добавляет опцию --slowmo."""
|
||||||
|
parser.addoption(
|
||||||
|
"--slowmo",
|
||||||
|
type=float,
|
||||||
|
default=0,
|
||||||
|
help="Delay between tests in seconds (for rate limiting)"
|
||||||
|
)
|
||||||
52
tests/e2e/test_auth.py
Normal file
52
tests/e2e/test_auth.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
E2E тесты аутентификации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from kwork_api import KworkClient
|
||||||
|
from kwork_api.errors import KworkAuthError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
async def test_login_success(require_credentials):
|
||||||
|
"""E2E: Успешная аутентификация."""
|
||||||
|
client = await KworkClient.login(
|
||||||
|
username=require_credentials["username"],
|
||||||
|
password=require_credentials["password"]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert client.token is not None
|
||||||
|
assert len(client.token) > 0
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
async def test_login_invalid_credentials():
|
||||||
|
"""E2E: Неверные credentials."""
|
||||||
|
with pytest.raises(KworkAuthError):
|
||||||
|
await KworkClient.login(
|
||||||
|
username="invalid_user_12345",
|
||||||
|
password="invalid_pass_12345"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
async def test_restore_session(require_credentials):
|
||||||
|
"""E2E: Восстановление сессии из токена."""
|
||||||
|
# First login
|
||||||
|
client1 = await KworkClient.login(
|
||||||
|
username=require_credentials["username"],
|
||||||
|
password=require_credentials["password"]
|
||||||
|
)
|
||||||
|
token = client1.token
|
||||||
|
await client1.aclose()
|
||||||
|
|
||||||
|
# Restore from token
|
||||||
|
client2 = KworkClient(token=token)
|
||||||
|
try:
|
||||||
|
user = await client2.user.get_info()
|
||||||
|
assert user.username == require_credentials["username"]
|
||||||
|
finally:
|
||||||
|
await client2.aclose()
|
||||||
Binary file not shown.
255
tests/integration/test_real_api.py
Normal file
255
tests/integration/test_real_api.py
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Integration tests with real Kwork API.
|
||||||
|
|
||||||
|
These tests require valid credentials and make real API calls.
|
||||||
|
Skip these tests in CI/CD or when running unit tests only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pytest tests/integration/ -m integration
|
||||||
|
|
||||||
|
Or with credentials:
|
||||||
|
KWORK_USERNAME=user KWORK_PASSWORD=pass pytest tests/integration/ -m integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kwork_api import KworkClient, KworkAuthError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client() -> Optional[KworkClient]:
|
||||||
|
"""
|
||||||
|
Create authenticated client for integration tests.
|
||||||
|
|
||||||
|
Requires KWORK_USERNAME and KWORK_PASSWORD environment variables.
|
||||||
|
Skip tests if not provided.
|
||||||
|
"""
|
||||||
|
username = os.getenv("KWORK_USERNAME")
|
||||||
|
password = os.getenv("KWORK_PASSWORD")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
pytest.skip("KWORK_USERNAME and KWORK_PASSWORD not set")
|
||||||
|
|
||||||
|
# Create client
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def create_client():
|
||||||
|
return await KworkClient.login(username, password)
|
||||||
|
|
||||||
|
return asyncio.run(create_client())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Test authentication with real API."""
|
||||||
|
|
||||||
|
def test_login_with_credentials(self):
|
||||||
|
"""Test login with real credentials."""
|
||||||
|
username = os.getenv("KWORK_USERNAME")
|
||||||
|
password = os.getenv("KWORK_PASSWORD")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
pytest.skip("Credentials not set")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def login():
|
||||||
|
client = await KworkClient.login(username, password)
|
||||||
|
assert client._token is not None
|
||||||
|
assert "userId" in client._cookies
|
||||||
|
await client.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = asyncio.run(login())
|
||||||
|
assert result
|
||||||
|
|
||||||
|
def test_invalid_credentials(self):
|
||||||
|
"""Test login with invalid credentials."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def try_login():
|
||||||
|
try:
|
||||||
|
await KworkClient.login("invalid_user_12345", "wrong_password")
|
||||||
|
return False
|
||||||
|
except KworkAuthError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = asyncio.run(try_login())
|
||||||
|
assert result # Should raise auth error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestCatalogAPI:
|
||||||
|
"""Test catalog endpoints with real API."""
|
||||||
|
|
||||||
|
def test_get_catalog_list(self, client: KworkClient):
|
||||||
|
"""Test getting catalog list."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch():
|
||||||
|
result = await client.catalog.get_list(page=1)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = asyncio.run(fetch())
|
||||||
|
|
||||||
|
assert result.kworks is not None
|
||||||
|
assert len(result.kworks) > 0
|
||||||
|
assert result.pagination is not None
|
||||||
|
|
||||||
|
def test_get_kwork_details(self, client: KworkClient):
|
||||||
|
"""Test getting kwork details."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch():
|
||||||
|
# First get a kwork ID from catalog
|
||||||
|
catalog = await client.catalog.get_list(page=1)
|
||||||
|
if not catalog.kworks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
kwork_id = catalog.kworks[0].id
|
||||||
|
details = await client.catalog.get_details(kwork_id)
|
||||||
|
return details
|
||||||
|
|
||||||
|
result = asyncio.run(fetch())
|
||||||
|
|
||||||
|
if result:
|
||||||
|
assert result.id is not None
|
||||||
|
assert result.title is not None
|
||||||
|
assert result.price is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestProjectsAPI:
|
||||||
|
"""Test projects endpoints with real API."""
|
||||||
|
|
||||||
|
def test_get_projects_list(self, client: KworkClient):
|
||||||
|
"""Test getting projects list."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch():
|
||||||
|
return await client.projects.get_list(page=1)
|
||||||
|
|
||||||
|
result = asyncio.run(fetch())
|
||||||
|
|
||||||
|
assert result.projects is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestReferenceAPI:
|
||||||
|
"""Test reference data endpoints."""
|
||||||
|
|
||||||
|
def test_get_cities(self, client: KworkClient):
|
||||||
|
"""Test getting cities."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch():
|
||||||
|
return await client.reference.get_cities()
|
||||||
|
|
||||||
|
result = asyncio.run(fetch())
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
# Kwork has many cities, should have at least some
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_get_countries(self, client: KworkClient):
|
||||||
|
"""Test getting countries."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(client.reference.get_countries())
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_get_timezones(self, client: KworkClient):
|
||||||
|
"""Test getting timezones."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(client.reference.get_timezones())
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestUserAPI:
|
||||||
|
"""Test user endpoints."""
|
||||||
|
|
||||||
|
def test_get_user_info(self, client: KworkClient):
|
||||||
|
"""Test getting current user info."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
result = asyncio.run(client.user.get_info())
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
# Should have user data
|
||||||
|
assert result # Not empty
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test error handling with real API."""
|
||||||
|
|
||||||
|
def test_invalid_kwork_id(self, client: KworkClient):
|
||||||
|
"""Test getting non-existent kwork."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
await client.catalog.get_details(999999999)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = asyncio.run(fetch())
|
||||||
|
# May or may not raise error depending on API behavior
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestRateLimiting:
|
||||||
|
"""Test rate limiting behavior."""
|
||||||
|
|
||||||
|
def test_multiple_requests(self, client: KworkClient):
|
||||||
|
"""Test making multiple requests."""
|
||||||
|
if not client:
|
||||||
|
pytest.skip("No client")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch_multiple():
|
||||||
|
results = []
|
||||||
|
for page in range(1, 4):
|
||||||
|
catalog = await client.catalog.get_list(page=page)
|
||||||
|
results.append(catalog)
|
||||||
|
# Small delay to avoid rate limiting
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
return results
|
||||||
|
|
||||||
|
results = asyncio.run(fetch_multiple())
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
for result in results:
|
||||||
|
assert result.kworks is not None
|
||||||
BIN
tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc
Normal file
BIN
tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc
Normal file
Binary file not shown.
332
tests/unit/test_client.py
Normal file
332
tests/unit/test_client.py
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for KworkClient with mocks.
|
||||||
|
|
||||||
|
These tests use respx for HTTP mocking and don't require real API access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from kwork_api import KworkClient, KworkAuthError, KworkApiError
|
||||||
|
from kwork_api.models import CatalogResponse, Kwork, ValidationResponse, ValidationIssue
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Test authentication flows."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_login_success(self):
|
||||||
|
"""Test successful login."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Mock login endpoint
|
||||||
|
login_route = respx.post("https://kwork.ru/signIn")
|
||||||
|
login_route.mock(return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"Set-Cookie": "userId=12345; slrememberme=token123"},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Mock token endpoint
|
||||||
|
token_route = respx.post("https://kwork.ru/getWebAuthToken").mock(
|
||||||
|
return_value=Response(
|
||||||
|
200,
|
||||||
|
json={"web_auth_token": "test_token_abc123"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login
|
||||||
|
client = await KworkClient.login("testuser", "testpass")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert login_route.called
|
||||||
|
assert token_route.called
|
||||||
|
assert client._token == "test_token_abc123"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_login_invalid_credentials(self):
|
||||||
|
"""Test login with invalid credentials."""
|
||||||
|
respx.post("https://kwork.ru/signIn").mock(
|
||||||
|
return_value=Response(401, json={"error": "Invalid credentials"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KworkAuthError):
|
||||||
|
await KworkClient.login("wrong", "wrong")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_login_no_userid(self):
|
||||||
|
"""Test login without userId in cookies."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
respx.post("https://kwork.ru/signIn").mock(
|
||||||
|
return_value=httpx.Response(200, headers={"Set-Cookie": "other=value"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KworkAuthError, match="no userId"):
|
||||||
|
await KworkClient.login("test", "test")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_login_no_token(self):
|
||||||
|
"""Test login without web_auth_token in response."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
respx.post("https://kwork.ru/signIn").mock(
|
||||||
|
return_value=httpx.Response(200, headers={"Set-Cookie": "userId=123"})
|
||||||
|
)
|
||||||
|
|
||||||
|
respx.post("https://kwork.ru/getWebAuthToken").mock(
|
||||||
|
return_value=Response(200, json={"other": "data"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KworkAuthError, match="No web_auth_token"):
|
||||||
|
await KworkClient.login("test", "test")
|
||||||
|
|
||||||
|
def test_init_with_token(self):
|
||||||
|
"""Test client initialization with token."""
|
||||||
|
client = KworkClient(token="test_token")
|
||||||
|
assert client._token == "test_token"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogAPI:
|
||||||
|
"""Test catalog endpoints."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_catalog(self):
|
||||||
|
"""Test getting catalog list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"kworks": [
|
||||||
|
{"id": 1, "title": "Test Kwork", "price": 1000.0},
|
||||||
|
{"id": 2, "title": "Another Kwork", "price": 2000.0},
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"current_page": 1,
|
||||||
|
"total_pages": 5,
|
||||||
|
"total_items": 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/catalogMainv2").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.catalog.get_list(page=1)
|
||||||
|
|
||||||
|
assert isinstance(result, CatalogResponse)
|
||||||
|
assert len(result.kworks) == 2
|
||||||
|
assert result.kworks[0].id == 1
|
||||||
|
assert result.pagination.total_pages == 5
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_kwork_details(self):
|
||||||
|
"""Test getting kwork details."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"id": 123,
|
||||||
|
"title": "Detailed Kwork",
|
||||||
|
"price": 5000.0,
|
||||||
|
"full_description": "Full description here",
|
||||||
|
"delivery_time": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/getKworkDetails").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.catalog.get_details(123)
|
||||||
|
|
||||||
|
assert result.id == 123
|
||||||
|
assert result.full_description == "Full description here"
|
||||||
|
assert result.delivery_time == 3
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_catalog_error(self):
|
||||||
|
"""Test catalog API error handling."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/catalogMainv2").mock(
|
||||||
|
return_value=Response(400, json={"message": "Invalid category"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KworkApiError):
|
||||||
|
await client.catalog.get_list(category_id=99999)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectsAPI:
|
||||||
|
"""Test projects endpoints."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_projects(self):
|
||||||
|
"""Test getting projects list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Test Project",
|
||||||
|
"description": "Test description",
|
||||||
|
"budget": 10000.0,
|
||||||
|
"status": "open",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {"current_page": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/projects").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.projects.get_list()
|
||||||
|
|
||||||
|
assert len(result.projects) == 1
|
||||||
|
assert result.projects[0].budget == 10000.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test error handling."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_404_error(self):
|
||||||
|
"""Test 404 error handling."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/getKworkDetails").mock(
|
||||||
|
return_value=Response(404)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KworkApiError) as exc_info:
|
||||||
|
await client.catalog.get_details(999)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_401_error(self):
|
||||||
|
"""Test 401 error handling."""
|
||||||
|
client = KworkClient(token="invalid")
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/catalogMainv2").mock(
|
||||||
|
return_value=Response(401)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KworkAuthError):
|
||||||
|
await client.catalog.get_list()
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_network_error(self):
|
||||||
|
"""Test network error handling."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/catalogMainv2").mock(
|
||||||
|
side_effect=Exception("Connection refused")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await client.catalog.get_list()
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextManager:
|
||||||
|
"""Test async context manager."""
|
||||||
|
|
||||||
|
async def test_context_manager(self):
|
||||||
|
"""Test using client as context manager."""
|
||||||
|
async with KworkClient(token="test") as client:
|
||||||
|
assert client._client is None # Not created yet
|
||||||
|
|
||||||
|
# Client should be created on first request
|
||||||
|
# (but we don't make actual requests in this test)
|
||||||
|
|
||||||
|
# Client should be closed after context
|
||||||
|
assert client._client is None or client._client.is_closed
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationAPI:
|
||||||
|
"""Test text validation endpoint."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_validate_text_success(self):
|
||||||
|
"""Test successful text validation."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"success": True,
|
||||||
|
"is_valid": True,
|
||||||
|
"issues": [],
|
||||||
|
"score": 95,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/api/validation/checktext").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.validate_text("Хороший текст для кворка")
|
||||||
|
|
||||||
|
assert isinstance(result, ValidationResponse)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.is_valid is True
|
||||||
|
assert len(result.issues) == 0
|
||||||
|
assert result.score == 95
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_validate_text_with_issues(self):
|
||||||
|
"""Test text validation with found issues."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"success": True,
|
||||||
|
"is_valid": False,
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": "CONTACT_INFO",
|
||||||
|
"message": "Текст содержит контактную информацию",
|
||||||
|
"position": 25,
|
||||||
|
"suggestion": "Удалите номер телефона",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "warning",
|
||||||
|
"code": "LENGTH",
|
||||||
|
"message": "Текст слишком короткий",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"score": 45,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/api/validation/checktext").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.validate_text(
|
||||||
|
"Звоните +7-999-000-00-00",
|
||||||
|
context="kwork_description",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.is_valid is False
|
||||||
|
assert len(result.issues) == 2
|
||||||
|
assert result.issues[0].code == "CONTACT_INFO"
|
||||||
|
assert result.issues[0].type == "error"
|
||||||
|
assert result.issues[1].type == "warning"
|
||||||
|
assert result.score == 45
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_validate_text_empty(self):
|
||||||
|
"""Test validation of empty text."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"success": False,
|
||||||
|
"is_valid": False,
|
||||||
|
"message": "Текст не может быть пустым",
|
||||||
|
"issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{client.base_url}/api/validation/checktext").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.validate_text("")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.message is not None
|
||||||
743
tests/unit/test_client_extended.py
Normal file
743
tests/unit/test_client_extended.py
Normal file
@ -0,0 +1,743 @@
|
|||||||
|
"""
|
||||||
|
Extended unit tests for KworkClient to improve coverage.
|
||||||
|
|
||||||
|
These tests cover additional API endpoints and edge cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from kwork_api import KworkClient, KworkAuthError, KworkApiError
|
||||||
|
from kwork_api.models import (
|
||||||
|
NotificationsResponse,
|
||||||
|
Kwork,
|
||||||
|
Dialog,
|
||||||
|
City,
|
||||||
|
Country,
|
||||||
|
TimeZone,
|
||||||
|
Feature,
|
||||||
|
Badge,
|
||||||
|
Project,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_URL = "https://api.kwork.ru"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientProperties:
|
||||||
|
"""Test client properties and initialization."""
|
||||||
|
|
||||||
|
def test_token_property(self):
|
||||||
|
"""Test token property getter."""
|
||||||
|
client = KworkClient(token="test_token_123")
|
||||||
|
assert client.token == "test_token_123"
|
||||||
|
|
||||||
|
def test_token_property_none(self):
|
||||||
|
"""Test token property when no token."""
|
||||||
|
client = KworkClient()
|
||||||
|
assert client.token is None
|
||||||
|
|
||||||
|
def test_cookies_property_empty(self):
|
||||||
|
"""Test cookies property with no cookies."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
assert client.cookies == {}
|
||||||
|
|
||||||
|
def test_cookies_property_with_cookies(self):
|
||||||
|
"""Test cookies property returns copy."""
|
||||||
|
client = KworkClient(token="test", cookies={"userId": "123", "session": "abc"})
|
||||||
|
cookies = client.cookies
|
||||||
|
assert cookies == {"userId": "123", "session": "abc"}
|
||||||
|
cookies["modified"] = "value"
|
||||||
|
assert "modified" not in client.cookies
|
||||||
|
|
||||||
|
def test_credentials_property(self):
|
||||||
|
"""Test credentials property returns token and cookies."""
|
||||||
|
client = KworkClient(token="test_token", cookies={"userId": "123"})
|
||||||
|
creds = client.credentials
|
||||||
|
assert creds["token"] == "test_token"
|
||||||
|
assert creds["cookies"] == {"userId": "123"}
|
||||||
|
|
||||||
|
def test_credentials_property_no_cookies(self):
|
||||||
|
"""Test credentials with no cookies."""
|
||||||
|
client = KworkClient(token="test_token")
|
||||||
|
creds = client.credentials
|
||||||
|
assert creds["token"] == "test_token"
|
||||||
|
assert creds["cookies"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogAPIExtended:
|
||||||
|
"""Extended tests for CatalogAPI."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_details_extra(self):
|
||||||
|
"""Test get_details_extra endpoint."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"id": 456,
|
||||||
|
"title": "Extra Details Kwork",
|
||||||
|
"extra_field": "extra_value",
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getKworkDetailsExtra").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.catalog.get_details_extra(456)
|
||||||
|
|
||||||
|
assert result["id"] == 456
|
||||||
|
assert result["extra_field"] == "extra_value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectsAPIExtended:
|
||||||
|
"""Extended tests for ProjectsAPI."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_payer_orders(self):
|
||||||
|
"""Test getting payer orders."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"orders": [
|
||||||
|
{"id": 101, "title": "Order 1", "amount": 5000.0, "status": "active"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/payerOrders").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.projects.get_payer_orders()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert isinstance(result[0], Project)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_worker_orders(self):
|
||||||
|
"""Test getting worker orders."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"orders": [
|
||||||
|
{"id": 202, "title": "Worker Order", "amount": 3000.0, "status": "completed"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/workerOrders").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.projects.get_worker_orders()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserAPI:
|
||||||
|
"""Tests for UserAPI."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_info(self):
|
||||||
|
"""Test getting user info."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"userId": 12345,
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"balance": 50000.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/user").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.user.get_info()
|
||||||
|
|
||||||
|
assert result["userId"] == 12345
|
||||||
|
assert result["username"] == "testuser"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_reviews(self):
|
||||||
|
"""Test getting user reviews."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"reviews": [
|
||||||
|
{"id": 1, "rating": 5, "comment": "Great work!", "author": {"id": 999, "username": "client1"}},
|
||||||
|
],
|
||||||
|
"pagination": {"current_page": 1, "total_pages": 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/userReviews").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.user.get_reviews(user_id=12345, page=1)
|
||||||
|
|
||||||
|
assert len(result.reviews) == 1
|
||||||
|
assert result.reviews[0].rating == 5
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_favorite_kworks(self):
|
||||||
|
"""Test getting favorite kworks."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"kworks": [
|
||||||
|
{"id": 100, "title": "Favorite Kwork 1", "price": 2000.0},
|
||||||
|
{"id": 101, "title": "Favorite Kwork 2", "price": 3000.0},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/favoriteKworks").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.user.get_favorite_kworks()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], Kwork)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReferenceAPI:
|
||||||
|
"""Tests for ReferenceAPI."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_cities(self):
|
||||||
|
"""Test getting cities list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"cities": [
|
||||||
|
{"id": 1, "name": "Москва", "country_id": 1},
|
||||||
|
{"id": 2, "name": "Санкт-Петербург", "country_id": 1},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/cities").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.reference.get_cities()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], City)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_countries(self):
|
||||||
|
"""Test getting countries list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"countries": [
|
||||||
|
{"id": 1, "name": "Россия", "code": "RU"},
|
||||||
|
{"id": 2, "name": "Беларусь", "code": "BY"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/countries").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.reference.get_countries()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], Country)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_timezones(self):
|
||||||
|
"""Test getting timezones list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"timezones": [
|
||||||
|
{"id": 1, "name": "Europe/Moscow", "offset": "+03:00"},
|
||||||
|
{"id": 2, "name": "Europe/Kaliningrad", "offset": "+02:00"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/timezones").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.reference.get_timezones()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], TimeZone)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_features(self):
|
||||||
|
"""Test getting features list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"features": [
|
||||||
|
{"id": 1, "name": "Feature 1", "category_id": 5, "price": 1000, "type": "extra"},
|
||||||
|
{"id": 2, "name": "Feature 2", "category_id": 5, "price": 2000, "type": "extra"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getAvailableFeatures").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.reference.get_features()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], Feature)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_public_features(self):
|
||||||
|
"""Test getting public features list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"features": [
|
||||||
|
{"id": 10, "name": "Public Feature", "is_public": True, "price": 500, "type": "extra"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getPublicFeatures").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.reference.get_public_features()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_badges_info(self):
|
||||||
|
"""Test getting badges info."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"badges": [
|
||||||
|
{"id": 1, "name": "Pro Seller", "icon_url": "https://example.com/badge1.png"},
|
||||||
|
{"id": 2, "name": "Verified", "icon_url": "https://example.com/badge2.png"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getBadgesInfo").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.reference.get_badges_info()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], Badge)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotificationsAPI:
|
||||||
|
"""Tests for NotificationsAPI."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_list(self):
|
||||||
|
"""Test getting notifications list."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"notifications": [
|
||||||
|
{"id": 1, "type": "order", "title": "New Order", "message": "New order received", "is_read": False},
|
||||||
|
{"id": 2, "type": "message", "title": "New Message", "message": "You have a new message", "is_read": True},
|
||||||
|
],
|
||||||
|
"unread_count": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/notifications").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.notifications.get_list()
|
||||||
|
|
||||||
|
assert isinstance(result, NotificationsResponse)
|
||||||
|
assert result.unread_count == 5
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_fetch(self):
|
||||||
|
"""Test fetching notifications."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"notifications": [
|
||||||
|
{"id": 3, "type": "system", "title": "System Update", "message": "System update available", "is_read": False},
|
||||||
|
],
|
||||||
|
"unread_count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/notificationsFetch").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.notifications.fetch()
|
||||||
|
|
||||||
|
assert isinstance(result, NotificationsResponse)
|
||||||
|
assert len(result.notifications) == 1
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_dialogs(self):
|
||||||
|
"""Test getting dialogs."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"dialogs": [
|
||||||
|
{"id": 1, "user_id": 100, "last_message": "Hello", "unread": 2},
|
||||||
|
{"id": 2, "user_id": 200, "last_message": "Hi", "unread": 0},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/dialogs").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.notifications.get_dialogs()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert isinstance(result[0], Dialog)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_blocked_dialogs(self):
|
||||||
|
"""Test getting blocked dialogs."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"dialogs": [
|
||||||
|
{"id": 99, "user_id": 999, "last_message": "Spam", "blocked": True},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/blockedDialogList").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.notifications.get_blocked_dialogs()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherAPI:
|
||||||
|
"""Tests for OtherAPI."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_wants(self):
|
||||||
|
"""Test getting wants."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"wants": [{"id": 1, "title": "I need a logo"}],
|
||||||
|
"count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/myWants").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_wants()
|
||||||
|
|
||||||
|
assert "wants" in result
|
||||||
|
assert result["count"] == 1
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_wants_status(self):
|
||||||
|
"""Test getting wants status."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"active_wants": 5,
|
||||||
|
"completed_wants": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/wantsStatusList").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_wants_status()
|
||||||
|
|
||||||
|
assert result["active_wants"] == 5
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_kworks_status(self):
|
||||||
|
"""Test getting kworks status."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"active_kworks": 3,
|
||||||
|
"total_sales": 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/kworksStatusList").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_kworks_status()
|
||||||
|
|
||||||
|
assert result["active_kworks"] == 3
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_offers(self):
|
||||||
|
"""Test getting offers."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"offers": [{"id": 1, "title": "Special offer"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/offers").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_offers()
|
||||||
|
|
||||||
|
assert "offers" in result
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_exchange_info(self):
|
||||||
|
"""Test getting exchange info."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"usd_rate": 90.5,
|
||||||
|
"eur_rate": 98.2,
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/exchangeInfo").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_exchange_info()
|
||||||
|
|
||||||
|
assert result["usd_rate"] == 90.5
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_channel(self):
|
||||||
|
"""Test getting channel info."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"channel_id": "main",
|
||||||
|
"name": "Main Channel",
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getChannel").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_channel()
|
||||||
|
|
||||||
|
assert result["channel_id"] == "main"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_in_app_notification(self):
|
||||||
|
"""Test getting in-app notifications."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"notifications": [{"id": 1, "message": "App update"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getInAppNotification").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_in_app_notification()
|
||||||
|
|
||||||
|
assert "notifications" in result
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_security_user_data(self):
|
||||||
|
"""Test getting security user data."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"two_factor_enabled": True,
|
||||||
|
"last_login": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/getSecurityUserData").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_security_user_data()
|
||||||
|
|
||||||
|
assert result["two_factor_enabled"] is True
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_is_dialog_allow_true(self):
|
||||||
|
"""Test is_dialog_allow returns True."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/isDialogAllow").mock(
|
||||||
|
return_value=Response(200, json={"allowed": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.is_dialog_allow(12345)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_is_dialog_allow_false(self):
|
||||||
|
"""Test is_dialog_allow returns False."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/isDialogAllow").mock(
|
||||||
|
return_value=Response(200, json={"allowed": False})
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.is_dialog_allow(67890)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_viewed_kworks(self):
|
||||||
|
"""Test getting viewed kworks."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"kworks": [
|
||||||
|
{"id": 500, "title": "Viewed Kwork", "price": 1500.0},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/viewedCatalogKworks").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_viewed_kworks()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert isinstance(result[0], Kwork)
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_favorite_categories(self):
|
||||||
|
"""Test getting favorite categories."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"categories": [1, 5, 10, 15],
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/favoriteCategories").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_favorite_categories()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert 1 in result
|
||||||
|
assert 5 in result
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_update_settings(self):
|
||||||
|
"""Test updating settings."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"success": True,
|
||||||
|
"updated": {"notifications_enabled": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/updateSettings").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = {"notifications_enabled": False, "theme": "dark"}
|
||||||
|
result = await client.other.update_settings(settings)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_go_offline(self):
|
||||||
|
"""Test going offline."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"success": True,
|
||||||
|
"status": "offline",
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/offline").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.go_offline()
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["status"] == "offline"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_actor(self):
|
||||||
|
"""Test getting actor info."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {
|
||||||
|
"actor_id": 123,
|
||||||
|
"name": "Test Actor",
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/actor").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client.other.get_actor()
|
||||||
|
|
||||||
|
assert result["actor_id"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientInternals:
|
||||||
|
"""Tests for internal client methods."""
|
||||||
|
|
||||||
|
def test_handle_response_success(self):
|
||||||
|
"""Test _handle_response with successful response."""
|
||||||
|
import httpx
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
response = httpx.Response(200, json={"success": True, "data": "test"})
|
||||||
|
result = client._handle_response(response)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["data"] == "test"
|
||||||
|
|
||||||
|
def test_handle_response_error(self):
|
||||||
|
"""Test _handle_response with error response."""
|
||||||
|
import httpx
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
response = httpx.Response(400, json={"message": "Bad request"})
|
||||||
|
|
||||||
|
with pytest.raises(KworkApiError) as exc_info:
|
||||||
|
client._handle_response(response)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_request_method(self):
|
||||||
|
"""Test _request method directly."""
|
||||||
|
client = KworkClient(token="test")
|
||||||
|
|
||||||
|
mock_data = {"result": "success"}
|
||||||
|
|
||||||
|
respx.post(f"{BASE_URL}/test-endpoint").mock(
|
||||||
|
return_value=Response(200, json=mock_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await client._request("POST", "/test-endpoint", json={"param": "value"})
|
||||||
|
|
||||||
|
assert result["result"] == "success"
|
||||||
|
|
||||||
|
async def test_context_manager_creates_client(self):
|
||||||
|
"""Test that context manager creates httpx client."""
|
||||||
|
async with KworkClient(token="test") as client:
|
||||||
|
assert client.token == "test"
|
||||||
|
|
||||||
|
assert client._client is None or client._client.is_closed
|
||||||
Loading…
Reference in New Issue
Block a user