2026-02-23 12:58:19 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Service\Scheduler;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Repository\Scheduler\ScheduledJobRepository;
|
|
|
|
|
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepository;
|
|
|
|
|
use MintyPHP\Service\Scheduler\ScheduleCalculator;
|
|
|
|
|
use MintyPHP\Service\Scheduler\ScheduledJobRegistry;
|
|
|
|
|
use MintyPHP\Service\Scheduler\ScheduledJobService;
|
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
class ScheduledJobServiceTest extends TestCase
|
|
|
|
|
{
|
feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00
|
|
|
public function testEnsureSystemJobsCreatesModuleJobDefinition(): void
|
|
|
|
|
{
|
|
|
|
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
|
|
|
|
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
|
|
|
|
$registry = $this->createMock(ScheduledJobRegistry::class);
|
|
|
|
|
$calculator = new ScheduleCalculator();
|
|
|
|
|
|
|
|
|
|
$registry->expects($this->once())->method('definitions')->willReturn([
|
|
|
|
|
'addressbook.sync' => [
|
|
|
|
|
'label' => 'Addressbook Sync',
|
|
|
|
|
'description' => 'Syncs address book data',
|
|
|
|
|
'default_enabled' => 1,
|
|
|
|
|
'default_timezone' => 'UTC',
|
|
|
|
|
'default_schedule_type' => 'daily',
|
|
|
|
|
'default_schedule_interval' => 1,
|
|
|
|
|
'default_schedule_time' => '03:00',
|
|
|
|
|
'default_schedule_weekdays_csv' => null,
|
|
|
|
|
'default_catchup_once' => 1,
|
|
|
|
|
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
$jobRepository->expects($this->once())
|
|
|
|
|
->method('findByKey')
|
|
|
|
|
->with('addressbook.sync')
|
|
|
|
|
->willReturn(null);
|
|
|
|
|
$jobRepository->expects($this->once())
|
|
|
|
|
->method('create')
|
|
|
|
|
->with($this->callback(static function (array $job): bool {
|
|
|
|
|
return ($job['job_key'] ?? null) === 'addressbook.sync'
|
|
|
|
|
&& ($job['label'] ?? null) === 'Addressbook Sync'
|
|
|
|
|
&& ($job['timezone'] ?? null) === 'UTC'
|
|
|
|
|
&& ($job['schedule_type'] ?? null) === 'daily';
|
|
|
|
|
}))
|
|
|
|
|
->willReturn(99);
|
|
|
|
|
$jobRepository->expects($this->never())->method('updateJobMeta');
|
|
|
|
|
|
|
|
|
|
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
|
|
|
|
$service->ensureSystemJobs();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testEnsureSystemJobsUpdatesExistingModuleJobMetadata(): void
|
|
|
|
|
{
|
|
|
|
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
|
|
|
|
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
|
|
|
|
$registry = $this->createMock(ScheduledJobRegistry::class);
|
|
|
|
|
$calculator = new ScheduleCalculator();
|
|
|
|
|
|
|
|
|
|
$registry->expects($this->once())->method('definitions')->willReturn([
|
|
|
|
|
'addressbook.sync' => [
|
|
|
|
|
'label' => 'Addressbook Sync',
|
|
|
|
|
'description' => 'Syncs address book data',
|
|
|
|
|
'default_enabled' => 1,
|
|
|
|
|
'default_timezone' => 'UTC',
|
|
|
|
|
'default_schedule_type' => 'daily',
|
|
|
|
|
'default_schedule_interval' => 1,
|
|
|
|
|
'default_schedule_time' => '03:00',
|
|
|
|
|
'default_schedule_weekdays_csv' => null,
|
|
|
|
|
'default_catchup_once' => 1,
|
|
|
|
|
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
$jobRepository->expects($this->once())
|
|
|
|
|
->method('findByKey')
|
|
|
|
|
->with('addressbook.sync')
|
|
|
|
|
->willReturn([
|
|
|
|
|
'id' => 12,
|
|
|
|
|
'job_key' => 'addressbook.sync',
|
|
|
|
|
'label' => 'Old Label',
|
|
|
|
|
'description' => 'Old description',
|
|
|
|
|
'enabled' => 1,
|
|
|
|
|
'timezone' => 'UTC',
|
|
|
|
|
'schedule_type' => 'daily',
|
|
|
|
|
'schedule_interval' => 1,
|
|
|
|
|
'schedule_time' => '03:00',
|
|
|
|
|
'schedule_weekdays_csv' => null,
|
|
|
|
|
'catchup_once' => 1,
|
|
|
|
|
'next_run_at' => '2026-01-01 03:00:00',
|
|
|
|
|
]);
|
|
|
|
|
$jobRepository->expects($this->once())
|
|
|
|
|
->method('updateJobMeta')
|
|
|
|
|
->with(12, $this->callback(static function (array $job): bool {
|
|
|
|
|
return ($job['label'] ?? null) === 'Addressbook Sync'
|
|
|
|
|
&& ($job['description'] ?? null) === 'Syncs address book data';
|
|
|
|
|
}))
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
$jobRepository->expects($this->never())->method('create');
|
|
|
|
|
|
|
|
|
|
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
|
|
|
|
$service->ensureSystemJobs();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
|
|
|
|
|
{
|
|
|
|
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
|
|
|
|
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
|
|
|
|
$registry = $this->createMock(ScheduledJobRegistry::class);
|
|
|
|
|
$calculator = new ScheduleCalculator();
|
|
|
|
|
|
|
|
|
|
$registry->expects($this->once())->method('definitions')->willReturn([]);
|
|
|
|
|
$jobRepository->expects($this->once())->method('find')->with(10)->willReturn([
|
|
|
|
|
'id' => 10,
|
|
|
|
|
'job_key' => 'user_lifecycle_run',
|
|
|
|
|
'label' => 'User lifecycle run',
|
|
|
|
|
'description' => '',
|
|
|
|
|
'enabled' => 1,
|
|
|
|
|
'timezone' => 'UTC',
|
|
|
|
|
'schedule_type' => 'daily',
|
|
|
|
|
'schedule_interval' => 1,
|
|
|
|
|
'schedule_time' => '02:15',
|
|
|
|
|
'schedule_weekdays_csv' => null,
|
|
|
|
|
'catchup_once' => 1,
|
|
|
|
|
]);
|
|
|
|
|
$registry->expects($this->once())->method('get')->with('user_lifecycle_run')->willReturn([
|
|
|
|
|
'label' => 'User lifecycle run',
|
|
|
|
|
'description' => '',
|
|
|
|
|
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
|
|
|
|
|
]);
|
|
|
|
|
$jobRepository->expects($this->never())->method('updateJobMeta');
|
|
|
|
|
|
|
|
|
|
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
|
|
|
|
$result = $service->updateFromAdmin(10, [
|
|
|
|
|
'enabled' => '1',
|
|
|
|
|
'timezone' => 'UTC',
|
|
|
|
|
'schedule_type' => 'weekly',
|
|
|
|
|
'schedule_interval' => '1',
|
|
|
|
|
'schedule_time' => '03:00',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertNotEmpty($result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testUpdateFromAdminPersistsNormalizedValuesAndReturnsFreshJob(): void
|
|
|
|
|
{
|
|
|
|
|
$jobRepository = $this->createMock(ScheduledJobRepository::class);
|
|
|
|
|
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
|
|
|
|
|
$registry = $this->createMock(ScheduledJobRegistry::class);
|
|
|
|
|
$calculator = new ScheduleCalculator();
|
|
|
|
|
|
|
|
|
|
$registry->expects($this->exactly(2))->method('definitions')->willReturn([]);
|
|
|
|
|
$jobRepository->expects($this->exactly(2))->method('find')->with(11)->willReturnOnConsecutiveCalls(
|
|
|
|
|
[
|
|
|
|
|
'id' => 11,
|
|
|
|
|
'job_key' => 'user_lifecycle_run',
|
|
|
|
|
'label' => 'User lifecycle run',
|
|
|
|
|
'description' => 'Runs lifecycle',
|
|
|
|
|
'enabled' => 1,
|
|
|
|
|
'timezone' => 'UTC',
|
|
|
|
|
'schedule_type' => 'daily',
|
|
|
|
|
'schedule_interval' => 1,
|
|
|
|
|
'schedule_time' => '02:15',
|
|
|
|
|
'schedule_weekdays_csv' => null,
|
|
|
|
|
'catchup_once' => 1,
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 11,
|
|
|
|
|
'job_key' => 'user_lifecycle_run',
|
|
|
|
|
'label' => 'User lifecycle run',
|
|
|
|
|
'description' => 'Runs lifecycle',
|
|
|
|
|
'enabled' => 0,
|
|
|
|
|
'timezone' => 'UTC',
|
|
|
|
|
'schedule_type' => 'daily',
|
|
|
|
|
'schedule_interval' => 1,
|
|
|
|
|
'schedule_time' => '04:00',
|
|
|
|
|
'schedule_weekdays_csv' => null,
|
|
|
|
|
'catchup_once' => 0,
|
|
|
|
|
'next_run_at' => null,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
$registry->expects($this->once())->method('get')->with('user_lifecycle_run')->willReturn([
|
|
|
|
|
'label' => 'User lifecycle run',
|
|
|
|
|
'description' => 'Runs lifecycle',
|
|
|
|
|
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
|
|
|
|
|
]);
|
|
|
|
|
$capturedUpdate = null;
|
|
|
|
|
$jobRepository->expects($this->once())
|
|
|
|
|
->method('updateJobMeta')
|
|
|
|
|
->with(11, $this->anything())
|
|
|
|
|
->willReturnCallback(function (int $id, array $data) use (&$capturedUpdate): bool {
|
|
|
|
|
$capturedUpdate = $data;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
|
|
|
|
|
$result = $service->updateFromAdmin(11, [
|
|
|
|
|
'timezone' => 'UTC',
|
|
|
|
|
'schedule_type' => 'daily',
|
|
|
|
|
'schedule_interval' => '1',
|
|
|
|
|
'schedule_time' => '04:00',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame(11, (int) (($result['job'] ?? [])['id'] ?? 0));
|
|
|
|
|
$this->assertSame(0, (int) (($result['job'] ?? [])['enabled'] ?? 1));
|
|
|
|
|
$this->assertIsArray($capturedUpdate);
|
|
|
|
|
$this->assertSame(0, (int) ($capturedUpdate['enabled'] ?? -1));
|
|
|
|
|
$this->assertSame('UTC', (string) ($capturedUpdate['timezone'] ?? ''));
|
|
|
|
|
$this->assertSame('daily', (string) ($capturedUpdate['schedule_type'] ?? ''));
|
|
|
|
|
$this->assertSame(1, (int) ($capturedUpdate['schedule_interval'] ?? -1));
|
|
|
|
|
$this->assertSame('04:00', (string) ($capturedUpdate['schedule_time'] ?? ''));
|
|
|
|
|
$this->assertSame(0, (int) ($capturedUpdate['catchup_once'] ?? -1));
|
|
|
|
|
$this->assertNull($capturedUpdate['next_run_at'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
}
|