File: /home/xedaptot/be.naniguide.com/tests/Unit/Subscription/EndIfExpiredTest.php
<?php
namespace Tests\Unit\Subscription;
use App\Model\Customer;
use App\Model\Setting;
use App\Model\Subscription;
use App\Notifications\Admin\CustomerSubscriptionOverdue;
use App\Notifications\Customer\SubscriptionPaymentOverdue;
use App\Services\Notifications\Notifier;
use App\Services\Subscription\SubscriptionManagementService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Mockery;
use Tests\TestCase;
/**
* Covers SubscriptionManagementService::endIfExpired — the three-branch decision
* the cron makes per active local subscription:
*
* - before current_period_ends_at → no-op
* - past period_end, inside grace window → notify customer + admins (state=grace)
* - past period_end + grace_period → end() the sub + notify both (state=expired)
*
* Plus the skip guards: remote-managed subs, null period_end, non-active.
*
* Uses real Setting writes (DB) so the grace_period value flows through the same
* Setting::get() codepath production uses. Service + Notifier are partial mocks
* so we can assert end()/dispatch() invocations without exercising their full
* lifecycle stacks.
*/
class EndIfExpiredTest extends TestCase
{
use DatabaseTransactions;
protected function tearDown(): void
{
if ($container = Mockery::getContainer()) {
$this->addToAssertionCount($container->mockery_getExpectationCount());
}
Mockery::close();
parent::tearDown();
}
private function buildService(): SubscriptionManagementService
{
$service = Mockery::mock(SubscriptionManagementService::class)->makePartial();
$service->shouldAllowMockingProtectedMethods();
return $service;
}
private function bindNotifier(): Notifier
{
$notifier = Mockery::mock(Notifier::class);
$this->app->instance(Notifier::class, $notifier);
return $notifier;
}
private function buildSubscription(?Carbon $endsAt, bool $remote = false, string $status = Subscription::STATUS_ACTIVE): Subscription
{
$customer = Mockery::mock(Customer::class)->makePartial();
$sub = Mockery::mock(Subscription::class)->makePartial();
$sub->shouldReceive('hasRemoteSubscription')->andReturn($remote);
$sub->status = $status;
$sub->current_period_ends_at = $endsAt;
$sub->customer = $customer;
return $sub;
}
public function test_grace_zero_expired_subscription_is_ended_and_notifies_expired_state(): void
{
Setting::set('grace_period', '0');
$sub = $this->buildSubscription(Carbon::now()->subSeconds(1));
$service = $this->buildService();
$service->shouldReceive('end')->once()->with($sub);
$notifier = $this->bindNotifier();
$notifier->shouldReceive('dispatch')
->once()
->with(
Mockery::on(fn ($e) => $e instanceof SubscriptionPaymentOverdue
&& $e->state === SubscriptionPaymentOverdue::STATE_EXPIRED),
$sub->customer,
);
$notifier->shouldReceive('dispatch')
->once()
->with(
Mockery::on(fn ($e) => $e instanceof CustomerSubscriptionOverdue
&& $e->state === CustomerSubscriptionOverdue::STATE_EXPIRED),
);
$service->endIfExpired($sub);
}
public function test_grace_three_one_day_overdue_stays_active_and_notifies_grace_state(): void
{
Setting::set('grace_period', '3');
$sub = $this->buildSubscription(Carbon::now()->subDay());
$service = $this->buildService();
$service->shouldReceive('end')->never();
$notifier = $this->bindNotifier();
$notifier->shouldReceive('dispatch')
->once()
->with(
Mockery::on(fn ($e) => $e instanceof SubscriptionPaymentOverdue
&& $e->state === SubscriptionPaymentOverdue::STATE_GRACE),
$sub->customer,
);
$notifier->shouldReceive('dispatch')
->once()
->with(
Mockery::on(fn ($e) => $e instanceof CustomerSubscriptionOverdue
&& $e->state === CustomerSubscriptionOverdue::STATE_GRACE),
);
$service->endIfExpired($sub);
}
public function test_grace_three_four_days_overdue_is_ended_and_notifies_expired_state(): void
{
Setting::set('grace_period', '3');
$sub = $this->buildSubscription(Carbon::now()->subDays(4));
$service = $this->buildService();
$service->shouldReceive('end')->once()->with($sub);
$notifier = $this->bindNotifier();
$notifier->shouldReceive('dispatch')->twice();
$service->endIfExpired($sub);
}
public function test_subscription_still_inside_paid_period_does_nothing(): void
{
Setting::set('grace_period', '3');
$sub = $this->buildSubscription(Carbon::now()->addDays(2));
$service = $this->buildService();
$service->shouldReceive('end')->never();
$notifier = $this->bindNotifier();
$notifier->shouldReceive('dispatch')->never();
$service->endIfExpired($sub);
}
public function test_remote_subscription_is_skipped_no_notifications(): void
{
Setting::set('grace_period', '0');
$sub = $this->buildSubscription(Carbon::now()->subDays(30), remote: true);
$service = $this->buildService();
$service->shouldReceive('end')->never();
$notifier = $this->bindNotifier();
$notifier->shouldReceive('dispatch')->never();
$service->endIfExpired($sub);
}
public function test_null_period_end_does_not_crash_or_end(): void
{
Setting::set('grace_period', '0');
$sub = $this->buildSubscription(null);
$service = $this->buildService();
$service->shouldReceive('end')->never();
$notifier = $this->bindNotifier();
$notifier->shouldReceive('dispatch')->never();
$service->endIfExpired($sub);
}
}