diff --git a/.github/workflows/run-tests-l10.yml b/.github/workflows/run-tests-l10.yml index 741ad2d..1f93cbe 100644 --- a/.github/workflows/run-tests-l10.yml +++ b/.github/workflows/run-tests-l10.yml @@ -11,6 +11,18 @@ jobs: tests: runs-on: ubuntu-latest + + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --quiet --eval 'db.runCommand({ping:1})'" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + strategy: fail-fast: false matrix: @@ -18,18 +30,38 @@ jobs: laravel: [ 10.* ] include: - laravel: 10.* + testbench: 10.* name: P${{ matrix.php }} - L${{ matrix.laravel }} steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: pdo, sqlite, pdo_sqlite + extensions: pdo, sqlite, pdo_sqlite, mongodb + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + 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 + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit + env: + MONGODB_HOST: 127.0.0.1 + MONGODB_PORT: 27017 + MONGODB_DATABASE: laravel_session_test diff --git a/.gitignore b/.gitignore index dfd6caa..b868185 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor -composer.lock \ No newline at end of file +composer.lock +.phpunit.cache diff --git a/README.md b/README.md index 5db9bf9..3fb89ec 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,71 @@ Advantages Enjoy! ------ + +## Testing + +This package includes a comprehensive test suite to ensure the MongoDB session handler works correctly. The tests cover: + +1. Basic session operations (read, write, destroy) +2. Integration with Laravel's session system +3. HTTP session functionality +4. Laravel's testing helpers integration + +### Running the Tests + +To run the tests, follow these steps: + +1. Make sure MongoDB is installed and running on your system +2. Install the package dependencies with Composer: + +```bash +composer install +``` + +3. Run the tests with PHPUnit: + +```bash +vendor/bin/phpunit +``` + +### Continuous Integration + +The package includes a GitHub Actions workflow that automatically runs tests on PHP 8.1 with Laravel 10.x against MongoDB 7. The workflow: + +1. Sets up a MongoDB service container +2. Installs PHP with MongoDB extension +3. Caches Composer dependencies for faster builds +4. Runs the test suite + +This ensures all tests pass before merging new changes. + +### Expected Test Results + +When all tests are passing, you should see output similar to: + +``` +PHPUnit 10.x.x by Sebastian Bergmann and contributors. + +............... 15 / 15 (100%) + +Time: 00:00.444, Memory: 32.00 MB + +OK (15 tests, 41 assertions) +``` + +### Testing Environments + +The tests are compatible with: + +- PHP 8.1+ +- Laravel 10.x +- MongoDB 4.0+ + +### Test Coverage + +- **Unit Tests**: These test the `MongoDbSessionHandler` methods directly (open, close, read, write, destroy, gc) +- **Feature Tests**: These test the integration with Laravel's session functionality +- **HTTP Tests**: These test session handling in HTTP requests and session persistence +- **Laravel Helper Tests**: These test integration with Laravel's testing helpers like `withSession` and `flushSession` + +If you encounter any issues with the tests, please submit an issue on the GitHub repository. diff --git a/composer.json b/composer.json index 3a86eee..459bdda 100644 --- a/composer.json +++ b/composer.json @@ -2,13 +2,16 @@ "name": "1ff/laravel-mongodb-session", "description": "A mongodb session driver for laravel", "type": "library", - "version": "5.0.4", "require": { "php": "^8.1", "illuminate/session": "^10.0", "mongodb/laravel-mongodb": "^4.0", "ext-mongodb": "*" }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "orchestra/testbench": "^8.0" + }, "license": "MIT", "authors": [ { @@ -26,11 +29,19 @@ "ForFit\\Session\\": "src" } }, + "autoload-dev": { + "psr-4": { + "ForFit\\Session\\Tests\\": "tests" + } + }, "extra": { "laravel": { "providers": [ "ForFit\\Session\\SessionServiceProvider" ] } + }, + "scripts": { + "test": "vendor/bin/phpunit" } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..41f3b6a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,27 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + + + + + \ No newline at end of file diff --git a/src/Console/Commands/MongodbSessionDropIndex.php b/src/Console/Commands/MongodbSessionDropIndex.php index 72ce1d7..d506777 100644 --- a/src/Console/Commands/MongodbSessionDropIndex.php +++ b/src/Console/Commands/MongodbSessionDropIndex.php @@ -4,7 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use \MongoDB\Driver\ReadPreference; +use MongoDB\Driver\ReadPreference; /** * Drop the indexes created by MongodbSessionIndex @@ -12,26 +12,14 @@ class MongodbSessionDropIndex extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ + /** The name and signature of the console command. */ protected $signature = 'mongodb:session:dropindex {index}'; - /** - * The console command description. - * - * @var string - */ + /** The console command description. */ protected $description = 'Drops the passed index from the mongodb `sessions` collection'; - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() + /** Execute the console command. */ + public function handle(): void { $collection = config('session.table'); @@ -39,7 +27,7 @@ public function handle() 'dropIndexes' => $collection, 'index' => $this->argument('index'), ], [ - 'readPreference' => new ReadPreference(ReadPreference::RP_PRIMARY) + 'readPreference' => new ReadPreference(ReadPreference::PRIMARY) ]); } } diff --git a/src/Console/Commands/MongodbSessionIndex.php b/src/Console/Commands/MongodbSessionIndex.php index 9b909d5..4ee93be 100644 --- a/src/Console/Commands/MongodbSessionIndex.php +++ b/src/Console/Commands/MongodbSessionIndex.php @@ -4,7 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use \MongoDB\Driver\ReadPreference; +use MongoDB\Driver\ReadPreference; /** * Create indexes for the Session collection @@ -12,26 +12,14 @@ class MongodbSessionIndex extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ + /** The name and signature of the console command. */ protected $signature = 'mongodb:session:index'; - /** - * The console command description. - * - * @var string - */ + /** The console command description. */ protected $description = 'Create indexes on the mongodb `sessions` collection'; - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() + /** Execute the console command. */ + public function handle(): void { $collection = config('session.table'); @@ -46,7 +34,7 @@ public function handle() ] ] ], [ - 'readPreference' => new ReadPreference(ReadPreference::RP_PRIMARY) + 'readPreference' => new ReadPreference(ReadPreference::PRIMARY) ]); } } diff --git a/src/Database/Migrations/2019_08_01_000000_index_mongodb_sessions_collection.php b/src/Database/Migrations/2019_08_01_000000_index_mongodb_sessions_collection.php index 917bec4..7b4c696 100644 --- a/src/Database/Migrations/2019_08_01_000000_index_mongodb_sessions_collection.php +++ b/src/Database/Migrations/2019_08_01_000000_index_mongodb_sessions_collection.php @@ -11,7 +11,7 @@ class IndexMongodbSessionsCollection extends Migration * * @return void */ - public function up() + public function up(): void { Artisan::call('mongodb:session:index'); } @@ -21,7 +21,7 @@ public function up() * * @return void */ - public function down() + public function down(): void { Artisan::call('mongodb:session:dropindex', ['index' => 'expires_at_ttl']); } diff --git a/tests/Feature/HttpSessionTest.php b/tests/Feature/HttpSessionTest.php new file mode 100644 index 0000000..f103e62 --- /dev/null +++ b/tests/Feature/HttpSessionTest.php @@ -0,0 +1,89 @@ +app['router']->get('/test-session', function () { + // Returns the session data for verification + return response()->json([ + 'session_data' => session()->all(), + ]); + }); + + // Make request with session data + $response = $this->withSession(['test_key' => 'test_value']) + ->get('/test-session'); + + $response->assertOk(); + $responseData = $response->json(); + + // Verify that the session data was properly stored and retrieved + $this->assertArrayHasKey('test_key', $responseData['session_data']); + $this->assertEquals('test_value', $responseData['session_data']['test_key']); + } + + /** + * Test session persistence between requests + */ + public function test_session_persists_between_requests(): void + { + // Create routes for testing + $this->app['router']->get('/set-session', function () { + session(['persisted_key' => 'persisted_value']); + return response()->json(['status' => 'session_set']); + }); + + $this->app['router']->get('/get-session', function () { + return response()->json([ + 'session_data' => session()->all(), + ]); + }); + + // First request to set the session + $this->get('/set-session')->assertOk(); + + // Second request to verify the session persists + $response = $this->get('/get-session'); + $response->assertOk(); + + $responseData = $response->json(); + $this->assertArrayHasKey('persisted_key', $responseData['session_data']); + $this->assertEquals('persisted_value', $responseData['session_data']['persisted_key']); + } + + /** + * Test session exists during requests + */ + public function test_session_data_in_request(): void + { + // Since we can't verify session in the request with `request()->hasSession()`, + // we'll test with the session facade instead + $this->app['router']->get('/session-test', function () { + return response()->json([ + 'has_session_facade' => session()->isStarted(), + 'session_data' => session()->all(), + ]); + }); + + // Make request with session data + $response = $this->withSession(['auth_test' => 'auth_value']) + ->get('/session-test'); + + $response->assertOk(); + $responseData = $response->json(); + + // Verify session data is present + $this->assertArrayHasKey('session_data', $responseData); + $this->assertArrayHasKey('auth_test', $responseData['session_data']); + $this->assertEquals('auth_value', $responseData['session_data']['auth_test']); + } +} diff --git a/tests/Feature/MongoDbSessionHandlerTest.php b/tests/Feature/MongoDbSessionHandlerTest.php new file mode 100644 index 0000000..fd67ec3 --- /dev/null +++ b/tests/Feature/MongoDbSessionHandlerTest.php @@ -0,0 +1,101 @@ +assertEquals('mongodb', config('session.driver')); + $this->assertInstanceOf(MongoDbSessionHandler::class, $this->app['session.store']->getHandler()); + } + + /** + * Test storing and retrieving session data + */ + public function test_session_store_and_retrieve(): void + { + // Set session data + Session::put('key1', 'value1'); + Session::put('key2', ['nested' => 'value2']); + + // Force session to be stored + Session::save(); + + // Clear the session from memory to force a reload from storage + Session::flush(); + + // Start a new session to read from storage + Session::start(); + + // Check that the values were correctly retrieved + $this->assertEquals('value1', Session::get('key1')); + $this->assertEquals(['nested' => 'value2'], Session::get('key2')); + } + + /** + * Test session data is correctly saved in MongoDB + */ + public function test_session_is_saved_in_mongodb(): void + { + // Generate a unique session ID + $sessionId = md5(uniqid()); + + // Create a session directly in the database + $this->app['db']->table(config('session.table'))->insert([ + '_id' => $sessionId, + 'payload' => new Binary('a:1:{s:4:"test";s:5:"value";}', Binary::TYPE_OLD_BINARY), + 'last_activity' => new \MongoDB\BSON\UTCDateTime(now()->timestamp * 1000), + 'expires_at' => new \MongoDB\BSON\UTCDateTime((now()->addMinutes(config('session.lifetime'))->timestamp) * 1000), + ]); + + // Manually retrieve session through the handler + $handler = $this->app['session.store']->getHandler(); + $data = $handler->read($sessionId); + + // Decode the session data + $sessionData = @unserialize($data); + + $this->assertEquals(['test' => 'value'], $sessionData); + } + + /** + * Test session destroy functionality + */ + public function test_session_can_be_destroyed(): void + { + // Set a value in the session + Session::put('key', 'value'); + $sessionId = Session::getId(); + + // Force save to database + Session::save(); + + // Verify it exists in the database + $count = $this->app['db']->table(config('session.table')) + ->where('_id', $sessionId) + ->count(); + $this->assertEquals(1, $count); + + // Get the handler and explicitly destroy the session + $handler = $this->app['session.store']->getHandler(); + $handler->destroy($sessionId); + + // Allow a moment for the delete operation to complete + usleep(100000); // 100ms pause + + // Verify it was removed from the database + $count = $this->app['db']->table(config('session.table')) + ->where('_id', $sessionId) + ->count(); + $this->assertEquals(0, $count); + } +} \ No newline at end of file diff --git a/tests/Feature/TestHelperIntegrationTest.php b/tests/Feature/TestHelperIntegrationTest.php new file mode 100644 index 0000000..785608a --- /dev/null +++ b/tests/Feature/TestHelperIntegrationTest.php @@ -0,0 +1,76 @@ + 'value2']); + + // Get the session ID before it's saved + $sessionId = Session::getId(); + + // Explicitly save the session + Session::save(); + + // Create a test route + $this->app['router']->get('/session-helper-test', function () { + return response()->json([ + 'session_data' => session()->all(), + ]); + }); + + // Make a request to access the session + $response = $this->get('/session-helper-test'); + $response->assertOk(); + + // Verify session data was stored in MongoDB + $sessionRecord = $this->app['db']->table(config('session.table')) + ->where('_id', $sessionId) + ->first(); + + $this->assertNotNull($sessionRecord, 'Session record was not found in MongoDB'); + + // Verify session data + $responseData = $response->json(); + $this->assertArrayHasKey('test_key1', $responseData['session_data']); + $this->assertEquals('test_value1', $responseData['session_data']['test_key1']); + $this->assertArrayHasKey('test_key2', $responseData['session_data']); + $this->assertEquals(['nested' => 'value2'], $responseData['session_data']['test_key2']); + } + + /** + * Test that Laravel's flushSession helper works with MongoDB sessions + */ + public function test_flush_session_helper(): void + { + // Create a test route + $this->app['router']->get('/flush-session-test', function () { + return response()->json([ + 'session_data' => session()->all(), + ]); + }); + + // Set some session data + $response = $this->withSession(['to_be_flushed' => 'value']) + ->get('/flush-session-test'); + + $response->assertOk(); + $this->assertArrayHasKey('to_be_flushed', $response->json()['session_data']); + + // Flush the session and verify it's empty + $response = $this->flushSession()->get('/flush-session-test'); + + $response->assertOk(); + $this->assertEmpty($response->json()['session_data']); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..76094e8 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,62 @@ +artisan('mongodb:session:index'); + } + + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function defineEnvironment($app): void + { + // Setup default database to use MongoDB + $app['config']->set('database.default', 'mongodb'); + $app['config']->set('database.connections.mongodb', [ + 'driver' => 'mongodb', + 'host' => env('MONGODB_HOST', '127.0.0.1'), + 'port' => env('MONGODB_PORT', 27017), + 'database' => env('MONGODB_DATABASE', 'laravel_session_test'), + 'username' => env('MONGODB_USERNAME', ''), + 'password' => env('MONGODB_PASSWORD', ''), + 'options' => [ + 'database' => env('MONGODB_AUTHENTICATION_DATABASE', 'admin'), + ], + ]); + + // Configure session to use MongoDB driver + $app['config']->set('session.driver', 'mongodb'); + $app['config']->set('session.lifetime', 120); + $app['config']->set('session.table', 'sessions'); + } + + /** + * Get package providers. + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageProviders($app): array + { + return [ + MongoDBServiceProvider::class, + SessionServiceProvider::class, + ]; + } +} \ No newline at end of file diff --git a/tests/Unit/MongoDbSessionHandlerTest.php b/tests/Unit/MongoDbSessionHandlerTest.php new file mode 100644 index 0000000..2c2c784 --- /dev/null +++ b/tests/Unit/MongoDbSessionHandlerTest.php @@ -0,0 +1,115 @@ +handler = $this->app['session.store']->getHandler(); + $this->sessionId = md5(uniqid('test_session')); + } + + /** + * Test the open method + */ + public function test_open_method(): void + { + $this->assertTrue($this->handler->open('path', 'name')); + } + + /** + * Test the close method + */ + public function test_close_method(): void + { + $this->assertTrue($this->handler->close()); + } + + /** + * Test reading non-existent session + */ + public function test_read_non_existent_session(): void + { + $this->assertEquals('', $this->handler->read('non_existent_id')); + } + + /** + * Test write and read session + */ + public function test_write_and_read_session(): void + { + $data = 'test_data_' . time(); + + // Write session data + $this->assertTrue($this->handler->write($this->sessionId, $data)); + + // Read it back + $readData = $this->handler->read($this->sessionId); + + $this->assertEquals($data, $readData); + + // Check database directly + $session = $this->app['db']->table(config('session.table')) + ->where('_id', $this->sessionId) + ->first(); + + $this->assertNotNull($session); + $this->assertInstanceOf(Binary::class, $session['payload']); + $this->assertInstanceOf(UTCDateTime::class, $session['expires_at']); + $this->assertInstanceOf(UTCDateTime::class, $session['last_activity']); + } + + /** + * Test destroy session + */ + public function test_destroy_session(): void + { + // First write a session + $this->handler->write($this->sessionId, 'test_data'); + + // Verify it exists + $exists = $this->app['db']->table(config('session.table')) + ->where('_id', $this->sessionId) + ->exists(); + $this->assertTrue($exists); + + // Now destroy it + $this->assertTrue($this->handler->destroy($this->sessionId)); + + // Verify it's gone + $exists = $this->app['db']->table(config('session.table')) + ->where('_id', $this->sessionId) + ->exists(); + $this->assertFalse($exists); + } + + /** + * Test garbage collection + */ + public function test_garbage_collection(): void + { + // gc should return a truthy value as it's handled by MongoDB TTL index + $this->assertNotFalse($this->handler->gc(100)); + } +} \ No newline at end of file