Browse Source

tests for jobs, settings for tests, agents and mcp

Alexander Musikhin 2 tuần trước cách đây
mục cha
commit
39881994b6

+ 26 - 0
.ai-factory.json

@@ -0,0 +1,26 @@
+{
+  "version": "2.0.0",
+  "agents": [
+    {
+      "id": "claude",
+      "skillsDir": ".claude/skills",
+      "installedSkills": [
+        "aif",
+        "aif-architecture",
+        "aif-best-practices",
+        "aif-docs",
+        "aif-implement",
+        "aif-improve",
+        "aif-loop",
+        "aif-plan",
+        "aif-security-checklist"
+      ],
+      "mcp": {
+        "github": false,
+        "filesystem": false,
+        "postgres": false,
+        "chromeDevtools": true
+      }
+    }
+  ]
+}

+ 7 - 13
.env.testing

@@ -14,16 +14,15 @@ MINIO_DEBUG_PORT=39757
 FORCE_HTTPS=false
 
 APP_NAME="Stroyprofit CRM"
-APP_ENV=local
+APP_ENV=testing
 APP_KEY=base64:nIrpAXvd/2pEWdtUMQXuf6xdF1C+p7e6bErjt1hMIMk=
 APP_DEBUG=true
-APP_URL=${APP_ADDR}
+APP_URL=http://localhost
 APP_LOCALE=ru
 APP_TIMEZONE=Europe/Moscow
 
 LOG_CHANNEL=stack
-LOG_DEPRECATIONS_CHANNEL=null
-LOG_LEVEL=debug
+LOG_LEVEL=error
 
 DB_CONNECTION=mysql
 DB_HOST=db
@@ -32,11 +31,10 @@ DB_DATABASE=crm_testing
 DB_USERNAME=dbuser
 DB_PASSWORD=password
 
-BROADCAST_DRIVER=redis
-CACHE_DRIVER=redis
+CACHE_STORE=array
 FILESYSTEM_DISK=local
-QUEUE_CONNECTION=redis
-SESSION_DRIVER=file
+QUEUE_CONNECTION=sync
+SESSION_DRIVER=array
 SESSION_LIFETIME=120
 
 REDIS_CLIENT=predis
@@ -46,15 +44,11 @@ REDIS_PORT=6379
 
 JWT_SECRET=c05c4346dc03362dabbf94c18a1befe78e9b301837f3163e43a57201d9cc09cb
 
-# Default pagination limit
 PAGINATION_LIMIT=2000
-
 WORDS_IN_TABLE_CELL_LIMIT=15
 
-# FCM settings
 FCM_PROJECT_ID=
 FCM_CLIENT_EMAIL=
 FCM_PRIVATE_KEY=
 
-# id Artemenko Denis
-APP_DEFAULT_MAF_ORDER_USER_ID=1
+APP_DEFAULT_MAF_ORDER_USER_ID=1

+ 8 - 0
.gitignore

@@ -27,3 +27,11 @@ yarn-error.log
 .editorconfig
 /.composer/
 docker-compose.local.yml
+/.trae/
+/.qwen/
+/.cursor/
+/.agents/
+/.claude/
+/.ai-factory/
+/.kilocode/
+/.claude/

+ 17 - 0
.mcp.json

@@ -0,0 +1,17 @@
+{
+  "mcpServers": {
+    "chromeDevtools": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "chrome-devtools-mcp@latest",
+        "--no-usage-statistics"
+      ]
+    },
+    "github": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-github"],
+      "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
+    }
+  }
+}

+ 136 - 0
AGENTS.md

@@ -0,0 +1,136 @@
+# AGENTS.md
+
+> Project map for AI agents. Keep this file up-to-date as the project evolves.
+
+## Project Overview
+Stroyprofit CRM is a Laravel 11 application for managing orders of building structures (windows, doors, MAF). Covers the full lifecycle: order creation, production tracking, document generation (Excel/PDF), reclamation management, spare parts, and scheduling.
+
+## Tech Stack
+- **Language:** PHP 8.2+
+- **Framework:** Laravel 11
+- **Database:** MySQL 8.0
+- **ORM:** Eloquent
+- **Frontend:** Blade + Bootstrap 5 + jQuery + Vite + Sass
+- **Queue:** Redis + Laravel Queue Jobs
+- **Cache:** Redis (predis)
+- **WebSocket:** Node.js custom server (JWT + Redis pub/sub)
+- **Documents:** PHPSpreadsheet (Excel templates in /templates/)
+- **DevOps:** Docker Compose + Nginx + PHP-FPM
+
+## Project Structure
+```
+strprfcrm/
+├── app/
+│   ├── Console/              # Artisan commands
+│   ├── Events/               # Broadcast events (WebSocket)
+│   ├── Helpers/              # Utility helpers
+│   ├── Http/
+│   │   ├── Controllers/      # Request handlers
+│   │   │   └── Admin/        # Admin-specific controllers
+│   │   └── Middleware/       # HTTP middleware
+│   ├── Jobs/                 # Queue jobs (export, import, generate, notify)
+│   │   ├── Export/           # Export jobs
+│   │   └── Import/           # Import jobs
+│   ├── Models/               # Eloquent models
+│   │   ├── Dictionary/       # Reference data models
+│   │   └── Scopes/           # Global query scopes
+│   ├── Notifications/        # Notification classes (FCM)
+│   ├── Observers/            # Model event observers
+│   ├── Providers/            # Service providers
+│   └── Services/             # Business logic layer
+│       ├── Export/           # Excel export services
+│       ├── Import/           # Excel import services
+│       └── Generate/         # Document generation services
+├── database/
+│   ├── migrations/           # Database migrations
+│   ├── seeders/              # Database seeders
+│   └── factories/            # Model factories
+├── resources/
+│   ├── views/                # Blade templates
+│   │   ├── layouts/          # Base layouts
+│   │   ├── partials/         # Reusable partials
+│   │   ├── orders/           # Order views
+│   │   ├── maf_orders/       # MAF order views
+│   │   ├── reclamations/     # Reclamation views
+│   │   ├── schedule/         # Schedule views
+│   │   ├── spare_parts/      # Spare parts views
+│   │   ├── spare_part_orders/# Spare part order views
+│   │   ├── catalog/          # Product catalog views
+│   │   ├── contracts/        # Contract views
+│   │   ├── reports/          # Report views
+│   │   └── admin/            # Admin panel views
+│   ├── sass/                 # SCSS stylesheets
+│   └── js/                   # JavaScript entry points
+├── routes/
+│   ├── web.php               # Main web routes
+│   ├── channels.php          # Broadcast channels
+│   └── console.php           # Artisan routes
+├── templates/                # Excel templates for document generation
+├── docker/                   # Docker service configs
+│   ├── nginx/                # Nginx config
+│   ├── php/                  # PHP-FPM config
+│   ├── mysql/                # MySQL config
+│   └── simple-ws/            # Node.js WebSocket server
+├── docs/                     # Development documentation
+├── tech-docs/                # Technical documentation
+├── Makefile                  # Dev shortcuts (make up, make install, etc.)
+├── docker-compose.yml        # Docker Compose services
+└── CLAUDE.md                 # Claude Code project instructions
+```
+
+## Key Entry Points
+| File | Purpose |
+|------|---------|
+| `routes/web.php` | All application routes |
+| `app/Http/Controllers/` | Request handlers |
+| `app/Services/` | Business logic (import/export/generate) |
+| `app/Jobs/` | Async queue jobs |
+| `app/Models/` | Eloquent models |
+| `templates/` | Excel templates for document generation |
+| `docker/simple-ws/server.js` | Node.js WebSocket server |
+| `Makefile` | Dev commands (make up, make install, etc.) |
+| `.env.example` | Environment variables reference |
+
+## Key Models
+| Model | Table/View | Purpose |
+|-------|-----------|---------|
+| `Order` | `orders` | Client orders |
+| `MafOrder` | `maf_orders` | MAF (small architectural forms) orders |
+| `OrderView` | `order_views` (view) | Aggregated order data |
+| `MafOrdersView` | `maf_orders_views` (view) | Aggregated MAF data |
+| `Product` | `products` | Product catalog |
+| `ProductSKU` | `product_skus` | Product SKU variants |
+| `Reclamation` | `reclamations` | Client reclamations |
+| `ReclamationView` | `reclamation_views` (view) | Aggregated reclamation data |
+| `Contract` | `contracts` | Client contracts |
+| `Schedule` | `schedules` | Production/delivery schedule |
+| `SparePart` | `spare_parts` | Spare parts inventory |
+| `SparePartOrder` | `spare_part_orders` | Spare part procurement orders |
+
+## Service Layer
+| Service | Purpose |
+|---------|---------|
+| `Services/Export/` | Excel export (orders, MAFs, schedules, reclamations) |
+| `Services/Import/` | Excel import (orders, MAFs, reclamations, catalog) |
+| `Services/Generate/` | Document pack generation (installation, handover, reclamation) |
+| `FileService` | File management |
+| `ShortageService` | Shortage/deficit tracking |
+| `SparePartInventoryService` | Spare part stock management |
+| `PdfConverterClient` | HTTP client for pdf-converter service |
+
+## Documentation
+| Document | Path | Description |
+|----------|------|-------------|
+| README | README.md | Project landing page |
+| Claude instructions | CLAUDE.md | Dev commands and architecture overview |
+| Spare parts module | SPARE_PARTS_MODULE.md | Spare parts feature documentation |
+| Spare parts enhancements | SPARE_PARTS_ENHANCEMENTS.md | Enhancement notes |
+| Tech docs | tech-docs/ | Technical documentation |
+
+## AI Context Files
+| File | Purpose |
+|------|---------|
+| AGENTS.md | This file — project structure map |
+| .ai-factory/DESCRIPTION.md | Project specification and tech stack |
+| .ai-factory/ARCHITECTURE.md | Architecture decisions and guidelines |
+| CLAUDE.md | Claude Code instructions and dev commands |

+ 15 - 1
Makefile

@@ -146,9 +146,23 @@ queue-run-debug:
 queue-log: ## Лог приложения
 	$(compose) logs app-queue -f
 
-test: ##  Run tests
+test: ## Run tests
 	$(application) php artisan test
 
+test-unit: ## Run unit tests only
+	$(application) php artisan test --testsuite=Unit
+
+test-feature: ## Run feature tests only
+	$(application) php artisan test --testsuite=Feature
+
+test-coverage: ## Run tests with coverage report (requires Xdebug or PCOV)
+	$(application) php artisan test --coverage
+
+test-setup: ## Create test database (crm_testing)
+	$(mysql) mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS crm_testing CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
+	$(mysql) mysql -uroot -proot -e "GRANT ALL PRIVILEGES ON crm_testing.* TO 'dbuser'@'%'; FLUSH PRIVILEGES;"
+	$(application) php artisan migrate --env=testing
+
 websocket: ## Консоль приложения websocket
 	$(websocket) bash
 

+ 256 - 0
docs/tests.md

@@ -0,0 +1,256 @@
+# Тестовое покрытие проекта Stroyprofit CRM
+
+## Текущее состояние
+
+### Инфраструктура тестирования
+
+- **Фреймворк:** PHPUnit 11
+- **Доп. зависимости:** Mockery (подключён, но не используется), Faker (используется в фабриках)
+- **БД в тестах:** реальная MySQL (SQLite закомментирован в `phpunit.xml`)
+- **Сброс БД:** `RefreshDatabase` + `$seed = true` в каждом тест-классе
+- **Моки:** не применяются — все зависимости реальные (интеграционный стиль)
+
+### Статистика
+
+| Категория        | Всего файлов | Покрыто | Покрытие |
+|------------------|-------------|---------|----------|
+| Тест-файлов      | 29          | —       | —        |
+| Тест-методов     | ~302        | —       | —        |
+| Модели           | 34          | 5       | ~15%     |
+| Сервисы          | 28          | 13      | ~46%     |
+| Jobs             | 21          | 0       | 0%       |
+| Контроллеры      | 30          | 4       | ~13%     |
+| Form Requests    | 22          | 0       | 0%       |
+| Helpers          | 5           | 3       | 60%      |
+| Observers        | 1           | 1       | 100%     |
+
+---
+
+## Что покрыто тестами
+
+### Unit — Helpers
+- `CountHelper` — числительные (единицы/2-4/5+)
+- `DateHelper` — конвертация дат (Excel, ISO, человеческий формат)
+- `Price` — форматирование цен с символом ₽
+
+### Unit — Models
+- `Order` — константы статусов, атрибуты, связи, soft delete
+- `Reclamation` — константы статусов, связи, activeReservations, openShortages
+- `InventoryMovement` — константы типов, акцессоры, связи, скоупы, касты
+- `Shortage` — статусы, акцессоры, связи, скоупы, методы addReserved/recalculate
+- `SparePartOrder` — статусы, reserved_qty, free_qty, canReserve, скоупы
+- `SparePart` — цены в копейках, складские остатки, резервы, minimum stock
+
+### Unit — Services
+- `FileService` — сохранение файлов (Unicode, спецсимволы, дубликаты)
+- `GenerateDocumentsService` — генерация ZIP-архивов, монтаж/сдача/рекламация (частично через markTestSkipped)
+- `ImportOrdersService` — импорт заказов из Excel
+- `ShortageService` — FIFO, processPartOrderReceipt, calculateOrderQuantity, getCriticalShortages
+- `SparePartInventoryService` — getStockInfo, getInventorySummary, getBelowMinStock
+- `SparePartIssueService` — issueReservation, issueForReclamation, directIssue, correctInventory
+- `SparePartReservationService` — reserve, cancelForReclamation, adjustReservation
+- `Export/ExportAreasService` — экспорт районов в XLSX
+- `Export/ExportDistrictsService` — экспорт округов в XLSX
+- `Import/ImportAreasService` — импорт районов (создание, обновление, восстановление)
+- `Import/ImportDistrictsService` — импорт округов
+- `Import/ImportMafOrdersService` — импорт MAF-заказов
+- `Import/ImportSparePartOrdersService` — импорт заказов запчастей
+
+### Unit — Observers
+- `SparePartOrderObserver` — автопокрытие shortages при создании/обновлении заказа
+
+### Feature
+- `AdminAreaController` — CRUD + import/export (16 тестов)
+- `AdminDistrictController` — CRUD + import/export (15 тестов)
+- `OrderController` — CRUD, MAF-операции, фото, документы, генерация, экспорт (22 теста)
+- `ReclamationController` — CRUD, файлы, запчасти, детали, генерация (22 теста)
+
+---
+
+## Доработки
+
+### Приоритет 1 — Критически важно
+
+#### 1.1 Перевести БД на SQLite in-memory для тестов
+**Файл:** `phpunit.xml`
+
+Раскомментировать строки:
+```xml
+<env name="DB_CONNECTION" value="sqlite"/>
+<env name="DB_DATABASE" value=":memory:"/>
+```
+Текущая конфигурация требует запущенную MySQL, что делает запуск тестов в CI и на чистой машине невозможным без дополнительной настройки. Нужно убедиться, что все миграции совместимы с SQLite (убрать специфичные для MySQL типы: `json` → `text`, `enum` → `string`, fulltext-индексы).
+
+#### 1.2 Покрыть Jobs тестами (0% покрытие)
+Все 21 джоб не имеют тестов. Минимально необходимо покрыть:
+
+| Job | Что тестировать |
+|-----|----------------|
+| `ExportOrdersJob` | диспетчеризация, создание файла, статус Import |
+| `ExportMafJob` | аналогично |
+| `ExportReclamationsJob` | аналогично |
+| `ExportScheduleJob` | аналогично |
+| `GenerateFilesPack` | создание ZIP, корректность содержимого |
+| `GenerateInstallationPack` | аналогично |
+| `GenerateHandoverPack` | аналогично |
+| `ImportJob` | обработка файла, ошибки валидации |
+| `NotifyManagerNewOrderJob` | отправка уведомления (мок FCM) |
+| `NotifyManagerChangeStatusJob` | аналогично |
+
+**Рекомендация:** использовать `Queue::fake()` + `Bus::fake()` для проверки диспетчеризации без реального выполнения.
+
+#### 1.3 Подключить Mockery и использовать моки
+Сейчас все тесты сервисов — интеграционные (реальная БД). Это медленно и хрупко. Нужно добавить unit-тесты с моками для изоляции зависимостей:
+
+```php
+// Пример: мокировать репозиторий в SparePartReservationService
+$sparePart = Mockery::mock(SparePart::class);
+$sparePart->shouldReceive('lockForUpdate')->andReturnSelf();
+```
+
+---
+
+### Приоритет 2 — Важно
+
+#### 2.1 Покрыть контроллеры Feature-тестами
+Не покрытые контроллеры с высоким бизнес-приоритетом:
+
+| Контроллер | Что тестировать |
+|-----------|----------------|
+| `MafOrderController` | CRUD, статусы, документы |
+| `SparePartController` | CRUD, цены, остатки |
+| `SparePartOrderController` | создание, статусы, отгрузки |
+| `SparePartReservationController` | резервирование, отмена |
+| `SparePartInventoryController` | инвентаризация |
+| `ContractController` | CRUD, загрузка файлов |
+| `ScheduleController` | создание расписания из заказа/рекламации |
+| `ImportController` | загрузка файла, диспетчеризация джоба |
+| `ReportController` | генерация отчётов, фильтрация |
+| `ProductController` / `ProductSKUController` | CRUD каталога |
+| `UserController` | создание, редактирование пользователей |
+
+Минимальный набор тестов для каждого контроллера:
+- Неаутентифицированный запрос → редирект на логин
+- Успешный запрос → корректный ответ (200/redirect)
+- Невалидные данные → 422 / ошибки валидации
+- Авторизация (роли) — если применима
+
+#### 2.2 Покрыть Form Requests тестами
+22 Request-класса не имеют изолированных тестов. Добавить тесты валидации:
+
+```php
+// tests/Unit/Requests/StoreOrderRequestTest.php
+public function test_required_fields(): void
+{
+    $request = new StoreOrderRequest();
+    $validator = Validator::make([], $request->rules());
+    $this->assertTrue($validator->fails());
+    $this->assertArrayHasKey('client_name', $validator->errors()->toArray());
+}
+```
+
+Приоритетные Request-классы:
+- `Order/StoreOrderRequest` — основная форма заказа
+- `StoreMafOrderRequest` — форма MAF
+- `StoreReclamationRequest` / `StoreReclamationDetailsRequest`
+- `StoreSparePartOrderRequest` / `ShipSparePartOrderRequest`
+- `StoreContractRequest`
+
+#### 2.3 Покрыть непокрытые сервисы
+
+| Сервис | Что тестировать |
+|--------|----------------|
+| `ExportOrdersService` | формирование XLSX, корректность данных |
+| `ExportMafService` | аналогично |
+| `ExportReclamationsService` | аналогично |
+| `ExportScheduleService` | аналогично |
+| `ExportSparePartsService` | аналогично |
+| `ImportReclamationsService` | импорт, маппинг, ошибки |
+| `ImportMafsService` | аналогично |
+| `ImportCatalogService` | создание/обновление товаров |
+| `Import/ImportSparePartsService` | аналогично |
+| `Import/ImportYearDataService` | аналогично |
+| `PdfConverterClient` | HTTP-запрос к PDF-сервису (мок HTTP-клиента) |
+| `Export/ExportYearDataService` | аналогично |
+
+---
+
+### Приоритет 3 — Желательно
+
+#### 3.1 Покрыть непокрытые модели
+Модели без тестов (нет отдельных тест-файлов):
+
+- `MafOrder` — статусы, связи, атрибуты
+- `Product` / `ProductSKU` — связи, SKU-логика
+- `Contract` — связи, загрузка файлов
+- `Schedule` — связи с Order/Reclamation, статусы
+- `Reservation` — связи, статусы, количество
+- `SparePartOrderShipment` — списание резервов
+- `Dictionary/Area` / `Dictionary/District` — soft delete, связи
+- Database Views (`OrderView`, `MafOrdersView` и т.д.) — корректность агрегации
+
+#### 3.2 Покрыть непокрытые Helpers
+
+- `ExcelHelper` — форматирование ячеек, типы данных
+- `roles.php` — проверка наличия/наименования ролей
+
+#### 3.3 Добавить тесты для Auth-контроллеров
+Покрыть базовые сценарии аутентификации:
+- Успешный логин
+- Логин с неверным паролем
+- Выход из системы
+- Сброс пароля (если используется)
+
+#### 3.4 Добавить тесты политик авторизации (Policies/Gates)
+Если в проекте есть ролевая модель (файл `roles.php`, `Role` модель) — добавить тесты:
+- Доступ к admin-контроллерам только для admin-роли
+- Запрет доступа для пользователей без нужной роли
+
+#### 3.5 Настроить покрытие кода (Coverage)
+Добавить генерацию HTML-отчёта покрытия:
+
+```xml
+<!-- phpunit.xml -->
+<coverage>
+    <report>
+        <html outputDirectory="coverage"/>
+        <clover outputFile="coverage.xml"/>
+    </report>
+</coverage>
+```
+
+Требуется Xdebug или PCOV. Добавить в `Makefile`:
+```bash
+make test-coverage   # php artisan test --coverage
+```
+
+---
+
+## Технические проблемы
+
+### Тесты зависят от наличия Excel-шаблонов
+`GenerateDocumentsServiceTest` использует `markTestSkipped()` если шаблоны отсутствуют в `/templates/`. Нужно:
+- Добавить тестовые шаблоны в `tests/fixtures/templates/`
+- Или сделать шаблоны доступными в CI через переменную окружения
+
+### Фикстура `tests/fixtures/test_orders_import.xlsx`
+`ImportOrdersServiceTest` требует реальный xlsx-файл. Убедиться что файл находится в git и содержит актуальные данные.
+
+### Нет изоляции тестов сервисов
+Все Unit-тесты сервисов реально обращаются к БД — это интеграционные тесты. Нужно:
+- Переименовать директорию `tests/Unit/Services/` → `tests/Integration/Services/`
+- Или создать настоящие Unit-тесты с моками зависимостей
+
+---
+
+## Рекомендуемый порядок выполнения доработок
+
+1. Настроить SQLite in-memory в `phpunit.xml` (быстрый прогресс без кода)
+2. Покрыть `MafOrderController` Feature-тестами (высокий бизнес-приоритет)
+3. Покрыть `SparePartController` + `SparePartOrderController` Feature-тестами
+4. Добавить тесты для `ExportOrdersService` и других экспорт-сервисов
+5. Покрыть Form Requests тестами (быстро, высокая ценность)
+6. Добавить тесты для Jobs с `Queue::fake()`
+7. Настроить Coverage-отчёт
+8. Добавить тесты Auth-контроллеров
+9. Покрыть оставшиеся модели и контроллеры

+ 1 - 2
phpunit.xml

@@ -22,8 +22,7 @@
         <env name="APP_MAINTENANCE_DRIVER" value="file"/>
         <env name="BCRYPT_ROUNDS" value="4"/>
         <env name="CACHE_STORE" value="array"/>
-        <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
-        <!-- <env name="DB_DATABASE" value=":memory:"/> -->
+        <env name="DB_DATABASE" value="crm_testing"/>
         <env name="MAIL_MAILER" value="array"/>
         <env name="PULSE_ENABLED" value="false"/>
         <env name="QUEUE_CONNECTION" value="sync"/>

+ 45 - 0
tests/Unit/Jobs/ExportMafJobTest.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\ExportMafJob;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ExportMafJobTest extends TestCase
+{
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        ExportMafJob::dispatch(1, ['year' => 2026]);
+
+        Bus::assertDispatched(ExportMafJob::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        ExportMafJob::dispatch(1, ['year' => 2026]);
+
+        Queue::assertPushed(ExportMafJob::class);
+    }
+
+    public function test_job_dispatched_without_year_filter(): void
+    {
+        Bus::fake();
+
+        ExportMafJob::dispatch(1, []);
+
+        Bus::assertDispatched(ExportMafJob::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(ExportMafJob::class);
+    }
+}

+ 47 - 0
tests/Unit/Jobs/ExportOrdersJobTest.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\ExportOrdersJob;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ExportOrdersJobTest extends TestCase
+{
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        ExportOrdersJob::dispatch(collect([]), 1);
+
+        Bus::assertDispatched(ExportOrdersJob::class);
+    }
+
+    public function test_job_dispatched_with_correct_user_id(): void
+    {
+        Bus::fake();
+
+        ExportOrdersJob::dispatch(collect(['order1', 'order2']), 42);
+
+        Bus::assertDispatched(ExportOrdersJob::class, function (ExportOrdersJob $job) {
+            return true;
+        });
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        ExportOrdersJob::dispatch(collect([]), 1);
+
+        Queue::assertPushed(ExportOrdersJob::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(ExportOrdersJob::class);
+    }
+}

+ 45 - 0
tests/Unit/Jobs/ExportReclamationsJobTest.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\ExportReclamationsJob;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ExportReclamationsJobTest extends TestCase
+{
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        ExportReclamationsJob::dispatch([1, 2, 3], 1);
+
+        Bus::assertDispatched(ExportReclamationsJob::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        ExportReclamationsJob::dispatch([1, 2, 3], 1);
+
+        Queue::assertPushed(ExportReclamationsJob::class);
+    }
+
+    public function test_job_dispatched_with_empty_ids(): void
+    {
+        Bus::fake();
+
+        ExportReclamationsJob::dispatch([], 1);
+
+        Bus::assertDispatched(ExportReclamationsJob::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(ExportReclamationsJob::class);
+    }
+}

+ 36 - 0
tests/Unit/Jobs/ExportScheduleJobTest.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\ExportScheduleJob;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ExportScheduleJobTest extends TestCase
+{
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        ExportScheduleJob::dispatch(collect([]), 1);
+
+        Bus::assertDispatched(ExportScheduleJob::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        ExportScheduleJob::dispatch(collect([]), 1);
+
+        Queue::assertPushed(ExportScheduleJob::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(ExportScheduleJob::class);
+    }
+}

+ 56 - 0
tests/Unit/Jobs/GenerateFilesPackTest.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\GenerateFilesPack;
+use App\Models\Order;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Mockery;
+use Tests\TestCase;
+
+class GenerateFilesPackTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateFilesPack::dispatch($order, collect([]), 1);
+
+        Bus::assertDispatched(GenerateFilesPack::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateFilesPack::dispatch($order, collect([]), 1);
+
+        Queue::assertPushed(GenerateFilesPack::class);
+    }
+
+    public function test_job_dispatched_with_custom_name(): void
+    {
+        Bus::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateFilesPack::dispatch($order, collect([]), 1, 'documents');
+
+        Bus::assertDispatched(GenerateFilesPack::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(GenerateFilesPack::class);
+    }
+}

+ 46 - 0
tests/Unit/Jobs/GenerateHandoverPackTest.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\GenerateHandoverPack;
+use App\Models\Order;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Mockery;
+use Tests\TestCase;
+
+class GenerateHandoverPackTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateHandoverPack::dispatch($order, 1);
+
+        Bus::assertDispatched(GenerateHandoverPack::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateHandoverPack::dispatch($order, 1);
+
+        Queue::assertPushed(GenerateHandoverPack::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(GenerateHandoverPack::class);
+    }
+}

+ 46 - 0
tests/Unit/Jobs/GenerateInstallationPackTest.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\GenerateInstallationPack;
+use App\Models\Order;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Mockery;
+use Tests\TestCase;
+
+class GenerateInstallationPackTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateInstallationPack::dispatch($order, 1);
+
+        Bus::assertDispatched(GenerateInstallationPack::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        $order = Mockery::mock(Order::class);
+        GenerateInstallationPack::dispatch($order, 1);
+
+        Queue::assertPushed(GenerateInstallationPack::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(GenerateInstallationPack::class);
+    }
+}

+ 86 - 0
tests/Unit/Jobs/NotifyManagerChangeStatusJobTest.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\NotifyManagerChangeStatusJob;
+use App\Models\Order;
+use App\Models\User;
+use App\Notifications\FireBaseNotification;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Mockery;
+use Tests\TestCase;
+
+class NotifyManagerChangeStatusJobTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        $order = Mockery::mock(Order::class);
+        NotifyManagerChangeStatusJob::dispatch($order);
+
+        Bus::assertDispatched(NotifyManagerChangeStatusJob::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        $order = Mockery::mock(Order::class);
+        NotifyManagerChangeStatusJob::dispatch($order);
+
+        Queue::assertPushed(NotifyManagerChangeStatusJob::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(NotifyManagerChangeStatusJob::class);
+    }
+
+    public function test_job_handle_sends_notification_when_token_exists(): void
+    {
+        $user = Mockery::mock(User::class)->makePartial();
+        $user->token_fcm = 'some-fcm-token';
+        $user->shouldReceive('notify')->once()->with(Mockery::type(FireBaseNotification::class));
+
+        $orderStatus = (object)['name' => 'В работе'];
+
+        $order = Mockery::mock(Order::class);
+        $order->shouldReceive('getAttribute')->with('common_name')->andReturn('ул. Тестовая 1');
+        $order->shouldReceive('getAttribute')->with('user')->andReturn($user);
+        $order->shouldReceive('getAttribute')->with('orderStatus')->andReturn($orderStatus);
+
+        $job = new NotifyManagerChangeStatusJob($order);
+        $job->handle();
+
+        $this->addToAssertionCount(1); // Mockery expectation: notify called once
+    }
+
+    public function test_job_handle_skips_notification_when_no_token(): void
+    {
+        $user = Mockery::mock(User::class)->makePartial();
+        $user->token_fcm = null;
+        $user->shouldNotReceive('notify');
+
+        $orderStatus = (object)['name' => 'В работе'];
+
+        $order = Mockery::mock(Order::class);
+        $order->shouldReceive('getAttribute')->with('common_name')->andReturn('ул. Тестовая 1');
+        $order->shouldReceive('getAttribute')->with('user')->andReturn($user);
+        $order->shouldReceive('getAttribute')->with('orderStatus')->andReturn($orderStatus);
+
+        $job = new NotifyManagerChangeStatusJob($order);
+        $job->handle();
+
+        $this->assertTrue(true);
+    }
+}

+ 102 - 0
tests/Unit/Jobs/NotifyManagerNewOrderJobTest.php

@@ -0,0 +1,102 @@
+<?php
+
+namespace Tests\Unit\Jobs;
+
+use App\Jobs\NotifyManagerNewOrderJob;
+use App\Models\Order;
+use App\Models\User;
+use App\Notifications\FireBaseNotification;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Queue;
+use Mockery;
+use Tests\TestCase;
+
+class NotifyManagerNewOrderJobTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_job_can_be_dispatched(): void
+    {
+        Bus::fake();
+
+        $order = Mockery::mock(Order::class);
+        NotifyManagerNewOrderJob::dispatch($order);
+
+        Bus::assertDispatched(NotifyManagerNewOrderJob::class);
+    }
+
+    public function test_job_is_queued_via_queue_fake(): void
+    {
+        Queue::fake();
+
+        $order = Mockery::mock(Order::class);
+        NotifyManagerNewOrderJob::dispatch($order);
+
+        Queue::assertPushed(NotifyManagerNewOrderJob::class);
+    }
+
+    public function test_job_not_dispatched_without_dispatch_call(): void
+    {
+        Bus::fake();
+
+        Bus::assertNotDispatched(NotifyManagerNewOrderJob::class);
+    }
+
+    public function test_job_handle_sends_notification_when_token_exists(): void
+    {
+        $user = Mockery::mock(User::class)->makePartial();
+        $user->token_fcm = 'some-fcm-token';
+        $user->shouldReceive('notify')->once()->with(Mockery::type(FireBaseNotification::class));
+
+        $orderStatus = (object)['name' => 'Новый'];
+
+        $order = Mockery::mock(Order::class);
+        // common_name вызывает $this->area->name и $this->district->shortname — мокируем getAttribute
+        $order->shouldReceive('getAttribute')->with('common_name')->andReturn('ул. Тестовая 1');
+        $order->shouldReceive('getAttribute')->with('user')->andReturn($user);
+        $order->shouldReceive('getAttribute')->with('orderStatus')->andReturn($orderStatus);
+
+        $job = new NotifyManagerNewOrderJob($order);
+        $job->handle();
+
+        $this->addToAssertionCount(1); // Mockery expectation: notify called once
+    }
+
+    public function test_job_handle_skips_notification_when_no_token(): void
+    {
+        $user = Mockery::mock(User::class)->makePartial();
+        $user->token_fcm = null;
+        $user->shouldNotReceive('notify');
+
+        $orderStatus = (object)['name' => 'Новый'];
+
+        $order = Mockery::mock(Order::class);
+        $order->shouldReceive('getAttribute')->with('common_name')->andReturn('ул. Тестовая 1');
+        $order->shouldReceive('getAttribute')->with('user')->andReturn($user);
+        $order->shouldReceive('getAttribute')->with('orderStatus')->andReturn($orderStatus);
+
+        $job = new NotifyManagerNewOrderJob($order);
+        $job->handle();
+
+        $this->assertTrue(true);
+    }
+
+    public function test_job_handle_skips_notification_when_no_user(): void
+    {
+        $orderStatus = (object)['name' => 'Новый'];
+
+        $order = Mockery::mock(Order::class);
+        $order->shouldReceive('getAttribute')->with('common_name')->andReturn('ул. Тестовая 1');
+        $order->shouldReceive('getAttribute')->with('user')->andReturn(null);
+        $order->shouldReceive('getAttribute')->with('orderStatus')->andReturn($orderStatus);
+
+        $job = new NotifyManagerNewOrderJob($order);
+        $job->handle();
+
+        $this->assertTrue(true);
+    }
+}