diff --git a/app-modules/onboarding/src/DTOs/GitChallengeDTO.php b/app-modules/onboarding/src/DTOs/GitChallengeDTO.php index 9206608b..230348f3 100644 --- a/app-modules/onboarding/src/DTOs/GitChallengeDTO.php +++ b/app-modules/onboarding/src/DTOs/GitChallengeDTO.php @@ -9,18 +9,15 @@ use Illuminate\Validation\ValidationException; /** - * Payload da conclusão do desafio do Git. - * - * TODO: #353 aperta o contrato com os campos do PR aprovado - * (repo, pr_number, approved_at) quando o evento GithubPullRequestApproved existir. + * Payload da conclusão do desafio do Git: os dados do PR aprovado que + * completou o step (ver He4rt\IntegrationGithub\Events\GithubPullRequestApproved). */ final readonly class GitChallengeDTO implements OnboardingStepDTO { - /** - * @param array $data - */ public function __construct( - public array $data = [], + public string $repo = '', + public int $prNumber = 0, + public string $approvedAt = '', ) {} /** @@ -29,13 +26,19 @@ public function __construct( public function validate(array $payload): static { $validated = Validator::make($payload, [ - 'data' => ['sometimes', 'array'], + 'repo' => ['required', 'string'], + 'pr_number' => ['required', 'integer', 'min:1'], + 'approved_at' => ['required', 'date'], ])->validate(); - $data = $validated['data'] ?? []; + $repo = $validated['repo']; + $prNumber = $validated['pr_number']; + $approvedAt = $validated['approved_at']; return new self( - data: is_array($data) ? $data : [], + repo: is_string($repo) ? $repo : '', + prNumber: is_numeric($prNumber) ? (int) $prNumber : 0, + approvedAt: is_string($approvedAt) ? $approvedAt : '', ); } @@ -44,6 +47,10 @@ public function validate(array $payload): static */ public function toArray(): array { - return $this->data; + return [ + 'repo' => $this->repo, + 'pr_number' => $this->prNumber, + 'approved_at' => $this->approvedAt, + ]; } } diff --git a/app-modules/onboarding/src/Listeners/CompleteGitChallengeOnPullRequestApproval.php b/app-modules/onboarding/src/Listeners/CompleteGitChallengeOnPullRequestApproval.php new file mode 100644 index 00000000..3620b29e --- /dev/null +++ b/app-modules/onboarding/src/Listeners/CompleteGitChallengeOnPullRequestApproval.php @@ -0,0 +1,82 @@ +isChallengeRepo($event->repo)) { + return; + } + + $onboarding = $this->pendingGitChallengeOnboarding($event->author_login); + + if (!$onboarding instanceof Onboarding) { + return; + } + + $this->advanceStep->handle($onboarding, [ + 'repo' => $event->repo, + 'pr_number' => $event->pr_number, + 'approved_at' => $event->approved_at, + ]); + } + + private function isChallengeRepo(string $repo): bool + { + return GithubRepository::query() + ->enabled() + ->where('full_name', $repo) + ->where('purpose', PurposeType::Challenge) + ->exists(); + } + + private function pendingGitChallengeOnboarding(string $authorLogin): ?Onboarding + { + $identity = ExternalIdentity::query() + ->where('provider', IdentityProvider::GitHub) + ->whereNotNull('connected_at') + ->whereNull('disconnected_at') + ->where('metadata->username', $authorLogin) + ->first(); + + if (!$identity instanceof ExternalIdentity || !$identity->user instanceof User) { + return null; + } + + return Onboarding::query() + ->whereBelongsTo($identity->user) + ->where('type', OnboardingType::Squads) + ->where('status', OnboardingStatus::InProgress) + ->whereHas('steps', function (Builder $query): void { + $query->where('step_key', 'git_challenge') + ->where('status', OnboardingStepStatus::Pending); + }) + ->first(); + } +} diff --git a/app-modules/onboarding/src/OnboardingServiceProvider.php b/app-modules/onboarding/src/OnboardingServiceProvider.php index 171e2466..f0af3925 100644 --- a/app-modules/onboarding/src/OnboardingServiceProvider.php +++ b/app-modules/onboarding/src/OnboardingServiceProvider.php @@ -4,8 +4,11 @@ namespace He4rt\Onboarding; +use He4rt\IntegrationGithub\Events\GithubPullRequestApproved; use He4rt\Onboarding\Contracts\OnboardingCompletionGate; +use He4rt\Onboarding\Listeners\CompleteGitChallengeOnPullRequestApproval; use He4rt\Onboarding\Services\EloquentOnboardingCompletionGate; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class OnboardingServiceProvider extends ServiceProvider @@ -18,5 +21,7 @@ public function register(): void public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + + Event::listen(GithubPullRequestApproved::class, [CompleteGitChallengeOnPullRequestApproval::class, 'handle']); } } diff --git a/app-modules/onboarding/tests/Feature/CompleteGitChallengeOnPullRequestApprovalTest.php b/app-modules/onboarding/tests/Feature/CompleteGitChallengeOnPullRequestApprovalTest.php new file mode 100644 index 00000000..e64f04d3 --- /dev/null +++ b/app-modules/onboarding/tests/Feature/CompleteGitChallengeOnPullRequestApprovalTest.php @@ -0,0 +1,151 @@ +create(); + Onboarding::factory()->for($user)->completed()->create(); + + ExternalIdentity::factory()->create([ + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::GitHub, + 'connected_at' => now(), + 'disconnected_at' => null, + 'metadata' => ['username' => $githubLogin], + ]); + + $onboarding = resolve(StartOnboarding::class)->handle($user, OnboardingType::Squads); + + resolve(AdvanceStep::class)->handle($onboarding, ['data' => ['terms' => true]]); + + $onboarding->refresh(); + + return ['user' => $user, 'onboarding' => $onboarding]; +} + +test('aprovação do PR em repo de challenge conclui o git_challenge e libera APTO', function (): void { + ['user' => $user, 'onboarding' => $onboarding] = linkedUserAtGitChallenge(); + + GithubRepository::factory()->create([ + 'full_name' => 'he4rt/git-challenge', + 'purpose' => PurposeType::Challenge, + ]); + + event(new GithubPullRequestApproved( + author_login: 'maria', + repo: 'he4rt/git-challenge', + pr_number: 42, + approved_at: '2026-06-01T12:00:00Z', + )); + + $onboarding->refresh(); + $step = $onboarding->steps()->where('step_key', 'git_challenge')->sole(); + + expect($step->status)->toBe(OnboardingStepStatus::Done) + ->and($step->data)->toBe([ + 'repo' => 'he4rt/git-challenge', + 'pr_number' => 42, + 'approved_at' => '2026-06-01T12:00:00Z', + ]) + ->and($onboarding->status)->toBe(OnboardingStatus::Completed) + ->and(resolve(OnboardingCompletionGate::class)->isCompleted($user, OnboardingType::Squads)) + ->toBeTrue(); +}); + +test('aprovação do PR em repo que não é de challenge é ignorada', function (): void { + ['onboarding' => $onboarding] = linkedUserAtGitChallenge(); + + GithubRepository::factory()->create([ + 'full_name' => 'he4rt/heartdevs.com', + 'purpose' => PurposeType::Contributions, + ]); + + event(new GithubPullRequestApproved( + author_login: 'maria', + repo: 'he4rt/heartdevs.com', + pr_number: 42, + approved_at: '2026-06-01T12:00:00Z', + )); + + $onboarding->refresh(); + $step = $onboarding->steps()->where('step_key', 'git_challenge')->sole(); + + expect($step->status)->toBe(OnboardingStepStatus::Pending) + ->and($onboarding->status)->toBe(OnboardingStatus::InProgress); +}); + +test('aprovação de autor sem GitHub vinculado é ignorada sem lançar exceção', function (): void { + ['onboarding' => $onboarding] = linkedUserAtGitChallenge(githubLogin: 'maria'); + + GithubRepository::factory()->create([ + 'full_name' => 'he4rt/git-challenge', + 'purpose' => PurposeType::Challenge, + ]); + + event(new GithubPullRequestApproved( + author_login: 'alguem-sem-conta', + repo: 'he4rt/git-challenge', + pr_number: 42, + approved_at: '2026-06-01T12:00:00Z', + )); + + $onboarding->refresh(); + $step = $onboarding->steps()->where('step_key', 'git_challenge')->sole(); + + expect($step->status)->toBe(OnboardingStepStatus::Pending) + ->and($onboarding->status)->toBe(OnboardingStatus::InProgress); +}); + +test('aprovação chegando antes do onboarding alcançar o step git_challenge não avança o step form', function (): void { + $user = User::factory()->create(); + Onboarding::factory()->for($user)->completed()->create(); + + ExternalIdentity::factory()->create([ + 'model_type' => $user->getMorphClass(), + 'model_id' => $user->id, + 'provider' => IdentityProvider::GitHub, + 'connected_at' => now(), + 'disconnected_at' => null, + 'metadata' => ['username' => 'maria'], + ]); + + // Onboarding ainda no step "form" — git_challenge nem foi criado. + $onboarding = resolve(StartOnboarding::class)->handle($user, OnboardingType::Squads); + + GithubRepository::factory()->create([ + 'full_name' => 'he4rt/git-challenge', + 'purpose' => PurposeType::Challenge, + ]); + + event(new GithubPullRequestApproved( + author_login: 'maria', + repo: 'he4rt/git-challenge', + pr_number: 42, + approved_at: '2026-06-01T12:00:00Z', + )); + + $onboarding->refresh(); + $step = $onboarding->steps()->sole(); + + expect($step->step_key)->toBe('form') + ->and($step->status)->toBe(OnboardingStepStatus::Pending); +}); diff --git a/app-modules/onboarding/tests/Feature/SquadsOnboardingFlowTest.php b/app-modules/onboarding/tests/Feature/SquadsOnboardingFlowTest.php index ffb80882..a5a826af 100644 --- a/app-modules/onboarding/tests/Feature/SquadsOnboardingFlowTest.php +++ b/app-modules/onboarding/tests/Feature/SquadsOnboardingFlowTest.php @@ -128,7 +128,11 @@ resolve(AdvanceStep::class)->handle($onboarding, ['data' => ['terms' => true]]); - resolve(AdvanceStep::class)->handle($onboarding, []); + resolve(AdvanceStep::class)->handle($onboarding, [ + 'repo' => 'he4rt/git-challenge', + 'pr_number' => 42, + 'approved_at' => now()->toIso8601String(), + ]); $onboarding->refresh(); $step = $onboarding->steps()->where('step_key', 'git_challenge')->sole();