]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
test(ERP-33): add unit tests for StockHistoryService (11 tests)
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 25 Feb 2026 14:27:18 +0000 (17:27 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 25 Feb 2026 14:27:18 +0000 (17:27 +0300)
Покрытие: collect(), runDqAssertions(), createPartition(), dropOldPartitions()
Все тесты работают с PHPUnit mocks без реальной БД.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/tests/unit/services/StockHistoryServiceTest.php [new file with mode: 0644]

diff --git a/erp24/tests/unit/services/StockHistoryServiceTest.php b/erp24/tests/unit/services/StockHistoryServiceTest.php
new file mode 100644 (file)
index 0000000..89f963a
--- /dev/null
@@ -0,0 +1,272 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use app\services\StockHistoryService;
+
+/**
+ * ERP-33: Тесты StockHistoryService — ETL для сбора остатков.
+ *
+ * @group services
+ * @group stock-history
+ * @group etl
+ */
+class StockHistoryServiceTest extends Unit
+{
+    protected $tester;
+
+    /**
+     * @return array{0: \yii\db\Connection, 1: \yii\db\Command}
+     */
+    private function createMockDbAndCommand(): array
+    {
+        $db = $this->createMock(\yii\db\Connection::class);
+        $command = $this->createMock(\yii\db\Command::class);
+
+        $db->method('createCommand')->willReturn($command);
+        $command->method('setSql')->willReturnSelf();
+        $command->method('bindValues')->willReturnSelf();
+        $command->method('bindValue')->willReturnSelf();
+
+        return [$db, $command];
+    }
+
+    // =========================================================
+    // collect() tests
+    // =========================================================
+
+    public function testCollect_Success_InsertsRecordsAndReturnsResult(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(
+                true,   // pg_try_advisory_lock
+                5,      // active stores (DQ-1)
+                10,     // current count (DQ-6 + DQ-5)
+                5,      // distinct stores in snapshot (DQ-1)
+                0,      // null required (DQ-2)
+                0,      // negative qty (DQ-3)
+                0,      // reserv > qty (DQ-4)
+                10,     // previous count (DQ-5)
+                true    // pg_advisory_unlock
+            );
+
+        $command->method('queryAll')->willReturn(
+            array_fill(0, 10, [
+                'store_id' => 'store-1', 'store_name' => 'Store',
+                'product_id' => 'prod-1', 'product_name' => 'Rose',
+                'articule' => 'ART-001', 'father_id' => null,
+                'components' => null, 'quantity' => 5.00, 'reserv' => 1.00,
+            ])
+        );
+        $command->method('execute')->willReturn(10);
+
+        $service = new StockHistoryService($db);
+        $result = $service->collect('08:00');
+
+        $this->assertTrue($result->isSuccess());
+        $this->assertEquals(10, $result->getRowCount());
+    }
+
+    public function testCollect_LockTimeout_ThrowsLockException(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        // Lock always fails
+        $command->method('queryScalar')->willReturn(false);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('advisory lock');
+
+        // Анонимный класс с timeout=1 сек для быстрого теста
+        $service = new class($db) extends StockHistoryService {
+            protected function getLockTimeoutSec(): int { return 1; }
+        };
+        $service->collect('08:00');
+    }
+
+    public function testCollect_EmptyBalances_ReturnsZeroRows(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(
+                true, 0, 0, 0, 0, 0, 0, 0, true
+            );
+        $command->method('queryAll')->willReturn([]);
+        $command->method('execute')->willReturn(0);
+
+        $service = new StockHistoryService($db);
+        $result = $service->collect('08:00');
+
+        $this->assertTrue($result->isSuccess());
+        $this->assertEquals(0, $result->getRowCount());
+    }
+
+    public function testCollect_OnConflictUpdate_UsesUpsertQuery(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(true, 1, 1, 1, 0, 0, 0, 1, true);
+
+        $executedSql = [];
+        $command->method('setSql')->willReturnCallback(
+            function ($sql) use ($command, &$executedSql) {
+                $executedSql[] = $sql;
+                return $command;
+            }
+        );
+        $command->method('queryAll')->willReturn([
+            ['store_id' => 's1', 'store_name' => 'S', 'product_id' => 'p1',
+             'product_name' => 'P', 'articule' => 'A', 'father_id' => null,
+             'components' => null, 'quantity' => 1, 'reserv' => 0],
+        ]);
+        $command->method('execute')->willReturn(1);
+
+        $service = new StockHistoryService($db);
+        $service->collect('08:00');
+
+        $hasOnConflict = false;
+        foreach ($executedSql as $sql) {
+            if (stripos($sql, 'ON CONFLICT') !== false) {
+                $hasOnConflict = true;
+            }
+        }
+        $this->assertTrue($hasOnConflict, 'SQL должен содержать ON CONFLICT DO UPDATE');
+    }
+
+    // =========================================================
+    // DQ Assertions tests
+    // =========================================================
+
+    public function testDqAssertions_AllPass_ReturnsSuccess(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(5, 5, 0, 0, 0, 100, 95);
+
+        $service = new StockHistoryService($db);
+        $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+        $this->assertTrue($result->allPassed());
+        $this->assertEmpty($result->getCriticalFailures());
+    }
+
+    public function testDqAssertions_MissingStores_ReturnsCritical(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(24, 20, 0, 0, 0, 100, 100);
+
+        $service = new StockHistoryService($db);
+        $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+        $this->assertFalse($result->allPassed());
+        $criticals = $result->getCriticalFailures();
+        $this->assertNotEmpty($criticals);
+        $this->assertStringContainsString('store', strtolower($criticals[0]['message']));
+    }
+
+    public function testDqAssertions_NegativeQuantity_ReturnsMajor(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(5, 5, 0, 3, 0, 100, 100);
+
+        $service = new StockHistoryService($db);
+        $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+        $majors = $result->getMajorFailures();
+        $this->assertNotEmpty($majors);
+    }
+
+    public function testDqAssertions_DeviationOver20Pct_ReturnsMajor(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(5, 5, 0, 0, 0, 100, 50);
+
+        $service = new StockHistoryService($db);
+        $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+        $majors = $result->getMajorFailures();
+        $this->assertNotEmpty($majors);
+    }
+
+    public function testDqAssertions_EmptySnapshot_ReturnsCritical(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryScalar')
+            ->willReturnOnConsecutiveCalls(5, 0, 0, 0, 0, 0, 100);
+
+        $service = new StockHistoryService($db);
+        $result = $service->runDqAssertions('2026-02-21', '08:00');
+
+        $this->assertFalse($result->allPassed());
+        $criticals = $result->getCriticalFailures();
+        $this->assertNotEmpty($criticals);
+    }
+
+    // =========================================================
+    // Partition management tests
+    // =========================================================
+
+    public function testCreatePartition_CreatesPartitionTable(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $executedSql = [];
+        $command->method('setSql')->willReturnCallback(
+            function ($sql) use ($command, &$executedSql) {
+                $executedSql[] = $sql;
+                return $command;
+            }
+        );
+        $command->method('execute')->willReturn(0);
+
+        $service = new StockHistoryService($db);
+        $service->createPartition('2026-04');
+
+        $sql = implode(' ', $executedSql);
+        $this->assertStringContainsString('PARTITION OF', $sql);
+        $this->assertStringContainsString('2026-04-01', $sql);
+        $this->assertStringContainsString('2026-05-01', $sql);
+    }
+
+    public function testDropOldPartitions_DropsPartitionsOlderThanRetention(): void
+    {
+        [$db, $command] = $this->createMockDbAndCommand();
+
+        $command->method('queryAll')->willReturn([
+            ['tablename' => 'stock_history_2023_11'],
+            ['tablename' => 'stock_history_2023_12'],
+            ['tablename' => 'stock_history_2026_01'],
+        ]);
+
+        $droppedSql = [];
+        $command->method('setSql')->willReturnCallback(
+            function ($sql) use ($command, &$droppedSql) {
+                if (stripos($sql, 'DROP TABLE') !== false) {
+                    $droppedSql[] = $sql;
+                }
+                return $command;
+            }
+        );
+        $command->method('execute')->willReturn(0);
+
+        $service = new StockHistoryService($db);
+        $service->dropOldPartitions(24);
+
+        $this->assertCount(2, $droppedSql, 'Должны быть удалены 2 старые партиции (2023_11 и 2023_12)');
+    }
+}