diff --git a/config/level-up.php b/config/level-up.php index f6e6b98..82fe3ec 100644 --- a/config/level-up.php +++ b/config/level-up.php @@ -26,10 +26,17 @@ |-------------------------------------------------------------------------- | | This value is the foreign key that will be used to relate the Experience model to the User model. + | + | 'foreign_key_type' controls the DB column type used for the user FK on + | every package table. Set to 'uuid' or 'ulid' if your host User model + | uses HasUuids / HasUlids. Leave as 'bigint' for standard auto-increment + | user IDs. This only affects fresh migrations; existing installs keep + | whichever column type they originally migrated with. | */ 'user' => [ 'foreign_key' => 'user_id', + 'foreign_key_type' => 'bigint', 'model' => App\Models\User::class, 'users_table' => 'users', ], diff --git a/database/migrations/create_achievement_user_pivot_table.php.stub b/database/migrations/create_achievement_user_pivot_table.php.stub index 66004a2..426d74e 100644 --- a/database/migrations/create_achievement_user_pivot_table.php.stub +++ b/database/migrations/create_achievement_user_pivot_table.php.stub @@ -3,13 +3,14 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use LevelUp\Experience\Support\UserForeignKey; return new class extends Migration { public function up(): void { Schema::create('achievement_user', function (Blueprint $table) { $table->entityId(); - $table->foreignId(column: config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + UserForeignKey::on($table)->constrained(config('level-up.user.users_table')); $table->entityForeignId(column: 'achievement_id')->constrained(); $table->integer(column: 'progress')->nullable()->index(); $table->timestamps(); diff --git a/database/migrations/create_challenge_user_table.php.stub b/database/migrations/create_challenge_user_table.php.stub index 0007819..84df060 100644 --- a/database/migrations/create_challenge_user_table.php.stub +++ b/database/migrations/create_challenge_user_table.php.stub @@ -3,13 +3,14 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use LevelUp\Experience\Support\UserForeignKey; return new class extends Migration { public function up(): void { Schema::create('challenge_user', function (Blueprint $table) { $table->entityId(); - $table->foreignId(column: config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + UserForeignKey::on($table)->constrained(config('level-up.user.users_table')); $table->entityForeignId(column: 'challenge_id')->constrained(); $table->json(column: 'progress')->nullable(); $table->timestamp(column: 'completed_at')->nullable(); diff --git a/database/migrations/create_experience_audits_table.php.stub b/database/migrations/create_experience_audits_table.php.stub index d7ab9bb..0afd42d 100644 --- a/database/migrations/create_experience_audits_table.php.stub +++ b/database/migrations/create_experience_audits_table.php.stub @@ -3,13 +3,14 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use LevelUp\Experience\Support\UserForeignKey; return new class extends Migration { public function up(): void { Schema::create('experience_audits', function (Blueprint $table) { $table->entityId(); - $table->foreignId(config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + UserForeignKey::on($table)->constrained(config('level-up.user.users_table')); $table->integer('points')->index(); $table->boolean('levelled_up')->default(false); $table->integer('level_to')->nullable(); diff --git a/database/migrations/create_experiences_table.php.stub b/database/migrations/create_experiences_table.php.stub index dbafee3..be993ee 100644 --- a/database/migrations/create_experiences_table.php.stub +++ b/database/migrations/create_experiences_table.php.stub @@ -3,6 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use LevelUp\Experience\Support\UserForeignKey; return new class extends Migration { @@ -10,7 +11,7 @@ return new class extends Migration { Schema::create(config('level-up.table'), function (Blueprint $table) { $table->entityId(); - $table->foreignId(config('level-up.user.foreign_key'))->constrained(config('level-up.user.users_table')); + UserForeignKey::on($table)->constrained(config('level-up.user.users_table')); $table->entityForeignId('level_id')->constrained(); $table->integer('experience_points')->default(0)->index(); $table->timestamps(); diff --git a/database/migrations/create_streak_histories_table.php.stub b/database/migrations/create_streak_histories_table.php.stub index 3ee3675..b4ace7d 100644 --- a/database/migrations/create_streak_histories_table.php.stub +++ b/database/migrations/create_streak_histories_table.php.stub @@ -3,6 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use LevelUp\Experience\Support\UserForeignKey; return new class extends Migration { @@ -10,7 +11,7 @@ return new class extends Migration { Schema::create(table: 'streak_histories', callback: function (Blueprint $table) { $table->entityId(); - $table->foreignId(column: config(key: 'level-up.user.foreign_key'))->constrained(table: config(key: 'level-up.user.users_table'))->cascadeOnDelete(); + UserForeignKey::on(table: $table)->constrained(table: config(key: 'level-up.user.users_table'))->cascadeOnDelete(); $table->entityForeignId(column: 'activity_id')->constrained(table: 'streak_activities'); $table->integer(column: 'count')->default(value: 1); $table->timestamp(column: 'started_at'); diff --git a/database/migrations/create_streaks_table.php.stub b/database/migrations/create_streaks_table.php.stub index 62060aa..0f54809 100644 --- a/database/migrations/create_streaks_table.php.stub +++ b/database/migrations/create_streaks_table.php.stub @@ -3,13 +3,14 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use LevelUp\Experience\Support\UserForeignKey; return new class extends Migration { public function up(): void { Schema::create('streaks', function (Blueprint $table) { $table->entityId(); - $table->foreignId(column: config('level-up.user.foreign_key'))->constrained()->onDelete('cascade'); + UserForeignKey::on($table)->constrained()->onDelete('cascade'); $table->entityForeignId(column: 'activity_id')->constrained('streak_activities')->onDelete('cascade'); $table->integer(column: 'count')->default(1); $table->timestamp(column: 'activity_at'); diff --git a/rector.php b/rector.php index 5ac21b8..44deab1 100644 --- a/rector.php +++ b/rector.php @@ -5,6 +5,7 @@ use Rector\Config\RectorConfig; use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector; use RectorLaravel\Rector\Class_\AddHasFactoryToModelsRector; +use RectorLaravel\Rector\Class_\TablePropertyToTableAttributeRector; use RectorLaravel\Rector\ClassMethod\MakeModelAttributesAndScopesProtectedRector; use RectorLaravel\Rector\FuncCall\RemoveDumpDataDeadCodeRector; use RectorLaravel\Set\LaravelSetList; @@ -21,6 +22,7 @@ AddOverrideAttributeToOverriddenMethodsRector::class, AddHasFactoryToModelsRector::class, MakeModelAttributesAndScopesProtectedRector::class, + TablePropertyToTableAttributeRector::class, ]) ->withPreparedSets( deadCode: true, diff --git a/src/Support/UserForeignKey.php b/src/Support/UserForeignKey.php new file mode 100644 index 0000000..c805ab0 --- /dev/null +++ b/src/Support/UserForeignKey.php @@ -0,0 +1,27 @@ + $table->foreignId($column), + 'uuid' => $table->foreignUuid($column), + 'ulid' => $table->foreignUlid($column), + default => throw new InvalidArgumentException( + "Unknown level-up.user.foreign_key_type [{$type}]. Expected 'bigint', 'uuid', or 'ulid'." + ), + }; + } +} diff --git a/tests/Fixtures/UlidUser.php b/tests/Fixtures/UlidUser.php new file mode 100644 index 0000000..c81d80f --- /dev/null +++ b/tests/Fixtures/UlidUser.php @@ -0,0 +1,32 @@ + 1, 'next_level_experience' => null], + ['level' => 2, 'next_level_experience' => 100], + ['level' => 3, 'next_level_experience' => 250], + ['level' => 4, 'next_level_experience' => 400], + ['level' => 5, 'next_level_experience' => 600], + ); + + return collect($levels)->keyBy('level'); +}; uses(TestCase::class, RefreshDatabase::class) - ->beforeEach(hook: function (): void { + ->beforeEach(function () use ($seedLevels): void { $this->user = new User; $this->user->fill(attributes: [ @@ -18,17 +35,45 @@ 'email_verified_at' => now(), ])->save(); - $levels = Level::add( - ['level' => 1, 'next_level_experience' => null], - ['level' => 2, 'next_level_experience' => 100], - ['level' => 3, 'next_level_experience' => 250], - ['level' => 4, 'next_level_experience' => 400], - ['level' => 5, 'next_level_experience' => 600], - ); + $this->levels = $seedLevels(); + }) + ->in(__DIR__.'/Concerns', __DIR__.'/Listeners', __DIR__.'/Models', __DIR__.'/Services'); + +uses(UuidUserTestCase::class, RefreshDatabase::class) + ->beforeEach(function () use ($seedLevels): void { + config()->set(key: 'level-up.multiplier.enabled', value: false); + config()->set(key: 'level-up.tiers.enabled', value: false); + + $this->user = new UuidUser; + + $this->user->fill(attributes: [ + 'name' => 'UUID User', + 'email' => 'uuid@example.test', + 'password' => bcrypt(value: 'password'), + 'email_verified_at' => now(), + ])->save(); + + $this->levels = $seedLevels(); + }) + ->in(__DIR__.'/Uuid'); + +uses(UlidUserTestCase::class, RefreshDatabase::class) + ->beforeEach(function () use ($seedLevels): void { + config()->set(key: 'level-up.multiplier.enabled', value: false); + config()->set(key: 'level-up.tiers.enabled', value: false); + + $this->user = new UlidUser; + + $this->user->fill(attributes: [ + 'name' => 'ULID User', + 'email' => 'ulid@example.test', + 'password' => bcrypt(value: 'password'), + 'email_verified_at' => now(), + ])->save(); - $this->levels = collect($levels)->keyBy('level'); + $this->levels = $seedLevels(); }) - ->in(__DIR__); + ->in(__DIR__.'/Ulid'); expect()->extend(name: 'toBeCarbon', extend: function (string $expected, ?string $format = null): object { if ($format === null) { diff --git a/tests/TestCase.php b/tests/TestCase.php index 583e727..0d8cede 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,6 +3,7 @@ namespace LevelUp\Experience\Tests; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use LevelUp\Experience\LevelUpServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; @@ -33,11 +34,12 @@ protected function getPackageProviders($app): array protected function getEnvironmentSetUp($app): void { config()->set('database.default', 'testing'); - config()->set('level-up.user.model', \LevelUp\Experience\Tests\Fixtures\User::class); config()->set('level-up.entities.id_type', env('LEVELUP_TEST_KEY_TYPE', 'bigint')); - Schema::create('users', function ($table): void { - $table->id(); + $this->defineUserConfig(); + + Schema::create('users', function (Blueprint $table): void { + $this->createUserIdColumn($table); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); @@ -78,4 +80,19 @@ protected function defineDatabaseMigrations(): void $migration->up(); } } + + protected function defineUserConfig(): void + { + config()->set('level-up.user.model', \LevelUp\Experience\Tests\Fixtures\User::class); + } + + protected function createUserIdColumn(Blueprint $table): void + { + match (config('level-up.user.foreign_key_type', 'bigint')) { + 'bigint' => $table->id(), + 'uuid' => $table->uuid('id')->primary(), + 'ulid' => $table->ulid('id')->primary(), + default => $table->id(), + }; + } } diff --git a/tests/Ulid/UserFlowsTest.php b/tests/Ulid/UserFlowsTest.php new file mode 100644 index 0000000..d3a4351 --- /dev/null +++ b/tests/Ulid/UserFlowsTest.php @@ -0,0 +1,79 @@ +user->addPoints(amount: 30); + + expect($experience->experience_points)->toBe(30); + expect($this->user->getPoints())->toBe(30); + expect($this->user->getLevel())->toBe(1); + + $this->user->addPoints(amount: 80); + + expect($this->user->getPoints())->toBe(110); + + $this->user->deductPoints(amount: 10); + + expect($this->user->getPoints())->toBe(100); + + $this->user->setPoints(amount: 250); + + expect($this->user->getPoints())->toBe(250); + + $this->user->levelUp(to: 2); + + expect($this->user->getLevel())->toBe(2); +}); + +test('streak flows work with a ULID-keyed user', function (): void { + $activity = Activity::query()->create([ + 'name' => 'ulid-activity', + 'description' => 'ulid streak test', + ]); + + $this->user->recordStreak(activity: $activity); + + expect($this->user->getCurrentStreakCount(activity: $activity))->toBe(1); + expect($this->user->hasStreakToday(activity: $activity))->toBeTrue(); + + $this->assertDatabaseHas('streaks', [ + 'user_id' => $this->user->id, + 'activity_id' => $activity->id, + ]); + + $this->user->freezeStreak(activity: $activity, days: 2); + + expect($this->user->isStreakFrozen(activity: $activity))->toBeTrue(); + + $this->user->unFreezeStreak(activity: $activity); + + expect($this->user->isStreakFrozen(activity: $activity))->toBeFalse(); +}); + +test('challenge flows work with a ULID-keyed user', function (): void { + $challenge = Challenge::factory()->create([ + 'conditions' => [['type' => 'points_earned', 'amount' => 50]], + 'rewards' => [['type' => 'points', 'amount' => 10]], + ]); + + $this->user->enrollInChallenge(challenge: $challenge); + + $this->assertDatabaseHas('challenge_user', [ + 'user_id' => $this->user->id, + 'challenge_id' => $challenge->id, + ]); + + expect($this->user->activeChallenges()->count())->toBe(1); + expect($this->user->completedChallenges()->count())->toBe(0); + + $this->user->unenrollFromChallenge(challenge: $challenge); + + $this->assertDatabaseMissing('challenge_user', [ + 'user_id' => $this->user->id, + 'challenge_id' => $challenge->id, + ]); +}); diff --git a/tests/UlidUserTestCase.php b/tests/UlidUserTestCase.php new file mode 100644 index 0000000..dc2ec86 --- /dev/null +++ b/tests/UlidUserTestCase.php @@ -0,0 +1,16 @@ +set('level-up.user.model', UlidUser::class); + config()->set('level-up.user.foreign_key_type', 'ulid'); + } +} diff --git a/tests/Uuid/UserFlowsTest.php b/tests/Uuid/UserFlowsTest.php new file mode 100644 index 0000000..e2b67b8 --- /dev/null +++ b/tests/Uuid/UserFlowsTest.php @@ -0,0 +1,79 @@ +user->addPoints(amount: 30); + + expect($experience->experience_points)->toBe(30); + expect($this->user->getPoints())->toBe(30); + expect($this->user->getLevel())->toBe(1); + + $this->user->addPoints(amount: 80); + + expect($this->user->getPoints())->toBe(110); + + $this->user->deductPoints(amount: 10); + + expect($this->user->getPoints())->toBe(100); + + $this->user->setPoints(amount: 250); + + expect($this->user->getPoints())->toBe(250); + + $this->user->levelUp(to: 2); + + expect($this->user->getLevel())->toBe(2); +}); + +test('streak flows work with a UUID-keyed user', function (): void { + $activity = Activity::query()->create([ + 'name' => 'uuid-activity', + 'description' => 'uuid streak test', + ]); + + $this->user->recordStreak(activity: $activity); + + expect($this->user->getCurrentStreakCount(activity: $activity))->toBe(1); + expect($this->user->hasStreakToday(activity: $activity))->toBeTrue(); + + $this->assertDatabaseHas('streaks', [ + 'user_id' => $this->user->id, + 'activity_id' => $activity->id, + ]); + + $this->user->freezeStreak(activity: $activity, days: 2); + + expect($this->user->isStreakFrozen(activity: $activity))->toBeTrue(); + + $this->user->unFreezeStreak(activity: $activity); + + expect($this->user->isStreakFrozen(activity: $activity))->toBeFalse(); +}); + +test('challenge flows work with a UUID-keyed user', function (): void { + $challenge = Challenge::factory()->create([ + 'conditions' => [['type' => 'points_earned', 'amount' => 50]], + 'rewards' => [['type' => 'points', 'amount' => 10]], + ]); + + $this->user->enrollInChallenge(challenge: $challenge); + + $this->assertDatabaseHas('challenge_user', [ + 'user_id' => $this->user->id, + 'challenge_id' => $challenge->id, + ]); + + expect($this->user->activeChallenges()->count())->toBe(1); + expect($this->user->completedChallenges()->count())->toBe(0); + + $this->user->unenrollFromChallenge(challenge: $challenge); + + $this->assertDatabaseMissing('challenge_user', [ + 'user_id' => $this->user->id, + 'challenge_id' => $challenge->id, + ]); +}); diff --git a/tests/UuidUserTestCase.php b/tests/UuidUserTestCase.php new file mode 100644 index 0000000..29a7e90 --- /dev/null +++ b/tests/UuidUserTestCase.php @@ -0,0 +1,16 @@ +set('level-up.user.model', UuidUser::class); + config()->set('level-up.user.foreign_key_type', 'uuid'); + } +}