From 31b073198f980b26816632e8032f25ffa31f705c Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 1 Jul 2026 09:28:15 +0200 Subject: [PATCH] fix: round up trial duration to avoid Carbon float truncation --- app/Models/PolydockAppInstance.php | 5 +- .../Unit/Models/TrialDateCalculationTest.php | 124 ++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Models/TrialDateCalculationTest.php diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index cd39d41e..0d006df0 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -1110,8 +1110,9 @@ public function calculateAndSetTrialDates(?int $overrideDurationDays = null, ?bo */ public function calculateAndSetTrialDatesFromEndDate($trialEndDateTime, bool $saveModel = false): self { - // Calculate days between now and end date - $durationDays = now()->diffInDays($trialEndDateTime); + // diffInDays() returns a float in Carbon 3; round up so a partial day still + // grants a full trial day rather than being truncated toward zero. + $durationDays = (int) ceil((float) now()->diffInDays($trialEndDateTime)); return $this->calculateAndSetTrialDates($durationDays, $saveModel); } diff --git a/tests/Unit/Models/TrialDateCalculationTest.php b/tests/Unit/Models/TrialDateCalculationTest.php new file mode 100644 index 00000000..0dcca7a6 --- /dev/null +++ b/tests/Unit/Models/TrialDateCalculationTest.php @@ -0,0 +1,124 @@ +create(); + $this->storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'trial_duration_days' => 7, + 'send_midtrial_email' => true, + 'send_one_day_left_email' => true, + 'send_trial_complete_email' => true, + ]); + $this->group = UserGroup::factory()->create(); + + Event::fake([ + PolydockAppInstanceCreatedWithNewStatus::class, + PolydockAppInstanceStatusChanged::class, + ]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + private function makeInstance(): PolydockAppInstance + { + $instance = new PolydockAppInstance; + $instance->polydock_store_app_id = $this->storeApp->id; + $instance->user_group_id = $this->group->id; + $instance->name = 'trial-date-test'; + $instance->app_type = PolydockAiApp::class; + $instance->status = PolydockAppInstanceStatus::NEW; + $instance->save(); + + return $instance; + } + + public function test_whole_days_in_future_sets_trial_ends_at_exactly_that_many_days_out(): void + { + Carbon::setTestNow('2026-07-01 12:00:00'); + + $instance = $this->makeInstance(); + $endDate = now()->copy()->addDays(5); // exactly 5 whole days + + $instance->calculateAndSetTrialDatesFromEndDate($endDate); + + $this->assertNotNull($instance->trial_ends_at); + $this->assertSame( + now()->copy()->addDays(5)->toDateTimeString(), + $instance->trial_ends_at->toDateTimeString() + ); + } + + public function test_partial_day_rounds_up_so_trial_is_not_short_changed(): void + { + // Regression: Carbon 3 diffInDays() returns a float (e.g. 5.5); truncation + // toward zero would leave the trial ~1 day short of the requested end. + Carbon::setTestNow('2026-07-01 12:00:00'); + + $instance = $this->makeInstance(); + $endDate = now()->copy()->addDays(5)->addHours(13); // 5 days + 13h => rounds up to 6 + + $instance->calculateAndSetTrialDatesFromEndDate($endDate); + + $this->assertNotNull($instance->trial_ends_at); + // Rounds up to 6 whole days rather than truncating to 5. + $this->assertSame( + now()->copy()->addDays(6)->toDateTimeString(), + $instance->trial_ends_at->toDateTimeString() + ); + // And the trial never ends before the requested end date. + $this->assertTrue( + $instance->trial_ends_at->greaterThanOrEqualTo($endDate), + 'trial_ends_at should be on or after the requested end date' + ); + } + + public function test_end_date_in_the_past_leaves_trial_ends_at_null(): void + { + Carbon::setTestNow('2026-07-01 12:00:00'); + + $instance = $this->makeInstance(); + + // Prime a valid trial first so the assertion actually exercises the + // null-reset path rather than passing on a fresh instance's null. + $instance->calculateAndSetTrialDates(7); + $this->assertNotNull($instance->trial_ends_at); + + $endDate = now()->copy()->subDays(3); // in the past + + $instance->calculateAndSetTrialDatesFromEndDate($endDate); + + // Guard ($durationDays > 0) means no negative trial is set. + $this->assertNull($instance->trial_ends_at); + } +}