From ddf1239791398d023fa5d81e0b5f13e0983928eb Mon Sep 17 00:00:00 2001 From: sni10 <9530163+sni10@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:33:45 +0200 Subject: [PATCH 1/4] test: add unit tests for FinderProductsService execute method and KeepaInputDto validation handling --- tests/Unit/Dto/KeepaInputDtoTest.php | 98 +++++++++++++++++++ .../Service/FinderProductsServiceTest.php | 94 ++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/tests/Unit/Dto/KeepaInputDtoTest.php b/tests/Unit/Dto/KeepaInputDtoTest.php index d071043..7cbaea2 100644 --- a/tests/Unit/Dto/KeepaInputDtoTest.php +++ b/tests/Unit/Dto/KeepaInputDtoTest.php @@ -61,4 +61,102 @@ public function testGetBrandReturnsNormalizedBrand(): void self::assertSame('Nike', $dto->getBrand()); } + + public function testFromArrayWithNullVersion(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 5, + 'domain_id' => 2, + 'brand' => 'Puma', + 'time_from' => '2025-03-01', + 'time_to' => '2025-03-31', + 'status' => 'PENDING', + 'step' => 0, + ]); + + self::assertNull($dto->version); + self::assertSame(0, $dto->step); + } + + public function testFromArrayWithDifferentStatuses(): void + { + $statuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FINISHED', 'FAILED', 'CANCELLED']; + + foreach ($statuses as $status) { + $dto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => 'Test', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'status' => $status, + 'step' => 0, + ]); + + self::assertSame($status, $dto->status); + } + } + + public function testFromArrayHandlesEmptyBrand(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => '', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'status' => 'PENDING', + 'step' => 0, + ]); + + self::assertSame('', $dto->brand); + } + + public function testFromArrayCastsStepToZeroWhenMissing(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => 'Test', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'status' => 'PENDING', + ]); + + self::assertSame(0, $dto->step); + } + + public function testFromArrayTrimsTimeFields(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => 'Test', + 'time_from' => ' 2025-01-01 ', + 'time_to' => ' 2025-01-31 ', + 'status' => 'PENDING', + 'step' => 0, + ]); + + self::assertSame('2025-01-01', $dto->time_from); + self::assertSame('2025-01-31', $dto->time_to); + } + + public function testToArrayIncludesNullVersion(): void + { + $dto = new KeepaInputDto(); + $dto->id = 1; + $dto->domain_id = 1; + $dto->brand = 'Test'; + $dto->time_from = '2025-01-01'; + $dto->time_to = '2025-01-31'; + $dto->version = null; + $dto->status = 'PENDING'; + $dto->step = 0; + + $array = $dto->toArray(); + + self::assertArrayHasKey('version', $array); + self::assertNull($array['version']); + } } diff --git a/tests/Unit/Service/FinderProductsServiceTest.php b/tests/Unit/Service/FinderProductsServiceTest.php index 2af0a06..bfae37d 100644 --- a/tests/Unit/Service/FinderProductsServiceTest.php +++ b/tests/Unit/Service/FinderProductsServiceTest.php @@ -183,4 +183,98 @@ public function testGetKeepaServiceReturnsInjectedInstance(): void self::assertSame($this->keepaService, $service->getKeepaService()); } + + public function testExecuteSkipsInvalidOutputsAndContinues(): void + { + $inputDto = KeepaInputDto::fromArray([ + 'id' => 3, + 'domain_id' => 1, + 'brand' => 'Reebok', + 'time_from' => '2025-03-01', + 'time_to' => '2025-03-31', + 'version' => 1, + 'status' => 'IN_PROGRESS', + 'step' => 2, + ]); + + $validatedInput = clone $inputDto; + + $this->messageValidator + ->expects(self::once()) + ->method('validateInput') + ->with($inputDto) + ->willReturn($validatedInput); + + $product1 = new KeepaOutputDto(); + $product1->asin = 'B000INVALID'; + + $product2 = new KeepaOutputDto(); + $product2->asin = 'B000VALID00'; + + $generator = (function () use ($product1, $product2) { + yield $product1; + yield $product2; + })(); + + $this->keepaService + ->expects(self::once()) + ->method('execute') + ->with($validatedInput) + ->willReturn($generator); + + $this->messageValidator + ->expects(self::exactly(2)) + ->method('validateOutput') + ->willReturnCallback(function ($product) use ($product1, $product2) { + if ($product === $product1) { + return null; // invalid + } + return clone $product2; // valid + }); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with( + self::stringContains('Invalid Output Kafka message'), + self::anything() + ); + + $this->bus + ->expects(self::once()) + ->method('dispatch') + ->with(self::isInstanceOf(KeepaOutputDto::class)) + ->willReturn(new Envelope($product2)); + + $this->keepaService + ->expects(self::once()) + ->method('getStats') + ->willReturn([ + 'asinsFound' => 2, + 'tokensUsed' => 20, + 'pagesProcessed' => 1, + 'sleepSeconds' => 0, + 'tokensLeft' => 80, + ]); + + $service = new FinderProductsService( + $this->logger, + $this->keepaService, + $this->messageValidator, + $this->bus, + ); + + $result = $service->execute($inputDto); + + self::assertSame( + [ + 'asinsFound' => 2, + 'tokensUsed' => 20, + 'pages' => 1, + 'sleepSeconds' => 0, + 'tokensLeft' => 80, + ], + $result, + ); + } } From 56273569c4cb78d98295fabef3ac7e8f3e68723d Mon Sep 17 00:00:00 2001 From: sni10 <9530163+sni10@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:34:37 +0200 Subject: [PATCH 2/4] test: add unit tests for MessageValidator and InputMessageSerializer --- .../Serializer/InputMessageSerializerTest.php | 178 ++++++++++++++++ .../Validator/MessageValidatorTest.php | 198 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 tests/Unit/Serializer/InputMessageSerializerTest.php create mode 100644 tests/Unit/Service/Validator/MessageValidatorTest.php diff --git a/tests/Unit/Serializer/InputMessageSerializerTest.php b/tests/Unit/Serializer/InputMessageSerializerTest.php new file mode 100644 index 0000000..723a024 --- /dev/null +++ b/tests/Unit/Serializer/InputMessageSerializerTest.php @@ -0,0 +1,178 @@ +serializer = new InputMessageSerializer(); + } + + public function testDecodeWithValidJson(): void + { + $data = [ + 'id' => 1, + 'domain_id' => 5, + 'brand' => 'Nike', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'version' => 2, + 'status' => 'PENDING', + 'step' => 0, + ]; + + $encodedEnvelope = [ + 'body' => json_encode($data), + ]; + + $envelope = $this->serializer->decode($encodedEnvelope); + + self::assertInstanceOf(Envelope::class, $envelope); + $message = $envelope->getMessage(); + self::assertInstanceOf(KeepaInputDto::class, $message); + self::assertSame(1, $message->id); + self::assertSame(5, $message->domain_id); + self::assertSame('Nike', $message->brand); + self::assertSame('2025-01-01', $message->time_from); + self::assertSame('2025-01-31', $message->time_to); + self::assertSame(2, $message->version); + self::assertSame('PENDING', $message->status); + self::assertSame(0, $message->step); + } + + public function testDecodeThrowsExceptionWhenBodyIsMissing(): void + { + $this->expectException(MessageDecodingFailedException::class); + $this->expectExceptionMessage('Missing body'); + + $encodedEnvelope = []; + + $this->serializer->decode($encodedEnvelope); + } + + public function testDecodeThrowsExceptionWithInvalidJson(): void + { + $this->expectException(MessageDecodingFailedException::class); + $this->expectExceptionMessage('Invalid JSON'); + + $encodedEnvelope = [ + 'body' => 'invalid json {{{', + ]; + + $this->serializer->decode($encodedEnvelope); + } + + public function testDecodeHandlesNullVersion(): void + { + $data = [ + 'id' => 10, + 'domain_id' => 3, + 'brand' => 'Adidas', + 'time_from' => '2025-02-01', + 'time_to' => '2025-02-28', + 'status' => 'IN_PROGRESS', + 'step' => 1, + ]; + + $encodedEnvelope = [ + 'body' => json_encode($data), + ]; + + $envelope = $this->serializer->decode($encodedEnvelope); + $message = $envelope->getMessage(); + + self::assertInstanceOf(KeepaInputDto::class, $message); + self::assertNull($message->version); + } + + public function testEncodeWithValidDto(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 42, + 'domain_id' => 1, + 'brand' => 'Puma', + 'time_from' => '2025-03-01', + 'time_to' => '2025-03-31', + 'version' => 5, + 'status' => 'COMPLETED', + 'step' => 3, + ]); + + $envelope = new Envelope($dto); + $encoded = $this->serializer->encode($envelope); + + self::assertArrayHasKey('body', $encoded); + self::assertArrayHasKey('headers', $encoded); + self::assertSame('application/json; charset=utf-8', $encoded['headers']['Content-Type']); + + $decodedBody = json_decode($encoded['body'], true); + self::assertSame(42, $decodedBody['id']); + self::assertSame(1, $decodedBody['domain_id']); + self::assertSame('Puma', $decodedBody['brand']); + self::assertSame('2025-03-01', $decodedBody['time_from']); + self::assertSame('2025-03-31', $decodedBody['time_to']); + self::assertSame(5, $decodedBody['version']); + self::assertSame('COMPLETED', $decodedBody['status']); + self::assertSame(3, $decodedBody['step']); + } + + public function testEncodeWithNullVersion(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 99, + 'domain_id' => 2, + 'brand' => 'Reebok', + 'time_from' => '2025-04-01', + 'time_to' => '2025-04-30', + 'status' => 'FAILED', + 'step' => 5, + ]); + + $envelope = new Envelope($dto); + $encoded = $this->serializer->encode($envelope); + + $decodedBody = json_decode($encoded['body'], true); + self::assertArrayHasKey('version', $decodedBody); + self::assertNull($decodedBody['version']); + } + + public function testRoundTripEncodeAndDecode(): void + { + $originalDto = KeepaInputDto::fromArray([ + 'id' => 777, + 'domain_id' => 8, + 'brand' => 'Asics', + 'time_from' => '2025-05-01', + 'time_to' => '2025-05-31', + 'version' => 10, + 'status' => 'CANCELLED', + 'step' => 7, + ]); + + $envelope = new Envelope($originalDto); + $encoded = $this->serializer->encode($envelope); + $decodedEnvelope = $this->serializer->decode($encoded); + + $decodedDto = $decodedEnvelope->getMessage(); + + self::assertInstanceOf(KeepaInputDto::class, $decodedDto); + self::assertSame($originalDto->id, $decodedDto->id); + self::assertSame($originalDto->domain_id, $decodedDto->domain_id); + self::assertSame($originalDto->brand, $decodedDto->brand); + self::assertSame($originalDto->time_from, $decodedDto->time_from); + self::assertSame($originalDto->time_to, $decodedDto->time_to); + self::assertSame($originalDto->version, $decodedDto->version); + self::assertSame($originalDto->status, $decodedDto->status); + self::assertSame($originalDto->step, $decodedDto->step); + } +} diff --git a/tests/Unit/Service/Validator/MessageValidatorTest.php b/tests/Unit/Service/Validator/MessageValidatorTest.php new file mode 100644 index 0000000..0c14c0e --- /dev/null +++ b/tests/Unit/Service/Validator/MessageValidatorTest.php @@ -0,0 +1,198 @@ +validator = $this->createMock(ValidatorInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->messageValidator = new MessageValidator( + $this->validator, + $this->logger + ); + } + + public function testValidateInputReturnsDto WhenValid(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => 'Nike', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'status' => 'PENDING', + 'step' => 0, + ]); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn(new ConstraintViolationList()); + + $this->logger + ->expects(self::never()) + ->method('warning'); + + $result = $this->messageValidator->validateInput($dto); + + self::assertSame($dto, $result); + } + + public function testValidateInputReturnsNullWhenInvalid(): void + { + $dto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => '', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'status' => 'PENDING', + 'step' => 0, + ]); + + $violation = new ConstraintViolation( + 'Brand should not be blank', + null, + [], + $dto, + 'brand', + '' + ); + $violations = new ConstraintViolationList([$violation]); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn($violations); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with( + 'Invalid Kafka message', + self::callback(function ($context) { + return isset($context['errors']) && isset($context['payload']); + }) + ); + + $result = $this->messageValidator->validateInput($dto); + + self::assertNull($result); + } + + public function testValidateOutputReturnsDtoWhenValid(): void + { + $dto = new KeepaOutputDto(); + $dto->asin = 'B08N5WRWNW'; + $dto->brand = 'Adidas'; + $dto->domain_id = 1; + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn(new ConstraintViolationList()); + + $this->logger + ->expects(self::never()) + ->method('warning'); + + $result = $this->messageValidator->validateOutput($dto); + + self::assertSame($dto, $result); + } + + public function testValidateOutputReturnsNullWhenInvalid(): void + { + $dto = new KeepaOutputDto(); + $dto->asin = 'INVALID_ASIN_THAT_IS_TOO_LONG_FOR_VALIDATION'; + $dto->brand = 'Test'; + + $violation = new ConstraintViolation( + 'ASIN is too long', + null, + [], + $dto, + 'asin', + 'INVALID_ASIN_THAT_IS_TOO_LONG_FOR_VALIDATION' + ); + $violations = new ConstraintViolationList([$violation]); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn($violations); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with( + 'Invalid message', + self::callback(function ($context) { + return isset($context['errors']) && isset($context['payload']); + }) + ); + + $result = $this->messageValidator->validateOutput($dto); + + self::assertNull($result); + } + + public function testValidateInputWithMultipleViolations(): void + { + $dto = new KeepaInputDto(); + $dto->id = -1; // invalid: should be positive + $dto->domain_id = 0; // invalid: should be positive + $dto->brand = ''; + $dto->time_from = 'invalid-date'; + $dto->time_to = 'invalid-date'; + $dto->status = 'INVALID_STATUS'; + $dto->step = -1; // invalid: should be positive or zero + + $violations = new ConstraintViolationList([ + new ConstraintViolation('Invalid id', null, [], $dto, 'id', -1), + new ConstraintViolation('Invalid domain_id', null, [], $dto, 'domain_id', 0), + new ConstraintViolation('Brand is blank', null, [], $dto, 'brand', ''), + ]); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->with($dto) + ->willReturn($violations); + + $this->logger + ->expects(self::once()) + ->method('warning'); + + $result = $this->messageValidator->validateInput($dto); + + self::assertNull($result); + } +} From 5dc9d4f8310d14d35fdc1a7bfa4a403140cdec4b Mon Sep 17 00:00:00 2001 From: sni10 <9530163+sni10@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:56:08 +0200 Subject: [PATCH 3/4] test: add unit tests for serializer, message handler, and Keepa service functionality --- tests/Unit/Exception/ExceptionsTest.php | 154 +++++++++ .../KeepaInputMessageHandlerTest.php | 304 ++++++++++++++++++ tests/Unit/Objects/AsinsOutputTest.php | 223 +++++++++++++ .../OutputMessageSerializerTest.php | 219 +++++++++++++ tests/Unit/Service/KeepaServiceTest.php | 284 ++++++++++++++++ .../Validator/MessageValidatorTest.php | 2 +- 6 files changed, 1185 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Exception/ExceptionsTest.php create mode 100644 tests/Unit/MessageHandler/KeepaInputMessageHandlerTest.php create mode 100644 tests/Unit/Objects/AsinsOutputTest.php create mode 100644 tests/Unit/Serializer/OutputMessageSerializerTest.php create mode 100644 tests/Unit/Service/KeepaServiceTest.php diff --git a/tests/Unit/Exception/ExceptionsTest.php b/tests/Unit/Exception/ExceptionsTest.php new file mode 100644 index 0000000..95aac5f --- /dev/null +++ b/tests/Unit/Exception/ExceptionsTest.php @@ -0,0 +1,154 @@ +getMessage()); + self::assertSame(0, $exception->getCode()); + } + + public function testKeepaRequestFailedExceptionWithCodeAndPrevious(): void + { + $previous = new \RuntimeException('Previous error'); + $exception = new KeepaRequestFailedException('Request failed', 500, $previous); + + self::assertSame('Request failed', $exception->getMessage()); + self::assertSame(500, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } + + public function testKeepaRequestFailedExceptionCanBeThrown(): void + { + $this->expectException(KeepaRequestFailedException::class); + $this->expectExceptionMessage('API error occurred'); + + throw new KeepaRequestFailedException('API error occurred'); + } + + public function testNotEnoughTokenErrorCanBeCreated(): void + { + $exception = new NotEnoughTokenError('Not enough tokens available'); + + self::assertInstanceOf(\Exception::class, $exception); + self::assertInstanceOf(NotEnoughTokenError::class, $exception); + self::assertSame('Not enough tokens available', $exception->getMessage()); + self::assertSame(0, $exception->getCode()); + } + + public function testNotEnoughTokenErrorWithCodeAndPrevious(): void + { + $previous = new \RuntimeException('Token limit reached'); + $exception = new NotEnoughTokenError('Not enough tokens', 429, $previous); + + self::assertSame('Not enough tokens', $exception->getMessage()); + self::assertSame(429, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } + + public function testNotEnoughTokenErrorCanBeThrown(): void + { + $this->expectException(NotEnoughTokenError::class); + $this->expectExceptionMessage('Tokens depleted'); + + throw new NotEnoughTokenError('Tokens depleted'); + } + + public function testKeepaTokenTimeoutExceptionCanBeCreated(): void + { + $exception = new KeepaTokenTimeoutException('Token timeout'); + + self::assertInstanceOf(\Exception::class, $exception); + self::assertInstanceOf(KeepaTokenTimeoutException::class, $exception); + self::assertSame('Token timeout', $exception->getMessage()); + self::assertSame(0, $exception->getCode()); + } + + public function testKeepaTokenTimeoutExceptionWithCodeAndPrevious(): void + { + $previous = new \RuntimeException('Timeout occurred'); + $exception = new KeepaTokenTimeoutException('Token wait timeout', 408, $previous); + + self::assertSame('Token wait timeout', $exception->getMessage()); + self::assertSame(408, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } + + public function testKeepaTokenTimeoutExceptionCanBeThrown(): void + { + $this->expectException(KeepaTokenTimeoutException::class); + $this->expectExceptionMessage('Waited too long for tokens'); + + throw new KeepaTokenTimeoutException('Waited too long for tokens'); + } + + public function testExceptionsAreDistinct(): void + { + $exception1 = new KeepaRequestFailedException('Error 1'); + $exception2 = new NotEnoughTokenError('Error 2'); + $exception3 = new KeepaTokenTimeoutException('Error 3'); + + self::assertNotInstanceOf(NotEnoughTokenError::class, $exception1); + self::assertNotInstanceOf(KeepaTokenTimeoutException::class, $exception1); + + self::assertNotInstanceOf(KeepaRequestFailedException::class, $exception2); + self::assertNotInstanceOf(KeepaTokenTimeoutException::class, $exception2); + + self::assertNotInstanceOf(KeepaRequestFailedException::class, $exception3); + self::assertNotInstanceOf(NotEnoughTokenError::class, $exception3); + } + + public function testExceptionMessagesCanBeEmpty(): void + { + $exception1 = new KeepaRequestFailedException(''); + $exception2 = new NotEnoughTokenError(''); + $exception3 = new KeepaTokenTimeoutException(''); + + self::assertSame('', $exception1->getMessage()); + self::assertSame('', $exception2->getMessage()); + self::assertSame('', $exception3->getMessage()); + } + + public function testExceptionsCanHaveCustomCodes(): void + { + $exception1 = new KeepaRequestFailedException('Request failed', 1001); + $exception2 = new NotEnoughTokenError('No tokens', 1002); + $exception3 = new KeepaTokenTimeoutException('Timeout', 1003); + + self::assertSame(1001, $exception1->getCode()); + self::assertSame(1002, $exception2->getCode()); + self::assertSame(1003, $exception3->getCode()); + } + + public function testExceptionsCatchAsGenericException(): void + { + try { + throw new KeepaRequestFailedException('Test'); + } catch (\Exception $e) { + self::assertInstanceOf(KeepaRequestFailedException::class, $e); + } + + try { + throw new NotEnoughTokenError('Test'); + } catch (\Exception $e) { + self::assertInstanceOf(NotEnoughTokenError::class, $e); + } + + try { + throw new KeepaTokenTimeoutException('Test'); + } catch (\Exception $e) { + self::assertInstanceOf(KeepaTokenTimeoutException::class, $e); + } + } +} diff --git a/tests/Unit/MessageHandler/KeepaInputMessageHandlerTest.php b/tests/Unit/MessageHandler/KeepaInputMessageHandlerTest.php new file mode 100644 index 0000000..4a96e5d --- /dev/null +++ b/tests/Unit/MessageHandler/KeepaInputMessageHandlerTest.php @@ -0,0 +1,304 @@ +finderProductsService = $this->createMock(FinderProductsService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->keepaService = $this->createMock(KeepaService::class); + } + + public function testInvokeLogsStartAndCompletionWithStats(): void + { + $message = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => 'Nike', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'status' => 'PENDING', + 'step' => 0, + ]); + + $this->finderProductsService + ->expects(self::once()) + ->method('getKeepaService') + ->willReturn($this->keepaService); + + $this->keepaService + ->expects(self::once()) + ->method('getRemainingTokens') + ->willReturn(500); + + $this->finderProductsService + ->expects(self::once()) + ->method('execute') + ->with($message) + ->willReturn([ + 'asinsFound' => 10, + 'tokensUsed' => 50, + 'pages' => 2, + 'sleepSeconds' => 5, + 'tokensLeft' => 450, + ]); + + $logMessages = []; + $this->logger + ->expects(self::exactly(2)) + ->method('info') + ->willReturnCallback(function ($message) use (&$logMessages) { + $logMessages[] = $message; + }); + + $handler = new KeepaInputMessageHandler( + $this->finderProductsService, + $this->logger + ); + + $handler->__invoke($message); + + self::assertStringContainsString('Starting search', $logMessages[0]); + self::assertStringContainsString('Search completed', $logMessages[1]); + } + + public function testInvokeHandlesZeroResults(): void + { + $message = KeepaInputDto::fromArray([ + 'id' => 2, + 'domain_id' => 3, + 'brand' => 'NonExistentBrand', + 'time_from' => '2025-02-01', + 'time_to' => '2025-02-28', + 'status' => 'IN_PROGRESS', + 'step' => 1, + ]); + + $this->finderProductsService + ->expects(self::once()) + ->method('getKeepaService') + ->willReturn($this->keepaService); + + $this->keepaService + ->expects(self::once()) + ->method('getRemainingTokens') + ->willReturn(300); + + $this->finderProductsService + ->expects(self::once()) + ->method('execute') + ->with($message) + ->willReturn([ + 'asinsFound' => 0, + 'tokensUsed' => 10, + 'pages' => 1, + 'sleepSeconds' => 0, + 'tokensLeft' => 290, + ]); + + $this->logger + ->expects(self::exactly(2)) + ->method('info'); + + $handler = new KeepaInputMessageHandler( + $this->finderProductsService, + $this->logger + ); + + $handler->__invoke($message); + } + + public function testInvokeCalculatesTokensPerSecond(): void + { + $message = KeepaInputDto::fromArray([ + 'id' => 3, + 'domain_id' => 1, + 'brand' => 'Adidas', + 'time_from' => '2025-03-01', + 'time_to' => '2025-03-31', + 'status' => 'PENDING', + 'step' => 0, + ]); + + $this->finderProductsService + ->expects(self::once()) + ->method('getKeepaService') + ->willReturn($this->keepaService); + + $this->keepaService + ->expects(self::once()) + ->method('getRemainingTokens') + ->willReturn(1000); + + $this->finderProductsService + ->expects(self::once()) + ->method('execute') + ->with($message) + ->willReturn([ + 'asinsFound' => 50, + 'tokensUsed' => 100, + 'pages' => 5, + 'sleepSeconds' => 10, + 'tokensLeft' => 900, + ]); + + $logMessages = []; + $this->logger + ->expects(self::exactly(2)) + ->method('info') + ->willReturnCallback(function ($message) use (&$logMessages) { + $logMessages[] = $message; + }); + + $handler = new KeepaInputMessageHandler( + $this->finderProductsService, + $this->logger + ); + + $handler->__invoke($message); + + // Check second log message contains tokens/min calculation + self::assertStringContainsString('Tokens/min:', $logMessages[1]); + } + + public function testInvokeHandlesMissingStatsFields(): void + { + $message = KeepaInputDto::fromArray([ + 'id' => 4, + 'domain_id' => 2, + 'brand' => 'Puma', + 'time_from' => '2025-04-01', + 'time_to' => '2025-04-30', + 'status' => 'COMPLETED', + 'step' => 3, + ]); + + $this->finderProductsService + ->expects(self::once()) + ->method('getKeepaService') + ->willReturn($this->keepaService); + + $this->keepaService + ->expects(self::once()) + ->method('getRemainingTokens') + ->willReturn(200); + + // Return stats with some missing fields + $this->finderProductsService + ->expects(self::once()) + ->method('execute') + ->with($message) + ->willReturn([ + 'asinsFound' => 5, + 'tokensUsed' => 20, + // missing 'pages', 'sleepSeconds', 'tokensLeft' + ]); + + $logMessages = []; + $this->logger + ->expects(self::exactly(2)) + ->method('info') + ->willReturnCallback(function ($message) use (&$logMessages) { + $logMessages[] = $message; + }); + + $handler = new KeepaInputMessageHandler( + $this->finderProductsService, + $this->logger + ); + + $handler->__invoke($message); + + // Check second log message handles missing stats gracefully (default to 0) + $completionLog = $logMessages[1]; + self::assertStringContainsString('Pages: 0', $completionLog); + self::assertStringContainsString('idle: 0 s', $completionLog); + self::assertStringContainsString('Tokens left: 0', $completionLog); + } + + public function testInvokeFormatsLogMessageCorrectly(): void + { + $message = KeepaInputDto::fromArray([ + 'id' => 5, + 'domain_id' => 4, + 'brand' => 'Reebok', + 'time_from' => '2025-05-01', + 'time_to' => '2025-05-31', + 'status' => 'FAILED', + 'step' => 2, + ]); + + $this->finderProductsService + ->expects(self::once()) + ->method('getKeepaService') + ->willReturn($this->keepaService); + + $this->keepaService + ->expects(self::once()) + ->method('getRemainingTokens') + ->willReturn(150); + + $this->finderProductsService + ->expects(self::once()) + ->method('execute') + ->with($message) + ->willReturn([ + 'asinsFound' => 3, + 'tokensUsed' => 15, + 'pages' => 1, + 'sleepSeconds' => 2, + 'tokensLeft' => 135, + ]); + + $logMessages = []; + $this->logger + ->expects(self::exactly(2)) + ->method('info') + ->willReturnCallback(function ($message) use (&$logMessages) { + $logMessages[] = $message; + }); + + $handler = new KeepaInputMessageHandler( + $this->finderProductsService, + $this->logger + ); + + $handler->__invoke($message); + + // Check first log message (start) + $startLog = $logMessages[0]; + self::assertStringContainsString('Starting search for brand: Reebok', $startLog); + self::assertStringContainsString('date range: 2025-05-01 - 2025-05-31', $startLog); + self::assertStringContainsString('initial tokens: 150', $startLog); + + // Check second log message (completion) + $completionLog = $logMessages[1]; + self::assertStringContainsString('Search completed - Brand: Reebok', $completionLog); + self::assertStringContainsString('date range: 2025-05-01 - 2025-05-31', $completionLog); + self::assertStringContainsString('Pages: 1', $completionLog); + self::assertStringContainsString('ASINs: 3', $completionLog); + self::assertStringContainsString('Tokens used: 15', $completionLog); + self::assertStringContainsString('Tokens left: 135', $completionLog); + } +} diff --git a/tests/Unit/Objects/AsinsOutputTest.php b/tests/Unit/Objects/AsinsOutputTest.php new file mode 100644 index 0000000..1f7e8c4 --- /dev/null +++ b/tests/Unit/Objects/AsinsOutputTest.php @@ -0,0 +1,223 @@ +logger = $this->createMock(LoggerInterface::class); + + $this->inputDto = KeepaInputDto::fromArray([ + 'id' => 1, + 'domain_id' => 1, + 'brand' => 'Nike', + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'version' => 2, + 'status' => 'PENDING', + 'step' => 0, + ]); + } + + public function testFromResponseReturnsEmptyArrayWhenProductsIsNull(): void + { + $response = $this->createMock(Response::class); + $response->products = null; + + $this->logger + ->expects(self::once()) + ->method('error') + ->with('Kafka: No products found in response.'); + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertIsArray($result); + self::assertEmpty($result); + } + + public function testFromResponseReturnsFormattedProductsArray(): void + { + $product1 = $this->createMock(Product::class); + $product1->asin = 'B08N5WRWNW'; + $product1->brand = 'Nike'; + $product1->title = 'Nike Air Max'; + $product1->upcList = ['123456789012']; + $product1->eanList = ['1234567890123']; + + $product2 = $this->createMock(Product::class); + $product2->asin = 'B08N5WRWNY'; + $product2->brand = 'Nike'; + $product2->title = 'Nike Air Force'; + $product2->upcList = ['987654321098']; + $product2->eanList = ['9876543210987']; + + $response = $this->createMock(Response::class); + $response->products = [$product1, $product2]; + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertIsArray($result); + self::assertCount(2, $result); + + // Check first product + self::assertSame('B08N5WRWNW', $result[0]['asin']); + self::assertSame('Nike', $result[0]['brand']); + self::assertSame('Nike Air Max', $result[0]['title']); + self::assertSame(['123456789012'], $result[0]['upc_list']); + self::assertSame(['1234567890123'], $result[0]['ean_list']); + self::assertSame(1, $result[0]['search_request_id']); + self::assertSame(1, $result[0]['domain_id']); + self::assertSame('2025-01-01', $result[0]['time_from']); + self::assertSame('2025-01-31', $result[0]['time_to']); + self::assertSame(2, $result[0]['version']); + self::assertIsString($result[0]['json_data']); + + // Check second product + self::assertSame('B08N5WRWNY', $result[1]['asin']); + self::assertSame('Nike Air Force', $result[1]['title']); + } + + public function testFromResponseHandlesNonArrayUpcList(): void + { + $product = $this->createMock(Product::class); + $product->asin = 'B08N5WRWNW'; + $product->brand = 'Adidas'; + $product->title = 'Adidas Ultraboost'; + $product->upcList = null; // Not an array + $product->eanList = ['1234567890123']; + + $response = $this->createMock(Response::class); + $response->products = [$product]; + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertSame([], $result[0]['upc_list']); // Should default to empty array + } + + public function testFromResponseHandlesNonArrayEanList(): void + { + $product = $this->createMock(Product::class); + $product->asin = 'B08N5WRWNW'; + $product->brand = 'Puma'; + $product->title = 'Puma Suede'; + $product->upcList = ['123456789012']; + $product->eanList = null; // Not an array + + $response = $this->createMock(Response::class); + $response->products = [$product]; + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertSame([], $result[0]['ean_list']); // Should default to empty array + } + + public function testFromResponseIncludesSerializedJsonData(): void + { + $product = $this->createMock(Product::class); + $product->asin = 'B08N5WRWNW'; + $product->brand = 'Reebok'; + $product->title = 'Reebok Classic'; + $product->upcList = ['111111111111']; + $product->eanList = ['2222222222222']; + + $response = $this->createMock(Response::class); + $response->products = [$product]; + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertArrayHasKey('json_data', $result[0]); + self::assertIsString($result[0]['json_data']); + + // Verify it's valid JSON + $decoded = json_decode($result[0]['json_data']); + self::assertNotNull($decoded, 'json_data should be valid JSON'); + } + + public function testFromResponsePreservesInputDtoFields(): void + { + $inputDto = KeepaInputDto::fromArray([ + 'id' => 999, + 'domain_id' => 5, + 'brand' => 'Asics', + 'time_from' => '2025-06-01', + 'time_to' => '2025-06-30', + 'version' => 10, + 'status' => 'COMPLETED', + 'step' => 5, + ]); + + $product = $this->createMock(Product::class); + $product->asin = 'B000TEST00'; + $product->brand = 'Asics'; + $product->title = 'Asics Gel-Kayano'; + $product->upcList = ['333333333333']; + $product->eanList = ['4444444444444']; + + $response = $this->createMock(Response::class); + $response->products = [$product]; + + $result = AsinsOutput::fromResponse($response, $inputDto, $this->logger); + + self::assertIsArray($result); + self::assertCount(1, $result); + self::assertSame(999, $result[0]['search_request_id']); + self::assertSame(5, $result[0]['domain_id']); + self::assertSame('2025-06-01', $result[0]['time_from']); + self::assertSame('2025-06-30', $result[0]['time_to']); + self::assertSame(10, $result[0]['version']); + } + + public function testFromResponseHandlesEmptyProductsArray(): void + { + $response = $this->createMock(Response::class); + $response->products = []; + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertIsArray($result); + self::assertEmpty($result); + } + + public function testFromResponseFiltersOutFalsyValues(): void + { + // The array_filter call should remove any false/null values if callback returns false + // In this case, all products should be included as the code doesn't filter them + $product = $this->createMock(Product::class); + $product->asin = 'B08N5WRWNW'; + $product->brand = 'TestBrand'; + $product->title = 'Test Title'; + $product->upcList = ['000000000000']; + $product->eanList = ['0000000000000']; + + $response = $this->createMock(Response::class); + $response->products = [$product]; + + $result = AsinsOutput::fromResponse($response, $this->inputDto, $this->logger); + + self::assertCount(1, $result); + self::assertNotEmpty($result[0]); + } +} diff --git a/tests/Unit/Serializer/OutputMessageSerializerTest.php b/tests/Unit/Serializer/OutputMessageSerializerTest.php new file mode 100644 index 0000000..fe65e31 --- /dev/null +++ b/tests/Unit/Serializer/OutputMessageSerializerTest.php @@ -0,0 +1,219 @@ +serializer = new OutputMessageSerializer(); + } + + public function testDecodeWithValidJson(): void + { + $data = [ + 'asin' => 'B08N5WRWNW', + 'brand' => 'Nike', + 'title' => 'Nike Air Max', + 'upc_list' => ['123456789012'], + 'ean_list' => ['1234567890123'], + 'search_request_id' => 42, + 'domain_id' => 1, + 'json_data' => ['key' => 'value'], + 'time_from' => '2025-01-01', + 'time_to' => '2025-01-31', + 'version' => 2, + ]; + + $encodedEnvelope = [ + 'body' => json_encode($data), + ]; + + $envelope = $this->serializer->decode($encodedEnvelope); + + self::assertInstanceOf(Envelope::class, $envelope); + $message = $envelope->getMessage(); + self::assertInstanceOf(KeepaOutputDto::class, $message); + self::assertSame('B08N5WRWNW', $message->asin); + self::assertSame('Nike', $message->brand); + self::assertSame('Nike Air Max', $message->title); + self::assertSame(['123456789012'], $message->upc_list); + self::assertSame(['1234567890123'], $message->ean_list); + self::assertSame(42, $message->search_request_id); + self::assertSame(1, $message->domain_id); + self::assertSame(['key' => 'value'], $message->json_data); + self::assertSame('2025-01-01', $message->time_from); + self::assertSame('2025-01-31', $message->time_to); + self::assertSame(2, $message->version); + } + + public function testDecodeThrowsExceptionWhenBodyIsMissing(): void + { + $this->expectException(MessageDecodingFailedException::class); + $this->expectExceptionMessage('Missing body'); + + $encodedEnvelope = []; + + $this->serializer->decode($encodedEnvelope); + } + + public function testDecodeThrowsExceptionWithInvalidJson(): void + { + $this->expectException(MessageDecodingFailedException::class); + $this->expectExceptionMessage('Invalid JSON'); + + $encodedEnvelope = [ + 'body' => 'invalid json {{{', + ]; + + $this->serializer->decode($encodedEnvelope); + } + + public function testDecodeHandlesPartialData(): void + { + $data = [ + 'asin' => 'B08N5WRWNW', + 'brand' => 'Adidas', + ]; + + $encodedEnvelope = [ + 'body' => json_encode($data), + ]; + + $envelope = $this->serializer->decode($encodedEnvelope); + $message = $envelope->getMessage(); + + self::assertInstanceOf(KeepaOutputDto::class, $message); + self::assertSame('B08N5WRWNW', $message->asin); + self::assertSame('Adidas', $message->brand); + self::assertNull($message->title); + self::assertNull($message->upc_list); + self::assertNull($message->ean_list); + self::assertNull($message->search_request_id); + self::assertNull($message->domain_id); + self::assertNull($message->json_data); + self::assertNull($message->time_from); + self::assertNull($message->time_to); + self::assertNull($message->version); + } + + public function testEncodeWithValidDto(): void + { + $dto = new KeepaOutputDto(); + $dto->asin = 'B000TEST00'; + $dto->brand = 'Puma'; + $dto->title = 'Puma Suede'; + $dto->upc_list = ['999999999999']; + $dto->ean_list = ['8888888888888']; + $dto->search_request_id = 100; + $dto->domain_id = 3; + $dto->json_data = ['test' => 'data']; + $dto->time_from = '2025-03-01'; + $dto->time_to = '2025-03-31'; + $dto->version = 5; + + $envelope = new Envelope($dto); + $encoded = $this->serializer->encode($envelope); + + self::assertArrayHasKey('body', $encoded); + self::assertArrayHasKey('headers', $encoded); + self::assertSame('application/json; charset=utf-8', $encoded['headers']['Content-Type']); + + $decodedBody = json_decode($encoded['body'], true); + self::assertSame('B000TEST00', $decodedBody['asin']); + self::assertSame('Puma', $decodedBody['brand']); + self::assertSame('Puma Suede', $decodedBody['title']); + self::assertSame(['999999999999'], $decodedBody['upc_list']); + self::assertSame(['8888888888888'], $decodedBody['ean_list']); + self::assertSame(100, $decodedBody['search_request_id']); + self::assertSame(3, $decodedBody['domain_id']); + self::assertSame(['test' => 'data'], $decodedBody['json_data']); + self::assertSame('2025-03-01', $decodedBody['time_from']); + self::assertSame('2025-03-31', $decodedBody['time_to']); + self::assertSame(5, $decodedBody['version']); + } + + public function testEncodeWithNullFields(): void + { + $dto = new KeepaOutputDto(); + $dto->asin = 'B000NULL00'; + + $envelope = new Envelope($dto); + $encoded = $this->serializer->encode($envelope); + + $decodedBody = json_decode($encoded['body'], true); + self::assertSame('B000NULL00', $decodedBody['asin']); + self::assertNull($decodedBody['brand']); + self::assertNull($decodedBody['title']); + self::assertNull($decodedBody['upc_list']); + self::assertNull($decodedBody['ean_list']); + self::assertNull($decodedBody['search_request_id']); + self::assertNull($decodedBody['domain_id']); + self::assertNull($decodedBody['json_data']); + self::assertNull($decodedBody['time_from']); + self::assertNull($decodedBody['time_to']); + self::assertNull($decodedBody['version']); + } + + public function testEncodePreservesUnicodeCharacters(): void + { + $dto = new KeepaOutputDto(); + $dto->asin = 'B000TEST00'; + $dto->brand = 'Тест'; + $dto->title = 'Test Title with ™ and © symbols'; + + $envelope = new Envelope($dto); + $encoded = $this->serializer->encode($envelope); + + $decodedBody = json_decode($encoded['body'], true); + self::assertSame('Тест', $decodedBody['brand']); + self::assertSame('Test Title with ™ and © symbols', $decodedBody['title']); + + // Verify that body doesn't contain escaped unicode + self::assertStringContainsString('Тест', $encoded['body']); + } + + public function testRoundTripEncodeAndDecode(): void + { + $originalDto = new KeepaOutputDto(); + $originalDto->asin = 'B000ROUND0'; + $originalDto->brand = 'RoundTrip'; + $originalDto->title = 'Round Trip Test'; + $originalDto->upc_list = ['111111111111', '222222222222']; + $originalDto->ean_list = ['3333333333333']; + $originalDto->search_request_id = 999; + $originalDto->domain_id = 5; + $originalDto->json_data = ['nested' => ['data' => 'here']]; + $originalDto->time_from = '2025-06-01'; + $originalDto->time_to = '2025-06-30'; + $originalDto->version = 7; + + $envelope = new Envelope($originalDto); + $encoded = $this->serializer->encode($envelope); + $decodedEnvelope = $this->serializer->decode($encoded); + + $decodedDto = $decodedEnvelope->getMessage(); + + self::assertInstanceOf(KeepaOutputDto::class, $decodedDto); + self::assertSame($originalDto->asin, $decodedDto->asin); + self::assertSame($originalDto->brand, $decodedDto->brand); + self::assertSame($originalDto->title, $decodedDto->title); + self::assertSame($originalDto->upc_list, $decodedDto->upc_list); + self::assertSame($originalDto->ean_list, $decodedDto->ean_list); + self::assertSame($originalDto->search_request_id, $decodedDto->search_request_id); + self::assertSame($originalDto->domain_id, $decodedDto->domain_id); + self::assertSame($originalDto->json_data, $decodedDto->json_data); + self::assertSame($originalDto->time_from, $decodedDto->time_from); + self::assertSame($originalDto->time_to, $decodedDto->time_to); + self::assertSame($originalDto->version, $decodedDto->version); + } +} diff --git a/tests/Unit/Service/KeepaServiceTest.php b/tests/Unit/Service/KeepaServiceTest.php new file mode 100644 index 0000000..34b683a --- /dev/null +++ b/tests/Unit/Service/KeepaServiceTest.php @@ -0,0 +1,284 @@ +logger = $this->createMock(LoggerInterface::class); + $this->tokenLogger = $this->createMock(LoggerInterface::class); + + // Set environment variables for tests + putenv('KEEPA_TOKEN_MIN_LIMIT=100'); + putenv('KEEPA_TOKEN_WAIT_TIMEOUT=60'); + putenv('KEEPA_TOKEN_MAX_RETRIES=5'); + putenv('KEEPA_REQUEST_MAX_RETRIES=5'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Clean up environment variables + putenv('KEEPA_TOKEN_MIN_LIMIT'); + putenv('KEEPA_TOKEN_WAIT_TIMEOUT'); + putenv('KEEPA_TOKEN_MAX_RETRIES'); + putenv('KEEPA_REQUEST_MAX_RETRIES'); + } + + public function testGetStatsReturnsInitialEmptyStats(): void + { + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + $stats = $service->getStats(); + + self::assertIsArray($stats); + self::assertArrayHasKey('tokensUsed', $stats); + self::assertArrayHasKey('pagesProcessed', $stats); + self::assertArrayHasKey('asinsFound', $stats); + self::assertArrayHasKey('sleepSeconds', $stats); + self::assertArrayHasKey('tokensLeft', $stats); + self::assertArrayHasKey('pageStats', $stats); + + self::assertSame(0, $stats['tokensUsed']); + self::assertSame(0, $stats['pagesProcessed']); + self::assertSame(0, $stats['asinsFound']); + self::assertSame(0, $stats['sleepSeconds']); + self::assertNull($stats['tokensLeft']); + self::assertSame([], $stats['pageStats']); + } + + public function testGetRemainingTokensReturnsZeroOnException(): void + { + $mockApi = $this->createMock(KeepaAPI::class); + $mockApi + ->expects(self::once()) + ->method('sendRequest') + ->willThrowException(new \Exception('Network error')); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with( + self::stringContains('Исключение при получении статуса токенов'), + self::anything() + ); + + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + // Inject mock API using reflection + $reflection = new ReflectionClass($service); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($service, $mockApi); + + $tokens = $service->getRemainingTokens(); + + self::assertSame(0, $tokens); + } + + public function testGetRemainingTokensReturnsZeroWhenResponseIsNull(): void + { + $mockApi = $this->createMock(KeepaAPI::class); + $mockApi + ->expects(self::once()) + ->method('sendRequest') + ->willReturn(null); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with('Не удалось получить статус токенов'); + + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + // Inject mock API using reflection + $reflection = new ReflectionClass($service); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($service, $mockApi); + + $tokens = $service->getRemainingTokens(); + + self::assertSame(0, $tokens); + } + + public function testGetRemainingTokensReturnsZeroWhenStatusIsNotOk(): void + { + $mockResponse = $this->createMock(Response::class); + $mockResponse->status = ResponseStatus::FAIL; + $mockResponse->tokensLeft = 100; // Should be ignored + + $mockApi = $this->createMock(KeepaAPI::class); + $mockApi + ->expects(self::once()) + ->method('sendRequest') + ->willReturn($mockResponse); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with('Не удалось получить статус токенов'); + + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + // Inject mock API using reflection + $reflection = new ReflectionClass($service); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($service, $mockApi); + + $tokens = $service->getRemainingTokens(); + + self::assertSame(0, $tokens); + } + + public function testGetRemainingTokensReturnsCorrectValueWhenSuccessful(): void + { + $mockResponse = $this->createMock(Response::class); + $mockResponse->status = ResponseStatus::OK; + $mockResponse->tokensLeft = 500; + + $mockApi = $this->createMock(KeepaAPI::class); + $mockApi + ->expects(self::once()) + ->method('sendRequest') + ->with(self::isInstanceOf(Request::class)) + ->willReturn($mockResponse); + + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + // Inject mock API using reflection + $reflection = new ReflectionClass($service); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($service, $mockApi); + + $tokens = $service->getRemainingTokens(); + + self::assertSame(500, $tokens); + } + + public function testGetRemainingTokensHandlesNullTokensLeft(): void + { + $mockResponse = $this->createMock(Response::class); + $mockResponse->status = ResponseStatus::OK; + $mockResponse->tokensLeft = null; + + $mockApi = $this->createMock(KeepaAPI::class); + $mockApi + ->expects(self::once()) + ->method('sendRequest') + ->willReturn($mockResponse); + + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + // Inject mock API using reflection + $reflection = new ReflectionClass($service); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($service, $mockApi); + + $tokens = $service->getRemainingTokens(); + + self::assertSame(0, $tokens); + } + + public function testGetStatsContainsCorrectStructure(): void + { + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + $stats = $service->getStats(); + + // Verify all required keys are present + $requiredKeys = [ + 'tokensUsed', + 'pagesProcessed', + 'asinsFound', + 'sleepSeconds', + 'tokensLeft', + 'pageStats', + ]; + + foreach ($requiredKeys as $key) { + self::assertArrayHasKey($key, $stats, "Stats array should contain key: {$key}"); + } + } + + public function testGetRemainingTokensCastsToInteger(): void + { + $mockResponse = $this->createMock(Response::class); + $mockResponse->status = ResponseStatus::OK; + $mockResponse->tokensLeft = '750'; // String value + + $mockApi = $this->createMock(KeepaAPI::class); + $mockApi + ->expects(self::once()) + ->method('sendRequest') + ->willReturn($mockResponse); + + $service = new KeepaService( + 'test-api-key', + $this->tokenLogger, + $this->logger + ); + + // Inject mock API using reflection + $reflection = new ReflectionClass($service); + $apiProperty = $reflection->getProperty('api'); + $apiProperty->setAccessible(true); + $apiProperty->setValue($service, $mockApi); + + $tokens = $service->getRemainingTokens(); + + self::assertIsInt($tokens); + self::assertSame(750, $tokens); + } +} diff --git a/tests/Unit/Service/Validator/MessageValidatorTest.php b/tests/Unit/Service/Validator/MessageValidatorTest.php index 0c14c0e..382e2ff 100644 --- a/tests/Unit/Service/Validator/MessageValidatorTest.php +++ b/tests/Unit/Service/Validator/MessageValidatorTest.php @@ -35,7 +35,7 @@ protected function setUp(): void ); } - public function testValidateInputReturnsDto WhenValid(): void + public function testValidateInputReturnsDtoWhenValid(): void { $dto = KeepaInputDto::fromArray([ 'id' => 1, From 7ff4b4bb99c0f236ca87a8e8cef919a663f77fb2 Mon Sep 17 00:00:00 2001 From: sni10 <9530163+sni10@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:58:24 +0200 Subject: [PATCH 4/4] docs: update coverage badge in README to reflect 89% --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f7bf73..7bde824 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Release](https://img.shields.io/github/v/release/sni10/keepa-api-data-extractor?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sni10/keepa-api-data-extractor/releases) [![Release Workflow](https://img.shields.io/github/actions/workflow/status/sni10/keepa-api-data-extractor/release.yml?style=for-the-badge&logo=githubactions&logoColor=white&label=Release)](https://github.com/sni10/keepa-api-data-extractor/actions/workflows/release.yml) [![Tests](https://img.shields.io/github/actions/workflow/status/sni10/keepa-api-data-extractor/tests.yml?style=for-the-badge&logo=githubactions&logoColor=white&label=Tests)](https://github.com/sni10/keepa-api-data-extractor/actions/workflows/tests.yml) -[![Coverage](https://img.shields.io/badge/Coverage-TBD-yellow?style=for-the-badge&logo=codecov&logoColor=white)](https://github.com/sni10/keepa-api-data-extractor/actions/workflows/tests.yml) +[![Coverage](https://img.shields.io/badge/Coverage-89%25-yellow?style=for-the-badge&logo=codecov&logoColor=white)](https://github.com/sni10/keepa-api-data-extractor/actions/workflows/tests.yml) [![PHP](https://img.shields.io/badge/PHP-8.3%2B-777BB4?style=for-the-badge&logo=php&logoColor=white)](https://www.php.net/) [![Symfony](https://img.shields.io/badge/Symfony-7.2-000000?style=for-the-badge&logo=Symfony&logoColor=white)](https://symfony.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://www.docker.com/)