From a12ae6208dbf38bfbf7ccea5617f793a4a8d3a9e Mon Sep 17 00:00:00 2001 From: Akisolu Date: Mon, 10 Aug 2026 21:38:19 -0400 Subject: [PATCH 1/5] feat: add rate limiter unit test --- tests/Unit/RateLimiterTest.php | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/Unit/RateLimiterTest.php diff --git a/tests/Unit/RateLimiterTest.php b/tests/Unit/RateLimiterTest.php new file mode 100644 index 0000000..d76d85b --- /dev/null +++ b/tests/Unit/RateLimiterTest.php @@ -0,0 +1,52 @@ +container = require __DIR__ . '/../../config/container.php'; + $this->redis = $this->container->get(RedisClient::class); + $this->rateLimiter = new RateLimiter($this->redis); + $this->testKey = 'test_rate_limit:' . uniqid(); + } + + public function tearDown(): void { + $this->redis->del($this->testKey); + } + + public function test_allows_requests_under_limit() { + $this->rateLimiter->tooManyAttempts($this->testKey, 10); + for ($i = 0; $i < 5; $i++) { + $this->rateLimiter->hit($this->testKey, 600); + } + $this->assertEquals(5, $this->rateLimiter->attempts($this->testKey)); + $this->assertFalse($this->rateLimiter->tooManyAttempts($this->testKey, 10)); + } + + public function test_blocks_requests_exceeding_limit() { + for ($i = 1; $i < 10; $i++){ + $this->rateLimiter->hit($this->testKey, 600); + } + $this->assertFalse($this->rateLimiter->tooManyAttempts($this->testKey, 10)); + $this->rateLimiter->hit($this->testKey, 600); + $this->assertTrue($this->rateLimiter->tooManyAttempts($this->testKey, 10)); + $this->assertEquals(10, $this->rateLimiter->attempts($this->testKey)); + } + + public function test_can_reset_attempts() { + $this->rateLimiter->hit($this->testKey); + $this->rateLimiter->hit($this->testKey); + $this->rateLimiter->hit($this->testKey); + $this->rateLimiter->resetAttempts($this->testKey); + $this->assertEquals(0, $this->rateLimiter->attempts($this->testKey)); + } +} \ No newline at end of file From 7c34475f88809181b8323788b593d7e91041cea1 Mon Sep 17 00:00:00 2001 From: Akisolu Date: Mon, 10 Aug 2026 21:47:21 -0400 Subject: [PATCH 2/5] ci: add github actions workflow for phpunit with postgres and redis services --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d1bc32 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI Suite + +on: + push: + branches: [ main, master, "feature/*" ] + pull_request: + branches: [ main, master ] + +jobs: + phpunit: + name: PHPUnit Tests (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['8.2'] + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: feedback + POSTGRES_USER: feedback_user + POSTGRES_PASSWORD: password123 + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pdo, pdo_pgsql, mbstring, ctype, iconv + coverage: none + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer Dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install Dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Create .env file for Testing + run: | + echo "APP_NAME=\"Anonymous Feedback System\"" > .env + echo "APP_ENV=testing" >> .env + echo "APP_DEBUG=true" >> .env + echo "APP_URL=http://localhost:8000" >> .env + echo "DB_DRIVER=pgsql" >> .env + echo "DB_HOST=127.0.0.1" >> .env + echo "DB_PORT=5432" >> .env + echo "DB_DATABASE=feedback" >> .env + echo "DB_USERNAME=feedback_user" >> .env + echo "DB_PASSWORD=password123" >> .env + echo "DB_CHARSET=utf8" >> .env + echo "DB_SCHEMA=public" >> .env + echo "REDIS_SCHEME=tcp" >> .env + echo "REDIS_HOST=127.0.0.1" >> .env + echo "REDIS_PORT=6379" >> .env + echo "RATE_LIMIT_MAX_REQUESTS=10" >> .env + echo "RATE_LIMIT_DECAY=600" >> .env + + - name: Run PHPUnit Tests + run: vendor/bin/phpunit \ No newline at end of file From 09f4eb2a7045d88336f54ce1680c2c268c16a4c2 Mon Sep 17 00:00:00 2001 From: Akisolu Date: Mon, 10 Aug 2026 22:08:33 -0400 Subject: [PATCH 3/5] feat(rate-limiter): implement RateLimiter service with Predis and add unit tests --- src/Services/RateLimiter.php | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/Services/RateLimiter.php diff --git a/src/Services/RateLimiter.php b/src/Services/RateLimiter.php new file mode 100644 index 0000000..fa553b0 --- /dev/null +++ b/src/Services/RateLimiter.php @@ -0,0 +1,38 @@ +attempts($key); + return $attempts >= $maxAttempts; + } + + public function hit(string $key, int $decaySeconds = 600): int { + $hits = (int) $this->redis->incr($key); + if ($hits === 1) { + $this->redis->expire($key, $decaySeconds); + } + return $hits; + } + + public function attempts(string $key): int { + $value = $this->redis->get($key); + if (is_null($value)){ + return 0; + } + return (int) $value; + } + + public function resetAttempts(string $key): void { + $this->redis->del([$key]); + } +} \ No newline at end of file From bd3b18f4fa55fd26d3fae9e4f3ee4a952a8aa3f0 Mon Sep 17 00:00:00 2001 From: Akisolu Date: Mon, 10 Aug 2026 22:20:48 -0400 Subject: [PATCH 4/5] ci: update php version to 8.4 in github actions workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d1bc32..2ab0865 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - php-version: ['8.2'] + php-version: ['8.4'] services: postgres: From e978b4d5ed59fcf2793037bb0060cd97a48e5b69 Mon Sep 17 00:00:00 2001 From: Akisolu Date: Mon, 10 Aug 2026 22:27:06 -0400 Subject: [PATCH 5/5] feat(container): register RateLimiter service in DI container and add integration test --- config/container.php | 7 ++++++- tests/Integration/DatabaseConnectionTest.php | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/config/container.php b/config/container.php index f0badee..3a82812 100644 --- a/config/container.php +++ b/config/container.php @@ -7,6 +7,8 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Predis\Client as RedisClient; +use Akisolu\AnonymousFeedback\Services\RateLimiter; + $config = require __DIR__ . '/config.php'; $builder = new ContainerBuilder(); @@ -32,7 +34,10 @@ }, RedisClient::class => function (ContainerInterface $c) { return new RedisClient($c->get('config')['redis']); - } + }, + RateLimiter::class => function (ContainerInterface $c) { + return new RateLimiter($c->get(RedisClient::class)); + }, ]); return $builder->build(); diff --git a/tests/Integration/DatabaseConnectionTest.php b/tests/Integration/DatabaseConnectionTest.php index 0023016..244357e 100644 --- a/tests/Integration/DatabaseConnectionTest.php +++ b/tests/Integration/DatabaseConnectionTest.php @@ -62,4 +62,16 @@ public function testRedisConnectionAndOperationsAreSuccessful(): void $redis->del([$testKey]); } + + public function testRateLimiterCanBeResolvedFromContainer(): void +{ + /** @var \Akisolu\AnonymousFeedback\Services\RateLimiter $rateLimiter */ + $rateLimiter = $this->container->get(\Akisolu\AnonymousFeedback\Services\RateLimiter::class); + + $this->assertInstanceOf(\Akisolu\AnonymousFeedback\Services\RateLimiter::class, $rateLimiter); + + // Verificación rápida de estado + $testKey = 'container_test_key_' . uniqid(); + $this->assertFalse($rateLimiter->tooManyAttempts($testKey, 5)); +} } \ No newline at end of file