diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0b62251 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,78 @@ +name: Run PHPUnit Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: [ "main" ] + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + + - name: Install dependencies + uses: php-actions/composer@v6 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + with: + php_version: '8.4' + + - name: Run PHPUnit tests + uses: php-actions/phpunit@v3 + env: + XDEBUG_MODE: coverage + with: + php_version: '8.4' + php_extensions: pcov + + - name: Ensure minimum code coverage + env: + MINIMUM_COVERAGE: 80 + run: | + COVERAGE=$(php -r ' + $xml = new SimpleXMLElement(file_get_contents("public/coverage/clover.xml")); + $m = $xml->project->metrics; + $pct = (int) round(((int) $m["coveredstatements"]) * 100 / (int) $m["statements"]); + echo $pct; + ') + echo "Coverage: ${COVERAGE}%" + if [ "${COVERAGE}" -lt ${{ env.MINIMUM_COVERAGE }} ]; then + echo "Code coverage below ${{ env.MINIMUM_COVERAGE }}% threshold." + exit 1 + fi + + - name: Upload artifact + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: public/coverage + + deploy: + if: github.ref == 'refs/heads/main' + needs: tests + environment: + name: code-coverage + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/composer.json b/composer.json index a6a9cc3..e3a8fab 100644 --- a/composer.json +++ b/composer.json @@ -49,8 +49,8 @@ } }, "scripts": { - "cs-check": "php-cs-fixer fix --dry-run --diff", - "cs-fix": "php-cs-fixer fix", + "cs-check": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --dry-run --diff", + "cs-fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix", "mutation-testing": "infection --threads=4", "pre-commit": [ "@cs-check", diff --git a/src/ArrayAccessConfigTrait.php b/src/ArrayAccessConfigTrait.php new file mode 100644 index 0000000..accc99f --- /dev/null +++ b/src/ArrayAccessConfigTrait.php @@ -0,0 +1,87 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +namespace FastForward\Config; + +use Dflydev\DotAccessData\Data; + +/** + * Trait ArrayAccessConfigTrait. + * + * This trait provides array-like access to configuration data. + * It MUST be used in classes that implement \ArrayAccess and provide + * the corresponding methods: `get`, `set`, `has`, and `remove`. + * + * @internal + * + * @see \ArrayAccess + */ +trait ArrayAccessConfigTrait +{ + /** + * Determines whether the given offset exists in the configuration data. + * + * This method SHALL return true if the offset is present, false otherwise. + * + * @param mixed $offset the offset to check for existence + * + * @return bool true if the offset exists, false otherwise + */ + public function offsetExists(mixed $offset): bool + { + return $this->has($offset); + } + + /** + * Retrieves the value associated with the given offset. + * + * This method MUST return the value mapped to the specified offset. + * If the offset does not exist, behavior SHALL depend on the implementation + * of the `get` method. + * + * @param mixed $offset the offset to retrieve + * + * @return mixed the value at the given offset + */ + public function offsetGet(mixed $offset): mixed + { + return $this->get($offset); + } + + /** + * Sets the value for the specified offset. + * + * This method SHALL assign the given value to the specified offset. + * + * @param mixed $offset the offset at which to set the value + * @param mixed $value the value to set + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->set($offset, $value); + } + + /** + * Unsets the specified offset. + * + * This method SHALL remove the specified offset and its associated value. + * + * @param mixed $offset the offset to remove + */ + public function offsetUnset(mixed $offset): void + { + $this->remove($offset); + } +} diff --git a/src/ArrayConfig.php b/src/ArrayConfig.php index 5a7f40d..7838345 100644 --- a/src/ArrayConfig.php +++ b/src/ArrayConfig.php @@ -30,6 +30,8 @@ */ final class ArrayConfig implements ConfigInterface { + use ArrayAccessConfigTrait; + /** * @var Data internal configuration storage instance */ @@ -111,6 +113,20 @@ public function set(array|ConfigInterface|string $key, mixed $value = null): voi $this->data->import(ConfigHelper::normalize($key)); } + /** + * Removes a configuration key and its associated value. + * + * If the key does not exist, this method SHALL do nothing. + * + * @param string $key the configuration key to remove + */ + public function remove(string $key): void + { + if ($this->has($key)) { + $this->data->remove($key); + } + } + /** * Retrieves a traversable set of flattened configuration data. * diff --git a/src/CachedConfig.php b/src/CachedConfig.php index 4443f99..5432326 100644 --- a/src/CachedConfig.php +++ b/src/CachedConfig.php @@ -87,4 +87,23 @@ public function set(array|ConfigInterface|string $key, mixed $value = null): voi $this->cache->set($this->cacheKey, $config->toArray()); } } + + /** + * Retrieves a configuration value by key. + * + * This method MUST return the cached value if it exists, or the default value if not found. + * + * @param string $key the configuration key to retrieve + * + * @return mixed the configuration value or the default value + */ + public function remove(mixed $key): void + { + $config = $this->getConfig(); + $config->remove($key); + + if ($this->persistent) { + $this->cache->set($this->cacheKey, $config->toArray()); + } + } } diff --git a/src/ConfigInterface.php b/src/ConfigInterface.php index f98d5d5..eaf9233 100644 --- a/src/ConfigInterface.php +++ b/src/ConfigInterface.php @@ -27,7 +27,7 @@ * Keys MAY use dot notation to access nested structures, e.g., `my.next.key` * corresponds to ['my' => ['next' => ['key' => $value]]]. */ -interface ConfigInterface extends \IteratorAggregate +interface ConfigInterface extends \IteratorAggregate, \ArrayAccess { /** * Determines if the specified key exists in the configuration. @@ -68,6 +68,16 @@ public function get(string $key, mixed $default = null): mixed; */ public function set(array|self|string $key, mixed $value = null): void; + /** + * Removes a configuration key and its associated value. + * + * Dot notation MAY be used to specify nested keys. + * If the key does not exist, this method MUST do nothing. + * + * @param string $key the configuration key to remove + */ + public function remove(string $key): void; + /** * Exports the configuration as a nested associative array. * diff --git a/src/LazyLoadConfigTrait.php b/src/LazyLoadConfigTrait.php index 393606f..0e2fb9a 100644 --- a/src/LazyLoadConfigTrait.php +++ b/src/LazyLoadConfigTrait.php @@ -24,6 +24,8 @@ */ trait LazyLoadConfigTrait { + use ArrayAccessConfigTrait; + /** * @var null|ConfigInterface holds the loaded configuration instance */ @@ -72,6 +74,16 @@ public function set(array|ConfigInterface|string $key, mixed $value = null): voi $this->getConfig()->set($key, $value); } + /** + * Removes a configuration key. + * + * @param string $key the configuration key to remove + */ + public function remove(string $key): void + { + $this->getConfig()->remove($key); + } + /** * Exports the entire configuration to an array. * diff --git a/tests/ArrayAccessConfigTraitTest.php b/tests/ArrayAccessConfigTraitTest.php new file mode 100644 index 0000000..3867d6b --- /dev/null +++ b/tests/ArrayAccessConfigTraitTest.php @@ -0,0 +1,110 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +use FastForward\Config\ArrayAccessConfigTrait; +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + */ +#[CoversTrait(ArrayAccessConfigTrait::class)] +final class ArrayAccessConfigTraitTest extends TestCase +{ + use ProphecyTrait; + + public function testOffsetExistsWillCallHasMethod(): void + { + $object = $this->createTraitInstance(has: true); + self::assertTrue($object->offsetExists('key')); + + $object = $this->createTraitInstance(has: false); + self::assertFalse($object->offsetExists('key')); + } + + public function testOffsetGetWillCallGetMethod(): void + { + $object = $this->createTraitInstance(get: 'foo'); + self::assertSame('foo', $object->offsetGet('bar')); + } + + public function testOffsetSetWillCallSetMethod(): void + { + $object = $this->createTraitInstance(); + $object->offsetSet('alpha', 'beta'); + + self::assertSame(['alpha' => 'beta'], $object->getSetCalls()); + } + + public function testOffsetUnsetWillCallRemoveMethod(): void + { + $object = $this->createTraitInstance(); + $object->offsetUnset('delta'); + + self::assertSame(['delta'], $object->getRemoveCalls()); + } + + private function createTraitInstance(bool $has = false, mixed $get = null): ArrayAccess + { + return new class($has, $get) implements ArrayAccess { + use ArrayAccessConfigTrait; + + private array $setCalls = []; + + private array $removeCalls = []; + + private bool $hasReturn; + + private mixed $getReturn; + + public function __construct(bool $has, mixed $get) + { + $this->hasReturn = $has; + $this->getReturn = $get; + } + + public function has(mixed $offset): bool + { + return $this->hasReturn; + } + + public function get(mixed $offset): mixed + { + return $this->getReturn; + } + + public function set(mixed $offset, mixed $value): void + { + $this->setCalls[$offset] = $value; + } + + public function remove(mixed $offset): void + { + $this->removeCalls[] = $offset; + } + + public function getSetCalls(): array + { + return $this->setCalls; + } + + public function getRemoveCalls(): array + { + return $this->removeCalls; + } + }; + } +} diff --git a/tests/ArrayConfigTest.php b/tests/ArrayConfigTest.php index c709ff7..f3547e4 100644 --- a/tests/ArrayConfigTest.php +++ b/tests/ArrayConfigTest.php @@ -144,4 +144,48 @@ public function testDotNotationMergesAssociativeNestedKeys(): void self::assertSame($expected, $config->toArray()); } + + #[Test] + public function testRemoveKeyRemovesTopLevelKeys(): void + { + $key = uniqid('key_'); + $val = uniqid('value_'); + + $config = new ArrayConfig([$key => $val]); + $config->remove($key); + + self::assertFalse($config->has($key)); + self::assertSame([], $config->toArray()); + } + + #[Test] + public function testRemoveKeyRemovesNestedKeys(): void + { + $key1 = uniqid('key1.'); + $key2 = $key1 . 'nested'; + $val = uniqid('value_'); + + $config = new ArrayConfig([$key2 => $val]); + $config->remove($key2); + + self::assertFalse($config->has($key2)); + self::assertSame(['key1' => []], $config->toArray()); + } + + #[Test] + public function testRemoveKeyRemovesDeepNestedKeys(): void + { + $key1 = uniqid('key1.'); + $key2 = $key1 . '.nested.'; + $key3 = $key2 . 'deep'; + $val = uniqid('value_'); + + $config = new ArrayConfig([$key3 => $val]); + $config->remove($key3); + + $config2 = new ArrayConfig([$key1 => ['nested' => []]]); + + self::assertFalse($config->has($key3)); + self::assertSame($config2->toArray(), $config->toArray()); + } } diff --git a/tests/CachedConfigTest.php b/tests/CachedConfigTest.php index 6ba78bd..9177b07 100644 --- a/tests/CachedConfigTest.php +++ b/tests/CachedConfigTest.php @@ -122,4 +122,40 @@ public function testSetWillNotUpdateCacheWhenPersistentIsFalse(): void $cachedConfig->set($key, $value); } + + #[Test] + public function testRemoveWillUpdateCacheWhenPersistentIsTrue(): void + { + $key = uniqid(); + + $this->cache->has($this->defaultConfig->reveal()::class)->willReturn(true); + $this->cache->get($this->defaultConfig->reveal()::class)->willReturn([$key => uniqid()]); + + $this->cache->set($this->defaultConfig->reveal()::class, [])->shouldBeCalled(); + + $this->cachedConfig->remove($key); + + self::assertFalse($this->cachedConfig->has($key)); + } + + #[Test] + public function testRemoveWillNotUpdateCacheWhenPersistentIsFalse(): void + { + $cachedConfig = new CachedConfig( + cache: $this->cache->reveal(), + defaultConfig: $this->defaultConfig->reveal(), + persistent: false + ); + + $key = uniqid(); + + $this->cache->has($this->defaultConfig->reveal()::class)->willReturn(true); + $this->cache->get($this->defaultConfig->reveal()::class)->willReturn([$key => uniqid()]); + + $this->cache->set($this->defaultConfig->reveal()::class, [])->shouldNotBeCalled(); + + $cachedConfig->remove($key); + + self::assertFalse($cachedConfig->has($key)); + } } diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index edaf56f..2db1efe 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -28,6 +28,7 @@ use PHPUnit\Framework\Attributes\CoversFunction; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -52,7 +53,7 @@ #[UsesClass(DirectoryConfig::class)] #[UsesClass(RecursiveDirectoryConfig::class)] #[UsesClass(LamiasConfigAggregatorConfig::class)] -#[UsesClass(LazyLoadConfigTrait::class)] +#[UsesTrait(LazyLoadConfigTrait::class)] final class FunctionsTest extends TestCase { use ProphecyTrait; diff --git a/tests/Helper/ConfigHelperTest.php b/tests/Helper/ConfigHelperTest.php index 395b473..e37db78 100644 --- a/tests/Helper/ConfigHelperTest.php +++ b/tests/Helper/ConfigHelperTest.php @@ -2,6 +2,17 @@ declare(strict_types=1); +/** + * This file is part of php-fast-forward/config. + * + * This source file is subject to the license bundled + * with this source code in the file LICENSE. + * + * @link https://github.com/php-fast-forward/config + * @copyright Copyright (c) 2025 Felipe Sayão Lobato Abreu + * @license https://opensource.org/licenses/MIT MIT License + */ + namespace FastForward\Config\Tests\Helper; use FastForward\Config\Helper\ConfigHelper; @@ -9,6 +20,9 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +/** + * @internal + */ #[CoversClass(ConfigHelper::class)] final class ConfigHelperTest extends TestCase { @@ -58,14 +72,14 @@ public function testNormalizeWillConvertDotNotationToNestedArray(): void $result = ConfigHelper::normalize($input); - self::assertSame($expected, $result); + self::assertEquals($expected, $result); } #[Test] public function testNormalizeWillHandleMixedNestedArrays(): void { $input = [ - 'database.host' => 'localhost', + 'database.host' => 'localhost', 'database.config' => ['port' => 3306], ]; @@ -78,7 +92,7 @@ public function testNormalizeWillHandleMixedNestedArrays(): void $result = ConfigHelper::normalize($input); - self::assertSame($expected, $result); + self::assertEquals($expected, $result); } #[Test] @@ -88,14 +102,14 @@ public function testNormalizeWillReturnIndexedArrayUnchanged(): void $result = ConfigHelper::normalize($input); - self::assertSame($input, $result); + self::assertEquals($input, $result); } #[Test] public function testNormalizeWillMergeArraysWhenKeysOverlap(): void { $input = [ - 'settings' => ['display' => ['resolution' => '1080p']], + 'settings' => ['display' => ['resolution' => '1080p']], 'settings.display' => ['theme' => 'dark'], ]; @@ -134,7 +148,7 @@ public function testFlattenWillConvertNestedArrayToDotNotation(): void $result = iterator_to_array(ConfigHelper::flatten($input)); - self::assertSame($expected, $result); + self::assertEquals($expected, $result); } #[Test] @@ -144,6 +158,6 @@ public function testFlattenWillHandleEmptyArray(): void $result = iterator_to_array(ConfigHelper::flatten($input)); - self::assertSame([], $result); + self::assertEquals([], $result); } } diff --git a/tests/LazyLoadConfigTraitTest.php b/tests/LazyLoadConfigTraitTest.php index c31548d..febb3ff 100644 --- a/tests/LazyLoadConfigTraitTest.php +++ b/tests/LazyLoadConfigTraitTest.php @@ -15,9 +15,10 @@ namespace FastForward\Config\Tests; +use FastForward\Config\ArrayAccessConfigTrait; use FastForward\Config\ConfigInterface; use FastForward\Config\LazyLoadConfigTrait; -use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -25,7 +26,7 @@ /** * @internal */ -#[CoversClass(LazyLoadConfigTrait::class)] +#[CoversTrait(LazyLoadConfigTrait::class)] final class LazyLoadConfigTraitTest extends TestCase { use ProphecyTrait; @@ -43,6 +44,25 @@ public function testGetDelegatesToConfig(): void self::assertSame($default, $fake->get(uniqid('missing_', true), $default)); } + #[Test] + public function testRemoveRemovesFromConfig(): void + { + $key1 = uniqid('key1_', true); + $key2 = uniqid('key2_', true); + $val1 = random_int(1, 100); + $val2 = random_int(101, 200); + + $fake = $this->createTestInstance([$key1 => $val1, $key2 => $val2]); + + self::assertTrue($fake->has($key1)); + self::assertTrue($fake->has($key2)); + + $fake->remove($key1); + + self::assertFalse($fake->has($key1)); + self::assertTrue($fake->has($key2)); + } + #[Test] public function testHasReturnsExpectedResults(): void { @@ -106,6 +126,8 @@ public function __construct(private array $data) {} public function __invoke(): ConfigInterface { return new class($this->data) implements ConfigInterface { + use ArrayAccessConfigTrait; + public function __construct(private array $items) {} public function get(string $key, mixed $default = null): mixed @@ -129,6 +151,13 @@ public function set(array|ConfigInterface|string $key, mixed $value = null): voi } } + public function remove(string $key): void + { + if ($this->has($key)) { + unset($this->items[$key]); + } + } + public function toArray(): array { return $this->items;