diff --git a/src/Worker/SearchAttributeInvocationCache/InMemorySearchAttributeInvocationCache.php b/src/Worker/SearchAttributeInvocationCache/InMemorySearchAttributeInvocationCache.php new file mode 100644 index 000000000..379964d90 --- /dev/null +++ b/src/Worker/SearchAttributeInvocationCache/InMemorySearchAttributeInvocationCache.php @@ -0,0 +1,45 @@ + + */ + private array $upserts = []; + + public function clear(): void + { + $this->upserts = []; + } + + public function recordUpsert(string $name, array $entry): void + { + $this->upserts[$name] = $entry; + } + + public function wasUpserted(string $name): bool + { + return \array_key_exists($name, $this->upserts); + } + + public function getUpsert(string $name): ?array + { + return $this->upserts[$name] ?? null; + } + + public function all(): array + { + return $this->upserts; + } +} diff --git a/src/Worker/SearchAttributeInvocationCache/RoadRunnerSearchAttributeInvocationCache.php b/src/Worker/SearchAttributeInvocationCache/RoadRunnerSearchAttributeInvocationCache.php new file mode 100644 index 000000000..855a1eb4d --- /dev/null +++ b/src/Worker/SearchAttributeInvocationCache/RoadRunnerSearchAttributeInvocationCache.php @@ -0,0 +1,82 @@ +cache = (new Factory(RPC::create($host)))->select($cacheName); + } + + public static function create(): self + { + $env = Environment::fromGlobals(); + $host = $env->getRPCAddress(); + if ($host === '') { + throw new \RuntimeException('RoadRunner RPC address is not set.'); + } + + return new self($host, self::CACHE_NAME); + } + + public function clear(): void + { + $this->cache->delete(self::STORE_KEY); + } + + public function recordUpsert(string $name, array $entry): void + { + $map = $this->map(); + $map[$name] = $entry; + $this->cache->set(self::STORE_KEY, $map); + } + + public function wasUpserted(string $name): bool + { + return \array_key_exists($name, $this->map()); + } + + public function getUpsert(string $name): ?array + { + return $this->map()[$name] ?? null; + } + + public function all(): array + { + return $this->map(); + } + + /** + * @return array + */ + private function map(): array + { + $map = $this->cache->get(self::STORE_KEY); + + return \is_array($map) ? $map : []; + } +} diff --git a/src/Worker/SearchAttributeInvocationCache/SearchAttributeInvocationCacheInterface.php b/src/Worker/SearchAttributeInvocationCache/SearchAttributeInvocationCacheInterface.php new file mode 100644 index 000000000..8f8919a01 --- /dev/null +++ b/src/Worker/SearchAttributeInvocationCache/SearchAttributeInvocationCacheInterface.php @@ -0,0 +1,42 @@ + + */ + public function all(): array; +} diff --git a/testing/src/MockSearchAttributeInterceptor.php b/testing/src/MockSearchAttributeInterceptor.php new file mode 100644 index 000000000..69231b958 --- /dev/null +++ b/testing/src/MockSearchAttributeInterceptor.php @@ -0,0 +1,49 @@ +cache = $cache ?? RoadRunnerSearchAttributeInvocationCache::create(); + } + + public function handleOutboundRequest(RequestInterface $request, callable $next): PromiseInterface + { + if (!$request instanceof UpsertTypedSearchAttributes) { + return $next($request); + } + + foreach ($request->getSearchAttributes() as $update) { + $entry = $update instanceof ValueSet + ? [ + 'operation' => SearchAttributeInvocationCacheInterface::OPERATION_SET, + 'type' => $update->type->value, + 'value' => $update->value, + ] + : [ + 'operation' => SearchAttributeInvocationCacheInterface::OPERATION_UNSET, + 'type' => $update->type->value, + ]; + + $this->cache->recordUpsert($update->name, $entry); + } + + return resolve(null); + } +} diff --git a/testing/src/SearchAttributeMocker.php b/testing/src/SearchAttributeMocker.php new file mode 100644 index 000000000..40e4fdd33 --- /dev/null +++ b/testing/src/SearchAttributeMocker.php @@ -0,0 +1,119 @@ +cache = $cache ?? RoadRunnerSearchAttributeInvocationCache::create(); + } + + public function clear(): void + { + $this->cache->clear(); + } + + /** + * @param non-empty-string $name + */ + public function wasUpserted(string $name): bool + { + return $this->cache->wasUpserted($name); + } + + /** + * @param non-empty-string $name + */ + public function assertUpserted(string $name): void + { + Assert::assertTrue( + $this->cache->wasUpserted($name), + \sprintf('Expected search attribute "%s" to be upserted, but it was not.', $name), + ); + } + + /** + * @param non-empty-string $name + */ + public function assertUpsertedValue(string $name, mixed $value): void + { + $entry = $this->cache->getUpsert($name); + + Assert::assertNotNull( + $entry, + \sprintf('Expected search attribute "%s" to be upserted, but it was not.', $name), + ); + Assert::assertSame( + SearchAttributeInvocationCacheInterface::OPERATION_SET, + $entry['operation'] ?? null, + \sprintf('Expected search attribute "%s" to be set, but it was unset.', $name), + ); + + $actual = $entry['value'] ?? null; + $message = \sprintf('Search attribute "%s" value mismatch.', $name); + + if ($value instanceof \DateTimeInterface) { + Assert::assertEquals($value, $actual, $message); + + return; + } + + Assert::assertSame($value, $actual, $message); + } + + /** + * @param non-empty-string $name + */ + public function assertUnset(string $name): void + { + $entry = $this->cache->getUpsert($name); + + Assert::assertNotNull( + $entry, + \sprintf('Expected search attribute "%s" to be unset, but it was not upserted at all.', $name), + ); + Assert::assertSame( + SearchAttributeInvocationCacheInterface::OPERATION_UNSET, + $entry['operation'] ?? null, + \sprintf('Expected search attribute "%s" to be unset, but it was set.', $name), + ); + } + + /** + * @param non-empty-string $name + */ + public function assertNotUpserted(string $name): void + { + Assert::assertFalse( + $this->cache->wasUpserted($name), + \sprintf('Expected search attribute "%s" NOT to be upserted, but it was.', $name), + ); + } + + /** + * @param non-empty-string $name + */ + public function getUpserted(string $name): mixed + { + $entry = $this->cache->getUpsert($name); + + return $entry === null ? null : ($entry['value'] ?? null); + } + + /** + * @return array + */ + public function getUpsertedAttributes(): array + { + return $this->cache->all(); + } +} diff --git a/testing/src/WorkflowTestCase.php b/testing/src/WorkflowTestCase.php index 61003bc64..35455c3c5 100644 --- a/testing/src/WorkflowTestCase.php +++ b/testing/src/WorkflowTestCase.php @@ -21,6 +21,7 @@ class WorkflowTestCase extends TestCase protected TestService $testingService; protected ActivityMocker $activityMocks; protected WorkflowMocker $workflowMocks; + protected SearchAttributeMocker $searchAttributeMocks; protected DelayedCallbackScheduler $delayedCallbacks; protected function setUp(): void @@ -29,6 +30,7 @@ protected function setUp(): void $this->testingService = TestService::create($temporalAddress); $this->activityMocks = new ActivityMocker(); $this->workflowMocks = new WorkflowMocker(); + $this->searchAttributeMocks = new SearchAttributeMocker(); $this->workflowClient = new WorkflowClient( ServiceClient::create($temporalAddress), interceptorProvider: new SimplePipelineProvider($this->clientInterceptors()), @@ -42,6 +44,7 @@ protected function tearDown(): void { $this->activityMocks->clear(); $this->workflowMocks->clear(); + $this->searchAttributeMocks->clear(); $this->assertTimeSkippingBalanced(); parent::tearDown(); diff --git a/tests/Fixtures/src/Workflow/UpsertTypedSearchAttributesWorkflow.php b/tests/Fixtures/src/Workflow/UpsertTypedSearchAttributesWorkflow.php new file mode 100644 index 000000000..4d58db86b --- /dev/null +++ b/tests/Fixtures/src/Workflow/UpsertTypedSearchAttributesWorkflow.php @@ -0,0 +1,33 @@ +valueSet($keyword), + SearchAttributeKey::forInteger('CustomInt')->valueSet(42), + SearchAttributeKey::forKeyword('index')->valueSet('idx'), + SearchAttributeKey::forKeyword('2024')->valueSet('year'), + ); + + return 'done'; + } +} diff --git a/tests/Functional/Client/UpsertTypedSearchAttributesWorkflowTestCase.php b/tests/Functional/Client/UpsertTypedSearchAttributesWorkflowTestCase.php new file mode 100644 index 000000000..d3407d4c3 --- /dev/null +++ b/tests/Functional/Client/UpsertTypedSearchAttributesWorkflowTestCase.php @@ -0,0 +1,39 @@ +workflowClient->newWorkflowStub(UpsertTypedSearchAttributesWorkflow::class); + + $run = $this->workflowClient->start($workflow, 'CustomValue'); + + self::assertSame('done', $run->getResult('string', 30)); + $this->searchAttributeMocks->assertUpsertedValue('CustomKeyword', 'CustomValue'); + $this->searchAttributeMocks->assertUpserted('CustomInt'); + self::assertSame(42, $this->searchAttributeMocks->getUpserted('CustomInt')); + $this->searchAttributeMocks->assertUpsertedValue('index', 'idx'); + $this->searchAttributeMocks->assertUpsertedValue('2024', 'year'); + + self::assertSame( + [ + 'CustomKeyword' => ['operation' => 'set', 'type' => 'keyword', 'value' => 'CustomValue'], + 'CustomInt' => ['operation' => 'set', 'type' => 'int64', 'value' => 42], + 'index' => ['operation' => 'set', 'type' => 'keyword', 'value' => 'idx'], + '2024' => ['operation' => 'set', 'type' => 'keyword', 'value' => 'year'], + ], + $this->searchAttributeMocks->getUpsertedAttributes(), + ); + } +} diff --git a/tests/Functional/bootstrap.php b/tests/Functional/bootstrap.php index 9fdddc09c..14b080523 100644 --- a/tests/Functional/bootstrap.php +++ b/tests/Functional/bootstrap.php @@ -7,8 +7,12 @@ use Temporal\Tests\SearchAttributeTestInvoker; use Temporal\Worker\FeatureFlags; -\chdir(__DIR__ . '/../..'); -require_once __DIR__ . '/../../vendor/autoload.php'; +$rootDir = \dirname(__DIR__, 2); +$configDir = $rootDir . '/tests/Functional'; +$configFile = $configDir . '/.rr.silent.yaml'; + +\chdir($rootDir); +require_once $rootDir . '/vendor/autoload.php'; $systemInfo = SystemInfo::detect(); @@ -17,10 +21,10 @@ (new SearchAttributeTestInvoker())(); $environment->startRoadRunner( rrCommand: [ - $systemInfo->rrExecutable, + $rootDir . DIRECTORY_SEPARATOR . $systemInfo->rrExecutable, 'serve', - '-c', '.rr.silent.yaml', - '-w', 'tests/Functional', + '-c', $configFile, + '-w', $configDir, '-o', 'server.command=' . \implode(',', [ PHP_BINARY, @@ -29,7 +33,7 @@ ...$environment->command->getCommandLineArguments(), ]), ], - configFile: 'tests/Functional/.rr.silent.yaml', + configFile: $configFile, ); \register_shutdown_function(static fn() => $environment->stop()); diff --git a/tests/Functional/worker.php b/tests/Functional/worker.php index ac4b2d87a..e0a38615d 100644 --- a/tests/Functional/worker.php +++ b/tests/Functional/worker.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Temporal\Testing\MockChildWorkflowInterceptor; +use Temporal\Testing\MockSearchAttributeInterceptor; use Temporal\Testing\MockSideEffectInterceptor; use Temporal\Testing\WorkerFactory; use Temporal\Tests\Fixtures\PipelineProvider; @@ -42,6 +43,7 @@ HeaderChanger::class, MockChildWorkflowInterceptor::class, MockSideEffectInterceptor::class, + MockSearchAttributeInterceptor::class, ]; $workers = [ diff --git a/tests/Unit/Testing/MockSearchAttributeInterceptorTestCase.php b/tests/Unit/Testing/MockSearchAttributeInterceptorTestCase.php new file mode 100644 index 000000000..68f744185 --- /dev/null +++ b/tests/Unit/Testing/MockSearchAttributeInterceptorTestCase.php @@ -0,0 +1,122 @@ +cache = new InMemorySearchAttributeInvocationCache(); + $this->interceptor = new MockSearchAttributeInterceptor($this->cache); + + parent::setUp(); + } + + public function testTypedUpsertIsSwallowedAndRecorded(): void + { + $request = new UpsertTypedSearchAttributes([ + SearchAttributeKey::forKeyword('CustomKeyword')->valueSet('CustomValue'), + SearchAttributeKey::forInteger('CustomInt')->valueSet(42), + ]); + + $nextCalled = false; + $result = $this->interceptor->handleOutboundRequest( + $request, + static function () use (&$nextCalled) { + $nextCalled = true; + return resolve('should-not-happen'); + }, + ); + + self::assertFalse($nextCalled); + + $resolved = 'unresolved'; + $result->then(static function ($value) use (&$resolved): void { + $resolved = $value; + }); + self::assertNull($resolved); + + self::assertTrue($this->cache->wasUpserted('CustomKeyword')); + self::assertSame('set', $this->cache->getUpsert('CustomKeyword')['operation']); + self::assertSame('CustomValue', $this->cache->getUpsert('CustomKeyword')['value']); + self::assertSame(42, $this->cache->getUpsert('CustomInt')['value']); + } + + public function testTypedUnsetIsRecordedAsUnset(): void + { + $request = new UpsertTypedSearchAttributes([ + SearchAttributeKey::forKeyword('Removed')->valueUnset(), + ]); + + $this->interceptor->handleOutboundRequest($request, static fn() => resolve(null)); + + $entry = $this->cache->getUpsert('Removed'); + self::assertSame('unset', $entry['operation']); + self::assertArrayNotHasKey('value', $entry); + } + + public function testDatetimeValueIsStoredRawNotStringified(): void + { + $when = new \DateTimeImmutable('2026-07-28T12:00:00.500000+00:00'); + $request = new UpsertTypedSearchAttributes([ + SearchAttributeKey::forDatetime('When')->valueSet($when), + ]); + + $this->interceptor->handleOutboundRequest($request, static fn() => resolve(null)); + + self::assertSame($when, $this->cache->getUpsert('When')['value']); + } + + public function testCollisionAndNumericNamesRecordCleanly(): void + { + $request = new UpsertTypedSearchAttributes([ + SearchAttributeKey::forKeyword('index')->valueSet('idx'), + SearchAttributeKey::forKeyword('2024')->valueSet('year'), + SearchAttributeKey::forKeyword('Other')->valueSet('o'), + ]); + + $this->interceptor->handleOutboundRequest($request, static fn() => resolve(null)); + + self::assertSame('idx', $this->cache->getUpsert('index')['value']); + self::assertSame('year', $this->cache->getUpsert('2024')['value']); + self::assertTrue($this->cache->wasUpserted('index')); + self::assertCount(3, $this->cache->all()); + } + + public function testUntypedUpsertPassesThroughAndForwardsResult(): void + { + $request = new UpsertSearchAttributes(['attr1' => 'value']); + + $nextCalled = false; + $result = $this->interceptor->handleOutboundRequest( + $request, + static function ($req) use (&$nextCalled) { + $nextCalled = true; + return resolve($req); + }, + ); + + self::assertTrue($nextCalled); + self::assertFalse($this->cache->wasUpserted('attr1')); + + $forwarded = null; + $result->then(static function ($value) use (&$forwarded): void { + $forwarded = $value; + }); + self::assertSame($request, $forwarded); + } +} diff --git a/tests/Unit/Testing/SearchAttributeMockerTestCase.php b/tests/Unit/Testing/SearchAttributeMockerTestCase.php new file mode 100644 index 000000000..481b27356 --- /dev/null +++ b/tests/Unit/Testing/SearchAttributeMockerTestCase.php @@ -0,0 +1,108 @@ +cache = new InMemorySearchAttributeInvocationCache(); + $this->mocker = new SearchAttributeMocker($this->cache); + + parent::setUp(); + } + + public function testAssertUpsertedAndValue(): void + { + $this->cache->recordUpsert('CustomKeyword', ['operation' => 'set', 'type' => 'keyword', 'value' => 'CustomValue']); + + $this->mocker->assertUpserted('CustomKeyword'); + $this->mocker->assertUpsertedValue('CustomKeyword', 'CustomValue'); + self::assertTrue($this->mocker->wasUpserted('CustomKeyword')); + self::assertSame('CustomValue', $this->mocker->getUpserted('CustomKeyword')); + } + + public function testAssertUpsertedValueIsStrict(): void + { + $this->cache->recordUpsert('CustomInt', ['operation' => 'set', 'type' => 'int64', 'value' => 42]); + + $this->mocker->assertUpsertedValue('CustomInt', 42); + + $this->expectException(ExpectationFailedException::class); + $this->mocker->assertUpsertedValue('CustomInt', '42'); + } + + public function testGetUpsertedReturnsRawValue(): void + { + $when = new \DateTimeImmutable('2026-07-28T12:00:00.500000+00:00'); + $this->cache->recordUpsert('When', ['operation' => 'set', 'type' => 'datetime', 'value' => $when]); + + self::assertSame($when, $this->mocker->getUpserted('When')); + $this->mocker->assertUpsertedValue('When', $when); + } + + public function testAssertUpsertedValueComparesDatetimeByValue(): void + { + $stored = new \DateTimeImmutable('2026-07-28T12:00:00.500000+00:00'); + $this->cache->recordUpsert('When', ['operation' => 'set', 'type' => 'datetime', 'value' => $stored]); + + $expected = new \DateTimeImmutable('2026-07-28T12:00:00.500000+00:00'); + self::assertNotSame($stored, $expected); + $this->mocker->assertUpsertedValue('When', $expected); + } + + public function testAssertUnset(): void + { + $this->cache->recordUpsert('Removed', ['operation' => 'unset', 'type' => 'keyword']); + + $this->mocker->assertUnset('Removed'); + self::assertTrue($this->mocker->wasUpserted('Removed')); + self::assertNull($this->mocker->getUpserted('Removed')); + } + + public function testCollisionAndNumericNames(): void + { + $this->cache->recordUpsert('index', ['operation' => 'set', 'type' => 'keyword', 'value' => 'idx']); + $this->cache->recordUpsert('2024', ['operation' => 'set', 'type' => 'keyword', 'value' => 'year']); + $this->cache->recordUpsert('Other', ['operation' => 'set', 'type' => 'keyword', 'value' => 'o']); + + $this->mocker->assertUpsertedValue('index', 'idx'); + $this->mocker->assertUpsertedValue('2024', 'year'); + self::assertTrue($this->mocker->wasUpserted('index')); + + $all = $this->mocker->getUpsertedAttributes(); + self::assertArrayHasKey('index', $all); + self::assertCount(3, $all); + } + + public function testAssertNotUpsertedAndEscapeHatch(): void + { + $this->cache->recordUpsert('CustomInt', ['operation' => 'set', 'type' => 'int64', 'value' => 42]); + + $this->mocker->assertNotUpserted('Missing'); + + $all = $this->mocker->getUpsertedAttributes(); + self::assertArrayHasKey('CustomInt', $all); + self::assertSame(42, $all['CustomInt']['value']); + } + + public function testClear(): void + { + $this->cache->recordUpsert('CustomKeyword', ['operation' => 'set', 'type' => 'keyword', 'value' => 'v']); + + $this->mocker->clear(); + + self::assertFalse($this->mocker->wasUpserted('CustomKeyword')); + self::assertSame([], $this->mocker->getUpsertedAttributes()); + } +}