diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index 03d425b..b359b41 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -207,6 +207,10 @@ public function setSynonyms(SynonymConfiguration $config): SynonymResponse /** * Get search synonyms for a specific language. * + * The returned response always carries a non-null synonyms array (empty + * when there are none), unlike SynonymResponse::fromArray() called directly + * without a synonyms key, which yields null. + * * @param string $language Language code (e.g., "en", "lt") * @return SynonymResponse Typed response with synonyms data */ @@ -216,7 +220,29 @@ public function getSynonyms(string $language): SynonymResponse $this->baseApiPath . 'synonyms?language=' . urlencode($language) ); - return SynonymResponse::fromArray($response); + return SynonymResponse::fromArray( + $this->withSynonymGetDefaults($response, $language) + ); + } + + /** + * The GET endpoint may omit synonym_count/requires_reindex; default them so + * SynonymResponse::fromArray() always receives its required fields. + * + * @param array $response + * @return array + */ + private function withSynonymGetDefaults(array $response, string $language): array + { + $synonyms = $response['synonyms'] ?? []; + $synonyms = is_array($synonyms) ? $synonyms : []; + + return [ + 'language' => $response['language'] ?? $language, + 'synonym_count' => $response['synonym_count'] ?? count($synonyms), + 'requires_reindex' => $response['requires_reindex'] ?? false, + 'synonyms' => $synonyms, + ]; } /** diff --git a/src/V2/ValueObjects/Index/IndexCreateRequest.php b/src/V2/ValueObjects/Index/IndexCreateRequest.php index 9ad2eb9..fb507d5 100644 --- a/src/V2/ValueObjects/Index/IndexCreateRequest.php +++ b/src/V2/ValueObjects/Index/IndexCreateRequest.php @@ -6,6 +6,7 @@ use BradSearch\SyncSdk\V2\Exceptions\InvalidArgumentException; use BradSearch\SyncSdk\V2\Exceptions\InvalidLocaleException; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\SynonymConfiguration; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; /** @@ -14,6 +15,9 @@ * This immutable ValueObject contains the required data for creating a new index: * - locales: Array of locale codes in 'xx-XX' format * - fields: Array of FieldDefinition objects + * - synonyms: Optional per-language synonym configurations, applied at index + * creation so they take effect on activation without a separate post-activation + * update (which would briefly close the active index). */ final readonly class IndexCreateRequest extends ValueObject { @@ -25,13 +29,16 @@ /** * @param array $locales Array of locale codes (e.g., ['lt-LT', 'en-US']) * @param array $fields Array of field definitions + * @param array $synonyms Optional per-language synonym configurations */ public function __construct( array $locales, - public array $fields + public array $fields, + public array $synonyms = [] ) { $this->validateLocales($locales); $this->validateFields($fields); + $this->validateSynonyms($synonyms); $this->locales = $locales; } @@ -42,7 +49,7 @@ public function __construct( */ public function withLocales(array $locales): self { - return new self($locales, $this->fields); + return new self($locales, $this->fields, $this->synonyms); } /** @@ -52,7 +59,17 @@ public function withLocales(array $locales): self */ public function withFields(array $fields): self { - return new self($this->locales, $fields); + return new self($this->locales, $fields, $this->synonyms); + } + + /** + * Returns a new instance with different synonym configurations. + * + * @param array $synonyms + */ + public function withSynonyms(array $synonyms): self + { + return new self($this->locales, $this->fields, $synonyms); } /** @@ -60,7 +77,7 @@ public function withFields(array $fields): self */ public function withAddedLocale(string $locale): self { - return new self([...$this->locales, $locale], $this->fields); + return new self([...$this->locales, $locale], $this->fields, $this->synonyms); } /** @@ -68,7 +85,15 @@ public function withAddedLocale(string $locale): self */ public function withAddedField(FieldDefinition $field): self { - return new self($this->locales, [...$this->fields, $field]); + return new self($this->locales, [...$this->fields, $field], $this->synonyms); + } + + /** + * Returns a new instance with an additional synonym configuration. + */ + public function withAddedSynonym(SynonymConfiguration $synonym): self + { + return new self($this->locales, $this->fields, [...$this->synonyms, $synonym]); } /** @@ -76,13 +101,22 @@ public function withAddedField(FieldDefinition $field): self */ public function jsonSerialize(): array { - return [ + $data = [ 'locales' => $this->locales, 'fields' => array_map( fn(FieldDefinition $field) => $field->jsonSerialize(), $this->fields ), ]; + + if ($this->synonyms !== []) { + $data['synonyms'] = array_map( + fn(SynonymConfiguration $synonym) => $synonym->jsonSerialize(), + $this->synonyms + ); + } + + return $data; } /** @@ -143,4 +177,44 @@ private function validateFields(array $fields): void } } } + + /** + * Validates that all synonym entries are SynonymConfiguration instances and + * that each language appears at most once. + * + * @param array $synonyms + * @throws InvalidArgumentException If an entry is not a SynonymConfiguration + * or a language is configured more than once + */ + private function validateSynonyms(array $synonyms): void + { + $seenLanguages = []; + + foreach ($synonyms as $index => $synonym) { + if (!$synonym instanceof SynonymConfiguration) { + throw new InvalidArgumentException( + sprintf( + 'Synonym at index %d must be an instance of SynonymConfiguration.', + $index + ), + 'synonyms', + $synonym + ); + } + + if (isset($seenLanguages[$synonym->language])) { + throw new InvalidArgumentException( + sprintf( + 'Duplicate synonym configuration for language "%s" at index %d; each language must appear at most once.', + $synonym->language, + $index + ), + 'synonyms', + $synonyms + ); + } + + $seenLanguages[$synonym->language] = true; + } + } } diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index 5d2c342..0d53324 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -5,6 +5,7 @@ namespace BradSearch\SyncSdk\V2\ValueObjects\Response; use BradSearch\SyncSdk\V2\Exceptions\InvalidArgumentException; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\NormalizesSynonymGroups; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; /** @@ -18,6 +19,8 @@ */ final readonly class SynonymResponse extends ValueObject { + use NormalizesSynonymGroups; + private const LANGUAGE_PATTERN = '/^[a-z]{2}$/'; /** @@ -57,10 +60,34 @@ public static function fromArray(array $data): self language: (string) $data['language'], synonymCount: (int) $data['synonym_count'], requiresReindex: (bool) $data['requires_reindex'], - synonyms: $data['synonyms'] ?? null + synonyms: self::normalizeSynonyms($data['synonyms'] ?? null) ); } + /** + * The API returns each synonym group as a Solr-format string + * ("laptop, notebook"); normalize those into term arrays. + * + * Non-array payloads (null, or the whole field arriving as a single + * string) yield null rather than a TypeError, since fromArray() is public. + * Malformed entries that normalize to an empty group are dropped so the + * result never carries an untraceable []. + * + * @param mixed $synonyms + * @return array>|null + */ + private static function normalizeSynonyms(mixed $synonyms): ?array + { + if (!is_array($synonyms)) { + return null; + } + + return array_values(array_filter( + array_map(self::normalizeGroup(...), $synonyms), + static fn(array $group): bool => !empty($group) + )); + } + /** * @return array */ diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php new file mode 100644 index 0000000..80fd938 --- /dev/null +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -0,0 +1,51 @@ + + */ + private static function normalizeGroup(mixed $group): array + { + if (is_string($group)) { + return self::trimTerms(explode(',', $group)); + } + + if (!is_array($group)) { + return []; + } + + return self::trimTerms(array_map( + static fn(mixed $term): string => is_string($term) ? $term : '', + $group + )); + } + + /** + * Trims terms and discards any that are empty after trimming. + * + * @param array $terms + * @return array + */ + private static function trimTerms(array $terms): array + { + return array_values(array_filter( + array_map('trim', $terms), + static fn(string $term): bool => $term !== '' + )); + } +} diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index 22814c2..23fe1a2 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -15,6 +15,8 @@ */ final readonly class SynonymConfiguration extends ValueObject { + use NormalizesSynonymGroups; + private const LANGUAGE_PATTERN = '/^[a-z]{2}$/'; /** @@ -58,13 +60,49 @@ public function addSynonym(array $synonymGroup): self } /** + * Creates a SynonymConfiguration from an API response payload, where each + * synonym group is a Solr-format string (e.g. "laptop, notebook"). + * + * Returns null when the response carries no synonyms — an empty list is a + * valid API state, whereas constructing a config to send requires at least + * one group. + * + * @param array $data + * + * @throws InvalidArgumentException If a synonym group cannot be parsed + * (e.g. a non-string/non-array entry that + * normalizes to an empty group), unlike + * SynonymResponse::normalizeSynonyms() + * which drops such groups silently. + */ + public static function fromApiResponse(array $data): ?self + { + $rawSynonyms = $data['synonyms'] ?? []; + $rawSynonyms = is_array($rawSynonyms) ? $rawSynonyms : []; + + if (empty($rawSynonyms)) { + return null; + } + + $synonyms = array_map(self::normalizeGroup(...), $rawSynonyms); + + return new self((string) ($data['language'] ?? ''), $synonyms); + } + + /** + * The API expects each synonym group as a Solr-format equivalence string + * ("laptop, notebook, computer"), not as a nested array. + * * @return array */ public function jsonSerialize(): array { return [ 'language' => $this->language, - 'synonyms' => $this->synonyms, + 'synonyms' => array_map( + static fn(array $group): string => implode(', ', $group), + $this->synonyms + ), ]; } @@ -105,52 +143,90 @@ private function validateSynonyms(array $synonyms): void } foreach ($synonyms as $index => $synonymGroup) { - if (!is_array($synonymGroup)) { - throw new InvalidArgumentException( - sprintf( - 'Synonym group at index %d must be an array, got %s.', - $index, - gettype($synonymGroup) - ), - 'synonyms', - $synonyms - ); - } - - if (empty($synonymGroup)) { - throw new InvalidArgumentException( - sprintf('Synonym group at index %d cannot be empty.', $index), - 'synonyms', - $synonyms - ); - } - - foreach ($synonymGroup as $termIndex => $term) { - if (!is_string($term)) { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] must be a string, got %s.', - $index, - $termIndex, - gettype($term) - ), - 'synonyms', - $synonyms - ); - } - - if (trim($term) === '') { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] cannot be empty.', - $index, - $termIndex - ), - 'synonyms', - $synonyms - ); - } - } + $this->validateSynonymGroup($index, $synonymGroup, $synonyms); + } + } + + /** + * Validates a single synonym group: it must be a non-empty array of terms. + * + * @param array> $synonyms Full set, for error context + * + * @throws InvalidArgumentException If the group is not a non-empty array + */ + private function validateSynonymGroup(int $index, mixed $synonymGroup, array $synonyms): void + { + if (!is_array($synonymGroup)) { + throw new InvalidArgumentException( + sprintf( + 'Synonym group at index %d must be an array, got %s.', + $index, + gettype($synonymGroup) + ), + 'synonyms', + $synonyms + ); + } + + if (empty($synonymGroup)) { + throw new InvalidArgumentException( + sprintf('Synonym group at index %d cannot be empty.', $index), + 'synonyms', + $synonyms + ); + } + + foreach ($synonymGroup as $termIndex => $term) { + $this->validateSynonymTerm($index, $termIndex, $term, $synonyms); + } + } + + /** + * Validates a single synonym term: a non-empty string free of Solr syntax + * characters ("," and "=>"). + * + * @param array> $synonyms Full set, for error context + * + * @throws InvalidArgumentException If the term is invalid + */ + private function validateSynonymTerm(int $index, int $termIndex, mixed $term, array $synonyms): void + { + if (!is_string($term)) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must be a string, got %s.', + $index, + $termIndex, + gettype($term) + ), + 'synonyms', + $synonyms + ); + } + + if (trim($term) === '') { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] cannot be empty.', + $index, + $termIndex + ), + 'synonyms', + $synonyms + ); + } + + if (str_contains($term, ',') || str_contains($term, '=>')) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', + $index, + $termIndex, + $term + ), + 'synonyms', + $synonyms + ); } } } diff --git a/tests/SyncV2SdkTest.php b/tests/SyncV2SdkTest.php index 65126f1..b5f31bf 100644 --- a/tests/SyncV2SdkTest.php +++ b/tests/SyncV2SdkTest.php @@ -967,13 +967,12 @@ public function testGetSynonymsSuccess(): void { $language = 'en'; + // GET returns the SynonymConfiguration shape: Solr strings, no count/reindex fields $apiResponse = [ 'language' => 'en', - 'synonym_count' => 2, - 'requires_reindex' => false, 'synonyms' => [ - ['happy', 'joyful', 'cheerful'], - ['sad', 'unhappy', 'sorrowful'], + 'happy, joyful, cheerful', + 'sad, unhappy, sorrowful', ], ]; @@ -991,7 +990,31 @@ public function testGetSynonymsSuccess(): void $this->assertEquals('en', $result->language); $this->assertEquals(2, $result->synonymCount); $this->assertFalse($result->requiresReindex); - $this->assertCount(2, $result->synonyms); + $this->assertEquals( + [ + ['happy', 'joyful', 'cheerful'], + ['sad', 'unhappy', 'sorrowful'], + ], + $result->synonyms + ); + } + + public function testGetSynonymsHandlesEmptyResult(): void + { + $httpClientMock = $this->createMock(HttpClient::class); + $httpClientMock + ->expects($this->once()) + ->method('get') + ->willReturn([ + 'language' => 'en', + 'synonyms' => null, + ]); + + $sdk = $this->createSdkWithMockedHttpClient($httpClientMock); + $result = $sdk->getSynonyms('en'); + + $this->assertEquals(0, $result->synonymCount); + $this->assertEquals([], $result->synonyms); } public function testGetSynonymsAppIdIncludedInUrlPath(): void diff --git a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php index abf9648..2e4a139 100644 --- a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php +++ b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php @@ -10,6 +10,7 @@ use BradSearch\SyncSdk\V2\ValueObjects\Index\FieldType; use BradSearch\SyncSdk\V2\ValueObjects\Index\IndexCreateRequest; use BradSearch\SyncSdk\V2\ValueObjects\Index\VariantAttribute; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\SynonymConfiguration; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; use JsonSerializable; use PHPUnit\Framework\TestCase; @@ -463,4 +464,127 @@ public function testInvalidFieldInMiddleOfArray(): void ] ); } + + public function testSynonymsDefaultToEmptyAndAreOmittedFromSerialization(): void + { + $request = new IndexCreateRequest( + ['lt-LT'], + [new FieldDefinition('id', FieldType::KEYWORD)] + ); + + $this->assertEquals([], $request->synonyms); + $this->assertArrayNotHasKey('synonyms', $request->jsonSerialize()); + } + + public function testSynonymsAreSerializedAsSolrStringsWhenProvided(): void + { + $request = new IndexCreateRequest( + ['en-US', 'lt-LT'], + [new FieldDefinition('name', FieldType::TEXT)], + [ + new SynonymConfiguration('en', [['laptop', 'notebook']]), + new SynonymConfiguration('lt', [['telefonas', 'mobilusis']]), + ] + ); + + $this->assertEquals( + [ + ['language' => 'en', 'synonyms' => ['laptop, notebook']], + ['language' => 'lt', 'synonyms' => ['telefonas, mobilusis']], + ], + $request->jsonSerialize()['synonyms'] + ); + } + + public function testWithSynonymsReturnsNewInstance(): void + { + $request = new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)] + ); + + $synonyms = [new SynonymConfiguration('en', [['laptop', 'notebook']])]; + $updated = $request->withSynonyms($synonyms); + + $this->assertNotSame($request, $updated); + $this->assertEquals([], $request->synonyms); + $this->assertEquals($synonyms, $updated->synonyms); + } + + public function testWithAddedSynonymReturnsNewInstance(): void + { + $originalSynonym = new SynonymConfiguration('en', [['laptop', 'notebook']]); + $newSynonym = new SynonymConfiguration('lt', [['telefonas', 'mobilusis']]); + + $request = new IndexCreateRequest( + ['en-US', 'lt-LT'], + [new FieldDefinition('name', FieldType::TEXT)], + [$originalSynonym] + ); + $updated = $request->withAddedSynonym($newSynonym); + + $this->assertNotSame($request, $updated); + $this->assertCount(1, $request->synonyms); + $this->assertCount(2, $updated->synonyms); + $this->assertSame($originalSynonym, $updated->synonyms[0]); + $this->assertSame($newSynonym, $updated->synonyms[1]); + } + + public function testThrowsExceptionForInvalidSynonymEntry(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Synonym at index 0 must be an instance of SynonymConfiguration.'); + + new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + [['language' => 'en', 'synonyms' => [['laptop', 'notebook']]]] + ); + } + + /** + * @dataProvider builderProvider + * @param callable(IndexCreateRequest): IndexCreateRequest $builder + */ + public function testBuildersPreserveSynonyms(callable $builder): void + { + $synonyms = [new SynonymConfiguration('en', [['laptop', 'notebook']])]; + $request = new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + $synonyms + ); + + $result = $builder($request); + + $this->assertEquals($synonyms, $result->synonyms); + } + + /** + * @return array + */ + public static function builderProvider(): array + { + return [ + 'withLocales' => [fn(IndexCreateRequest $r) => $r->withLocales(['lt-LT'])], + 'withFields' => [fn(IndexCreateRequest $r) => $r->withFields([new FieldDefinition('title', FieldType::TEXT)])], + 'withAddedLocale' => [fn(IndexCreateRequest $r) => $r->withAddedLocale('lt-LT')], + 'withAddedField' => [fn(IndexCreateRequest $r) => $r->withAddedField(new FieldDefinition('title', FieldType::TEXT))], + ]; + } + + public function testThrowsExceptionForDuplicateSynonymLanguage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Duplicate synonym configuration for language "en" at index 1'); + + new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + [ + new SynonymConfiguration('en', [['laptop', 'notebook']]), + new SynonymConfiguration('en', [['phone', 'mobile']]), + ] + ); + } } diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index e6f7eae..d9fdaac 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -88,6 +88,82 @@ public function testFromArrayWithSynonyms(): void $this->assertEquals($synonyms, $response->synonyms); } + public function testFromArrayNormalizesSolrStringSynonymsIntoGroups(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 2, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, notebook,computer', + 'phone, mobile', + ], + ]); + + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile'], + ], + $response->synonyms + ); + } + + public function testFromArrayHandlesNonArraySynonymsWithoutTypeError(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 0, + 'requires_reindex' => false, + 'synonyms' => 'laptop, notebook', + ]); + + $this->assertNull($response->synonyms); + } + + public function testFromArrayDropsMalformedGroupsThatNormalizeToEmpty(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, notebook', + 123, + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + + public function testFromArrayTrimsArrayFormGroupsAndDropsNonStringElements(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + ['laptop ', ' notebook', 123], + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + + public function testFromArrayDropsEmptyTermsFromSolrStringGroups(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, , notebook', + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); @@ -223,23 +299,30 @@ public function testToArrayReturnsJsonSerializeOutput(): void */ public function testMatchesOpenApiExampleResponse(): void { - $apiResponse = [ - 'language' => 'en', - 'synonym_count' => 3, - 'requires_reindex' => true, - 'synonyms' => [ - ['laptop', 'notebook', 'computer'], - ['phone', 'mobile', 'smartphone'], - ['shoes', 'footwear', 'sneakers'], - ], - ]; + $fixture = json_decode( + (string) file_get_contents( + __DIR__ . '/../../../fixtures/openapi-examples/synonyms-ecommerce-en.json' + ), + true + ); - $response = SynonymResponse::fromArray($apiResponse); + // The fixture is the Solr-string GET shape and omits count/reindex; + // supply defaults that the fixture overrides if it ever gains them. + $response = SynonymResponse::fromArray(array_merge([ + 'synonym_count' => count($fixture['synonyms']), + 'requires_reindex' => false, + ], $fixture)); $this->assertEquals('en', $response->language); $this->assertEquals(3, $response->synonymCount); - $this->assertTrue($response->requiresReindex); - $this->assertCount(3, $response->synonyms); + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile', 'smartphone'], + ['shoes', 'footwear', 'sneakers'], + ], + $response->synonyms + ); } public function testJsonEncodeProducesValidJson(): void diff --git a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php index a683540..1f1bf27 100644 --- a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php +++ b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php @@ -138,7 +138,7 @@ public function testRejectsWhitespaceOnlyStringInSynonymGroup(): void new SynonymConfiguration('en', [[' ', 'laptop']]); } - public function testJsonSerializeReturnsCorrectStructure(): void + public function testJsonSerializeReturnsSolrFormatStrings(): void { $synonyms = [ ['laptop', 'notebook'], @@ -149,12 +149,64 @@ public function testJsonSerializeReturnsCorrectStructure(): void $expected = [ 'language' => 'en', - 'synonyms' => $synonyms, + 'synonyms' => [ + 'laptop, notebook', + 'phone, mobile', + ], ]; $this->assertEquals($expected, $config->jsonSerialize()); } + public function testRejectsCommaInSynonymTerm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain "," or "=>"'); + + new SynonymConfiguration('en', [['laptop, notebook', 'computer']]); + } + + public function testRejectsExplicitMappingArrowInSynonymTerm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain "," or "=>"'); + + new SynonymConfiguration('en', [['laptop => notebook', 'computer']]); + } + + public function testFromApiResponseParsesSolrStringsIntoGroups(): void + { + $config = SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + 'synonyms' => [ + 'laptop, notebook,computer', + 'phone, mobile', + ], + ]); + + $this->assertNotNull($config); + $this->assertEquals('en', $config->language); + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile'], + ], + $config->synonyms + ); + } + + public function testFromApiResponseReturnsNullForEmptySynonyms(): void + { + $this->assertNull(SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + 'synonyms' => [], + ])); + + $this->assertNull(SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + ])); + } + public function testToArrayReturnsJsonSerializeOutput(): void { $config = new SynonymConfiguration('en', [['laptop', 'notebook']]); @@ -242,7 +294,13 @@ public function testJsonEncodeProducesValidJson(): void $decoded = json_decode($json, true); $this->assertEquals('en', $decoded['language']); - $this->assertEquals($synonyms, $decoded['synonyms']); + $this->assertEquals( + [ + 'laptop, notebook, computer', + 'phone, mobile, smartphone', + ], + $decoded['synonyms'] + ); } public function testChainedWithMethods(): void @@ -313,9 +371,9 @@ public function testMatchesOpenApiEcommerceEnExample(): void $expected = [ 'language' => 'en', 'synonyms' => [ - ['laptop', 'notebook', 'computer'], - ['phone', 'mobile', 'smartphone'], - ['shoes', 'footwear', 'sneakers'], + 'laptop, notebook, computer', + 'phone, mobile, smartphone', + 'shoes, footwear, sneakers', ], ]; @@ -353,10 +411,10 @@ public function testAcceptsLargeSynonymGroup(): void public function testAcceptsManySynonymGroups(): void { - $synonyms = []; - for ($i = 0; $i < 100; $i++) { - $synonyms[] = ["term{$i}a", "term{$i}b"]; - } + $synonyms = array_map( + fn(int $i): array => ["term{$i}a", "term{$i}b"], + range(0, 99) + ); $config = new SynonymConfiguration('en', $synonyms); diff --git a/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json b/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json index 5747d07..2b17889 100644 --- a/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json +++ b/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json @@ -1,8 +1,8 @@ { "language": "en", "synonyms": [ - ["laptop", "notebook", "computer"], - ["phone", "mobile", "smartphone"], - ["shoes", "footwear", "sneakers"] + "laptop, notebook, computer", + "phone, mobile, smartphone", + "shoes, footwear, sneakers" ] }