diff --git a/app/Models/User.php b/app/Models/User.php index 708f85646..da1395d29 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,8 @@ use App\Enums\LivePlatform; use App\Enums\Log; use App\Enums\RemotesFrom; +use App\Enums\UnixRank; +use App\Queries\UserAchievementsQuery; use Carbon\CarbonInterface; use Database\Factories\UserFactory; use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract; @@ -252,13 +254,14 @@ public function commits(): HasMany } /** - * Ten followers are enough, so the check stops at the tenth instead of counting every follower. + * Requires daemon-level public XP. Stop at the tenth follower instead of counting every follower. */ public function hasGoodReputation(): bool { return $this->created_at->lt(now()->subHour()) && $this->commits()->approved()->exists() - && $this->followers()->skip(9)->exists(); + && $this->followers()->skip(9)->exists() + && resolve(UserAchievementsQuery::class)->get($this, null)['xp'] >= UnixRank::Daemon->minXp(); } /** diff --git a/tests/Unit/Actions/CreateCommitTest.php b/tests/Unit/Actions/CreateCommitTest.php index 7ed3b12bf..60af5ff30 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -16,6 +16,7 @@ use App\Models\LinkPreview; use App\Models\Notification; use App\Models\User; +use App\Queries\UserAchievementsQuery; use App\Services\TheAlgorithm; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; @@ -68,10 +69,10 @@ followersOf($user, $followers); if ($previousStatus instanceof CommitStatus) { - $previous = Commit::factory()->for($user)->create(['status' => $previousStatus]); + $previous = Commit::factory()->for($user)->count(4)->create(['status' => $previousStatus]); if ($deleted) { - $previous->delete(); + $previous->each->delete(); } } @@ -99,6 +100,41 @@ 'deleted approved history' => [3601, 10, CommitStatus::Approved, true, false], ]); +it('requires at least daemon public xp to skip ai review', function (int $commits, int $starsGiven, int $starsReceived, int $xp, bool $approved): void { + config(['services.openai.key' => 'sk-test']); + Queue::fake(); + + $user = User::factory()->create(['created_at' => now()->subHours(2)]); + followersOf($user, 10); + $history = Commit::factory()->for($user)->count($commits)->create(); + $user->starredCommits()->attach(Commit::factory()->count($starsGiven)->create()->modelKeys()); + foreach (User::factory()->count($starsReceived)->create() as $stargazer) { + $stargazer->starredCommits()->attach($history->first()); + } + + // Private activity must not boost moderation eligibility, even when the queued job rechecks it. + Commit::factory()->for($user)->pending()->count(5)->create(); + Commit::factory()->for($user)->rejected()->count(5)->create(); + $deleted = Commit::factory()->for($user)->create(); + $deleted->delete(); + + expect(resolve(UserAchievementsQuery::class)->get($user, null)['xp'])->toBe($xp); + + $commit = resolve(CreateCommit::class)->handle($user, 'A new commit'); + + expect($commit->status)->toBe($approved ? CommitStatus::Approved : CommitStatus::Pending); + + if ($approved) { + Queue::assertNotPushed(ModerateCommit::class); + } else { + Queue::assertPushed(ModerateCommit::class); + } +})->with([ + 'below daemon' => [3, 2, 0, 49, false], + 'exactly daemon' => [3, 0, 1, 50, true], + 'higher rank' => [14, 0, 0, 155, true], +]); + it('notifies the parent author about replies of admins', function (): void { config(['app.admins' => ['nunomaduro']]); @@ -476,6 +512,7 @@ $reported = Commit::factory()->for($user)->create(); if ($reputable) { followersOf($user, 10); + Commit::factory()->for($user)->count(3)->create(); } CommitReport::factory()->for($reported)->create(); diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index b8049202f..d2c35cb5e 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -373,11 +373,21 @@ function reviewResponses(array $responses = []): Closure expect($commit->refresh()->ai_decision)->toBeNull(); }); -it('approves queued commits without calling the ai when the author has good reputation', function (): void { - Http::fake(); +it('only skips ai review for queued commits with daemon public xp', function (bool $daemon): void { + Http::fake([ + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); $user = User::factory()->create(['created_at' => now()->subHour()->subSecond()]); - Commit::factory()->for($user)->create(); + $history = Commit::factory()->for($user)->count(3)->create(); + if ($daemon) { + User::factory()->create()->starredCommits()->attach($history->first()); + } else { + $user->starredCommits()->attach(Commit::factory()->count(2)->create()->modelKeys()); + } + + Commit::factory()->for($user)->rejected()->count(5)->create(); $commit = Commit::factory()->for($user)->pending()->create(); followersOf($user, 10); @@ -385,10 +395,14 @@ function reviewResponses(array $responses = []): Closure expect($commit->refresh()) ->status->toBe(CommitStatus::Approved) - ->ai_decision->toBeNull(); + ->ai_decision->toBe($daemon ? null : CommitStatus::Approved); - Http::assertNothingSent(); -}); + if ($daemon) { + Http::assertNothingSent(); + } else { + Http::assertSentCount(2); + } +})->with([false, true]); it('skips commits that were deleted in the meantime', function (): void { Http::fake(); @@ -469,6 +483,7 @@ function reviewResponses(array $responses = []): Closure $user = User::factory()->create(['created_at' => now()->subHours(2)]); $reported = Commit::factory()->for($user)->create(); followersOf($user, 10); + Commit::factory()->for($user)->count(3)->create(); $commit = Commit::factory()->for($user)->pending()->create(); CommitReport::factory()->for($reported)->create();