From b5e8dcfa2adcf482e1580f37c7047958fb18dc4b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 3 Jun 2026 06:29:08 +0700 Subject: [PATCH 1/3] Add StructArmed to QA --- .github/workflows/structarmed.yml | 17 +++ composer.json | 3 +- structarmed.php | 9 ++ tests/Feature/AliasesTest.php | 2 +- tests/Feature/AnalyticsEventsTest.php | 6 +- tests/Feature/AnalyticsEventsV1Test.php | 8 +- tests/Feature/AnalyticsRulesTest.php | 12 +-- tests/Feature/AnalyticsRulesV1Test.php | 2 +- tests/Feature/ApiCallRetryTest.php | 120 ++++++++++----------- tests/Feature/ClientTest.php | 2 +- tests/Feature/CollectionsTest.php | 2 +- tests/Feature/ConfigurationTest.php | 2 +- tests/Feature/ConversationModelTest.php | 2 +- tests/Feature/ConversationModelsTest.php | 2 +- tests/Feature/ConversationTest.php | 2 +- tests/Feature/CurationSetItemsTest.php | 10 +- tests/Feature/CurationSetsTest.php | 8 +- tests/Feature/DebugTest.php | 2 +- tests/Feature/DocumentTest.php | 2 +- tests/Feature/DocumentsTest.php | 2 +- tests/Feature/FilterByTest.php | 2 +- tests/Feature/HealthTest.php | 2 +- tests/Feature/HttpClientsTest.php | 2 +- tests/Feature/KeysTest.php | 2 +- tests/Feature/MetricsTest.php | 2 +- tests/Feature/MultiSearchTest.php | 2 +- tests/Feature/NLSearchModelsTest.php | 12 +-- tests/Feature/OperationsTest.php | 2 +- tests/Feature/OverridesTest.php | 6 +- tests/Feature/PresetsTest.php | 2 +- tests/Feature/StemmingDictionariesTest.php | 4 +- tests/Feature/StopwordsTest.php | 2 +- tests/Feature/SynonymSetItemsTest.php | 2 +- tests/Feature/SynonymSetsTest.php | 8 +- tests/Feature/SynonymsTest.php | 6 +- 35 files changed, 149 insertions(+), 122 deletions(-) create mode 100644 .github/workflows/structarmed.yml create mode 100644 structarmed.php diff --git a/.github/workflows/structarmed.yml b/.github/workflows/structarmed.yml new file mode 100644 index 00000000..8dc8910f --- /dev/null +++ b/.github/workflows/structarmed.yml @@ -0,0 +1,17 @@ +name: StructArmed + +on: [push, pull_request] + +jobs: + test: + name: Run StructArmed static analysis with PHP v8.4 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + - uses: php-actions/composer@v6 + - name: Run StructArmed + run: vendor/bin/structarmed analyze diff --git a/composer.json b/composer.json index c97f9e04..adcf6a42 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "phpunit/phpunit": "^11.2", "squizlabs/php_codesniffer": "3.*", "symfony/http-client": "^5.2", - "mockery/mockery": "^1.6" + "mockery/mockery": "^1.6", + "boundwize/structarmed": "^0.10.1" }, "config": { "optimize-autoloader": true, diff --git a/structarmed.php b/structarmed.php new file mode 100644 index 00000000..2cd501fd --- /dev/null +++ b/structarmed.php @@ -0,0 +1,9 @@ +withPreset(Preset::PSR4()); diff --git a/tests/Feature/AliasesTest.php b/tests/Feature/AliasesTest.php index 6d7cccb8..903b2058 100644 --- a/tests/Feature/AliasesTest.php +++ b/tests/Feature/AliasesTest.php @@ -1,6 +1,6 @@ "test_user" ] ]; - + $this->client()->analytics->events()->create($event); $response = $this->client()->analytics->events()->retrieve([ @@ -165,4 +165,4 @@ public function testCanCreateEventWithDifferentEventTypes(): void $conversionResponse = $this->client()->analytics->events()->create($conversionEvent); $this->assertIsArray($conversionResponse); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/Feature/AnalyticsEventsV1Test.php b/tests/Feature/AnalyticsEventsV1Test.php index 12c809c7..da5331d3 100644 --- a/tests/Feature/AnalyticsEventsV1Test.php +++ b/tests/Feature/AnalyticsEventsV1Test.php @@ -1,6 +1,6 @@ isV30OrAbove()) { $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); } - + $this->client()->collections->create([ "name" => "products", "fields" => [ @@ -58,7 +58,7 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); - + if (!$this->isV30OrAbove()) { try { $this->client()->analyticsV1->rules()->{'product_queries_aggregation'}->delete(); diff --git a/tests/Feature/AnalyticsRulesTest.php b/tests/Feature/AnalyticsRulesTest.php index 03ea912c..d98e0594 100644 --- a/tests/Feature/AnalyticsRulesTest.php +++ b/tests/Feature/AnalyticsRulesTest.php @@ -1,6 +1,6 @@ client()->analytics->rules()->create($rules); $this->assertIsArray($response); - + $allRules = $this->client()->analytics->rules()->retrieve(); $this->assertIsArray($allRules); - + $ruleNames = array_column($allRules, 'name'); $this->assertContains('test_rule_1', $ruleNames); $this->assertContains('test_rule_2', $ruleNames); @@ -161,10 +161,10 @@ public function testArrayAccessCompatibility(): void { $rule = $this->client()->analytics->rules()[$this->ruleName]; $this->assertInstanceOf('Typesense\AnalyticsRule', $rule); - + $this->assertTrue(isset($this->client()->analytics->rules()[$this->ruleName])); - + $rule = $this->client()->analytics->rules()[$this->ruleName]; $this->assertInstanceOf('Typesense\AnalyticsRule', $rule); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/Feature/AnalyticsRulesV1Test.php b/tests/Feature/AnalyticsRulesV1Test.php index 2f55468b..c9603f37 100644 --- a/tests/Feature/AnalyticsRulesV1Test.php +++ b/tests/Feature/AnalyticsRulesV1Test.php @@ -1,6 +1,6 @@ createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount, $expectedCalls) { $callCount++; - + if ($callCount < $expectedCalls) { $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(500); @@ -62,9 +62,9 @@ public function testRetriesOnHttpExceptionWithNon408Status(): void ]); $apiCall = new ApiCall($config); - + $result = $apiCall->get('/test', []); - + $this->assertEquals(['success' => true], $result); $this->assertEquals($expectedCalls, $callCount); } @@ -73,12 +73,12 @@ public function testRetriesExhaustedThrowsLastException(): void { $callCount = 0; $expectedCalls = 3; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(500); throw new HttpException('Server error', $this->createMock(RequestInterface::class), $response); @@ -95,12 +95,12 @@ public function testRetriesExhaustedThrowsLastException(): void ]); $apiCall = new ApiCall($config); - + $this->expectException(ServerError::class); $this->expectExceptionMessage('Server error'); - + $apiCall->get('/test', []); - + $this->assertEquals($expectedCalls, $callCount); } @@ -108,12 +108,12 @@ public function testRetriesOnTypesenseClientError(): void { $callCount = 0; $expectedCalls = 1; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount, $expectedCalls) { $callCount++; - + if ($callCount < $expectedCalls) { throw new RequestMalformed('Bad request'); } else { @@ -133,12 +133,12 @@ public function testRetriesOnTypesenseClientError(): void ]); $apiCall = new ApiCall($config); - + try { $apiCall->get('/test', []); } catch (ServerError $e) { } - + $this->assertEquals($expectedCalls, $callCount); } @@ -146,12 +146,12 @@ public function testRetriesOnHttpClientException(): void { $callCount = 0; $expectedCalls = 3; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount, $expectedCalls) { $callCount++; - + if ($callCount < $expectedCalls) { throw new TransferException('Connection error'); } else { @@ -171,9 +171,9 @@ public function testRetriesOnHttpClientException(): void ]); $apiCall = new ApiCall($config); - + $result = $apiCall->get('/test', []); - + $this->assertEquals(['success' => true], $result); $this->assertEquals($expectedCalls, $callCount); } @@ -181,12 +181,12 @@ public function testRetriesOnHttpClientException(): void public function testSkips408TimeoutErrorsAndContinuesRetrying(): void { $callCount = 0; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + if ($callCount === 1) { $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(408); @@ -212,9 +212,9 @@ public function testSkips408TimeoutErrorsAndContinuesRetrying(): void ]); $apiCall = new ApiCall($config); - + $result = $apiCall->get('/test', []); - + $this->assertEquals(['success' => true], $result); $this->assertEquals(3, $callCount); } @@ -222,12 +222,12 @@ public function testSkips408TimeoutErrorsAndContinuesRetrying(): void public function testNodeHealthCheckAfterExceptions(): void { $callCount = 0; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(500); throw new HttpException('Server error', $this->createMock(RequestInterface::class), $response); @@ -244,32 +244,32 @@ public function testNodeHealthCheckAfterExceptions(): void ]); $apiCall = new ApiCall($config); - + $node1 = $config->getNodes()[0]; $node2 = $config->getNodes()[1]; - + $this->assertTrue($node1->isHealthy()); $this->assertTrue($node2->isHealthy()); - + try { $apiCall->get('/test', []); } catch (ServerError $e) { $this->assertFalse($node1->isHealthy()); $this->assertFalse($node2->isHealthy()); } - + $this->assertEquals(3, $callCount); } public function test400ErrorsAreNotRetried(): void { $callCount = 0; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(400); throw new HttpException('Bad Request', $this->createMock(RequestInterface::class), $response); @@ -286,24 +286,24 @@ public function test400ErrorsAreNotRetried(): void ]); $apiCall = new ApiCall($config); - + $this->expectException(\Typesense\Exceptions\RequestMalformed::class); $this->expectExceptionMessage('Bad Request'); - + $apiCall->get('/test', []); - + $this->assertEquals(1, $callCount); } public function test401ErrorsAreNotRetried(): void { $callCount = 0; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(401); throw new HttpException('Unauthorized', $this->createMock(RequestInterface::class), $response); @@ -320,24 +320,24 @@ public function test401ErrorsAreNotRetried(): void ]); $apiCall = new ApiCall($config); - + $this->expectException(\Typesense\Exceptions\RequestUnauthorized::class); $this->expectExceptionMessage('Unauthorized'); - + $apiCall->get('/test', []); - + $this->assertEquals(1, $callCount); } public function test404ErrorsAreNotRetried(): void { $callCount = 0; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(404); throw new HttpException('Not Found', $this->createMock(RequestInterface::class), $response); @@ -354,24 +354,24 @@ public function test404ErrorsAreNotRetried(): void ]); $apiCall = new ApiCall($config); - + $this->expectException(\Typesense\Exceptions\ObjectNotFound::class); $this->expectExceptionMessage('Not Found'); - + $apiCall->get('/test', []); - + $this->assertEquals(1, $callCount); } public function test408ErrorsAreSkippedAndRetryingContinues(): void { $callCount = 0; - + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + if ($callCount === 1) { $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(408); @@ -392,9 +392,9 @@ public function test408ErrorsAreSkippedAndRetryingContinues(): void ]); $apiCall = new ApiCall($config); - + $result = $apiCall->get('/test', []); - + $this->assertEquals(['success' => true], $result); $this->assertEquals(2, $callCount); } @@ -402,13 +402,13 @@ public function test408ErrorsAreSkippedAndRetryingContinues(): void public function testDoesNotSleepOnFinalRetryAttempt(): void { $callCount = 0; - $retryIntervalSeconds = 0.1; - + $retryIntervalSeconds = 0.1; + $httpClient = $this->createMock(ClientInterface::class); $httpClient->method('sendRequest') ->willReturnCallback(function() use (&$callCount) { $callCount++; - + $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(500); throw new HttpException('Server error', $this->createMock(RequestInterface::class), $response); @@ -419,37 +419,37 @@ public function testDoesNotSleepOnFinalRetryAttempt(): void 'nodes' => [ ['host' => 'node1', 'port' => 8108, 'protocol' => 'http'] ], - 'num_retries' => 2, + 'num_retries' => 2, 'retry_interval_seconds' => $retryIntervalSeconds, 'client' => $httpClient ]); $apiCall = new ApiCall($config); - + $startTime = microtime(true); - + try { $apiCall->get('/test', []); } catch (ServerError $e) { } - + $endTime = microtime(true); $actualDuration = $endTime - $startTime; - + // 2 sleep intervals (between 1st->2nd and 2nd->3rd attempts) // no sleep after the final (3rd) attempt - $expectedDuration = $retryIntervalSeconds * 2; - + $expectedDuration = $retryIntervalSeconds * 2; + $tolerance = 0.05; - + $this->assertEquals(3, $callCount, 'Should make exactly 3 attempts'); $this->assertLessThan( - $expectedDuration + $tolerance, + $expectedDuration + $tolerance, $actualDuration, "Execution took too long ({$actualDuration}s), suggesting sleep was called on final attempt" ); $this->assertGreaterThan( - $expectedDuration - $tolerance, + $expectedDuration - $tolerance, $actualDuration, "Execution was too fast ({$actualDuration}s), suggesting sleep intervals were skipped" ); diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index 7881d0dc..3824ed5c 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -1,6 +1,6 @@ isV30OrAbove()) { $this->markTestSkipped('CurationSetItems is only supported in Typesense v30+'); } - + $this->curationSets = $this->client()->curationSets; $this->curationSets->upsert('test-curation-set-items', $this->curationSetData); } @@ -54,7 +54,7 @@ protected function tearDown(): void public function testCanListItemsInACurationSet(): void { $items = $this->curationSets['test-curation-set-items']->getItems()->retrieve(); - + $this->assertIsArray($items); $this->assertGreaterThan(0, count($items)); $this->assertEquals('123', $items[0]['includes'][0]['id']); @@ -75,7 +75,7 @@ public function testCanUpsertRetrieveAndDeleteAnItem(): void ], ], ]); - + $this->assertEquals('rule-1', $upserted['id']); $fetched = $this->curationSets['test-curation-set-items']->getItems()['rule-1']->retrieve(); diff --git a/tests/Feature/CurationSetsTest.php b/tests/Feature/CurationSetsTest.php index b7355e3b..4219663d 100644 --- a/tests/Feature/CurationSetsTest.php +++ b/tests/Feature/CurationSetsTest.php @@ -1,6 +1,6 @@ isV30OrAbove()) { $this->markTestSkipped('CurationSets is only supported in Typesense v30+'); } - + $this->curationSets = $this->client()->curationSets; $this->upsertResponse = $this->curationSets->upsert('test-curation-set', $this->curationSetData); } @@ -64,7 +64,7 @@ public function testCanRetrieveAllCurationSets(): void $returnData = $this->curationSets->retrieve(); $this->assertIsArray($returnData); $this->assertGreaterThan(0, count($returnData)); - + $created = null; foreach ($returnData as $curationSet) { if ($curationSet['name'] === 'test-curation-set') { diff --git a/tests/Feature/DebugTest.php b/tests/Feature/DebugTest.php index 5546ba18..4b117a53 100644 --- a/tests/Feature/DebugTest.php +++ b/tests/Feature/DebugTest.php @@ -1,6 +1,6 @@ assertArrayHasKey('id', $response); $this->assertEquals('test-collection-model', $response['id']); $this->assertEquals('openai/gpt-3.5-turbo', $response['model_name']); - + $this->client()->nlSearchModels['test-collection-model']->delete(); } @@ -36,12 +36,12 @@ public function testCanRetrieveAllModels(): void "system_prompt" => "Test model for retrieval.", "max_bytes" => 8192 ]; - + $this->client()->nlSearchModels->create($testData); - + $response = $this->client()->nlSearchModels->retrieve(); $this->assertIsArray($response); - + $foundModel = false; foreach ($response as $model) { if ($model['id'] === 'retrieve-test-model') { @@ -126,4 +126,4 @@ public function testDelete(): void $this->assertEquals('test-collection-model', $response['id']); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/Feature/OperationsTest.php b/tests/Feature/OperationsTest.php index 0af53256..83941bf3 100644 --- a/tests/Feature/OperationsTest.php +++ b/tests/Feature/OperationsTest.php @@ -1,6 +1,6 @@ isV30OrAbove()) { $this->markTestSkipped('Overrides are deprecated in Typesense v30+, use CurationSets instead'); } - + $this->setUpCollection('books'); $override = [ diff --git a/tests/Feature/PresetsTest.php b/tests/Feature/PresetsTest.php index baf14aa0..e3c7f01c 100644 --- a/tests/Feature/PresetsTest.php +++ b/tests/Feature/PresetsTest.php @@ -1,6 +1,6 @@ client()->stemming->dictionaries()->upsert( - $this->dictionaryId, + $this->dictionaryId, $this->dictionary ); } diff --git a/tests/Feature/StopwordsTest.php b/tests/Feature/StopwordsTest.php index 7c283042..0a8672ac 100644 --- a/tests/Feature/StopwordsTest.php +++ b/tests/Feature/StopwordsTest.php @@ -1,6 +1,6 @@ isV30OrAbove()) { $this->markTestSkipped('SynonymSets is only supported in Typesense v30+'); } - + $this->synonymSets = $this->client()->synonymSets; $this->upsertResponse = $this->synonymSets->upsert('test-synonym-set', $this->synonymSetData); } @@ -58,4 +58,4 @@ public function testCanDeleteASynonymSet(): void $this->expectException(ObjectNotFound::class); $this->synonymSets['test-synonym-set']->retrieve(); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/Feature/SynonymsTest.php b/tests/Feature/SynonymsTest.php index 5a61b871..3ced2daa 100644 --- a/tests/Feature/SynonymsTest.php +++ b/tests/Feature/SynonymsTest.php @@ -1,6 +1,6 @@ isV30OrAbove()) { $this->markTestSkipped('Synonyms is deprecated in Typesense v30+, use SynonymSets instead'); } - + $this->setUpCollection('books'); $this->synonyms = $this->client()->collections['books']->synonyms; From 323505435f3dcf0f019c6ee448557d6a626b9f63 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 3 Jun 2026 06:31:39 +0700 Subject: [PATCH 2/3] only include in workflow --- .github/workflows/structarmed.yml | 2 ++ composer.json | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/structarmed.yml b/.github/workflows/structarmed.yml index 8dc8910f..0ceb7cf3 100644 --- a/.github/workflows/structarmed.yml +++ b/.github/workflows/structarmed.yml @@ -13,5 +13,7 @@ jobs: with: php-version: "8.4" - uses: php-actions/composer@v6 + - name: Include StructArmed + run: composer require --dev boundwize/structarmed:^0.10 - name: Run StructArmed run: vendor/bin/structarmed analyze diff --git a/composer.json b/composer.json index adcf6a42..c97f9e04 100644 --- a/composer.json +++ b/composer.json @@ -50,8 +50,7 @@ "phpunit/phpunit": "^11.2", "squizlabs/php_codesniffer": "3.*", "symfony/http-client": "^5.2", - "mockery/mockery": "^1.6", - "boundwize/structarmed": "^0.10.1" + "mockery/mockery": "^1.6" }, "config": { "optimize-autoloader": true, From 76400261560e0270742fc962c48297ba91be704b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 3 Jun 2026 07:03:33 +0700 Subject: [PATCH 3/3] final touch: register structarmed.php to .gitattributes --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index adeb4464..1aaf2770 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,4 @@ phpcs.xml export-ignore phpunit.xml.dist export-ignore README.md export-ignore tests/ export-ignore +structarmed.php export-ignore