diff --git a/src/Model/Command/UserRecommendation.php b/src/Model/Command/UserRecommendation.php new file mode 100644 index 0000000..e6761f5 --- /dev/null +++ b/src/Model/Command/UserRecommendation.php @@ -0,0 +1,131 @@ += NOW']; + + private function __construct(string $userId, int $count, string $scenario, float $rotationRate, int $rotationTime) + { + $this->userId = $userId; // TODO: assert format + $this->count = $count; // TODO: assert greater than 0 + $this->scenario = $scenario; // TODO: assert format + $this->rotationRate = $rotationRate; // TODO: assert value between 0.0 and 1.0 + $this->rotationTime = $rotationTime; // TODO: assert valid time interval + } + + /** + * @param string $userId + * @param int $count Number of requested recommendations. The real number of recommended items could be lower or + * even zero when there are no items relevant for the user. + * @param string $scenario Name of the place where recommendations are applied - eg. 'search-results-page', + * 'emailing', 'empty-search-results, 'homepage', ... + * @param float $rotationRate How much should the item be penalized for being recommended again in the near future. + * Set from 0.0 for no rotation (same items will be recommended) up to 1.0 (same items should not be recommended). + * @param int $rotationTime Specify for how long will the item's rotationRate be taken in account and so the item + * is penalized for recommendations. + * @return UserRecommendation + */ + public static function create( + string $userId, + int $count, + string $scenario, + float $rotationRate, + int $rotationTime + ): self { + return new static($userId, $count, $scenario, $rotationRate, $rotationTime); + } + + /** + * Even with rotation rate 1.0 user could still obtain the same recommendations in some edge cases. + * To prevent this, enable hard rotation - recommended items are then excluded until rotation time is expired. + * By default hard rotation is not enabled. + */ + public function enableHardRotation(): self + { + $this->hardRotation = true; + + return $this; + } + + /** + * Define threshold of how much relevant must the recommended items be to be returned. + * Default minimal relevance is "low". + */ + public function setMinimalRelevance(string $minimalRelevance): self + { + // TODO: assert one of MIN_RELEVANCE_* + $this->minimalRelevance = $minimalRelevance; + + return $this; + } + + /** + * Add a filter to already added filters (including the default filter). + */ + public function addFilter(string $filter): self + { + $this->filters[] = $filter; + + return $this; + } + + /** + * Overwrite all filters by custom one. Note this will override also the default filter. + */ + public function setFilters(array $filters): self + { + $this->filters = $filters; + + return $this; + } + + protected function getCommandType(): string + { + return 'user-based-recommendations'; + } + + protected function getCommandParameters(): array + { + return [ + 'user_id' => $this->userId, + 'count' => $this->count, + 'scenario' => $this->scenario, + 'rotation_rate' => $this->rotationRate, + 'rotation_time' => $this->rotationTime, + 'hard_rotation' => $this->hardRotation, + 'min_relevance' => $this->minimalRelevance, + 'filter' => $this->assembleFiltersString(), + ]; + } + + protected function assembleFiltersString(): string + { + return implode(' ' . $this->filterOperator . ' ', $this->filters); + } +} diff --git a/src/RequestBuilder/RecommendationsRequestBuilder.php b/src/RequestBuilder/RecommendationsRequestBuilder.php new file mode 100644 index 0000000..0da764e --- /dev/null +++ b/src/RequestBuilder/RecommendationsRequestBuilder.php @@ -0,0 +1,49 @@ +userRecommendationCommand = $userRecommendationCommand; + } + + public function addUserMerge(UserMerge $merge): self + { + $this->userMergeCommand = $merge; + + return $this; + } + + public function addInteraction(Interaction $interaction): self + { + $this->interactionCommand = $interaction; + + return $this; + } + + public function build(): Request + { + return new Request( + self::ENDPOINT_PATH, + RequestMethodInterface::METHOD_POST, + [$this->interactionCommand, $this->userMergeCommand, $this->userRecommendationCommand] + ); + } +} diff --git a/src/RequestBuilder/RequestBuilderFactory.php b/src/RequestBuilder/RequestBuilderFactory.php index 13afa7e..5963a32 100644 --- a/src/RequestBuilder/RequestBuilderFactory.php +++ b/src/RequestBuilder/RequestBuilderFactory.php @@ -4,6 +4,7 @@ use Lmc\Matej\Http\RequestManager; use Lmc\Matej\Model\Command\Sorting; +use Lmc\Matej\Model\Command\UserRecommendation; /** * Factory to create concrete RequestBuilder which helps you to create request for each Matej API @@ -55,6 +56,14 @@ public function sorting(Sorting $sorting): SortingRequestBuilder return $this->createConfiguredBuilder(SortingRequestBuilder::class, $sorting); } + /** + * @return RecommendationsRequestBuilder + */ + public function recommendations(UserRecommendation $recommendation): RecommendationsRequestBuilder + { + return $this->createConfiguredBuilder(RecommendationsRequestBuilder::class, $recommendation); + } + // TODO: builders for other endpoints /** diff --git a/tests/IntegrationTests/RecommendationsRequestBuilderTest.php b/tests/IntegrationTests/RecommendationsRequestBuilderTest.php new file mode 100644 index 0000000..16bd08a --- /dev/null +++ b/tests/IntegrationTests/RecommendationsRequestBuilderTest.php @@ -0,0 +1,69 @@ +createRecommendationRequestBuilder() + ->send(); + + $this->assertResponseCommandStatuses($response, 'SKIPPED', 'SKIPPED', 'OK'); + } + + /** @test */ + public function shouldExecuteRecommendationRequestWithInteraction(): void + { + $response = $this->createRecommendationRequestBuilder() + ->addInteraction(Interaction::bookmark('integration-test-php-client-user-id-A', 'itemA')) + ->send(); + + $this->assertResponseCommandStatuses($response, 'OK', 'SKIPPED', 'OK'); + } + + /** @test */ + public function shouldExecuteRecommendationRequestWithUserMerge(): void + { + $response = $this->createRecommendationRequestBuilder() + ->addUserMerge(UserMerge::mergeInto('integration-test-php-client-user-id-A', 'integration-test-php-client-user-id-B')) + ->send(); + + $this->assertResponseCommandStatuses($response, 'SKIPPED', 'OK', 'OK'); + } + + /** @test */ + public function shouldExecuteRecommendationRequestWithUserMergeAndInteraction(): void + { + $response = $this->createRecommendationRequestBuilder() + ->addUserMerge(UserMerge::mergeInto('integration-test-php-client-user-id-A', 'integration-test-php-client-user-id-B')) + ->addInteraction(Interaction::bookmark('integration-test-php-client-user-id-A', 'itemA')) + ->send(); + + $this->assertResponseCommandStatuses($response, 'OK', 'OK', 'OK'); + } + + private function createRecommendationRequestBuilder(): RecommendationsRequestBuilder + { + return $this->createMatejInstance() + ->request() + ->recommendations(UserRecommendation::create( + 'integration-test-php-client-user-id-A', + 5, + 'integration-test-scenario', + 0.50, + 3600 + )) + ; + } +} diff --git a/tests/Model/Command/UserRecommendationTest.php b/tests/Model/Command/UserRecommendationTest.php new file mode 100644 index 0000000..472c5e9 --- /dev/null +++ b/tests/Model/Command/UserRecommendationTest.php @@ -0,0 +1,89 @@ +assertInstanceOf(UserRecommendation::class, $command); + $this->assertSame( + [ + 'type' => 'user-based-recommendations', + 'parameters' => [ + 'user_id' => 'user-id', + 'count' => 333, + 'scenario' => 'test-scenario', + 'rotation_rate' => 1.0, + 'rotation_time' => 3600, + 'hard_rotation' => false, + 'min_relevance' => UserRecommendation::MINIMAL_RELEVANCE_LOW, + 'filter' => 'valid_to >= NOW', + ], + ], + $command->jsonSerialize() + ); + } + + /** @test */ + public function shouldUseCustomParameters(): void + { + $userId = 'user-' . md5(microtime()); + $count = random_int(1, 100); + $scenario = 'scenario-' . md5(microtime()); + $rotationRate = mt_rand() / mt_getrandmax(); + $rotationTime = random_int(1, 86400); + + $command = UserRecommendation::create($userId, $count, $scenario, $rotationRate, $rotationTime); + + $command->setMinimalRelevance(UserRecommendation::MINIMAL_RELEVANCE_HIGH) + ->enableHardRotation() + ->setFilters(['foo = bar', 'baz = ban']); + + $this->assertInstanceOf(UserRecommendation::class, $command); + $this->assertSame( + [ + 'type' => 'user-based-recommendations', + 'parameters' => [ + 'user_id' => $userId, + 'count' => $count, + 'scenario' => $scenario, + 'rotation_rate' => $rotationRate, + 'rotation_time' => $rotationTime, + 'hard_rotation' => true, + 'min_relevance' => UserRecommendation::MINIMAL_RELEVANCE_HIGH, + 'filter' => 'foo = bar and baz = ban', + ], + ], + $command->jsonSerialize() + ); + } + + /** @test */ + public function shouldAssembleFilters(): void + { + $command = UserRecommendation::create('user-id', 333, 'test-scenario', 1.0, 3600); + + // Default filter + $this->assertSame('valid_to >= NOW', $command->jsonSerialize()['parameters']['filter']); + + // Add custom filters to the default one + $command->addFilter('foo = bar') + ->addFilter('bar = baz'); + + $this->assertSame( + 'valid_to >= NOW and foo = bar and bar = baz', + $command->jsonSerialize()['parameters']['filter'] + ); + + // Overwrite all filters + $command->setFilters(['my_filter = 1', 'other_filter = foo']); + + $this->assertSame('my_filter = 1 and other_filter = foo', $command->jsonSerialize()['parameters']['filter']); + } +} diff --git a/tests/RequestBuilder/RecommendationsRequestBuilderTest.php b/tests/RequestBuilder/RecommendationsRequestBuilderTest.php new file mode 100644 index 0000000..0d72193 --- /dev/null +++ b/tests/RequestBuilder/RecommendationsRequestBuilderTest.php @@ -0,0 +1,71 @@ +addInteraction($interactionCommand); + + $userMergeCommand = UserMerge::mergeFromSourceToTargetUser('sourceId1', 'targetId1'); + $builder->addUserMerge($userMergeCommand); + + $request = $builder->build(); + + $this->assertInstanceOf(Request::class, $request); + $this->assertSame(RequestMethodInterface::METHOD_POST, $request->getMethod()); + $this->assertSame('/recommendations', $request->getPath()); + + $requestData = $request->getData(); + $this->assertCount(3, $requestData); + $this->assertSame($interactionCommand, $requestData[0]); + $this->assertSame($userMergeCommand, $requestData[1]); + $this->assertSame($recommendationsCommand, $requestData[2]); + } + + /** @test */ + public function shouldThrowExceptionWhenSendingCommandsWithoutRequestManager(): void + { + $recommendationsCommand = UserRecommendation::create('userId1', 5, 'test-scenario', 0.5, 3600); + $builder = new RecommendationsRequestBuilder($recommendationsCommand); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Instance of RequestManager must be set to request builder'); + $builder->send(); + } + + /** @test */ + public function shouldSendRequestViaRequestManager(): void + { + $requestManagerMock = $this->createMock(RequestManager::class); + $requestManagerMock->expects($this->once()) + ->method('sendRequest') + ->with($this->isInstanceOf(Request::class)) + ->willReturn(new Response(0, 0, 0, 0)); + + $builder = new SortingRequestBuilder(Sorting::create('userId1', ['itemId1', 'itemId2'])); + $builder->setRequestManager($requestManagerMock); + $builder->send(); + } +} diff --git a/tests/RequestBuilder/RequestBuilderFactoryTest.php b/tests/RequestBuilder/RequestBuilderFactoryTest.php index 87f297c..a29cf7f 100644 --- a/tests/RequestBuilder/RequestBuilderFactoryTest.php +++ b/tests/RequestBuilder/RequestBuilderFactoryTest.php @@ -6,6 +6,7 @@ use Lmc\Matej\Model\Command\ItemProperty; use Lmc\Matej\Model\Command\ItemPropertySetup; use Lmc\Matej\Model\Command\Sorting; +use Lmc\Matej\Model\Command\UserRecommendation; use Lmc\Matej\Model\Request; use Lmc\Matej\Model\Response; use PHPUnit\Framework\TestCase; @@ -59,14 +60,17 @@ public function provideBuilderMethods(): array $builder->addItemProperty(ItemProperty::create('item-id', [])); }; - $sortingInit = function (SortingRequestBuilder $builder): void { + $voidInit = function ($builder): void { }; + $userRecommendation = UserRecommendation::create('user-id', 1, 'test-scenario', 0.5, 3600); + return [ ['setupItemProperties', ItemPropertiesSetupRequestBuilder::class, $itemPropertiesSetupInit], ['deleteItemProperties', ItemPropertiesSetupRequestBuilder::class, $itemPropertiesSetupInit], ['events', EventsRequestBuilder::class, $eventInit], - ['sorting', SortingRequestBuilder::class, $sortingInit, Sorting::create('user-a', ['item-a', 'item-b', 'item-c'])], + ['sorting', SortingRequestBuilder::class, $voidInit, Sorting::create('user-a', ['item-a', 'item-b', 'item-c'])], + ['recommendations', RecommendationsRequestBuilder::class, $voidInit, $userRecommendation], ]; } }