From 8877d0e6edcb2ddd13757dc9bb1598baf4663d96 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 1 Jul 2026 10:32:06 +0200 Subject: [PATCH 1/2] test: cover trial-email dispatch and pre-warm maintenance commands --- .../DispatchMidtrialEmailJobsCommandTest.php | 138 ++++++++++++++ ...patchTrialCompleteEmailJobsCommandTest.php | 138 ++++++++++++++ ...ialCompleteStageRemovalJobsCommandTest.php | 130 +++++++++++++ .../MaintainPreWarmInstancesCommandTest.php | 176 ++++++++++++++++++ 4 files changed, 582 insertions(+) create mode 100644 tests/Feature/Console/Commands/DispatchMidtrialEmailJobsCommandTest.php create mode 100644 tests/Feature/Console/Commands/DispatchTrialCompleteEmailJobsCommandTest.php create mode 100644 tests/Feature/Console/Commands/DispatchTrialCompleteStageRemovalJobsCommandTest.php create mode 100644 tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php diff --git a/tests/Feature/Console/Commands/DispatchMidtrialEmailJobsCommandTest.php b/tests/Feature/Console/Commands/DispatchMidtrialEmailJobsCommandTest.php new file mode 100644 index 00000000..7310d467 --- /dev/null +++ b/tests/Feature/Console/Commands/DispatchMidtrialEmailJobsCommandTest.php @@ -0,0 +1,138 @@ +create(); + + return PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'send_midtrial_email' => $sendMidtrialEmail, + ]); + } + + private function createInstance( + PolydockStoreApp $storeApp, + bool $isTrial = true, + ?\DateTimeInterface $sendMidtrialEmailAt = null, + bool $midtrialEmailSent = false, + ): PolydockAppInstance { + $instance = new PolydockAppInstance; + $instance->uuid = 'test-'.uniqid(); + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'test-instance'; + $instance->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance->app_type = 'test_app_type'; + $instance->data = []; + $instance->is_trial = $isTrial; + $instance->send_midtrial_email_at = $sendMidtrialEmailAt; + $instance->midtrial_email_sent = $midtrialEmailSent; + $instance->saveQuietly(); + + return $instance; + } + + public function test_dispatches_job_for_eligible_instance(): void + { + $storeApp = $this->createStoreApp(); + $instance = $this->createInstance($storeApp, sendMidtrialEmailAt: now()->subHour()); + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertPushed(ProcessMidtrialEmailJob::class, 1); + } + + public function test_does_not_dispatch_when_email_already_sent(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, sendMidtrialEmailAt: now()->subHour(), midtrialEmailSent: true); + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessMidtrialEmailJob::class); + } + + public function test_does_not_dispatch_when_send_time_in_future(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, sendMidtrialEmailAt: now()->addHour()); + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessMidtrialEmailJob::class); + } + + public function test_does_not_dispatch_when_not_a_trial(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, isTrial: false, sendMidtrialEmailAt: now()->subHour()); + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessMidtrialEmailJob::class); + } + + public function test_does_not_dispatch_when_send_time_is_null(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, sendMidtrialEmailAt: null); + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessMidtrialEmailJob::class); + } + + public function test_does_not_dispatch_when_store_app_flag_disabled(): void + { + $storeApp = $this->createStoreApp(sendMidtrialEmail: false); + $this->createInstance($storeApp, sendMidtrialEmailAt: now()->subHour()); + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessMidtrialEmailJob::class); + } + + public function test_respects_per_run_cap(): void + { + config()->set('polydock.max_per_run_dispatch_midtrial_emails', 2); + + $storeApp = $this->createStoreApp(); + for ($i = 0; $i < 4; $i++) { + $this->createInstance($storeApp, sendMidtrialEmailAt: now()->subHour()); + } + + $this->artisan('polydock:dispatch-midtrial-emails')->assertExitCode(0); + + Queue::assertPushed(ProcessMidtrialEmailJob::class, 2); + } +} diff --git a/tests/Feature/Console/Commands/DispatchTrialCompleteEmailJobsCommandTest.php b/tests/Feature/Console/Commands/DispatchTrialCompleteEmailJobsCommandTest.php new file mode 100644 index 00000000..ec8c681b --- /dev/null +++ b/tests/Feature/Console/Commands/DispatchTrialCompleteEmailJobsCommandTest.php @@ -0,0 +1,138 @@ +create(); + + return PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'send_trial_complete_email' => $sendTrialCompleteEmail, + ]); + } + + private function createInstance( + PolydockStoreApp $storeApp, + bool $isTrial = true, + ?\DateTimeInterface $trialEndsAt = null, + bool $trialCompleteEmailSent = false, + ): PolydockAppInstance { + $instance = new PolydockAppInstance; + $instance->uuid = 'test-'.uniqid(); + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'test-instance'; + $instance->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance->app_type = 'test_app_type'; + $instance->data = []; + $instance->is_trial = $isTrial; + $instance->trial_ends_at = $trialEndsAt; + $instance->trial_complete_email_sent = $trialCompleteEmailSent; + $instance->saveQuietly(); + + return $instance; + } + + public function test_dispatches_job_for_eligible_instance(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: now()->subHour()); + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertPushed(ProcessTrialCompleteEmailJob::class, 1); + } + + public function test_does_not_dispatch_when_email_already_sent(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: now()->subHour(), trialCompleteEmailSent: true); + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteEmailJob::class); + } + + public function test_does_not_dispatch_when_trial_ends_in_future(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: now()->addHour()); + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteEmailJob::class); + } + + public function test_does_not_dispatch_when_not_a_trial(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, isTrial: false, trialEndsAt: now()->subHour()); + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteEmailJob::class); + } + + public function test_does_not_dispatch_when_trial_ends_at_is_null(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: null); + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteEmailJob::class); + } + + public function test_does_not_dispatch_when_store_app_flag_disabled(): void + { + $storeApp = $this->createStoreApp(sendTrialCompleteEmail: false); + $this->createInstance($storeApp, trialEndsAt: now()->subHour()); + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteEmailJob::class); + } + + public function test_respects_per_run_cap(): void + { + config()->set('polydock.max_per_run_dispatch_trial_complete_emails', 2); + + $storeApp = $this->createStoreApp(); + for ($i = 0; $i < 4; $i++) { + $this->createInstance($storeApp, trialEndsAt: now()->subHour()); + } + + $this->artisan('polydock:dispatch-trial-complete-emails')->assertExitCode(0); + + Queue::assertPushed(ProcessTrialCompleteEmailJob::class, 2); + } +} diff --git a/tests/Feature/Console/Commands/DispatchTrialCompleteStageRemovalJobsCommandTest.php b/tests/Feature/Console/Commands/DispatchTrialCompleteStageRemovalJobsCommandTest.php new file mode 100644 index 00000000..9f94f228 --- /dev/null +++ b/tests/Feature/Console/Commands/DispatchTrialCompleteStageRemovalJobsCommandTest.php @@ -0,0 +1,130 @@ +create(); + + return PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + ]); + } + + private function createInstance( + PolydockStoreApp $storeApp, + bool $isTrial = true, + PolydockAppInstanceStatus $status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ?\DateTimeInterface $trialEndsAt = null, + ): PolydockAppInstance { + $instance = new PolydockAppInstance; + $instance->uuid = 'test-'.uniqid(); + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'test-instance'; + $instance->status = $status; + $instance->app_type = 'test_app_type'; + $instance->data = []; + $instance->is_trial = $isTrial; + $instance->trial_ends_at = $trialEndsAt; + $instance->saveQuietly(); + + return $instance; + } + + public function test_dispatches_job_for_eligible_instance(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: now()->subHour()); + + $this->artisan('polydock:dispatch-trial-complete-stage-removal')->assertExitCode(0); + + Queue::assertPushed(ProcessTrialCompleteStageRemovalJob::class, 1); + } + + public function test_does_not_dispatch_when_trial_ends_in_future(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: now()->addHour()); + + $this->artisan('polydock:dispatch-trial-complete-stage-removal')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteStageRemovalJob::class); + } + + public function test_does_not_dispatch_when_not_a_trial(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, isTrial: false, trialEndsAt: now()->subHour()); + + $this->artisan('polydock:dispatch-trial-complete-stage-removal')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteStageRemovalJob::class); + } + + public function test_does_not_dispatch_when_trial_ends_at_is_null(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance($storeApp, trialEndsAt: null); + + $this->artisan('polydock:dispatch-trial-complete-stage-removal')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteStageRemovalJob::class); + } + + public function test_does_not_dispatch_when_status_not_running_healthy_claimed(): void + { + $storeApp = $this->createStoreApp(); + $this->createInstance( + $storeApp, + status: PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, + trialEndsAt: now()->subHour(), + ); + + $this->artisan('polydock:dispatch-trial-complete-stage-removal')->assertExitCode(0); + + Queue::assertNotPushed(ProcessTrialCompleteStageRemovalJob::class); + } + + public function test_respects_per_run_cap(): void + { + config()->set('polydock.max_per_run_dispatch_trial_complete_stage_removal', 2); + + $storeApp = $this->createStoreApp(); + for ($i = 0; $i < 4; $i++) { + $this->createInstance($storeApp, trialEndsAt: now()->subHour()); + } + + $this->artisan('polydock:dispatch-trial-complete-stage-removal')->assertExitCode(0); + + Queue::assertPushed(ProcessTrialCompleteStageRemovalJob::class, 2); + } +} diff --git a/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php b/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php new file mode 100644 index 00000000..45d55cc7 --- /dev/null +++ b/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php @@ -0,0 +1,176 @@ +create(); + config()->set('polydock.default_user_group_id_for_unallocated_instances', $defaultGroup->id); + } + + #[\Override] + protected function tearDown(): void + { + PolydockAppInstance::flushEventListeners(); + parent::tearDown(); + } + + private function createStoreApp(int $target = 1): PolydockStoreApp + { + $store = PolydockStore::factory()->create(); + + return PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + 'target_unallocated_app_instances' => $target, + ]); + } + + private function createUnallocatedInstance( + PolydockStoreApp $storeApp, + PolydockAppInstanceStatus $status = PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, + ): PolydockAppInstance { + $instance = new PolydockAppInstance; + $instance->uuid = 'test-'.uniqid(); + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'test-instance'; + $instance->status = $status; + $instance->app_type = 'test_app_type'; + $instance->data = []; + $instance->user_group_id = null; + $instance->saveQuietly(); + + return $instance; + } + + public function test_dispatches_refill_job_even_when_no_maintenance_needed(): void + { + // Target 1, exactly one unallocated instance -> no excess, nothing to remove. + $storeApp = $this->createStoreApp(target: 1); + $instance = $this->createUnallocatedInstance($storeApp); + + $this->artisan('polydock:maintain-prewarm-instances', ['--force' => true]) + ->assertExitCode(0); + + Queue::assertPushed(EnsureUnallocatedAppInstancesJob::class, 1); + + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $instance->status); + } + + public function test_queues_excess_instances_for_removal(): void + { + // Target 1, three unallocated -> two excess should be queued for removal. + $storeApp = $this->createStoreApp(target: 1); + $a = $this->createUnallocatedInstance($storeApp); + $b = $this->createUnallocatedInstance($storeApp); + $c = $this->createUnallocatedInstance($storeApp); + + $this->artisan('polydock:maintain-prewarm-instances', ['--force' => true]) + ->expectsOutputToContain('Queued 2 pre-warm instance(s)') + ->assertExitCode(0); + + Queue::assertPushed(EnsureUnallocatedAppInstancesJob::class, 1); + + $removed = collect([$a, $b, $c]) + ->each->refresh() + ->filter(fn ($i) => $i->status === PolydockAppInstanceStatus::PENDING_PRE_REMOVE); + + $this->assertCount(2, $removed); + } + + public function test_does_not_queue_removal_when_at_target(): void + { + $storeApp = $this->createStoreApp(target: 2); + $a = $this->createUnallocatedInstance($storeApp); + $b = $this->createUnallocatedInstance($storeApp); + + $this->artisan('polydock:maintain-prewarm-instances', ['--force' => true]) + ->assertExitCode(0); + + $a->refresh(); + $b->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $a->status); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $b->status); + + Queue::assertPushed(EnsureUnallocatedAppInstancesJob::class, 1); + } + + public function test_app_filter_limits_maintenance_to_named_app(): void + { + $targetApp = $this->createStoreApp(target: 1); + $targetExcess1 = $this->createUnallocatedInstance($targetApp); + $targetExcess2 = $this->createUnallocatedInstance($targetApp); + + $otherApp = $this->createStoreApp(target: 1); + $otherExcess1 = $this->createUnallocatedInstance($otherApp); + $otherExcess2 = $this->createUnallocatedInstance($otherApp); + + $this->artisan('polydock:maintain-prewarm-instances', [ + '--app' => $targetApp->uuid, + '--force' => true, + ])->assertExitCode(0); + + $targetRemoved = collect([$targetExcess1, $targetExcess2]) + ->each->refresh() + ->filter(fn ($i) => $i->status === PolydockAppInstanceStatus::PENDING_PRE_REMOVE); + $this->assertCount(1, $targetRemoved); + + // The other app should be untouched. + $otherExcess1->refresh(); + $otherExcess2->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $otherExcess1->status); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $otherExcess2->status); + } + + public function test_confirmation_prompt_aborts_without_force(): void + { + $storeApp = $this->createStoreApp(target: 1); + $excess = $this->createUnallocatedInstance($storeApp); + $this->createUnallocatedInstance($storeApp); + + $this->artisan('polydock:maintain-prewarm-instances') + ->expectsConfirmation('Do you want to queue pre-warm maintenance for these apps?', 'no') + ->expectsOutputToContain('Operation cancelled by user.') + ->assertExitCode(0); + + $excess->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $excess->status); + + Queue::assertNotPushed(EnsureUnallocatedAppInstancesJob::class); + } + + public function test_reports_when_no_store_apps_match(): void + { + $this->artisan('polydock:maintain-prewarm-instances', [ + '--app' => '00000000-0000-0000-0000-000000000000', + '--force' => true, + ]) + ->expectsOutputToContain('No store apps found matching the given filters.') + ->assertExitCode(0); + + Queue::assertNotPushed(EnsureUnallocatedAppInstancesJob::class); + } +} From f226fa361cc54f8f2df8c8f3efd37336c8cabf8b Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 1 Jul 2026 15:39:52 +0200 Subject: [PATCH 2/2] test: assert refill dispatch in app-filter case and cover --refresh-all --- .../MaintainPreWarmInstancesCommandTest.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php b/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php index 45d55cc7..38e38964 100644 --- a/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php +++ b/tests/Feature/Console/Commands/MaintainPreWarmInstancesCommandTest.php @@ -143,6 +143,31 @@ public function test_app_filter_limits_maintenance_to_named_app(): void $otherExcess2->refresh(); $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $otherExcess1->status); $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED, $otherExcess2->status); + + // The unconditional refill job is still dispatched exactly once. + Queue::assertPushed(EnsureUnallocatedAppInstancesJob::class, 1); + } + + public function test_refresh_all_queues_all_removable_instances_regardless_of_target(): void + { + // At target (2 of 2): normal runs would remove nothing. + $storeApp = $this->createStoreApp(target: 2); + $a = $this->createUnallocatedInstance($storeApp); + $b = $this->createUnallocatedInstance($storeApp); + + $this->artisan('polydock:maintain-prewarm-instances', [ + '--refresh-all' => true, + '--force' => true, + ])->assertExitCode(0); + + // With --refresh-all, every removable instance is queued for removal + // even though the pool is at target. + $a->refresh(); + $b->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::PENDING_PRE_REMOVE, $a->status); + $this->assertEquals(PolydockAppInstanceStatus::PENDING_PRE_REMOVE, $b->status); + + Queue::assertPushed(EnsureUnallocatedAppInstancesJob::class, 1); } public function test_confirmation_prompt_aborts_without_force(): void