Commit 0d3d6a5c authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(training): make cancelling an enrolment actually work

EnrollmentCancelled takes (enrolment, reason, actor). The service dispatched
(enrolment, actor), so PHP put the User where the string reason was expected and
found no third argument. dispatch() constructs the event before the dispatcher
ever sees it, so this threw an ArgumentCountError on the line itself — inside
the service's own DB::transaction, which then rolled the whole cancellation
back.

Every cancellation on every tenant failed this way. The row stayed active, the
group count stayed high, and the nightly renewal run kept billing an enrolment
the desk believed it had ended. The only symptom was an error page, and being
ShouldDispatchAfterCommit made no difference — the object is built at dispatch,
not at commit.

Found while removing three test participants from a live tenant: the cleanup
could not cancel their enrolments.

Pinned by a test that cancels a real enrolment against a restored tenant and
asserts it reaches 'cancelled' — which is the whole proof, since a wrong
argument list cannot get that far. Event::fake is deliberately not used: it
would assert nothing here, because the event is correctly withheld until a
commit the test rolls back. A second test pins the event's parameter names so
the contract is caught even with no tenant to run against.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 25e2be96
......@@ -291,7 +291,12 @@ public function cancel(Enrollment $enrollment, string $reason, User $actor): Enr
$group = $enrollment->group;
$this->groupService->decrementCount($group);
EnrollmentCancelled::dispatch($enrollment, $actor);
// The event takes (enrolment, reason, actor). Dropping the reason
// put the User where a string was expected and left the third
// argument missing, so dispatch() threw an ArgumentCountError before
// the transaction could commit — every cancellation, on every tenant,
// rolled straight back. The desk could not cancel an enrolment at all.
EnrollmentCancelled::dispatch($enrollment, $reason, $actor);
return $enrollment->fresh();
});
......
<?php
namespace Tests\Feature;
use App\Domain\Shared\Models\Academy;
use App\Domain\Training\Events\EnrollmentCancelled;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Services\EnrollmentService;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Cancelling an enrolment.
*
* `EnrollmentCancelled` takes (enrolment, reason, actor); the service dispatched
* (enrolment, actor). PHP put the User where the string reason was expected and
* found no third argument, so dispatch() threw an ArgumentCountError — inside
* the service's own DB::transaction, which then rolled the cancellation back.
*
* Every cancellation on every tenant failed this way, and the only trace was an
* error page: the row stayed active, the group count stayed high, and the
* enrolment kept being billed by the nightly renewal run.
*
* Nothing caught it because the arity mismatch is only reachable at runtime, on
* the one line that constructs the event.
*
* DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_DATABASE=oc_sport_test \
* ./vendor/bin/phpunit --filter EnrollmentCancellationTest
*/
class EnrollmentCancellationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant.');
}
if (! $academy = Academy::first()) {
$this->markTestSkipped('No academy in the restored tenant.');
}
app()->instance('current_academy', $academy);
DB::beginTransaction();
}
protected function tearDown(): void
{
DB::rollBack();
parent::tearDown();
}
public function test_an_active_enrolment_can_be_cancelled(): void
{
$enrollment = Enrollment::withoutGlobalScopes()
->where('status', 'active')
->whereHas('group')
->first();
$actor = User::first();
if (! $enrollment || ! $actor) {
$this->markTestSkipped('Tenant has no active enrolment to cancel.');
}
// No Event::fake here, deliberately. dispatch() constructs the event
// before the dispatcher ever sees it, so a wrong argument list throws on
// this line whether events are faked or not — and the throw happens
// inside the service's transaction, so a cancellation that reaches
// 'cancelled' at all is the proof the signature matches.
//
// Asserting on the dispatched event instead would prove nothing: the
// event is ShouldDispatchAfterCommit and this test rolls back, so it is
// correctly never delivered.
$cancelled = app(EnrollmentService::class)
->cancel($enrollment, 'اختبار الإلغاء', $actor);
$this->assertSame('cancelled', $cancelled->status->value);
$this->assertSame('اختبار الإلغاء', $cancelled->withdrawal_reason);
$this->assertNotNull($cancelled->withdrawal_date);
}
public function test_the_cancellation_event_still_takes_a_reason(): void
{
// The contract the service has to satisfy, pinned separately so a change
// to the event is caught even without a tenant to cancel against.
$params = (new \ReflectionMethod(EnrollmentCancelled::class, '__construct'))->getParameters();
$this->assertSame(['enrollment', 'reason', 'actor'], array_map(
fn (\ReflectionParameter $p) => $p->getName(),
$params,
));
}
public function test_an_already_cancelled_enrolment_is_refused(): void
{
$enrollment = Enrollment::withoutGlobalScopes()->where('status', 'cancelled')->first();
$actor = User::first();
if (! $enrollment || ! $actor) {
$this->markTestSkipped('Tenant has no cancelled enrolment to check.');
}
$this->expectExceptionMessage('لا يمكن إلغاء هذا التسجيل');
app(EnrollmentService::class)->cancel($enrollment, 'مرة أخرى', $actor);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment