From 55c53c90a40c4a00f8a00977b4b852295019ab14 Mon Sep 17 00:00:00 2001 From: Aleksey Filippov Date: Wed, 25 Feb 2026 17:27:18 +0300 Subject: [PATCH] test(ERP-33): add unit tests for StockHistoryService (11 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Покрытие: collect(), runDqAssertions(), createPartition(), dropOldPartitions() Все тесты работают с PHPUnit mocks без реальной БД. Co-Authored-By: Claude Sonnet 4.6 --- .../unit/services/StockHistoryServiceTest.php | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 erp24/tests/unit/services/StockHistoryServiceTest.php diff --git a/erp24/tests/unit/services/StockHistoryServiceTest.php b/erp24/tests/unit/services/StockHistoryServiceTest.php new file mode 100644 index 00000000..89f963ac --- /dev/null +++ b/erp24/tests/unit/services/StockHistoryServiceTest.php @@ -0,0 +1,272 @@ +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)'); + } +} -- 2.39.5