From f33d0b1d724b327ab3e513feb76f44b5b0d529c3 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 14 Oct 2020 20:13:36 -0700 Subject: [PATCH 1/4] Update schedule view UI --- package.json | 8 +- .../scripts/components/elements/Button.tsx | 4 +- .../scripts/components/elements/Icon.tsx | 31 +++++ .../scripts/components/elements/Modal.tsx | 11 +- .../server/schedules/DeleteScheduleButton.tsx | 2 +- .../server/schedules/NewTaskButton.tsx | 3 +- .../server/schedules/ScheduleCronRow.tsx | 35 +++++ .../schedules/ScheduleEditContainer.tsx | 126 ++++++++++++------ .../server/schedules/ScheduleRow.tsx | 32 ++--- .../server/schedules/ScheduleTaskRow.tsx | 67 +++++----- resources/views/templates/base/core.blade.php | 1 + yarn.lock | 44 +++--- 12 files changed, 230 insertions(+), 134 deletions(-) create mode 100644 resources/scripts/components/elements/Icon.tsx create mode 100644 resources/scripts/components/server/schedules/ScheduleCronRow.tsx diff --git a/package.json b/package.json index 15e96c4bd..251623fc1 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "pterodactyl-panel", "dependencies": { - "@fortawesome/fontawesome-svg-core": "1.2.19", - "@fortawesome/free-solid-svg-icons": "^5.9.0", - "@fortawesome/react-fontawesome": "0.1.4", + "@fortawesome/fontawesome-svg-core": "^1.2.32", + "@fortawesome/free-solid-svg-icons": "^5.15.1", + "@fortawesome/react-fontawesome": "^0.1.11", "axios": "^0.19.2", "chart.js": "^2.8.0", "codemirror": "^5.57.0", @@ -23,9 +23,9 @@ "react": "^16.13.1", "react-dom": "npm:@hot-loader/react-dom", "react-fast-compare": "^3.2.0", + "react-ga": "^3.1.2", "react-google-recaptcha": "^2.0.1", "react-helmet": "^6.1.0", - "react-ga": "^3.1.2", "react-hot-loader": "^4.12.21", "react-i18next": "^11.2.1", "react-redux": "^7.1.0", diff --git a/resources/scripts/components/elements/Button.tsx b/resources/scripts/components/elements/Button.tsx index 300f1a9ea..8577fad7f 100644 --- a/resources/scripts/components/elements/Button.tsx +++ b/resources/scripts/components/elements/Button.tsx @@ -57,8 +57,8 @@ const ButtonStyle = styled.button>` `}; `}; - ${props => props.size === 'xsmall' && tw`p-2 text-xs`}; - ${props => (!props.size || props.size === 'small') && tw`p-3`}; + ${props => props.size === 'xsmall' && tw`px-2 py-1 text-xs`}; + ${props => (!props.size || props.size === 'small') && tw`px-4 py-2`}; ${props => props.size === 'large' && tw`p-4 text-sm`}; ${props => props.size === 'xlarge' && tw`p-4 w-full`}; diff --git a/resources/scripts/components/elements/Icon.tsx b/resources/scripts/components/elements/Icon.tsx new file mode 100644 index 000000000..a7d837896 --- /dev/null +++ b/resources/scripts/components/elements/Icon.tsx @@ -0,0 +1,31 @@ +import React, { CSSProperties } from 'react'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import tw from 'twin.macro'; + +interface Props { + icon: IconDefinition; + className?: string; + style?: CSSProperties; +} + +const Icon = ({ icon, className, style }: Props) => { + let [ width, height, , , paths ] = icon.icon; + + paths = Array.isArray(paths) ? paths : [ paths ]; + + return ( + + {paths.map((path, index) => ( + + ))} + + ); +}; + +export default Icon; diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index 68d6493de..ae618a42d 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import Spinner from '@/components/elements/Spinner'; import tw from 'twin.macro'; import styled, { css } from 'styled-components/macro'; import { breakpoint } from '@/theme'; import Fade from '@/components/elements/Fade'; +import { createPortal } from 'react-dom'; export interface RequiredModalProps { visible: boolean; @@ -124,4 +125,10 @@ const Modal: React.FC = ({ visible, appear, dismissable, showSpinner ); }; -export default Modal; +const PortaledModal: React.FC = ({ children, ...props }) => { + const element = useRef(document.getElementById('modal-portal')); + + return createPortal({children}, element.current!); +}; + +export default PortaledModal; diff --git a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx index 198060388..463202dce 100644 --- a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx +++ b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx @@ -49,7 +49,7 @@ export default ({ scheduleId, onDeleted }: Props) => { Are you sure you want to delete this schedule? All tasks will be removed and any running processes will be terminated. - diff --git a/resources/scripts/components/server/schedules/NewTaskButton.tsx b/resources/scripts/components/server/schedules/NewTaskButton.tsx index b46124e64..9234f5b42 100644 --- a/resources/scripts/components/server/schedules/NewTaskButton.tsx +++ b/resources/scripts/components/server/schedules/NewTaskButton.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Schedule } from '@/api/server/schedules/getServerSchedules'; import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; import Button from '@/components/elements/Button'; +import tw from 'twin.macro'; interface Props { schedule: Schedule; @@ -18,7 +19,7 @@ export default ({ schedule }: Props) => { onDismissed={() => setVisible(false)} /> } - diff --git a/resources/scripts/components/server/schedules/ScheduleCronRow.tsx b/resources/scripts/components/server/schedules/ScheduleCronRow.tsx new file mode 100644 index 000000000..e7918a132 --- /dev/null +++ b/resources/scripts/components/server/schedules/ScheduleCronRow.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import tw from 'twin.macro'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; + +interface Props { + cron: Schedule['cron']; + className?: string; +} + +const ScheduleCronRow = ({ cron, className }: Props) => ( +
+
+

{cron.minute}

+

Minute

+
+
+

{cron.hour}

+

Hour

+
+
+

{cron.dayOfMonth}

+

Day (Month)

+
+
+

*

+

Month

+
+
+

{cron.dayOfWeek}

+

Day (Week)

+
+
+); + +export default ScheduleCronRow; diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx index 8f9f07fce..aea4778ae 100644 --- a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { Schedule } from '@/api/server/schedules/getServerSchedules'; import getServerSchedule from '@/api/server/schedules/getServerSchedule'; import Spinner from '@/components/elements/Spinner'; import FlashMessageRender from '@/components/FlashMessageRender'; import { httpErrorToHuman } from '@/api/http'; -import ScheduleRow from '@/components/server/schedules/ScheduleRow'; -import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow'; import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import NewTaskButton from '@/components/server/schedules/NewTaskButton'; import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; @@ -16,7 +14,10 @@ import { ServerContext } from '@/state/server'; import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; -import GreyRowBox from '@/components/elements/GreyRowBox'; +import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow'; +import isEqual from 'react-fast-compare'; +import { format } from 'date-fns'; +import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow'; interface Params { id: string; @@ -26,6 +27,24 @@ interface State { schedule?: Schedule; } +const CronBox = ({ title, value }: { title: string; value: string }) => ( +
+

{title}

+

{value}

+
+); + +const ActivePill = ({ active }: { active: boolean }) => ( + + {active ? 'Active' : 'Inactive'} + +); + export default ({ match, history, location: { state } }: RouteComponentProps, State>) => { const id = ServerContext.useStoreState(state => state.server.data!.id); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); @@ -34,7 +53,8 @@ export default ({ match, history, location: { state } }: RouteComponentProps st.schedules.data.find(s => s.id === state.schedule?.id), [ match ]); + // @ts-ignore + const schedule: Schedule | undefined = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), isEqual); const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); useEffect(() => { @@ -53,6 +73,10 @@ export default ({ match, history, location: { state } }: RouteComponentProps setIsLoading(false)); }, [ match ]); + const toggleEditModal = useCallback(() => { + setShowEditModal(s => !s); + }, []); + return ( @@ -60,52 +84,68 @@ export default ({ match, history, location: { state } }: RouteComponentProps : <> - - - - setShowEditModal(false)} - /> -
-
-

Configured Tasks

+ +
+ + + + + +
+
+
+
+

+ {schedule.name} + {schedule.isProcessing ? + + + Processing + + : + + } +

+

+ Last run at:  + {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'} +

+
+
+ + + + +
+
+
+ {schedule.tasks.length > 0 ? + schedule.tasks.map(task => ( + + )) + : + null + }
- {schedule.tasks.length > 0 ? - <> - { - schedule.tasks - .sort((a, b) => a.sequenceId - b.sequenceId) - .map(task => ( - - )) - } - {schedule.tasks.length > 1 && -

- Task delays are relative to the previous task in the listing. -

- } - - : -

- There are no tasks configured for this schedule. -

- } -
+ +
history.push(`/server/${id}/schedules`)} /> - - - -
} diff --git a/resources/scripts/components/server/schedules/ScheduleRow.tsx b/resources/scripts/components/server/schedules/ScheduleRow.tsx index eb9ff68a5..eccdd0f96 100644 --- a/resources/scripts/components/server/schedules/ScheduleRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleRow.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCalendarAlt } from '@fortawesome/free-solid-svg-icons'; import { format } from 'date-fns'; import tw from 'twin.macro'; +import ScheduleCronRow from '@/components/server/schedules/ScheduleCronRow'; export default ({ schedule }: { schedule: Schedule }) => ( <> @@ -27,36 +28,19 @@ export default ({ schedule }: { schedule: Schedule }) => ( {schedule.isActive ? 'Active' : 'Inactive'}

-
-
-

{schedule.cron.minute}

-

Minute

-
-
-

{schedule.cron.hour}

-

Hour

-
-
-

{schedule.cron.dayOfMonth}

-

Day (Month)

-
-
-

*

-

Month

-
-
-

{schedule.cron.dayOfWeek}

-

Day (Week)

-
-
+
diff --git a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx index c79fafd03..1ee55af2c 100644 --- a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask'; import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; @@ -11,6 +11,7 @@ import useFlash from '@/plugins/useFlash'; import { ServerContext } from '@/state/server'; import tw from 'twin.macro'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import Icon from '@/components/elements/Icon'; interface Props { schedule: Schedule; @@ -56,7 +57,7 @@ export default ({ schedule, task }: Props) => { const [ title, icon ] = getActionDetails(task.action); return ( -
+
{isEditing && { Are you sure you want to delete this task? This action cannot be undone.
} diff --git a/routes/api-client.php b/routes/api-client.php index 51a2f0dec..fa0455018 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -72,6 +72,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::post('/', 'Servers\ScheduleController@store'); Route::get('/{schedule}', 'Servers\ScheduleController@view'); Route::post('/{schedule}', 'Servers\ScheduleController@update'); + Route::post('/{schedule}/execute', 'Servers\ScheduleController@execute'); Route::delete('/{schedule}', 'Servers\ScheduleController@delete'); Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store'); diff --git a/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php b/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php deleted file mode 100644 index 01bbac149..000000000 --- a/tests/Unit/Services/Schedules/ProcessScheduleServiceTest.php +++ /dev/null @@ -1,85 +0,0 @@ -dispatcher = m::mock(Dispatcher::class); - $this->scheduleRepository = m::mock(ScheduleRepositoryInterface::class); - $this->taskRepository = m::mock(TaskRepositoryInterface::class); - } - - /** - * Test that a schedule can be updated and first task set to run. - */ - public function testScheduleIsUpdatedAndRun() - { - $model = factory(Schedule::class)->make(['id' => 123]); - $model->setRelation('tasks', collect([$task = factory(Task::class)->make([ - 'sequence_id' => 1, - ])])); - - $this->scheduleRepository->shouldReceive('loadTasks')->with($model)->once()->andReturn($model); - - $formatted = sprintf('%s %s %s * %s', $model->cron_minute, $model->cron_hour, $model->cron_day_of_month, $model->cron_day_of_week); - $this->scheduleRepository->shouldReceive('update')->with($model->id, [ - 'is_processing' => true, - 'next_run_at' => CronExpression::factory($formatted)->getNextRunDate(), - ]); - - $this->taskRepository->shouldReceive('update')->with($task->id, ['is_queued' => true])->once(); - - $this->dispatcher->shouldReceive('dispatch')->with(m::on(function ($class) use ($model, $task) { - $this->assertInstanceOf(RunTaskJob::class, $class); - $this->assertSame($task->time_offset, $class->delay); - $this->assertSame($task->id, $class->task->id); - - return true; - }))->once(); - - $this->getService()->handle($model); - } - - /** - * Return an instance of the service for testing purposes. - * - * @return \Pterodactyl\Services\Schedules\ProcessScheduleService - */ - private function getService(): ProcessScheduleService - { - return new ProcessScheduleService($this->dispatcher, $this->scheduleRepository, $this->taskRepository); - } -} From e7c64bc60e5e0e8e3ef2ecdc84ffb97b674f3226 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 14 Oct 2020 21:06:27 -0700 Subject: [PATCH 3/4] Add test coverage for schedule execution --- .../Schedules/TriggerScheduleRequest.php | 6 +- .../Schedules/ProcessScheduleService.php | 9 +- .../schedules/ScheduleEditContainer.tsx | 2 +- .../Server/Schedule/ExecuteScheduleTest.php | 94 +++++++++++++++++++ 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/Api/Client/Server/Schedule/ExecuteScheduleTest.php diff --git a/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php b/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php index 7651b7419..d89f5ed30 100644 --- a/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Schedules/TriggerScheduleRequest.php @@ -3,9 +3,9 @@ namespace Pterodactyl\Http\Requests\Api\Client\Servers\Schedules; use Pterodactyl\Models\Permission; -use Illuminate\Foundation\Http\FormRequest; +use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; -class TriggerScheduleRequest extends FormRequest +class TriggerScheduleRequest extends ClientApiRequest { /** * @return string @@ -18,7 +18,7 @@ class TriggerScheduleRequest extends FormRequest /** * @return array */ - public function rules() + public function rules(): array { return []; } diff --git a/app/Services/Schedules/ProcessScheduleService.php b/app/Services/Schedules/ProcessScheduleService.php index 5d4ad60cf..1f810d6f5 100644 --- a/app/Services/Schedules/ProcessScheduleService.php +++ b/app/Services/Schedules/ProcessScheduleService.php @@ -6,6 +6,7 @@ use Pterodactyl\Models\Schedule; use Illuminate\Contracts\Bus\Dispatcher; use Pterodactyl\Jobs\Schedule\RunTaskJob; use Illuminate\Database\ConnectionInterface; +use Pterodactyl\Exceptions\DisplayException; class ProcessScheduleService { @@ -42,7 +43,13 @@ class ProcessScheduleService public function handle(Schedule $schedule, bool $now = false) { /** @var \Pterodactyl\Models\Task $task */ - $task = $schedule->tasks()->where('sequence_id', 1)->firstOrFail(); + $task = $schedule->tasks()->where('sequence_id', 1)->first(); + + if (is_null($task)) { + throw new DisplayException( + 'Cannot process schedule for task execution: no tasks are registered.' + ); + } $this->connection->transaction(function () use ($schedule, $task) { $schedule->forceFill([ diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx index 5b2265845..d7c5f2abf 100644 --- a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx @@ -146,7 +146,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps history.push(`/server/${id}/schedules`)} /> - {schedule.isActive && + {schedule.isActive && schedule.tasks.length > 0 && diff --git a/tests/Integration/Api/Client/Server/Schedule/ExecuteScheduleTest.php b/tests/Integration/Api/Client/Server/Schedule/ExecuteScheduleTest.php new file mode 100644 index 000000000..00c57d4a4 --- /dev/null +++ b/tests/Integration/Api/Client/Server/Schedule/ExecuteScheduleTest.php @@ -0,0 +1,94 @@ +generateTestAccount($permissions); + + Bus::fake(); + + /** @var \Pterodactyl\Models\Schedule $schedule */ + $schedule = factory(Schedule::class)->create([ + 'server_id' => $server->id, + ]); + + $response = $this->actingAs($user)->postJson($this->link($schedule, '/execute')); + $response->assertStatus(Response::HTTP_BAD_REQUEST); + $response->assertJsonPath('errors.0.code', 'DisplayException'); + $response->assertJsonPath('errors.0.detail', 'Cannot process schedule for task execution: no tasks are registered.'); + + /** @var \Pterodactyl\Models\Task $task */ + $task = factory(Task::class)->create([ + 'schedule_id' => $schedule->id, + 'sequence_id' => 1, + 'time_offset' => 2, + ]); + + $this->actingAs($user)->postJson($this->link($schedule, '/execute'))->assertStatus(Response::HTTP_ACCEPTED); + + Bus::assertDispatched(function (RunTaskJob $job) use ($task) { + $this->assertSame($task->time_offset, $job->delay); + $this->assertSame($task->id, $job->task->id); + + return true; + }); + } + + /** + * Test that the schedule is not executed if it is not currently active. + */ + public function testScheduleIsNotExecutedIfNotActive() + { + [$user, $server] = $this->generateTestAccount(); + + /** @var \Pterodactyl\Models\Schedule $schedule */ + $schedule = factory(Schedule::class)->create([ + 'server_id' => $server->id, + 'is_active' => false, + ]); + + $response = $this->actingAs($user)->postJson($this->link($schedule, "/execute")); + + $response->assertStatus(Response::HTTP_BAD_REQUEST); + $response->assertJsonPath('errors.0.code', 'BadRequestHttpException'); + $response->assertJsonPath('errors.0.detail', 'Cannot trigger schedule exection for a schedule that is not currently active.'); + } + + /** + * Test that a user without the schedule update permission cannot execute it. + */ + public function testUserWithoutScheduleUpdatePermissionCannotExecute() + { + [$user, $server] = $this->generateTestAccount([Permission::ACTION_SCHEDULE_CREATE]); + + /** @var \Pterodactyl\Models\Schedule $schedule */ + $schedule = factory(Schedule::class)->create(['server_id' => $server->id]); + + $this->actingAs($user)->postJson($this->link($schedule, '/execute'))->assertForbidden(); + } + + /** + * @return array + */ + public function permissionsDataProvider(): array + { + return [[[]], [[Permission::ACTION_SCHEDULE_UPDATE]]]; + } +} From 14099c164baff1a50ecd9726db932956d8a54e63 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 14 Oct 2020 21:17:57 -0700 Subject: [PATCH 4/4] Add test coverage for schedule service --- .../Schedules/ProcessScheduleServiceTest.php | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/Integration/Services/Schedules/ProcessScheduleServiceTest.php diff --git a/tests/Integration/Services/Schedules/ProcessScheduleServiceTest.php b/tests/Integration/Services/Schedules/ProcessScheduleServiceTest.php new file mode 100644 index 000000000..4941a9bd9 --- /dev/null +++ b/tests/Integration/Services/Schedules/ProcessScheduleServiceTest.php @@ -0,0 +1,99 @@ +createServerModel(); + $schedule = factory(Schedule::class)->create(['server_id' => $server->id]); + + $this->expectException(DisplayException::class); + $this->expectExceptionMessage('Cannot process schedule for task execution: no tasks are registered.'); + + $this->getService()->handle($schedule); + } + + /** + * Test that an error during the schedule update is not persisted to the database. + */ + public function testErrorDuringScheduleDataUpdateDoesNotPersistChanges() + { + $server = $this->createServerModel(); + + /** @var \Pterodactyl\Models\Schedule $schedule */ + $schedule = factory(Schedule::class)->create([ + 'server_id' => $server->id, + 'cron_minute' => 'hodor', // this will break the getNextRunDate() function. + ]); + + /** @var \Pterodactyl\Models\Task $task */ + $task = factory(Task::class)->create(['schedule_id' => $schedule->id, 'sequence_id' => 1]); + + $this->expectException(InvalidArgumentException::class); + + $this->getService()->handle($schedule); + + $this->assertDatabaseMissing('schedules', ['id' => $schedule->id, 'is_processing' => true]); + $this->assertDatabaseMissing('tasks', ['id' => $task->id, 'is_queued' => true]); + } + + /** + * Test that a job is dispatched as expected using the initial delay. + * + * @param bool $now + * @dataProvider dispatchNowDataProvider + */ + public function testJobCanBeDispatchedWithExpectedInitialDelay($now) + { + $this->swap(Dispatcher::class, $dispatcher = Mockery::mock(Dispatcher::class)); + + $server = $this->createServerModel(); + + /** @var \Pterodactyl\Models\Schedule $schedule */ + $schedule = factory(Schedule::class)->create(['server_id' => $server->id]); + + /** @var \Pterodactyl\Models\Task $task */ + $task = factory(Task::class)->create(['schedule_id' => $schedule->id, 'time_offset' => 10, 'sequence_id' => 1]); + + $dispatcher->expects($now ? 'dispatchNow' : 'dispatch')->with(Mockery::on(function (RunTaskJob $job) use ($task) { + return $task->id === $job->task->id && $job->delay === 10; + })); + + $this->getService()->handle($schedule, $now); + + $this->assertDatabaseHas('schedules', ['id' => $schedule->id, 'is_processing' => true]); + $this->assertDatabaseHas('tasks', ['id' => $task->id, 'is_queued' => true]); + } + + /** + * @return array + */ + public function dispatchNowDataProvider(): array + { + return [[true], [false]]; + } + + /** + * @return \Pterodactyl\Services\Schedules\ProcessScheduleService + */ + private function getService() + { + return $this->app->make(ProcessScheduleService::class); + } +}