diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..29c2e96 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..0b24203 --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,35 @@ +name: Fix PHP code style issues + +on: + push: + branches: [main, master] + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Fix PHP code style issues + run: vendor/bin/pint + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Fix styling diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..3379d00 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,101 @@ +name: Run Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: [8.2, 8.3, 8.4] + laravel: [9.*, 10.*, 11.*, 12.*] + stability: [prefer-stable] + include: + - laravel: 9.* + testbench: 7.* + pest: 1.* + - laravel: 10.* + testbench: 8.* + pest: 2.* + - laravel: 11.* + testbench: 9.* + pest: 2.* + - laravel: 12.* + testbench: 10.* + pest: 3.* + exclude: + - laravel: 9.* + php: 8.4 + - laravel: 10.* + php: 8.4 + - laravel: 11.* + php: 8.2 + - laravel: 12.* + php: 8.2 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "pestphp/pest:${{ matrix.pest }}" --dev --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest + + php85-test: + runs-on: ubuntu-latest + name: P8.5 - L12.* - prefer-stable - ubuntu-latest + continue-on-error: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:12.*" "orchestra/testbench:10.*" "pestphp/pest:3.*" --dev --no-interaction --no-update + composer update --prefer-stable --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest diff --git a/composer.json b/composer.json index 6a655fc..ae8aafa 100644 --- a/composer.json +++ b/composer.json @@ -18,16 +18,18 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^8.2", + "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", "illuminate/contracts": "^9.0 || ^10.0 || ^11.0 || ^12.0", - "laravel/prompts": "^0.1.18 || ^0.2.0 || ^0.3.0" + "illuminate/http": "^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0" }, "require-dev": { "laravel/pint": "^1.0", + "mockery/mockery": "^1.6", "nunomaduro/collision": "^6.0 || ^7.0 || ^8.0", "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0", "pestphp/pest": "^1.0 || ^2.0 || ^3.0", - "pestphp/pest-plugin-laravel": "^1.0 || ^2.0 || ^v3.0" + "pestphp/pest-plugin-laravel": "^1.0 || ^2.0 || ^3.0" }, "autoload": { "psr-4": { diff --git a/src/Api/TalentLmsApiClient.php b/src/Api/TalentLmsApiClient.php index a972b05..9edc294 100644 --- a/src/Api/TalentLmsApiClient.php +++ b/src/Api/TalentLmsApiClient.php @@ -7,16 +7,11 @@ class TalentLmsApiClient { - public PendingRequest $request; - public function __construct( protected string $apiKey, protected string $domain, protected string $version, - ) - { - $this->request = Http::withBasicAuth($this->apiKey, '') - ->acceptJson(); + ) { } public static function fromConfig(array $config): self @@ -28,9 +23,15 @@ public static function fromConfig(array $config): self ); } + protected function request(): PendingRequest + { + return Http::withBasicAuth($this->apiKey, '') + ->acceptJson(); + } + public function get(string $endpoint, array $query = []): object|array|null { - return $this->request + return $this->request() ->throw() ->get($this->buildEndpointUrl($endpoint, $query)) ->object(); @@ -38,7 +39,7 @@ public function get(string $endpoint, array $query = []): object|array|null public function post(string $endpoint, array $data = []): object|array|null { - return $this->request + return $this->request() ->throw() ->post($this->buildEndpointUrl($endpoint), $data) ->object(); @@ -53,7 +54,7 @@ protected function buildEndpointUrl(string $endpoint, array $query = []): string } $urlQuery = collect($query) - ->map(fn($value, $key) => "{$key}:{$value}") + ->map(fn ($value, $key) => "{$key}:{$value}") ->implode(','); return "{$baseRequest}/{$urlQuery}"; diff --git a/src/Data/ApiResources/Group.php b/src/Data/ApiResources/Group.php index ec7a3af..8da6a69 100644 --- a/src/Data/ApiResources/Group.php +++ b/src/Data/ApiResources/Group.php @@ -27,6 +27,9 @@ public function __construct( public static function fromResponse(object $response): self { + $maxRedemptions = $response->max_redemptions ?? null; + $redemptionsSoFar = $response->redemptions_sofar ?? null; + return new self( id: $response->id, name: $response->name, @@ -34,13 +37,13 @@ public static function fromResponse(object $response): self key: $response->key ?? null, price: $response->price ?? null, ownerId: $response->owner_id ?? null, - belongsToBranch: empty($response->belongs_to_branch) ? null : $response->belongs_to_branch, - maxRedemptions: empty($response->max_redemptions) && $response->max_redemptions != 0 ? null : $response->max_redemptions, - redemptionsSoFar: empty($response->redemptions_sofar) && $response->redemptions_sofar != 0 ? null : $response->redemptions_sofar, + belongsToBranch: empty($response->belongs_to_branch ?? null) ? null : $response->belongs_to_branch, + maxRedemptions: $maxRedemptions === null || ($maxRedemptions === '' && $maxRedemptions !== 0) ? null : (int) $maxRedemptions, + redemptionsSoFar: $redemptionsSoFar === null || ($redemptionsSoFar === '' && $redemptionsSoFar !== 0) ? null : (int) $redemptionsSoFar, users: collect($response->users ?? []) - ->map(fn($user) => UserGroup::fromResponse($user)), + ->map(fn ($user) => UserGroup::fromResponse($user)), courses: collect($response->courses ?? []) - ->map(fn($course) => GroupCourse::fromResponse($course)) + ->map(fn ($course) => GroupCourse::fromResponse($course)) ); } } diff --git a/src/Data/ApiResources/UserCertification.php b/src/Data/ApiResources/UserCertification.php index 5ba440e..e8c28b4 100644 --- a/src/Data/ApiResources/UserCertification.php +++ b/src/Data/ApiResources/UserCertification.php @@ -28,12 +28,12 @@ public static function fromResponse(object $response): self id: $response->unique_id, courseId: $response->course_id, courseName: $response->course_name, - issuedAt: ApiParsing::parseTimestamp($response->issued_date_timestamp), - expiresAt: ApiParsing::parseTimestamp($response->expiration_date_timestamp), + issuedAt: ApiParsing::parseTimestamp($response->issued_date_timestamp ?? null), + expiresAt: ApiParsing::parseTimestamp($response->expiration_date_timestamp ?? null), downloadUrl: $response->download_url ?? null, publicUrl: $response->public_url ?? null, badges: collect($response->badges ?? []) - ->map(fn($badge) => CertificationBadge::fromResponse($badge)) + ->map(fn ($badge) => CertificationBadge::fromResponse($badge)) ); } diff --git a/src/LaravelTalentLmsServiceProvider.php b/src/LaravelTalentLmsServiceProvider.php index 7305adc..9a14c26 100644 --- a/src/LaravelTalentLmsServiceProvider.php +++ b/src/LaravelTalentLmsServiceProvider.php @@ -4,7 +4,6 @@ use Bernskiold\LaravelTalentLms\Api\TalentLms; use Bernskiold\LaravelTalentLms\Api\TalentLmsApiClient; -use Bernskiold\LaravelTalentLms\Commands\PurgeDownloadedFilesCommand; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Support\Facades\RateLimiter; @@ -18,7 +17,7 @@ public function boot(): void { AboutCommand::add('Laravel TalentLMS', fn () => ['Version' => '1.0.0']); - RateLimiter::for('talentlms', function() { + RateLimiter::for('talentlms', function () { return Limit::perSecond(200, 5); }); @@ -33,10 +32,14 @@ public function register(): void __DIR__.'/../config/talentlms.php', 'talentlms' ); - $this->app->bind(TalentLmsApiClient::class, function () { + $this->app->singleton(TalentLmsApiClient::class, function () { return TalentLmsApiClient::fromConfig(config('talentlms.api')); }); + $this->app->singleton(TalentLms::class, function ($app) { + return new TalentLms($app->make(TalentLmsApiClient::class)); + }); + $this->app->alias(TalentLms::class, 'laravel-talentlms'); } } diff --git a/tests/Feature/FacadeTest.php b/tests/Feature/FacadeTest.php new file mode 100644 index 0000000..5adecf2 --- /dev/null +++ b/tests/Feature/FacadeTest.php @@ -0,0 +1,26 @@ +toBeInstanceOf(Courses::class); + }); + + it('can access users resource', function () { + $users = TalentLms::users(); + + expect($users)->toBeInstanceOf(Users::class); + }); + + it('can access groups resource', function () { + $groups = TalentLms::groups(); + + expect($groups)->toBeInstanceOf(Groups::class); + }); +}); diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php new file mode 100644 index 0000000..b6804b2 --- /dev/null +++ b/tests/Feature/ServiceProviderTest.php @@ -0,0 +1,41 @@ +toBeInstanceOf(TalentLmsApiClient::class); + }); + + it('registers the laravel-talentlms alias', function () { + $talentLms = app('laravel-talentlms'); + + expect($talentLms)->toBeInstanceOf(TalentLms::class); + }); + + it('publishes the config file', function () { + $this->artisan('vendor:publish', [ + '--provider' => 'Bernskiold\LaravelTalentLms\LaravelTalentLmsServiceProvider', + '--tag' => 'config', + ]); + + expect(config('talentlms'))->toBeArray(); + expect(config('talentlms.api'))->toHaveKeys(['api_key', 'domain', 'version']); + }); + + it('loads the config file', function () { + expect(config('talentlms.api.api_key'))->toBe('test-api-key'); + expect(config('talentlms.api.domain'))->toBe('https://test.talentlms.com'); + expect(config('talentlms.api.version'))->toBe('1'); + }); + + it('registers the rate limiter', function () { + $limiter = app(\Illuminate\Cache\RateLimiting\Limit::class); + + // Rate limiter is registered, we can check if the RateLimiter facade has a 'talentlms' limiter + expect(\Illuminate\Support\Facades\RateLimiter::limiter('talentlms'))->not->toBeNull(); + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 59f03c0..5c224bb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,9 @@ namespace Bernskiold\LaravelTalentLms\Tests; +use Bernskiold\LaravelTalentLms\Api\TalentLmsApiClient; use Bernskiold\LaravelTalentLms\LaravelTalentLmsServiceProvider; -use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Facades\Http; use Orchestra\Testbench\TestCase as Orchestra; class TestCase extends Orchestra @@ -11,10 +12,6 @@ class TestCase extends Orchestra protected function setUp(): void { parent::setUp(); - - Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Bernskiold\\LaravelTalentLms\\Database\\Factories\\'.class_basename($modelName).'Factory' - ); } protected function getPackageProviders($app) @@ -27,5 +24,33 @@ protected function getPackageProviders($app) public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); + config()->set('talentlms.api.api_key', 'test-api-key'); + config()->set('talentlms.api.domain', 'https://test.talentlms.com'); + config()->set('talentlms.api.version', '1'); + } + + protected function createMockApiClient(): TalentLmsApiClient + { + return new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + } + + protected function fakeHttpResponse(string $endpoint, array|object $response, int $status = 200): void + { + Http::fake([ + "*{$endpoint}*" => Http::response($response, $status), + ]); + } + + protected function fakeHttpResponses(array $responses): void + { + $fakes = []; + foreach ($responses as $endpoint => $response) { + $fakes["*{$endpoint}*"] = Http::response($response, 200); + } + Http::fake($fakes); } } diff --git a/tests/Unit/Api/Resources/CoursesTest.php b/tests/Unit/Api/Resources/CoursesTest.php new file mode 100644 index 0000000..09d0325 --- /dev/null +++ b/tests/Unit/Api/Resources/CoursesTest.php @@ -0,0 +1,196 @@ +client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + $this->courses = new Courses($this->client); + }); + + describe('all()', function () { + it('returns a ListResponse with courses', function () { + Http::fake([ + '*courses*' => Http::response([ + ['id' => 1, 'name' => 'Course 1', 'code' => 'C1'], + ['id' => 2, 'name' => 'Course 2', 'code' => 'C2'], + ], 200), + ]); + + $result = $this->courses->all(); + + expect($result)->toBeInstanceOf(ListResponse::class); + expect($result->get())->toHaveCount(2); + expect($result->get()->first())->toBeInstanceOf(Course::class); + }); + + it('returns empty ListResponse when no courses exist', function () { + Http::fake([ + '*courses*' => Http::response([], 200), + ]); + + $result = $this->courses->all(); + + expect($result)->toBeInstanceOf(ListResponse::class); + expect($result->get())->toHaveCount(0); + }); + }); + + describe('get()', function () { + it('returns a Course when found', function () { + Http::fake([ + '*courses/id:1*' => Http::response([ + 'id' => 1, + 'name' => 'Test Course', + 'code' => 'TC1', + 'description' => 'A test course', + ], 200), + ]); + + $result = $this->courses->get(1); + + expect($result)->toBeInstanceOf(Course::class); + expect($result->id)->toBe(1); + expect($result->name)->toBe('Test Course'); + }); + + it('returns null when course not found', function () { + Http::fake([ + '*courses/id:999*' => Http::response(null, 200), + ]); + + $result = $this->courses->get(999); + + expect($result)->toBeNull(); + }); + }); + + describe('enrollUser()', function () { + it('enrolls user by IDs', function () { + Http::fake([ + '*addusertocourse*' => Http::response([ + 'user_id' => 1, + 'course_id' => 2, + 'role' => 'learner', + ], 200), + ]); + + $result = $this->courses->enrollUser(2, 1); + + expect($result)->toBeInstanceOf(UserCourseEnrollment::class); + expect($result->userId)->toBe(1); + expect($result->courseId)->toBe(2); + expect($result->role)->toBe('learner'); + }); + + it('enrolls user by email and course name', function () { + Http::fake([ + '*addusertocourse*' => Http::response([ + 'user_id' => 1, + 'course_id' => 2, + 'role' => 'learner', + ], 200), + ]); + + $result = $this->courses->enrollUser('Course Name', 'user@example.com'); + + expect($result)->toBeInstanceOf(UserCourseEnrollment::class); + }); + + it('enrolls user as instructor', function () { + Http::fake([ + '*addusertocourse*' => Http::response([ + 'user_id' => 1, + 'course_id' => 2, + 'role' => 'instructor', + ], 200), + ]); + + $result = $this->courses->enrollUser(2, 1, true); + + expect($result)->toBeInstanceOf(UserCourseEnrollment::class); + expect($result->role)->toBe('instructor'); + }); + + it('returns null on empty response', function () { + Http::fake([ + '*addusertocourse*' => Http::response(null, 200), + ]); + + $result = $this->courses->enrollUser(2, 1); + + expect($result)->toBeNull(); + }); + }); + + describe('unenrollUser()', function () { + it('unenrolls user from course', function () { + Http::fake([ + '*removeuserfromcourse*' => Http::response([ + 'user_id' => 1, + 'course_id' => 2, + 'role' => 'learner', + ], 200), + ]); + + $result = $this->courses->unenrollUser(2, 1); + + expect($result)->toBeInstanceOf(UserCourseEnrollment::class); + expect($result->userId)->toBe(1); + expect($result->courseId)->toBe(2); + }); + + it('returns null on empty response', function () { + Http::fake([ + '*removeuserfromcourse*' => Http::response(null, 200), + ]); + + $result = $this->courses->unenrollUser(2, 1); + + expect($result)->toBeNull(); + }); + }); + + describe('courseLoginUrl()', function () { + it('returns the course login URL', function () { + Http::fake([ + '*gotocourse*' => Http::response([ + 'goto_url' => 'https://test.talentlms.com/course/login/123', + ], 200), + ]); + + $result = $this->courses->courseLoginUrl(1, 2); + + expect($result)->toBe('https://test.talentlms.com/course/login/123'); + }); + + it('returns null when response is empty', function () { + Http::fake([ + '*gotocourse*' => Http::response(null, 200), + ]); + + $result = $this->courses->courseLoginUrl(1, 2); + + expect($result)->toBeNull(); + }); + + it('returns null when goto_url is missing', function () { + Http::fake([ + '*gotocourse*' => Http::response(['other_field' => 'value'], 200), + ]); + + $result = $this->courses->courseLoginUrl(1, 2); + + expect($result)->toBeNull(); + }); + }); +}); diff --git a/tests/Unit/Api/Resources/GroupsTest.php b/tests/Unit/Api/Resources/GroupsTest.php new file mode 100644 index 0000000..5c5c48e --- /dev/null +++ b/tests/Unit/Api/Resources/GroupsTest.php @@ -0,0 +1,222 @@ +client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + $this->groups = new Groups($this->client); + }); + + describe('all()', function () { + it('returns a ListResponse with groups', function () { + Http::fake([ + '*groups*' => Http::response([ + ['id' => 1, 'name' => 'Group 1', 'key' => 'group-1'], + ['id' => 2, 'name' => 'Group 2', 'key' => 'group-2'], + ], 200), + ]); + + $result = $this->groups->all(); + + expect($result)->toBeInstanceOf(ListResponse::class); + expect($result->get())->toHaveCount(2); + expect($result->get()->first())->toBeInstanceOf(Group::class); + }); + + it('returns empty ListResponse when no groups exist', function () { + Http::fake([ + '*groups*' => Http::response([], 200), + ]); + + $result = $this->groups->all(); + + expect($result)->toBeInstanceOf(ListResponse::class); + expect($result->get())->toHaveCount(0); + }); + }); + + describe('get()', function () { + it('returns a Group when found', function () { + Http::fake([ + '*groups/id:1*' => Http::response([ + 'id' => 1, + 'name' => 'Test Group', + 'description' => 'A test group', + 'key' => 'test-group', + 'price' => '0', + ], 200), + ]); + + $result = $this->groups->get(1); + + expect($result)->toBeInstanceOf(Group::class); + expect($result->id)->toBe(1); + expect($result->name)->toBe('Test Group'); + expect($result->key)->toBe('test-group'); + }); + + it('returns null when group not found', function () { + Http::fake([ + '*groups/id:999*' => Http::response(null, 200), + ]); + + $result = $this->groups->get(999); + + expect($result)->toBeNull(); + }); + }); + + describe('create()', function () { + it('creates a new group with minimal data', function () { + Http::fake([ + '*groups*' => Http::response([ + 'id' => 1, + 'name' => 'New Group', + 'key' => 'new-group', + ], 200), + ]); + + $result = $this->groups->create('New Group'); + + expect($result)->toBeInstanceOf(Group::class); + expect($result->name)->toBe('New Group'); + }); + + it('creates a new group with all optional parameters', function () { + Http::fake([ + '*groups*' => Http::response([ + 'id' => 1, + 'name' => 'Premium Group', + 'description' => 'A premium group', + 'key' => 'premium-key', + 'price' => '99.99', + 'max_redemptions' => 100, + 'owner_id' => 1, + ], 200), + ]); + + $result = $this->groups->create( + name: 'Premium Group', + description: 'A premium group', + price: '99.99', + key: 'premium-key', + maxRedemptions: 100, + creatorId: 1 + ); + + expect($result)->toBeInstanceOf(Group::class); + expect($result->name)->toBe('Premium Group'); + expect($result->price)->toBe('99.99'); + }); + }); + + describe('addUser()', function () { + it('adds user to group by group key and user ID', function () { + Http::fake([ + '*addusertogroup*' => Http::response([ + 'user_id' => 1, + 'group_id' => 2, + 'group_name' => 'Test Group', + ], 200), + ]); + + $result = $this->groups->addUser('group-key', 1); + + expect($result)->toBeInstanceOf(UserGroupEnrollment::class); + expect($result->userId)->toBe(1); + expect($result->groupId)->toBe(2); + expect($result->groupName)->toBe('Test Group'); + }); + + it('adds user to group using Group object', function () { + Http::fake([ + '*addusertogroup*' => Http::response([ + 'user_id' => 1, + 'group_id' => 2, + 'group_name' => 'Test Group', + ], 200), + ]); + + $group = new Group(id: 2, name: 'Test Group', key: 'test-key'); + $result = $this->groups->addUser($group, 1); + + expect($result)->toBeInstanceOf(UserGroupEnrollment::class); + }); + + it('adds user to group using User object', function () { + Http::fake([ + '*addusertogroup*' => Http::response([ + 'user_id' => 5, + 'group_id' => 2, + 'group_name' => 'Test Group', + ], 200), + ]); + + $user = new User(id: 5, username: 'testuser'); + $result = $this->groups->addUser('group-key', $user); + + expect($result)->toBeInstanceOf(UserGroupEnrollment::class); + expect($result->userId)->toBe(5); + }); + }); + + describe('removeUser()', function () { + it('removes user from group by IDs', function () { + Http::fake([ + '*removeuserfromgroup*' => Http::response([ + 'user_id' => 1, + 'group_id' => 2, + 'group_name' => 'Test Group', + ], 200), + ]); + + $result = $this->groups->removeUser(2, 1); + + expect($result)->toBeInstanceOf(UserGroupEnrollment::class); + expect($result->userId)->toBe(1); + expect($result->groupId)->toBe(2); + }); + + it('removes user from group using Group object', function () { + Http::fake([ + '*removeuserfromgroup*' => Http::response([ + 'user_id' => 1, + 'group_id' => 2, + 'group_name' => 'Test Group', + ], 200), + ]); + + $group = new Group(id: 2, name: 'Test Group'); + $result = $this->groups->removeUser($group, 1); + + expect($result)->toBeInstanceOf(UserGroupEnrollment::class); + }); + + it('removes user from group using User object', function () { + Http::fake([ + '*removeuserfromgroup*' => Http::response([ + 'user_id' => 5, + 'group_id' => 2, + 'group_name' => 'Test Group', + ], 200), + ]); + + $user = new User(id: 5, username: 'testuser'); + $result = $this->groups->removeUser(2, $user); + + expect($result)->toBeInstanceOf(UserGroupEnrollment::class); + expect($result->userId)->toBe(5); + }); + }); +}); diff --git a/tests/Unit/Api/Resources/UsersTest.php b/tests/Unit/Api/Resources/UsersTest.php new file mode 100644 index 0000000..76703c2 --- /dev/null +++ b/tests/Unit/Api/Resources/UsersTest.php @@ -0,0 +1,196 @@ +client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + $this->users = new Users($this->client); + }); + + describe('all()', function () { + it('returns a ListResponse with users', function () { + Http::fake([ + '*users*' => Http::response([ + ['id' => 1, 'login' => 'user1', 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com'], + ['id' => 2, 'login' => 'user2', 'first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@example.com'], + ], 200), + ]); + + $result = $this->users->all(); + + expect($result)->toBeInstanceOf(ListResponse::class); + expect($result->get())->toHaveCount(2); + expect($result->get()->first())->toBeInstanceOf(User::class); + }); + + it('returns empty ListResponse when no users exist', function () { + Http::fake([ + '*users*' => Http::response([], 200), + ]); + + $result = $this->users->all(); + + expect($result)->toBeInstanceOf(ListResponse::class); + expect($result->get())->toHaveCount(0); + }); + }); + + describe('get()', function () { + it('returns a User when found', function () { + Http::fake([ + '*users/id:1*' => Http::response([ + 'id' => 1, + 'login' => 'johndoe', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'user_type' => 'Learner', + 'status' => 'active', + ], 200), + ]); + + $result = $this->users->get(1); + + expect($result)->toBeInstanceOf(User::class); + expect($result->id)->toBe(1); + expect($result->username)->toBe('johndoe'); + expect($result->firstName)->toBe('John'); + expect($result->lastName)->toBe('Doe'); + }); + + it('returns null when user not found', function () { + Http::fake([ + '*users/id:999*' => Http::response(null, 200), + ]); + + $result = $this->users->get(999); + + expect($result)->toBeNull(); + }); + }); + + describe('create()', function () { + it('creates a new user with minimal data', function () { + Http::fake([ + '*usersignup*' => Http::response([ + 'id' => 1, + 'login' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + ], 200), + ]); + + $result = $this->users->create('John', 'Doe', 'john@example.com'); + + expect($result)->toBeInstanceOf(User::class); + expect($result->firstName)->toBe('John'); + expect($result->lastName)->toBe('Doe'); + expect($result->email)->toBe('john@example.com'); + }); + + it('creates a new user with custom username and password', function () { + Http::fake([ + '*usersignup*' => Http::response([ + 'id' => 1, + 'login' => 'johndoe', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + ], 200), + ]); + + $result = $this->users->create('John', 'Doe', 'john@example.com', 'johndoe', 'password123'); + + expect($result)->toBeInstanceOf(User::class); + expect($result->username)->toBe('johndoe'); + + Http::assertSent(function ($request) { + $body = $request->body(); + + return str_contains($body, 'login') && str_contains($body, 'password'); + }); + }); + }); + + describe('lookupByEmail()', function () { + it('returns a User when found by email', function () { + Http::fake([ + '*users/email:john@example.com*' => Http::response([ + [ + 'id' => 1, + 'login' => 'johndoe', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + ], + ], 200), + ]); + + $result = $this->users->lookupByEmail('john@example.com'); + + expect($result)->toBeInstanceOf(User::class); + expect($result->email)->toBe('john@example.com'); + }); + + it('returns null when user not found', function () { + Http::fake([ + '*users/email:notfound@example.com*' => Http::response([], 200), + ]); + + $result = $this->users->lookupByEmail('notfound@example.com'); + + expect($result)->toBeNull(); + }); + + it('returns null on request exception', function () { + Http::fake([ + '*users/email:error@example.com*' => Http::response(null, 404), + ]); + + $result = $this->users->lookupByEmail('error@example.com'); + + expect($result)->toBeNull(); + }); + }); + + describe('lookupByUsername()', function () { + it('returns a User when found by username', function () { + Http::fake([ + '*users/username:johndoe*' => Http::response([ + [ + 'id' => 1, + 'login' => 'johndoe', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + ], + ], 200), + ]); + + $result = $this->users->lookupByUsername('johndoe'); + + expect($result)->toBeInstanceOf(User::class); + expect($result->username)->toBe('johndoe'); + }); + + it('returns null when user not found', function () { + Http::fake([ + '*users/username:notfound*' => Http::response([], 200), + ]); + + $result = $this->users->lookupByUsername('notfound'); + + expect($result)->toBeNull(); + }); + }); +}); diff --git a/tests/Unit/Api/TalentLmsApiClientTest.php b/tests/Unit/Api/TalentLmsApiClientTest.php new file mode 100644 index 0000000..d18f9b9 --- /dev/null +++ b/tests/Unit/Api/TalentLmsApiClientTest.php @@ -0,0 +1,117 @@ +toBeInstanceOf(TalentLmsApiClient::class); + }); + + it('can be created from config array', function () { + $client = TalentLmsApiClient::fromConfig([ + 'api_key' => 'test-api-key', + 'domain' => 'https://test.talentlms.com', + 'version' => '1', + ]); + + expect($client)->toBeInstanceOf(TalentLmsApiClient::class); + }); + + it('uses default version when not provided in config', function () { + $client = TalentLmsApiClient::fromConfig([ + 'api_key' => 'test-api-key', + 'domain' => 'https://test.talentlms.com', + ]); + + expect($client)->toBeInstanceOf(TalentLmsApiClient::class); + }); + + it('makes GET requests to the correct endpoint', function () { + Http::fake([ + '*' => Http::response(['id' => 1, 'name' => 'Test Course'], 200), + ]); + + $client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + + $result = $client->get('/courses'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'https://test.talentlms.com/api/v1/courses'); + }); + }); + + it('makes POST requests to the correct endpoint', function () { + Http::fake([ + '*' => Http::response(['id' => 1, 'first_name' => 'John'], 200), + ]); + + $client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + + $result = $client->post('/usersignup', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + ]); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' && + str_contains($request->url(), 'https://test.talentlms.com/api/v1/usersignup'); + }); + }); + + it('builds query parameters correctly', function () { + Http::fake([ + '*' => Http::response(['id' => 1], 200), + ]); + + $client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + + $client->get('/users', ['id' => 123]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/users/id:123'); + }); + }); + + it('sends basic auth with API key', function () { + Http::fake([ + '*' => Http::response(['id' => 1], 200), + ]); + + $client = new TalentLmsApiClient( + apiKey: 'my-secret-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + + $client->get('/courses'); + + Http::assertSent(function ($request) { + $authHeader = $request->header('Authorization')[0] ?? ''; + return str_starts_with($authHeader, 'Basic '); + }); + }); +}); diff --git a/tests/Unit/Api/TalentLmsTest.php b/tests/Unit/Api/TalentLmsTest.php new file mode 100644 index 0000000..f2c9044 --- /dev/null +++ b/tests/Unit/Api/TalentLmsTest.php @@ -0,0 +1,30 @@ +client = new TalentLmsApiClient( + apiKey: 'test-api-key', + domain: 'https://test.talentlms.com', + version: '1' + ); + $this->talentLms = new TalentLms($this->client); + }); + + it('returns Courses resource', function () { + expect($this->talentLms->courses())->toBeInstanceOf(Courses::class); + }); + + it('returns Users resource', function () { + expect($this->talentLms->users())->toBeInstanceOf(Users::class); + }); + + it('returns Groups resource', function () { + expect($this->talentLms->groups())->toBeInstanceOf(Groups::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/CourseTest.php b/tests/Unit/Data/ApiResources/CourseTest.php new file mode 100644 index 0000000..8b35753 --- /dev/null +++ b/tests/Unit/Data/ApiResources/CourseTest.php @@ -0,0 +1,226 @@ +toBeInstanceOf(Course::class); + expect($course->id)->toBe(1); + expect($course->name)->toBe('Test Course'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Introduction to Testing', + 'code' => 'ITT101', + 'category_id' => 5, + 'description' => 'A course about testing', + 'price' => '99.99', + 'status' => 'active', + 'creation_date' => '2021-01-01 10:00:00', + 'last_update_on' => '2021-06-15 14:30:00', + 'creator_id' => 1, + 'hide_from_catalog' => '0', + 'time_limit' => '1', + 'start_datetime' => '2021-02-01 00:00:00', + 'expiration_datetime' => '2021-12-31 23:59:59', + 'level' => 2, + 'shared' => '1', + 'shared_url' => 'https://example.com/shared/course', + 'avatar' => 'https://example.com/avatar.png', + 'big_avatar' => 'https://example.com/big_avatar.png', + 'certification' => 'Test Certification', + 'certification_duration' => '1 year', + ]; + + $course = Course::fromResponse($response); + + expect($course->id)->toBe(1); + expect($course->name)->toBe('Introduction to Testing'); + expect($course->code)->toBe('ITT101'); + expect($course->categoryId)->toBe(5); + expect($course->description)->toBe('A course about testing'); + expect($course->price)->toBe('99.99'); + expect($course->sattus)->toBe('active'); + expect($course->createdById)->toBe(1); + expect($course->hideFromCatalog)->toBeFalse(); + expect($course->hasTimeLimit)->toBeTrue(); + expect($course->level)->toBe(2); + expect($course->isShared)->toBeTrue(); + expect($course->sharedUrl)->toBe('https://example.com/shared/course'); + expect($course->avatarUrl)->toBe('https://example.com/avatar.png'); + expect($course->bigAvatarUrl)->toBe('https://example.com/big_avatar.png'); + expect($course->certification)->toBe('Test Certification'); + expect($course->certificationDuration)->toBe('1 year'); + expect($course->createdAt)->not->toBeNull(); + expect($course->updatedAt)->not->toBeNull(); + expect($course->startsAt)->not->toBeNull(); + expect($course->expiresAt)->not->toBeNull(); + }); + + it('handles missing optional fields', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Minimal Course', + ]; + + $course = Course::fromResponse($response); + + expect($course->id)->toBe(1); + expect($course->name)->toBe('Minimal Course'); + expect($course->code)->toBeNull(); + expect($course->categoryId)->toBeNull(); + expect($course->description)->toBeNull(); + expect($course->createdAt)->toBeNull(); + expect($course->hideFromCatalog)->toBeFalse(); + }); + + it('parses users from response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Course', + 'users' => [ + (object) [ + 'id' => 1, + 'role' => 'learner', + 'enrolled_on_timestamp' => 1609459200, + 'completion_status' => 'completed', + ], + ], + ]; + + $course = Course::fromResponse($response); + + expect($course->users)->toHaveCount(1); + expect($course->users->first())->toBeInstanceOf(UserCourse::class); + }); + + it('parses units from response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Course', + 'units' => [ + (object) [ + 'id' => 1, + 'type' => 'video', + 'name' => 'Introduction Video', + 'url' => 'https://example.com/video', + ], + ], + ]; + + $course = Course::fromResponse($response); + + expect($course->units)->toHaveCount(1); + expect($course->units->first())->toBeInstanceOf(Unit::class); + }); + + it('parses prerequisites from response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Advanced Course', + 'prerequisites' => [ + (object) [ + 'course_id' => 2, + 'course_name' => 'Basic Course', + ], + ], + ]; + + $course = Course::fromResponse($response); + + expect($course->prerequisites)->toHaveCount(1); + expect($course->prerequisites->first())->toBeInstanceOf(PrerequisiteCourse::class); + }); + + it('parses prerequisite rule sets from response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Advanced Course', + 'prerequisite_rule_sets' => [ + (object) [ + 'course_id' => 2, + 'course_name' => 'Prerequisite Course', + 'rule_set' => 'all', + ], + ], + ]; + + $course = Course::fromResponse($response); + + expect($course->prerequisiteRuleSets)->toHaveCount(1); + expect($course->prerequisiteRuleSets->first())->toBeInstanceOf(PrerequisiteRuleSet::class); + }); + + it('parses rules as collection', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Course', + 'rules' => [ + 'rule1' => 'value1', + 'rule2' => 'value2', + ], + ]; + + $course = Course::fromResponse($response); + + expect($course->rules)->toHaveCount(2); + }); + + it('has readonly properties', function () { + $course = new Course( + id: 1, + name: 'Test', + code: null, + categoryId: null, + description: null, + price: null, + sattus: null, + createdAt: null, + updatedAt: null, + createdById: null, + hideFromCatalog: false, + hasTimeLimit: false, + startsAt: null, + expiresAt: null, + level: null, + isShared: false, + sharedUrl: null, + avatarUrl: null, + bigAvatarUrl: null, + certification: null, + certificationDuration: null, + ); + + expect(fn () => $course->id = 2)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/GroupTest.php b/tests/Unit/Data/ApiResources/GroupTest.php new file mode 100644 index 0000000..5e55068 --- /dev/null +++ b/tests/Unit/Data/ApiResources/GroupTest.php @@ -0,0 +1,159 @@ +toBeInstanceOf(Group::class); + expect($group->id)->toBe(1); + expect($group->name)->toBe('Test Group'); + }); + + it('can be instantiated with all parameters', function () { + $group = new Group( + id: 1, + name: 'Premium Group', + description: 'A premium group', + key: 'premium-key', + price: '99.99', + ownerId: 5, + belongsToBranch: 'Main Branch', + maxRedemptions: 100, + redemptionsSoFar: 50, + ); + + expect($group->id)->toBe(1); + expect($group->name)->toBe('Premium Group'); + expect($group->description)->toBe('A premium group'); + expect($group->key)->toBe('premium-key'); + expect($group->price)->toBe('99.99'); + expect($group->ownerId)->toBe(5); + expect($group->belongsToBranch)->toBe('Main Branch'); + expect($group->maxRedemptions)->toBe(100); + expect($group->redemptionsSoFar)->toBe(50); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Group', + 'description' => 'A test group description', + 'key' => 'test-key', + 'price' => '49.99', + 'owner_id' => 1, + 'belongs_to_branch' => 'Branch A', + 'max_redemptions' => 50, + 'redemptions_sofar' => 10, + ]; + + $group = Group::fromResponse($response); + + expect($group->id)->toBe(1); + expect($group->name)->toBe('Test Group'); + expect($group->description)->toBe('A test group description'); + expect($group->key)->toBe('test-key'); + expect($group->price)->toBe('49.99'); + expect($group->ownerId)->toBe(1); + expect($group->belongsToBranch)->toBe('Branch A'); + expect($group->maxRedemptions)->toBe(50); + expect($group->redemptionsSoFar)->toBe(10); + }); + + it('handles missing optional fields', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Minimal Group', + ]; + + $group = Group::fromResponse($response); + + expect($group->id)->toBe(1); + expect($group->name)->toBe('Minimal Group'); + expect($group->description)->toBeNull(); + expect($group->key)->toBeNull(); + expect($group->price)->toBeNull(); + expect($group->ownerId)->toBeNull(); + }); + + it('handles empty belongs_to_branch', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Group', + 'belongs_to_branch' => '', + ]; + + $group = Group::fromResponse($response); + + expect($group->belongsToBranch)->toBeNull(); + }); + + it('handles zero max_redemptions', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Group', + 'max_redemptions' => 0, + ]; + + $group = Group::fromResponse($response); + + expect($group->maxRedemptions)->toBe(0); + }); + + it('handles zero redemptions_sofar', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Group', + 'redemptions_sofar' => 0, + ]; + + $group = Group::fromResponse($response); + + expect($group->redemptionsSoFar)->toBe(0); + }); + + it('parses users from response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Group', + 'users' => [ + (object) [ + 'id' => 1, + 'name' => 'John Doe', + ], + ], + ]; + + $group = Group::fromResponse($response); + + expect($group->users)->toHaveCount(1); + expect($group->users->first())->toBeInstanceOf(UserGroup::class); + }); + + it('parses courses from response', function () { + $response = (object) [ + 'id' => 1, + 'name' => 'Test Group', + 'courses' => [ + (object) [ + 'id' => 1, + 'name' => 'Course 1', + ], + ], + ]; + + $group = Group::fromResponse($response); + + expect($group->courses)->toHaveCount(1); + expect($group->courses->first())->toBeInstanceOf(GroupCourse::class); + }); + + it('has readonly properties', function () { + $group = new Group(id: 1, name: 'Test'); + + expect(fn () => $group->id = 2)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/MiscDtosTest.php b/tests/Unit/Data/ApiResources/MiscDtosTest.php new file mode 100644 index 0000000..65b5b81 --- /dev/null +++ b/tests/Unit/Data/ApiResources/MiscDtosTest.php @@ -0,0 +1,157 @@ +toBeInstanceOf(UserBranch::class); + expect($branch->id)->toBe(1); + expect($branch->name)->toBe('Main Branch'); + }); + + it('can be created from API response', function () { + $response = (object) ['id' => 1, 'name' => 'Test Branch']; + $branch = UserBranch::fromResponse($response); + + expect($branch->id)->toBe(1); + expect($branch->name)->toBe('Test Branch'); + }); + + it('handles missing name', function () { + $response = (object) ['id' => 1]; + $branch = UserBranch::fromResponse($response); + + expect($branch->name)->toBe(''); + }); +}); + +describe('UserGroup DTO', function () { + it('can be instantiated', function () { + $group = new UserGroup(id: 1, name: 'Test Group'); + + expect($group)->toBeInstanceOf(UserGroup::class); + expect($group->id)->toBe(1); + expect($group->name)->toBe('Test Group'); + }); + + it('can be created from API response', function () { + $response = (object) ['id' => 1, 'name' => 'Premium Members']; + $group = UserGroup::fromResponse($response); + + expect($group->id)->toBe(1); + expect($group->name)->toBe('Premium Members'); + }); + + it('handles missing name', function () { + $response = (object) ['id' => 1]; + $group = UserGroup::fromResponse($response); + + expect($group->name)->toBe(''); + }); +}); + +describe('GroupCourse DTO', function () { + it('can be instantiated', function () { + $course = new GroupCourse(id: 1, name: 'Test Course'); + + expect($course)->toBeInstanceOf(GroupCourse::class); + expect($course->id)->toBe(1); + expect($course->name)->toBe('Test Course'); + }); + + it('can be created from API response', function () { + $response = (object) ['id' => 1, 'name' => 'Introduction Course']; + $course = GroupCourse::fromResponse($response); + + expect($course->id)->toBe(1); + expect($course->name)->toBe('Introduction Course'); + }); +}); + +describe('PrerequisiteCourse DTO', function () { + it('can be instantiated', function () { + $course = new PrerequisiteCourse(id: 1, name: 'Basic Course'); + + expect($course)->toBeInstanceOf(PrerequisiteCourse::class); + expect($course->id)->toBe(1); + expect($course->name)->toBe('Basic Course'); + }); + + it('can be created from API response', function () { + $response = (object) ['course_id' => 1, 'course_name' => 'Prerequisite Course']; + $course = PrerequisiteCourse::fromResponse($response); + + expect($course->id)->toBe(1); + expect($course->name)->toBe('Prerequisite Course'); + }); +}); + +describe('PrerequisiteRuleSet DTO', function () { + it('can be instantiated', function () { + $ruleSet = new PrerequisiteRuleSet(id: 1, name: 'Test Course', ruleSet: 'all'); + + expect($ruleSet)->toBeInstanceOf(PrerequisiteRuleSet::class); + expect($ruleSet->id)->toBe(1); + expect($ruleSet->name)->toBe('Test Course'); + expect($ruleSet->ruleSet)->toBe('all'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'course_id' => 1, + 'course_name' => 'Advanced Course', + 'rule_set' => 'any', + ]; + $ruleSet = PrerequisiteRuleSet::fromResponse($response); + + expect($ruleSet->id)->toBe(1); + expect($ruleSet->name)->toBe('Advanced Course'); + expect($ruleSet->ruleSet)->toBe('any'); + }); +}); + +describe('UserCertification DTO', function () { + it('can be instantiated', function () { + $cert = new UserCertification( + id: 'cert-123', + courseId: 1, + courseName: 'Test Course', + ); + + expect($cert)->toBeInstanceOf(UserCertification::class); + expect($cert->id)->toBe('cert-123'); + expect($cert->courseId)->toBe(1); + expect($cert->courseName)->toBe('Test Course'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'unique_id' => 'cert-456', + 'course_id' => 2, + 'course_name' => 'Certified Course', + 'issued_date_timestamp' => 1609459200, + 'expiration_date_timestamp' => 1640995200, + 'download_url' => 'https://example.com/cert.pdf', + 'public_url' => 'https://example.com/verify/cert-456', + 'badges' => [], + ]; + $cert = UserCertification::fromResponse($response); + + expect($cert->id)->toBe('cert-456'); + expect($cert->courseId)->toBe(2); + expect($cert->courseName)->toBe('Certified Course'); + expect($cert->issuedAt)->not->toBeNull(); + expect($cert->expiresAt)->not->toBeNull(); + expect($cert->downloadUrl)->toBe('https://example.com/cert.pdf'); + expect($cert->publicUrl)->toBe('https://example.com/verify/cert-456'); + expect($cert->badges)->toHaveCount(0); + }); +}); diff --git a/tests/Unit/Data/ApiResources/UnitTest.php b/tests/Unit/Data/ApiResources/UnitTest.php new file mode 100644 index 0000000..6e0e107 --- /dev/null +++ b/tests/Unit/Data/ApiResources/UnitTest.php @@ -0,0 +1,74 @@ +toBeInstanceOf(Unit::class); + expect($unit->id)->toBe(1); + expect($unit->type)->toBe('video'); + expect($unit->name)->toBe('Introduction Video'); + }); + + it('can be instantiated with all parameters', function () { + $unit = new Unit( + id: 1, + type: 'document', + name: 'Course Materials', + url: 'https://example.com/document.pdf', + aggregatedDelayTimeInMinutes: 30, + ); + + expect($unit->id)->toBe(1); + expect($unit->type)->toBe('document'); + expect($unit->name)->toBe('Course Materials'); + expect($unit->url)->toBe('https://example.com/document.pdf'); + expect($unit->aggregatedDelayTimeInMinutes)->toBe(30); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'id' => 1, + 'type' => 'test', + 'name' => 'Final Exam', + 'url' => 'https://example.com/test/1', + 'aggregated_delay_time' => 60, + ]; + + $unit = Unit::fromResponse($response); + + expect($unit->id)->toBe(1); + expect($unit->type)->toBe('test'); + expect($unit->name)->toBe('Final Exam'); + expect($unit->url)->toBe('https://example.com/test/1'); + expect($unit->aggregatedDelayTimeInMinutes)->toBe(60); + }); + + it('handles missing optional fields', function () { + $response = (object) [ + 'id' => 1, + 'type' => 'video', + 'name' => 'Basic Video', + ]; + + $unit = Unit::fromResponse($response); + + expect($unit->id)->toBe(1); + expect($unit->type)->toBe('video'); + expect($unit->name)->toBe('Basic Video'); + expect($unit->url)->toBeNull(); + expect($unit->aggregatedDelayTimeInMinutes)->toBeNull(); + }); + + it('has readonly properties', function () { + $unit = new Unit(id: 1, type: 'video', name: 'Test'); + + expect(fn () => $unit->id = 2)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/UserCourseEnrollmentTest.php b/tests/Unit/Data/ApiResources/UserCourseEnrollmentTest.php new file mode 100644 index 0000000..ba873bc --- /dev/null +++ b/tests/Unit/Data/ApiResources/UserCourseEnrollmentTest.php @@ -0,0 +1,55 @@ +toBeInstanceOf(UserCourseEnrollment::class); + expect($enrollment->userId)->toBe(1); + expect($enrollment->courseId)->toBe(2); + expect($enrollment->role)->toBe('learner'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'user_id' => 5, + 'course_id' => 10, + 'role' => 'instructor', + ]; + + $enrollment = UserCourseEnrollment::fromResponse($response); + + expect($enrollment->userId)->toBe(5); + expect($enrollment->courseId)->toBe(10); + expect($enrollment->role)->toBe('instructor'); + }); + + it('handles missing role', function () { + $response = (object) [ + 'user_id' => 5, + 'course_id' => 10, + ]; + + $enrollment = UserCourseEnrollment::fromResponse($response); + + expect($enrollment->userId)->toBe(5); + expect($enrollment->courseId)->toBe(10); + expect($enrollment->role)->toBeNull(); + }); + + it('has readonly properties', function () { + $enrollment = new UserCourseEnrollment( + userId: 1, + courseId: 2, + role: 'learner', + ); + + expect(fn () => $enrollment->userId = 5)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/UserCourseTest.php b/tests/Unit/Data/ApiResources/UserCourseTest.php new file mode 100644 index 0000000..5b53491 --- /dev/null +++ b/tests/Unit/Data/ApiResources/UserCourseTest.php @@ -0,0 +1,87 @@ +toBeInstanceOf(UserCourse::class); + expect($userCourse->id)->toBe(1); + expect($userCourse->role)->toBe('learner'); + }); + + it('can be instantiated with all parameters', function () { + $enrolledAt = \Carbon\Carbon::now(); + $completedAt = \Carbon\Carbon::now()->addDays(30); + $expiredAt = \Carbon\Carbon::now()->addYear(); + + $userCourse = new UserCourse( + id: 1, + role: 'learner', + enrolledAt: $enrolledAt, + completedAt: $completedAt, + completionStatus: 'completed', + completionPercentage: 100.0, + expiredAt: $expiredAt, + lastAccessedUnitUrl: 'https://example.com/unit/5', + ); + + expect($userCourse->id)->toBe(1); + expect($userCourse->role)->toBe('learner'); + expect($userCourse->enrolledAt)->toBe($enrolledAt); + expect($userCourse->completedAt)->toBe($completedAt); + expect($userCourse->completionStatus)->toBe('completed'); + expect($userCourse->completionPercentage)->toBe(100.0); + expect($userCourse->expiredAt)->toBe($expiredAt); + expect($userCourse->lastAccessedUnitUrl)->toBe('https://example.com/unit/5'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'id' => 1, + 'role' => 'instructor', + 'enrolled_on_timestamp' => 1609459200, + 'completed_on_timestamp' => 1612137600, + 'completion_status' => 'completed', + 'completion_percentage' => 100, + 'expired_on_timestamp' => 1640995200, + 'last_accessed_unit_url' => 'https://example.com/course/unit/10', + ]; + + $userCourse = UserCourse::fromResponse($response); + + expect($userCourse->id)->toBe(1); + expect($userCourse->role)->toBe('instructor'); + expect($userCourse->enrolledAt)->not->toBeNull(); + expect($userCourse->completedAt)->not->toBeNull(); + expect($userCourse->completionStatus)->toBe('completed'); + expect($userCourse->completionPercentage)->toBe(100.0); + expect($userCourse->expiredAt)->not->toBeNull(); + expect($userCourse->lastAccessedUnitUrl)->toBe('https://example.com/course/unit/10'); + }); + + it('handles missing optional fields', function () { + $response = (object) [ + 'id' => 1, + 'role' => 'learner', + ]; + + $userCourse = UserCourse::fromResponse($response); + + expect($userCourse->id)->toBe(1); + expect($userCourse->role)->toBe('learner'); + expect($userCourse->enrolledAt)->toBeNull(); + expect($userCourse->completedAt)->toBeNull(); + expect($userCourse->completionStatus)->toBeNull(); + expect($userCourse->completionPercentage)->toBeNull(); + expect($userCourse->expiredAt)->toBeNull(); + expect($userCourse->lastAccessedUnitUrl)->toBeNull(); + }); + + it('has readonly properties', function () { + $userCourse = new UserCourse(id: 1, role: 'learner'); + + expect(fn () => $userCourse->id = 2)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/UserGroupEnrollmentTest.php b/tests/Unit/Data/ApiResources/UserGroupEnrollmentTest.php new file mode 100644 index 0000000..b85cd15 --- /dev/null +++ b/tests/Unit/Data/ApiResources/UserGroupEnrollmentTest.php @@ -0,0 +1,42 @@ +toBeInstanceOf(UserGroupEnrollment::class); + expect($enrollment->userId)->toBe(1); + expect($enrollment->groupId)->toBe(2); + expect($enrollment->groupName)->toBe('Test Group'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'user_id' => 5, + 'group_id' => 10, + 'group_name' => 'Premium Members', + ]; + + $enrollment = UserGroupEnrollment::fromResponse($response); + + expect($enrollment->userId)->toBe(5); + expect($enrollment->groupId)->toBe(10); + expect($enrollment->groupName)->toBe('Premium Members'); + }); + + it('has readonly properties', function () { + $enrollment = new UserGroupEnrollment( + userId: 1, + groupId: 2, + groupName: 'Test Group', + ); + + expect(fn () => $enrollment->userId = 5)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ApiResources/UserTest.php b/tests/Unit/Data/ApiResources/UserTest.php new file mode 100644 index 0000000..dc443dd --- /dev/null +++ b/tests/Unit/Data/ApiResources/UserTest.php @@ -0,0 +1,177 @@ +toBeInstanceOf(User::class); + expect($user->id)->toBe(1); + }); + + it('can be instantiated with all parameters', function () { + $user = new User( + id: 1, + username: 'johndoe', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + restrictEmail: true, + userType: 'Learner', + timezone: 'UTC', + language: 'en', + status: 'active', + ); + + expect($user->id)->toBe(1); + expect($user->username)->toBe('johndoe'); + expect($user->firstName)->toBe('John'); + expect($user->lastName)->toBe('Doe'); + expect($user->email)->toBe('john@example.com'); + expect($user->restrictEmail)->toBeTrue(); + expect($user->userType)->toBe('Learner'); + expect($user->timezone)->toBe('UTC'); + expect($user->language)->toBe('en'); + expect($user->status)->toBe('active'); + }); + + it('can be created from API response', function () { + $response = (object) [ + 'id' => 1, + 'login' => 'johndoe', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'restrict_email' => '1', + 'user_type' => 'Learner', + 'timezone' => 'America/New_York', + 'language' => 'en', + 'status' => 'active', + 'level' => 5, + 'points' => 100, + 'created_on_timestamp' => 1609459200, + 'last_updated_timestamp' => 1609545600, + 'avatar' => 'https://example.com/avatar.png', + 'bio' => 'A test user', + 'login_key' => 'abc123', + ]; + + $user = User::fromResponse($response); + + expect($user->id)->toBe(1); + expect($user->username)->toBe('johndoe'); + expect($user->firstName)->toBe('John'); + expect($user->lastName)->toBe('Doe'); + expect($user->email)->toBe('john@example.com'); + expect($user->restrictEmail)->toBeTrue(); + expect($user->userType)->toBe('Learner'); + expect($user->timezone)->toBe('America/New_York'); + expect($user->language)->toBe('en'); + expect($user->status)->toBe('active'); + expect($user->level)->toBe(5); + expect($user->points)->toBe(100); + expect($user->avatarUrl)->toBe('https://example.com/avatar.png'); + expect($user->biography)->toBe('A test user'); + expect($user->loginKey)->toBe('abc123'); + expect($user->createdAt)->not->toBeNull(); + expect($user->updatedAt)->not->toBeNull(); + }); + + it('handles missing optional fields', function () { + $response = (object) [ + 'id' => 1, + ]; + + $user = User::fromResponse($response); + + expect($user->id)->toBe(1); + expect($user->username)->toBeNull(); + expect($user->firstName)->toBeNull(); + expect($user->lastName)->toBeNull(); + expect($user->email)->toBeNull(); + expect($user->restrictEmail)->toBeFalse(); + expect($user->createdAt)->toBeNull(); + }); + + it('parses courses from response', function () { + $response = (object) [ + 'id' => 1, + 'courses' => [ + (object) [ + 'id' => 1, + 'role' => 'learner', + 'enrolled_on_timestamp' => 1609459200, + ], + ], + ]; + + $user = User::fromResponse($response); + + expect($user->courses)->toHaveCount(1); + expect($user->courses->first())->toBeInstanceOf(UserCourse::class); + }); + + it('parses branches from response', function () { + $response = (object) [ + 'id' => 1, + 'branches' => [ + (object) [ + 'id' => 1, + 'name' => 'Main Branch', + ], + ], + ]; + + $user = User::fromResponse($response); + + expect($user->branches)->toHaveCount(1); + expect($user->branches->first())->toBeInstanceOf(UserBranch::class); + }); + + it('parses groups from response', function () { + $response = (object) [ + 'id' => 1, + 'groups' => [ + (object) [ + 'id' => 1, + 'name' => 'Test Group', + ], + ], + ]; + + $user = User::fromResponse($response); + + expect($user->groups)->toHaveCount(1); + expect($user->groups->first())->toBeInstanceOf(UserGroup::class); + }); + + it('parses certifications from response', function () { + $response = (object) [ + 'id' => 1, + 'certifications' => [ + (object) [ + 'course_id' => 1, + 'course_name' => 'Test Course', + 'unique_id' => 'cert-123', + 'issued_date' => '2021-01-01', + ], + ], + ]; + + $user = User::fromResponse($response); + + expect($user->certifications)->toHaveCount(1); + expect($user->certifications->first())->toBeInstanceOf(UserCertification::class); + }); + + it('has readonly properties', function () { + $user = new User(id: 1, username: 'test'); + + expect(fn () => $user->id = 2)->toThrow(Error::class); + }); +}); diff --git a/tests/Unit/Data/ListResponseTest.php b/tests/Unit/Data/ListResponseTest.php new file mode 100644 index 0000000..410fa69 --- /dev/null +++ b/tests/Unit/Data/ListResponseTest.php @@ -0,0 +1,58 @@ +get())->toBeInstanceOf(Collection::class); + expect($response->get())->toHaveCount(0); + }); + + it('can be instantiated with an empty array', function () { + $response = new ListResponse([]); + + expect($response->get())->toBeInstanceOf(Collection::class); + expect($response->get())->toHaveCount(0); + }); + + it('can be instantiated with an array of items', function () { + $items = ['item1', 'item2', 'item3']; + $response = new ListResponse($items); + + expect($response->get())->toBeInstanceOf(Collection::class); + expect($response->get())->toHaveCount(3); + expect($response->get()->first())->toBe('item1'); + }); + + it('can be instantiated with a Collection', function () { + $collection = collect(['item1', 'item2']); + $response = new ListResponse($collection); + + expect($response->get())->toBeInstanceOf(Collection::class); + expect($response->get())->toHaveCount(2); + expect($response->get())->toBe($collection); + }); + + it('returns the same collection on multiple get() calls', function () { + $response = new ListResponse(['item1', 'item2']); + + $firstCall = $response->get(); + $secondCall = $response->get(); + + expect($firstCall)->toBe($secondCall); + }); + + it('handles objects in the array', function () { + $items = [ + (object) ['id' => 1, 'name' => 'Item 1'], + (object) ['id' => 2, 'name' => 'Item 2'], + ]; + $response = new ListResponse($items); + + expect($response->get())->toHaveCount(2); + expect($response->get()->first()->id)->toBe(1); + }); +}); diff --git a/tests/Unit/Support/ApiParsingTest.php b/tests/Unit/Support/ApiParsingTest.php new file mode 100644 index 0000000..7b682af --- /dev/null +++ b/tests/Unit/Support/ApiParsingTest.php @@ -0,0 +1,110 @@ +toBeFalse(); + }); + + it('returns true for boolean true', function () { + expect(ApiParsing::parseBoolean(true))->toBeTrue(); + }); + + it('returns false for boolean false', function () { + expect(ApiParsing::parseBoolean(false))->toBeFalse(); + }); + + it('returns true for string "1"', function () { + expect(ApiParsing::parseBoolean('1'))->toBeTrue(); + }); + + it('returns false for string "0"', function () { + expect(ApiParsing::parseBoolean('0'))->toBeFalse(); + }); + + it('returns true for integer 1', function () { + expect(ApiParsing::parseBoolean(1))->toBeTrue(); + }); + + it('returns false for integer 0', function () { + expect(ApiParsing::parseBoolean(0))->toBeFalse(); + }); + + it('returns true for non-zero string', function () { + expect(ApiParsing::parseBoolean('yes'))->toBeTrue(); + }); + + it('returns true for non-empty string', function () { + expect(ApiParsing::parseBoolean('active'))->toBeTrue(); + }); + }); + + describe('parseTimestamp()', function () { + it('returns null for empty timestamp', function () { + expect(ApiParsing::parseTimestamp(null))->toBeNull(); + expect(ApiParsing::parseTimestamp(''))->toBeNull(); + expect(ApiParsing::parseTimestamp(0))->toBeNull(); + }); + + it('parses valid unix timestamp', function () { + $timestamp = 1609459200; // 2021-01-01 00:00:00 UTC + $result = ApiParsing::parseTimestamp($timestamp); + + expect($result)->toBeInstanceOf(CarbonInterface::class); + expect($result->year)->toBe(2021); + expect($result->month)->toBe(1); + expect($result->day)->toBe(1); + }); + + it('parses string timestamp', function () { + $timestamp = '1609459200'; + $result = ApiParsing::parseTimestamp($timestamp); + + expect($result)->toBeInstanceOf(CarbonInterface::class); + expect($result->year)->toBe(2021); + }); + }); + + describe('parseDateTime()', function () { + it('returns null for empty datetime', function () { + expect(ApiParsing::parseDateTime(null))->toBeNull(); + expect(ApiParsing::parseDateTime(''))->toBeNull(); + }); + + it('parses valid datetime string', function () { + $datetime = '2021-06-15 14:30:00'; + $result = ApiParsing::parseDateTime($datetime); + + expect($result)->toBeInstanceOf(CarbonInterface::class); + expect($result->year)->toBe(2021); + expect($result->month)->toBe(6); + expect($result->day)->toBe(15); + expect($result->hour)->toBe(14); + expect($result->minute)->toBe(30); + }); + + it('parses date-only string', function () { + $datetime = '2021-12-25'; + $result = ApiParsing::parseDateTime($datetime); + + expect($result)->toBeInstanceOf(CarbonInterface::class); + expect($result->year)->toBe(2021); + expect($result->month)->toBe(12); + expect($result->day)->toBe(25); + }); + + it('parses ISO 8601 format', function () { + $datetime = '2021-06-15T14:30:00Z'; + $result = ApiParsing::parseDateTime($datetime); + + expect($result)->toBeInstanceOf(CarbonInterface::class); + expect($result->year)->toBe(2021); + expect($result->month)->toBe(6); + expect($result->day)->toBe(15); + }); + }); +});