diff --git a/app/config/events.php b/app/config/events.php index c6006b569f..11dc2e0e4a 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -405,6 +405,14 @@ return [ '$description' => 'This event triggers when a provider is deleted.' ], ], + 'schedules' => [ + '$model' => Response::MODEL_SCHEDULE, + '$resource' => true, + '$description' => 'This event triggers on any schedule event.', + 'create' => [ + '$description' => 'This event triggers when a schedule is created.', + ], + ], 'rules' => [ '$model' => Response::MODEL_PROXY_RULE, '$resource' => true, diff --git a/app/config/roles.php b/app/config/roles.php index 25a6bac4da..4473176c23 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -91,6 +91,8 @@ $admins = [ 'subscribers.read', 'tokens.read', 'tokens.write', + 'schedules.read', + 'schedules.write', ]; return [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index a6ad98e4e0..0a3feba9e7 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -145,6 +145,12 @@ return [ // List of publicly visible scopes 'rules.write' => [ 'description' => 'Access to create, update, and delete your project\'s proxy rules', ], + 'schedules.read' => [ + 'description' => 'Access to read your project\'s schedules', + ], + 'schedules.write' => [ + 'description' => 'Access to create, update, and delete your project\'s schedules', + ], 'migrations.read' => [ 'description' => 'Access to read your project\'s migrations', ], diff --git a/app/init/models.php b/app/init/models.php index b935136a63..d432852660 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -114,6 +114,7 @@ use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; use Appwrite\Utopia\Response\Model\Runtime; +use Appwrite\Utopia\Response\Model\Schedule; use Appwrite\Utopia\Response\Model\Session; use Appwrite\Utopia\Response\Model\Site; use Appwrite\Utopia\Response\Model\Specification; @@ -198,6 +199,7 @@ Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'met Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); +Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); Response::setModel(new BaseList('Locale codes list', Response::MODEL_LOCALE_CODE_LIST, 'localeCodes', Response::MODEL_LOCALE_CODE)); Response::setModel(new BaseList('Provider list', Response::MODEL_PROVIDER_LIST, 'providers', Response::MODEL_PROVIDER)); Response::setModel(new BaseList('Message list', Response::MODEL_MESSAGE_LIST, 'messages', Response::MODEL_MESSAGE)); @@ -339,6 +341,7 @@ Response::setModel(new UsageProject()); Response::setModel(new Headers()); Response::setModel(new Specification()); Response::setModel(new Rule()); +Response::setModel(new Schedule()); Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); diff --git a/composer.lock b/composer.lock index 5a6958c241..f17670f7b1 100644 --- a/composer.lock +++ b/composer.lock @@ -3797,16 +3797,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.0", + "version": "5.3.1", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "5e49f32ebb6e40a78209231564c40e5068810429" + "reference": "e3305f6ca64eb5dd715e30212fd32d5d834a3a46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/5e49f32ebb6e40a78209231564c40e5068810429", - "reference": "5e49f32ebb6e40a78209231564c40e5068810429", + "url": "https://api.github.com/repos/utopia-php/database/zipball/e3305f6ca64eb5dd715e30212fd32d5d834a3a46", + "reference": "e3305f6ca64eb5dd715e30212fd32d5d834a3a46", "shasum": "" }, "require": { @@ -3849,9 +3849,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.0" + "source": "https://github.com/utopia-php/database/tree/5.3.1" }, - "time": "2026-02-17T11:50:01+00:00" + "time": "2026-02-18T07:15:40+00:00" }, { "name": "utopia-php/detector", @@ -8885,7 +8885,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/docs/references/projects/create-schedule.md b/docs/references/projects/create-schedule.md new file mode 100644 index 0000000000..a10a0db1e7 --- /dev/null +++ b/docs/references/projects/create-schedule.md @@ -0,0 +1 @@ +Create a new schedule for a resource. \ No newline at end of file diff --git a/docs/references/projects/get-schedule.md b/docs/references/projects/get-schedule.md new file mode 100644 index 0000000000..3a78e273e0 --- /dev/null +++ b/docs/references/projects/get-schedule.md @@ -0,0 +1 @@ +Get a schedule by its unique ID. \ No newline at end of file diff --git a/docs/references/projects/list-schedules.md b/docs/references/projects/list-schedules.md new file mode 100644 index 0000000000..fe564386fa --- /dev/null +++ b/docs/references/projects/list-schedules.md @@ -0,0 +1 @@ +Get a list of all the project's schedules. You can use the query params to filter your results. \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php new file mode 100644 index 0000000000..e00809300d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php @@ -0,0 +1,154 @@ + 'functions', + SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions', + SCHEDULE_RESOURCE_TYPE_MESSAGE => 'messages', + default => throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Invalid resource type: ' . $resourceType), + }; + } + + protected function getNotFoundException(string $resourceType): string + { + return match ($resourceType) { + SCHEDULE_RESOURCE_TYPE_FUNCTION => Exception::FUNCTION_NOT_FOUND, + SCHEDULE_RESOURCE_TYPE_EXECUTION => Exception::EXECUTION_NOT_FOUND, + SCHEDULE_RESOURCE_TYPE_MESSAGE => Exception::MESSAGE_NOT_FOUND, + default => Exception::GENERAL_ARGUMENT_INVALID, + }; + } + + public function __construct() + { + $resourceTypes = $this->getResourceTypes(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/projects/:projectId/schedules') + ->desc('Create schedule') + ->groups(['api', 'projects']) + ->label('scope', 'schedules.write') + ->label('event', 'schedules.[scheduleId].create') + ->label('audits.event', 'schedule.create') + ->label('audits.resource', 'schedule/{response.$id}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'schedules', + name: 'createSchedule', + description: '/docs/references/projects/create-schedule.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_SCHEDULE, + ), + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('resourceType', '', new WhiteList($resourceTypes, true), 'The resource type for the schedule. Possible values: '.implode(', ', $resourceTypes).'.') + ->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.') + ->param('schedule', '', new Cron(), 'Schedule CRON expression.') + ->param('active', false, new Boolean(), 'Whether the schedule is active.', true) + ->param('data', null, new JSON(), 'Schedule data as a JSON string. Used to store resource-specific context needed for execution.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('getProjectDB') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $projectId, + string $resourceType, + string $resourceId, + string $schedule, + bool $active, + ?string $data, + Response $response, + Database $dbForPlatform, + callable $getProjectDB, + Event $queueForEvents, + ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $dbForProject = $getProjectDB($project); + + $collection = $this->getCollection($resourceType); + $resource = $dbForProject->getDocument($collection, $resourceId); + + if ($resource->isEmpty()) { + throw new Exception($this->getNotFoundException($resourceType), 'Resource not found'); + } + + $attributes = [ + 'region' => $project->getAttribute('region'), + 'resourceType' => $resourceType, + 'resourceId' => $resourceId, + 'resourceInternalId' => $resource->getSequence(), + 'resourceUpdatedAt' => DateTime::now(), + 'projectId' => $project->getId(), + 'schedule' => $schedule, + 'active' => $active, + ]; + + if ($data !== null) { + $attributes['data'] = \json_decode($data, true); + } + + try { + $doc = $dbForPlatform->createDocument('schedules', new Document($attributes)); + } catch (DuplicateException) { + throw new Exception(Exception::DOCUMENT_ALREADY_EXISTS); + } + + $queueForEvents->setParam('scheduleId', $doc->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($doc, Response::MODEL_SCHEDULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php new file mode 100644 index 0000000000..d14d4c361d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php @@ -0,0 +1,76 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/projects/:projectId/schedules/:scheduleId') + ->desc('Get schedule') + ->groups(['api', 'projects']) + ->label('scope', 'schedules.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'schedules', + name: 'getSchedule', + description: '/docs/references/projects/get-schedule.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_SCHEDULE, + ) + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('scheduleId', '', new UID(), 'Schedule ID.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $projectId, + string $scheduleId, + Response $response, + Database $dbForPlatform, + ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $schedule = $dbForPlatform->getDocument('schedules', $scheduleId); + + if ($schedule->isEmpty()) { + throw new Exception(Exception::SCHEDULE_NOT_FOUND); + } + + if ($schedule->getAttribute('projectId') !== $project->getId()) { + throw new Exception(Exception::SCHEDULE_NOT_FOUND); + } + + $response->dynamic($schedule, Response::MODEL_SCHEDULE); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php new file mode 100644 index 0000000000..66879f798f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php @@ -0,0 +1,114 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/projects/:projectId/schedules') + ->desc('List schedules') + ->groups(['api', 'projects']) + ->label('scope', 'schedules.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'schedules', + name: 'listSchedules', + description: '/docs/references/projects/list-schedules.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_SCHEDULE_LIST, + ) + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('queries', [], new Schedules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Schedules::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action( + string $projectId, + array $queries, + bool $includeTotal, + Response $response, + Database $dbForPlatform, + ): void { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('projectId', [$project->getId()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $scheduleId = $cursor->getValue(); + $cursorDocument = $dbForPlatform->getDocument('schedules', $scheduleId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Schedule '{$scheduleId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $schedules = $dbForPlatform->find('schedules', $queries); + $total = $includeTotal ? $dbForPlatform->count('schedules', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'schedules' => $schedules, + 'total' => $total, + ]), Response::MODEL_SCHEDULE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 587f101d61..8b0d6f87c8 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -12,6 +12,9 @@ use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProj use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; +use Appwrite\Platform\Modules\Projects\Http\Schedules\Create as CreateSchedule; +use Appwrite\Platform\Modules\Projects\Http\Schedules\Get as GetSchedule; +use Appwrite\Platform\Modules\Projects\Http\Schedules\XList as ListSchedules; use Utopia\Platform\Service; class Http extends Service @@ -30,5 +33,9 @@ class Http extends Service $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); + + $this->addAction(CreateSchedule::getName(), new CreateSchedule()); + $this->addAction(GetSchedule::getName(), new GetSchedule()); + $this->addAction(ListSchedules::getName(), new ListSchedules()); } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php b/src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php new file mode 100644 index 0000000000..6798985c22 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Schedules.php @@ -0,0 +1,24 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Schedule ID.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Schedule creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Schedule update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('resourceType', [ + 'type' => self::TYPE_STRING, + 'description' => 'The resource type associated with this schedule.', + 'default' => '', + 'example' => 'function', + ]) + ->addRule('resourceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'The resource ID associated with this schedule.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('resourceUpdatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Change-tracking timestamp used by the scheduler to detect resource changes in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('projectId', [ + 'type' => self::TYPE_STRING, + 'description' => 'The project ID associated with this schedule.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) + ->addRule('schedule', [ + 'type' => self::TYPE_STRING, + 'description' => 'The CRON schedule expression.', + 'default' => '', + 'example' => '5 4 * * *', + ]) + ->addRule('data', [ + 'type' => self::TYPE_JSON, + 'description' => 'Schedule data used to store resource-specific context needed for execution.', + 'default' => [], + 'example' => [], + ]) + ->addRule('active', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the schedule is active.', + 'default' => false, + 'example' => true, + ]) + ->addRule('region', [ + 'type' => self::TYPE_STRING, + 'description' => 'The region where the schedule is deployed.', + 'default' => '', + 'example' => 'fra', + ]) + ; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'Schedule'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_SCHEDULE; + } +} diff --git a/tests/e2e/Services/Projects/Schedules/SchedulesBase.php b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php new file mode 100644 index 0000000000..4c89917bf3 --- /dev/null +++ b/tests/e2e/Services/Projects/Schedules/SchedulesBase.php @@ -0,0 +1,60 @@ +client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Schedule Test Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Schedule Test Project', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + + $projectId = $project['body']['$id']; + + $key = $this->client->call(Client::METHOD_POST, '/projects/'.$projectId.'/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => ID::unique(), + 'name' => 'Schedule Test Key', + 'scopes' => [ + 'functions.read', + 'functions.write', + 'execution.read', + 'execution.write', + 'messages.read', + 'messages.write', + ], + ]); + + $this->assertEquals(201, $key['headers']['status-code']); + + return [ + 'projectId' => $projectId, + 'apiKey' => $key['body']['secret'], + ]; + } +} diff --git a/tests/e2e/Services/Projects/Schedules/SchedulesConsoleClientTest.php b/tests/e2e/Services/Projects/Schedules/SchedulesConsoleClientTest.php new file mode 100644 index 0000000000..879a54ce5d --- /dev/null +++ b/tests/e2e/Services/Projects/Schedules/SchedulesConsoleClientTest.php @@ -0,0 +1,341 @@ +client->call(Client::METHOD_POST, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $apiKey, + ], [ + 'functionId' => ID::unique(), + 'name' => 'Test Schedule Function', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'execute' => ['any'], + ]); + + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'resourceId' => $functionId, + 'schedule' => '0 0 * * *', + 'active' => true, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['$createdAt']); + $this->assertNotEmpty($response['body']['$updatedAt']); + $this->assertEquals('function', $response['body']['resourceType']); + $this->assertEquals($functionId, $response['body']['resourceId']); + $this->assertNotEmpty($response['body']['resourceUpdatedAt']); + $this->assertNotEmpty($response['body']['projectId']); + $this->assertEquals('0 0 * * *', $response['body']['schedule']); + $this->assertArrayHasKey('data', $response['body']); + $this->assertTrue($response['body']['active']); + $this->assertNotEmpty($response['body']['region']); + + // Create with data + $scheduleData = ['key' => 'value']; + $responseWithData = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'resourceId' => $functionId, + 'schedule' => '0 12 * * *', + 'active' => true, + 'data' => json_encode($scheduleData), + ]); + + $this->assertEquals(201, $responseWithData['headers']['status-code']); + $this->assertEquals($scheduleData, $responseWithData['body']['data']); + + $data = array_merge($data, [ + 'scheduleId' => $response['body']['$id'], + 'functionId' => $functionId, + ]); + + /** + * Test for FAILURE + */ + + // Resource not found + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Invalid resource type + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'invalid', + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Invalid cron + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + 'schedule' => 'not-a-cron', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Missing resourceType + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceId' => ID::unique(), + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Missing resourceId + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'schedule' => '0 0 * * *', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Missing schedule + $response = $this->client->call(Client::METHOD_POST, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceType' => 'function', + 'resourceId' => ID::unique(), + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + return $data; + } + + /** + * @depends testCreateSchedule + */ + public function testGetSchedule($data): array + { + $id = $data['projectId'] ?? ''; + $scheduleId = $data['scheduleId'] ?? ''; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules/'.$scheduleId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($scheduleId, $response['body']['$id']); + $this->assertEquals('function', $response['body']['resourceType']); + $this->assertEquals('0 0 * * *', $response['body']['schedule']); + $this->assertArrayHasKey('data', $response['body']); + $this->assertTrue($response['body']['active']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules/'.ID::unique(), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(404, $response['headers']['status-code']); + + return $data; + } + + /** + * @depends testCreateSchedule + */ + public function testListSchedules($data): array + { + $id = $data['projectId'] ?? ''; + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['schedules']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['schedules'])); + + // Verify schedule structure + $schedule = $response['body']['schedules'][0]; + $this->assertArrayHasKey('$id', $schedule); + $this->assertArrayHasKey('$createdAt', $schedule); + $this->assertArrayHasKey('$updatedAt', $schedule); + $this->assertArrayHasKey('resourceType', $schedule); + $this->assertArrayHasKey('resourceId', $schedule); + $this->assertArrayHasKey('projectId', $schedule); + $this->assertArrayHasKey('schedule', $schedule); + $this->assertArrayHasKey('data', $schedule); + $this->assertArrayHasKey('active', $schedule); + $this->assertArrayHasKey('region', $schedule); + + /** Filter by resourceType */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('resourceType', ['function'])->toString()], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + + foreach ($response['body']['schedules'] as $schedule) { + $this->assertEquals('function', $schedule['resourceType']); + } + + /** Filter by active status */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('active', [true])->toString()], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + foreach ($response['body']['schedules'] as $schedule) { + $this->assertTrue($schedule['active']); + } + + /** List with total disabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + $this->assertIsArray($response['body']['schedules']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$id.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('nonexistent', ['value'])->toString()], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + return $data; + } + + /** + * @depends testCreateSchedule + */ + public function testScheduleProjectIsolation($data): void + { + $scheduleId = $data['scheduleId'] ?? ''; + + // Create a second project + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Isolation Test Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + + $otherProject = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Isolation Test Project', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertEquals(201, $otherProject['headers']['status-code']); + $otherProjectId = $otherProject['body']['$id']; + + // Try to get the schedule from the other project + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$otherProjectId.'/schedules/'.$scheduleId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(404, $response['headers']['status-code']); + + // List should not include schedules from other projects + $response = $this->client->call(Client::METHOD_GET, '/projects/'.$otherProjectId.'/schedules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $response['headers']['status-code']); + + $scheduleIds = array_column($response['body']['schedules'], '$id'); + $this->assertNotContains($scheduleId, $scheduleIds); + } +}