From 3ceac9dc002c3e16bde4e1ae38e3e46f4efe0293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 26 Nov 2025 04:59:06 +0100 Subject: [PATCH 1/5] feat: gcalendar addon --- forms-bridge/addons/gcalendar/assets/logo.png | Bin 0 -> 6904 bytes .../gcalendar/class-gcalendar-addon.php | 212 ++++++++++++++++++ .../gcalendar/class-gcalendar-form-bridge.php | 178 +++++++++++++++ forms-bridge/addons/gcalendar/hooks.php | 180 +++++++++++++++ .../gsheets/class-gsheets-form-bridge.php | 3 + 5 files changed, 573 insertions(+) create mode 100644 forms-bridge/addons/gcalendar/assets/logo.png create mode 100644 forms-bridge/addons/gcalendar/class-gcalendar-addon.php create mode 100644 forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php create mode 100644 forms-bridge/addons/gcalendar/hooks.php diff --git a/forms-bridge/addons/gcalendar/assets/logo.png b/forms-bridge/addons/gcalendar/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0767b22c56838e846a125bef3a40f8aadc3ec64d GIT binary patch literal 6904 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 literal 0 HcmV?d00001 diff --git a/forms-bridge/addons/gcalendar/class-gcalendar-addon.php b/forms-bridge/addons/gcalendar/class-gcalendar-addon.php new file mode 100644 index 00000000..bcf79323 --- /dev/null +++ b/forms-bridge/addons/gcalendar/class-gcalendar-addon.php @@ -0,0 +1,212 @@ + '__gcalendar-' . time(), + 'backend' => $backend, + 'endpoint' => '/calendars/primary', + 'method' => 'GET', + ) + ); + + $backend = $bridge->backend; + if ( ! $backend ) { + Logger::log( 'Google Calendar backend ping error: Bridge has no valid backend', Logger::ERROR ); + return false; + } + + $credential = $backend->credential; + if ( ! $credential ) { + Logger::log( 'Google Calendar backend ping error: Backend has no valid credential', Logger::ERROR ); + return false; + } + + $parsed = wp_parse_url( $backend->base_url ); + $host = $parsed['host'] ?? ''; + + if ( 'www.googleapis.com' !== $host ) { + Logger::log( 'Google Calendar backend ping error: Backend does not point to the Google Calendar API endpoints', Logger::ERROR ); + return false; + } + + $access_token = $credential->get_access_token(); + + if ( ! $access_token ) { + Logger::log( 'Google Calendar backend ping error: Unable to recover the credential access token', Logger::ERROR ); + return false; + } + + return true; + } + + /** + * Performs a GET request against the backend endpoint and retrieve the response data. + * + * @param string $endpoint Calendar ID or endpoint. + * @param string $backend Backend name. + * + * @return array|WP_Error + */ + public function fetch( $endpoint, $backend ) { + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return new WP_Error( 'invalid_backend' ); + } + + $credential = $backend->credential; + if ( ! $credential ) { + return new WP_Error( 'invalid_credential' ); + } + + $access_token = $credential->get_access_token(); + if ( ! $access_token ) { + return new WP_Error( 'invalid_credential' ); + } + + $response = http_bridge_get( + 'https://www.googleapis.com/calendar/v3/users/me/calendarList', + array(), + array( + 'Authorization' => "Bearer {$access_token}", + 'Accept' => 'application/json', + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return $response; + } + + /** + * Performs an introspection of the backend endpoint and returns API fields + * and accepted content type. + * + * @param string $endpoint Calendar ID. + * @param string $backend Backend name. + * @param string|null $method HTTP method. + * + * @return array List of fields and content type of the endpoint. + */ + public function get_endpoint_schema( $endpoint, $backend, $method = null ) { + if ( 'POST' !== $method ) { + return array(); + } + + // Google Calendar events have a standard schema + $fields = array( + array( + 'name' => 'summary', + 'schema' => array( + 'type' => 'string', + 'description' => 'Event title', + ), + ), + array( + 'name' => 'description', + 'schema' => array( + 'type' => 'string', + 'description' => 'Event description', + ), + ), + array( + 'name' => 'location', + 'schema' => array( + 'type' => 'string', + 'description' => 'Event location', + ), + ), + array( + 'name' => 'start.dateTime', + 'schema' => array( + 'type' => 'string', + 'description' => 'Start date and time (ISO 8601 format)', + ), + ), + array( + 'name' => 'start.timeZone', + 'schema' => array( + 'type' => 'string', + 'description' => 'Start timezone', + ), + ), + array( + 'name' => 'end.dateTime', + 'schema' => array( + 'type' => 'string', + 'description' => 'End date and time (ISO 8601 format)', + ), + ), + array( + 'name' => 'end.timeZone', + 'schema' => array( + 'type' => 'string', + 'description' => 'End timezone', + ), + ), + array( + 'name' => 'attendees', + 'schema' => array( + 'type' => 'string', + 'description' => 'Comma-separated list of attendee emails', + ), + ), + ); + + return $fields; + } +} + +GCalendar_Addon::setup(); diff --git a/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php new file mode 100644 index 00000000..61282563 --- /dev/null +++ b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php @@ -0,0 +1,178 @@ +is_valid ) { + return new WP_Error( + 'invalid_bridge', + 'Bridge data is invalid', + (array) $this->data, + ); + } + + $backend = $this->backend; + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend not found' ); + } + + $event = $this->transform_to_event( $payload ); + + if ( is_wp_error( $event ) ) { + return $event; + } + + $endpoint = $this->endpoint; + $method = $this->method; + + return $this->backend->$method( $endpoint, $event ); + } + + /** + * Transforms the form payload into a Google Calendar event structure. + * + * @param array $payload Form submission payload. + * + * @return array|WP_Error Calendar event data. + */ + private function transform_to_event( $payload ) { + $event = array(); + + if ( isset( $payload['summary'] ) ) { + $event['summary'] = $payload['summary']; + } + + if ( isset( $payload['description'] ) ) { + $event['description'] = $payload['description']; + } + + if ( isset( $payload['location'] ) ) { + $event['location'] = $payload['location']; + } + + if ( isset( $payload['start'] ) ) { + $event['start'] = $this->parse_datetime( $payload['start'] ); + } + + if ( isset( $payload['end'] ) ) { + $event['end'] = $this->parse_datetime( $payload['end'] ); + } + + if ( ! isset( $event['end'] ) && isset( $event['start']['dateTime'] ) ) { + $start_time = strtotime( $event['start']['dateTime'] ); + $end_time = $start_time + 3600; + $event['end'] = array( + 'dateTime' => gmdate( 'Y-m-d\TH:i:s', $end_time ), + 'timeZone' => $event['start']['timeZone'], + ); + } + + if ( isset( $payload['attendees'] ) ) { + $attendees = array(); + if ( is_string( $payload['attendees'] ) ) { + $emails = array_map( 'trim', explode( ',', $payload['attendees'] ) ); + foreach ( $emails as $email ) { + if ( is_email( $email ) ) { + $attendees[] = array( 'email' => $email ); + } + } + } elseif ( is_array( $payload['attendees'] ) ) { + foreach ( $payload['attendees'] as $attendee ) { + if ( is_string( $attendee ) && is_email( $attendee ) ) { + $attendees[] = array( 'email' => $attendee ); + } elseif ( is_array( $attendee ) && isset( $attendee['email'] ) ) { + $attendees[] = $attendee; + } + } + } + + if ( ! empty( $attendees ) ) { + $event['attendees'] = $attendees; + } + } + + if ( isset( $payload['reminders'] ) ) { + $event['reminders'] = $payload['reminders']; + } + + if ( isset( $payload['colorId'] ) ) { + $event['colorId'] = $payload['colorId']; + } + + if ( ! isset( $event['summary'] ) ) { + return new WP_Error( + 'missing_summary', + 'Event must have a summary (title)', + $payload + ); + } + + return $event; + } + + /** + * Parses a datetime value into Google Calendar format. + * + * @param mixed $datetime DateTime value (timestamp, string, or array). + * + * @return array DateTime structure for Google Calendar. + */ + private function parse_datetime( $datetime ) { + if ( is_array( $datetime ) && isset( $datetime['dateTime'] ) ) { + return $datetime; + } + + $timezone = wp_timezone_string(); + + if ( is_numeric( $datetime ) ) { + $dt = gmdate( 'Y-m-d\TH:i:s', $datetime ); + } elseif ( is_string( $datetime ) ) { + $timestamp = strtotime( $datetime ); + if ( false === $timestamp ) { + $dt = gmdate( 'Y-m-d\TH:i:s' ); + } else { + $dt = gmdate( 'Y-m-d\TH:i:s', $timestamp ); + } + } else { + $dt = gmdate( 'Y-m-d\TH:i:s' ); + } + + return array( + 'dateTime' => $dt, + 'timeZone' => $timezone, + ); + } +} diff --git a/forms-bridge/addons/gcalendar/hooks.php b/forms-bridge/addons/gcalendar/hooks.php new file mode 100644 index 00000000..ba4bb6fb --- /dev/null +++ b/forms-bridge/addons/gcalendar/hooks.php @@ -0,0 +1,180 @@ + array( + array( + 'ref' => '#credential', + 'name' => 'name', + 'label' => __( 'Name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'schema', + 'type' => 'text', + 'value' => 'Bearer', + ), + array( + 'ref' => '#credential', + 'name' => 'oauth_url', + 'label' => __( 'Authorization URL', 'forms-bridge' ), + 'type' => 'text', + 'value' => 'https://accounts.google.com/o/oauth2/v2', + ), + array( + 'ref' => '#credential', + 'name' => 'client_id', + 'label' => __( 'Client ID', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'client_secret', + 'label' => __( 'Client secret', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'scope', + 'label' => __( 'Scope', 'forms-bridge' ), + 'type' => 'text', + 'value' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events', + 'required' => true, + ), + array( + 'ref' => '#bridge', + 'name' => 'endpoint', + 'label' => __( 'Calendar', 'forms-bridge' ), + 'type' => 'select', + 'options' => array( + 'endpoint' => '/calendar/v3/users/me/calendarList', + 'finger' => array( + 'value' => 'items[].id', + 'label' => 'items[].summary', + ), + ), + 'required' => true, + ), + array( + 'ref' => '#bridge', + 'name' => 'method', + 'value' => 'POST', + ), + array( + 'ref' => '#backend', + 'name' => 'name', + 'default' => 'Calendar API', + ), + array( + 'ref' => '#backend', + 'name' => 'base_url', + 'value' => 'https://www.googleapis.com', + ), + ), + 'backend' => array( + 'name' => 'Calendar API', + 'base_url' => 'https://www.googleapis.com', + 'headers' => array( + array( + 'name' => 'Accept', + 'value' => 'application/json', + ), + ), + ), + 'bridge' => array( + 'backend' => 'Calendar API', + 'endpoint' => '', + ), + 'credential' => array( + 'name' => '', + 'schema' => 'Bearer', + 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', + 'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events', + 'client_id' => '', + 'client_secret' => '', + 'access_token' => '', + 'expires_at' => 0, + 'refresh_token' => '', + ), + ), + $defaults, + $schema + ); + + return $defaults; + }, + 10, + 3 +); + +add_filter( + 'forms_bridge_template_data', + function ( $data, $template_id ) { + if ( strpos( $template_id, 'gcalendar-' ) !== 0 ) { + return $data; + } + + $data['bridge']['endpoint'] = '/calendar/v3/calendars/' . $data['bridge']['endpoint'] . '/events'; + return $data; + }, + 10, + 2 +); + +add_filter( + 'http_bridge_oauth_url', + function ( $url, $verb ) { + if ( false === strpos( $url, 'accounts.google.com' ) ) { + return $url; + } + + if ( 'auth' === $verb ) { + return $url; + } + + return "https://oauth2.googleapis.com/{$verb}"; + }, + 10, + 2 +); diff --git a/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php b/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php index cd93cfb6..8d855fde 100644 --- a/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php +++ b/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php @@ -186,6 +186,9 @@ public function submit( $payload = array(), $attachments = array() ) { } $backend = $this->backend; + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend not found' ); + } $sheets = $this->get_sheets( $backend ); if ( is_wp_error( $sheets ) ) { From 9351ef063d7075d273f7343db0f0670475828e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 26 Nov 2025 11:45:53 +0100 Subject: [PATCH 2/5] feat: google calendar template and jobs --- .../gcalendar/class-gcalendar-addon.php | 65 +++-- .../gcalendar/class-gcalendar-form-bridge.php | 8 + forms-bridge/addons/gcalendar/hooks.php | 9 +- .../addons/gcalendar/jobs/event-dates.php | 88 +++++++ .../addons/gcalendar/templates/meetings.php | 248 ++++++++++++++++++ 5 files changed, 385 insertions(+), 33 deletions(-) create mode 100644 forms-bridge/addons/gcalendar/jobs/event-dates.php create mode 100644 forms-bridge/addons/gcalendar/templates/meetings.php diff --git a/forms-bridge/addons/gcalendar/class-gcalendar-addon.php b/forms-bridge/addons/gcalendar/class-gcalendar-addon.php index bcf79323..5985589d 100644 --- a/forms-bridge/addons/gcalendar/class-gcalendar-addon.php +++ b/forms-bridge/addons/gcalendar/class-gcalendar-addon.php @@ -55,7 +55,7 @@ public function ping( $backend ) { array( 'name' => '__gcalendar-' . time(), 'backend' => $backend, - 'endpoint' => '/calendars/primary', + 'endpoint' => '/calendar/v3/users/me/calendarList', 'method' => 'GET', ) ); @@ -141,12 +141,11 @@ public function fetch( $endpoint, $backend ) { * @return array List of fields and content type of the endpoint. */ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { - if ( 'POST' !== $method ) { + if ( ! in_array( $method, array( 'POST', 'PUT' ), true ) ) { return array(); } - // Google Calendar events have a standard schema - $fields = array( + return array( array( 'name' => 'summary', 'schema' => array( @@ -169,43 +168,53 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { ), ), array( - 'name' => 'start.dateTime', + 'name' => 'start', 'schema' => array( - 'type' => 'string', - 'description' => 'Start date and time (ISO 8601 format)', - ), - ), - array( - 'name' => 'start.timeZone', - 'schema' => array( - 'type' => 'string', - 'description' => 'Start timezone', - ), - ), - array( - 'name' => 'end.dateTime', - 'schema' => array( - 'type' => 'string', - 'description' => 'End date and time (ISO 8601 format)', + 'type' => 'object', + 'properties' => array( + 'dateTime' => array( + 'type' => 'string', + 'description' => 'Start date and time (ISO 8601 format)', + ), + 'timeZone' => array( + 'type' => 'string', + 'description' => 'Start timezone', + ), + ), + 'additionalProperties' => false, + 'required' => array( 'dateTime' ), ), ), array( - 'name' => 'end.timeZone', + 'name' => 'end', 'schema' => array( - 'type' => 'string', - 'description' => 'End timezone', + 'type' => 'object', + 'properties' => array( + 'dateTime' => array( + 'type' => 'string', + 'description' => 'End date and time (ISO 8601 format)', + ), + 'timeZone' => array( + 'type' => 'string', + 'description' => 'End timezone', + ), + ), + 'additionalProperties' => false, + 'required' => array( 'dateTime' ), ), ), array( 'name' => 'attendees', 'schema' => array( - 'type' => 'string', - 'description' => 'Comma-separated list of attendee emails', + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'description' => 'attendee email address', + ), + 'additionalItems' => true, ), ), ); - - return $fields; } } diff --git a/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php index 61282563..af95fbc9 100644 --- a/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php +++ b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php @@ -132,6 +132,14 @@ private function transform_to_event( $payload ) { $event['colorId'] = $payload['colorId']; } + if ( ! ( isset( $event['start'] ) && isset( $event['end'] ) ) ) { + return new WP_Error( + 'missing_event_dates', + 'Event must have a start and an end date', + $payload, + ); + } + if ( ! isset( $event['summary'] ) ) { return new WP_Error( 'missing_summary', diff --git a/forms-bridge/addons/gcalendar/hooks.php b/forms-bridge/addons/gcalendar/hooks.php index ba4bb6fb..2cbed1e5 100644 --- a/forms-bridge/addons/gcalendar/hooks.php +++ b/forms-bridge/addons/gcalendar/hooks.php @@ -16,8 +16,7 @@ function ( $schema, $addon ) { return $schema; } - $schema['properties']['endpoint']['default'] = - '/calendars/primary/events'; + $schema['properties']['endpoint']['default'] = '/calendars/v3/calendar/{$calendarId}/events'; $schema['properties']['backend']['default'] = 'Calendar API'; @@ -79,7 +78,7 @@ function ( $defaults, $addon, $schema ) { 'name' => 'scope', 'label' => __( 'Scope', 'forms-bridge' ), 'type' => 'text', - 'value' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events', + 'value' => 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events', 'required' => true, ), array( @@ -124,13 +123,13 @@ function ( $defaults, $addon, $schema ) { ), 'bridge' => array( 'backend' => 'Calendar API', - 'endpoint' => '', + 'endpoint' => '/calendar/v3/calendars/{$calendarId}/events', ), 'credential' => array( 'name' => '', 'schema' => 'Bearer', 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', - 'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events', + 'scope' => 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events', 'client_id' => '', 'client_secret' => '', 'access_token' => '', diff --git a/forms-bridge/addons/gcalendar/jobs/event-dates.php b/forms-bridge/addons/gcalendar/jobs/event-dates.php new file mode 100644 index 00000000..250dcb1b --- /dev/null +++ b/forms-bridge/addons/gcalendar/jobs/event-dates.php @@ -0,0 +1,88 @@ + __( 'Event dates', 'forms-bridge' ), + 'description' => array( + 'Given a datetime and a duration in hours and minuts, sets up the event dates', + 'forms-bridge', + ), + 'method' => 'forms_bridge_gcalendar_event_dates', + 'input' => array( + array( + 'name' => 'datetime', + 'schema' => array( 'type' => 'string' ), + 'required' => true, + ), + array( + 'name' => 'duration_hours', + 'schema' => array( 'type' => 'string' ), + 'required' => true, + ), + array( + 'name' => 'duration_minutes', + 'schema' => array( 'type' => 'string' ), + ), + ), + 'output' => array( + array( + 'name' => 'start', + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'dateTime' => array( 'type' => 'string' ), + 'timeZone' => array( 'type' => 'string' ), + ), + 'additionalProperties' => false, + 'required' => array( 'dateTime' ), + ), + ), + array( + 'name' => 'end', + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'dateTime' => array( 'type' => 'string' ), + 'timeZone' => array( 'type' => 'string' ), + ), + 'additionalProperties' => false, + 'required' => array( 'dateTime' ), + ), + ), + ), +); + +/** + * Given a datetime and a duration in hours and minuts, sets up the event dates, + * + * @param array $payload Bridge payload. + * + * @return array + */ +function forms_bridge_gcalendar_event_dates( $payload ) { + $datetime = $payload['datetime']; + $time = strtotime( $datetime ); + $endtime = $time + $payload['duration_hours'] * 3600 + ( $payload['duration_minutes'] ?? 0 ) * 60; + + $timezone = wp_timezone_string(); + + $payload['start'] = array( + 'dateTime' => gmdate( 'Y-m-d\TH:i:s', $time ), + 'timeZone' => $timezone, + ); + + $payload['end'] = array( + 'dateTime' => gmdate( 'Y-m-d\TH:i:s', $endtime ), + 'timeZone' => $timezone, + ); + + return $payload; +} diff --git a/forms-bridge/addons/gcalendar/templates/meetings.php b/forms-bridge/addons/gcalendar/templates/meetings.php new file mode 100644 index 00000000..1975d738 --- /dev/null +++ b/forms-bridge/addons/gcalendar/templates/meetings.php @@ -0,0 +1,248 @@ + __( 'Meetings', 'forms-bridge' ), + 'description' => __( + 'Meetings bridge template. The resulting bridge will convert form submission into Google Calendar events.', + 'forms-bridge', + ), + 'fields' => array( + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'duration_hours', + 'label' => __( 'Duration (Hours)', 'forms-bridge' ), + 'type' => 'number', + 'required' => true, + 'default' => 1, + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'duration_minutes', + 'label' => __( 'Duration (Minutes)', 'forms-bridge' ), + 'type' => 'number', + 'default' => 0, + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'location', + 'label' => __( 'Location', 'forms-bridge' ), + 'description' => __( + 'Geographic location of the event as free-form text', + 'forms-bridge', + ), + 'type' => 'text', + ), + ), + 'bridge' => array( + 'workflow' => array( 'date-fields-to-date', 'event-dates' ), + 'mutations' => array( + array( + array( + 'from' => 'email', + 'to' => 'attendees[0].email', + 'cast' => 'string', + ), + array( + 'from' => 'summary', + 'to' => 'attendees[0].displayName', + 'cast' => 'copy', + ), + ), + ), + ), + 'form' => array( + 'title' => __( 'Meetings', 'forms-bridge' ), + 'fields' => array( + array( + 'name' => 'summary', + 'label' => __( 'Your Name', 'forms-bridge' ), + 'type' => 'text', + ), + array( + 'name' => 'email', + 'label' => __( 'Your Email', 'forms-bridge' ), + 'type' => 'email', + ), + array( + 'name' => 'description', + 'label' => __( 'Comments', 'forms-bridge' ), + 'type' => 'textarea', + ), + array( + 'name' => 'date', + 'label' => __( 'Date', 'forms-bridge' ), + 'type' => 'date', + 'required' => true, + ), + array( + 'name' => 'hour', + 'label' => __( 'Hour', 'forms-bridge' ), + 'type' => 'select', + 'required' => true, + 'options' => array( + array( + 'label' => __( '1 AM', 'forms-bridge' ), + 'value' => '01', + ), + array( + 'label' => __( '2 AM', 'forms-bridge' ), + 'value' => '02', + ), + array( + 'label' => __( '3 AM', 'forms-bridge' ), + 'value' => '03', + ), + array( + 'label' => __( '4 AM', 'forms-bridge' ), + 'value' => '04', + ), + array( + 'label' => __( '5 AM', 'forms-bridge' ), + 'value' => '05', + ), + array( + 'label' => __( '6 AM', 'forms-bridge' ), + 'value' => '06', + ), + array( + 'label' => __( '7 AM', 'forms-bridge' ), + 'value' => '07', + ), + array( + 'label' => __( '8 AM', 'forms-bridge' ), + 'value' => '08', + ), + array( + 'label' => __( '9 AM', 'forms-bridge' ), + 'value' => '09', + ), + array( + 'label' => __( '10 AM', 'forms-bridge' ), + 'value' => '10', + ), + array( + 'label' => __( '11 AM', 'forms-bridge' ), + 'value' => '11', + ), + array( + 'label' => __( '12 AM', 'forms-bridge' ), + 'value' => '12', + ), + array( + 'label' => __( '1 PM', 'forms-bridge' ), + 'value' => '13', + ), + array( + 'label' => __( '2 PM', 'forms-bridge' ), + 'value' => '14', + ), + array( + 'label' => __( '3 PM', 'forms-bridge' ), + 'value' => '15', + ), + array( + 'label' => __( '4 PM', 'forms-bridge' ), + 'value' => '16', + ), + array( + 'label' => __( '5 PM', 'forms-bridge' ), + 'value' => '17', + ), + array( + 'label' => __( '6 PM', 'forms-bridge' ), + 'value' => '18', + ), + array( + 'label' => __( '7 PM', 'forms-bridge' ), + 'value' => '19', + ), + array( + 'label' => __( '8 PM', 'forms-bridge' ), + 'value' => '20', + ), + array( + 'label' => __( '9 PM', 'forms-bridge' ), + 'value' => '21', + ), + array( + 'label' => __( '10 PM', 'forms-bridge' ), + 'value' => '22', + ), + array( + 'label' => __( '11 PM', 'forms-bridge' ), + 'value' => '23', + ), + array( + 'label' => __( '12 PM', 'forms-bridge' ), + 'value' => '24', + ), + ), + ), + array( + 'name' => 'minute', + 'label' => __( 'Minute', 'forms-bridge' ), + 'type' => 'select', + 'required' => true, + 'options' => array( + array( + 'label' => '00', + 'value' => '00.0', + ), + array( + 'label' => '05', + 'value' => '05', + ), + array( + 'label' => '10', + 'value' => '10', + ), + array( + 'label' => '15', + 'value' => '15', + ), + array( + 'label' => '20', + 'value' => '20', + ), + array( + 'label' => '25', + 'value' => '25', + ), + array( + 'label' => '30', + 'value' => '30', + ), + array( + 'label' => '35', + 'value' => '35', + ), + array( + 'label' => '40', + 'value' => '40', + ), + array( + 'label' => '45', + 'value' => '45', + ), + array( + 'label' => '50', + 'value' => '50', + ), + array( + 'label' => '55', + 'value' => '55', + ), + ), + ), + ), + ), +); From 3534baf9e4b8af8417db568197fba5f40927616b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 26 Nov 2025 12:02:12 +0100 Subject: [PATCH 3/5] feat: send updates bridge custom field --- .../gcalendar/class-gcalendar-form-bridge.php | 4 +++ forms-bridge/addons/gcalendar/hooks.php | 32 +++++++++++++++++++ .../addons/gcalendar/templates/meetings.php | 27 ---------------- forms-bridge/addons/suitecrm/hooks.php | 2 +- forms-bridge/addons/vtiger/hooks.php | 2 +- 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php index af95fbc9..d09b0d19 100644 --- a/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php +++ b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php @@ -132,6 +132,10 @@ private function transform_to_event( $payload ) { $event['colorId'] = $payload['colorId']; } + if ( isset( $payload['sendUpdates'] ) ) { + $event['sendUpdates'] = (bool) $payload['sendUpdates']; + } + if ( ! ( isset( $event['start'] ) && isset( $event['end'] ) ) ) { return new WP_Error( 'missing_event_dates', diff --git a/forms-bridge/addons/gcalendar/hooks.php b/forms-bridge/addons/gcalendar/hooks.php index 2cbed1e5..55bdf1e1 100644 --- a/forms-bridge/addons/gcalendar/hooks.php +++ b/forms-bridge/addons/gcalendar/hooks.php @@ -100,6 +100,38 @@ function ( $defaults, $addon, $schema ) { 'name' => 'method', 'value' => 'POST', ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'duration_hours', + 'label' => __( 'Duration (Hours)', 'forms-bridge' ), + 'type' => 'number', + 'required' => true, + 'default' => 1, + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'duration_minutes', + 'label' => __( 'Duration (Minutes)', 'forms-bridge' ), + 'type' => 'number', + 'default' => 0, + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'location', + 'label' => __( 'Location', 'forms-bridge' ), + 'description' => __( + 'Geographic location of the event as free-form text', + 'forms-bridge', + ), + 'type' => 'text', + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'sendUpdates', + 'label' => __( 'Send email notification', 'forms-bridge' ), + 'type' => 'boolean', + 'default' => true, + ), array( 'ref' => '#backend', 'name' => 'name', diff --git a/forms-bridge/addons/gcalendar/templates/meetings.php b/forms-bridge/addons/gcalendar/templates/meetings.php index 1975d738..719a0ace 100644 --- a/forms-bridge/addons/gcalendar/templates/meetings.php +++ b/forms-bridge/addons/gcalendar/templates/meetings.php @@ -15,33 +15,6 @@ 'Meetings bridge template. The resulting bridge will convert form submission into Google Calendar events.', 'forms-bridge', ), - 'fields' => array( - array( - 'ref' => '#bridge/custom_fields[]', - 'name' => 'duration_hours', - 'label' => __( 'Duration (Hours)', 'forms-bridge' ), - 'type' => 'number', - 'required' => true, - 'default' => 1, - ), - array( - 'ref' => '#bridge/custom_fields[]', - 'name' => 'duration_minutes', - 'label' => __( 'Duration (Minutes)', 'forms-bridge' ), - 'type' => 'number', - 'default' => 0, - ), - array( - 'ref' => '#bridge/custom_fields[]', - 'name' => 'location', - 'label' => __( 'Location', 'forms-bridge' ), - 'description' => __( - 'Geographic location of the event as free-form text', - 'forms-bridge', - ), - 'type' => 'text', - ), - ), 'bridge' => array( 'workflow' => array( 'date-fields-to-date', 'event-dates' ), 'mutations' => array( diff --git a/forms-bridge/addons/suitecrm/hooks.php b/forms-bridge/addons/suitecrm/hooks.php index b53729c3..75dbc4d1 100644 --- a/forms-bridge/addons/suitecrm/hooks.php +++ b/forms-bridge/addons/suitecrm/hooks.php @@ -101,7 +101,7 @@ function ( $defaults, $addon, $schema ) { 'name' => 'base_url', 'label' => __( 'SuiteCRM URL', 'forms-bridge' ), 'description' => __( - 'Base URL of your SuiteCRM installation (e.g., https://crm.example.com)', + 'Base URL of your SuiteCRM installation (e.g., https://crm.example.coop)', 'forms-bridge' ), 'type' => 'url', diff --git a/forms-bridge/addons/vtiger/hooks.php b/forms-bridge/addons/vtiger/hooks.php index a11aee77..ab4e9f21 100644 --- a/forms-bridge/addons/vtiger/hooks.php +++ b/forms-bridge/addons/vtiger/hooks.php @@ -104,7 +104,7 @@ function ( $defaults, $addon, $schema ) { 'name' => 'base_url', 'label' => __( 'Vtiger URL', 'forms-bridge' ), 'description' => __( - 'Base URL of your Vtiger installation (e.g., https://crm.example.com)', + 'Base URL of your Vtiger installation (e.g., https://crm.example.coop)', 'forms-bridge' ), 'type' => 'url', From 1e1a678506192a4d47680de5bc1e0d3ef449bd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 26 Nov 2025 12:26:28 +0100 Subject: [PATCH 4/5] feat: google addons tests --- tests/addons/test-gcalendar.php | 708 +++++++++++++++++++++++++++ tests/addons/test-gsheets.php | 824 +++++++++++++++++++++++++++++++- tests/addons/test-vtiger.php | 8 +- 3 files changed, 1534 insertions(+), 6 deletions(-) create mode 100644 tests/addons/test-gcalendar.php diff --git a/tests/addons/test-gcalendar.php b/tests/addons/test-gcalendar.php new file mode 100644 index 00000000..ef7dcd3e --- /dev/null +++ b/tests/addons/test-gcalendar.php @@ -0,0 +1,708 @@ + 'gcalendar-test-credential', + 'schema' => 'Bearer', + 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'access_token' => 'test-access-token-12345', + 'expires_at' => time() + 3600, + 'refresh_token' => 'test-refresh-token', + ) + ), + ); + } + + /** + * Test backend provider. + * + * @return Backend[] + */ + public static function backends_provider() { + return array( + new Backend( + array( + 'name' => 'gcalendar-test-backend', + 'base_url' => 'https://www.googleapis.com', + 'credential' => 'gcalendar-test-credential', + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'Accept', + 'value' => 'application/json', + ), + ), + ) + ), + ); + } + + /** + * HTTP requests interceptor. + * + * @param mixed $pre Initial pre hook value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public static function pre_http_request( $pre, $args, $url ) { + self::$request = array( + 'args' => $args, + 'url' => $url, + ); + + // Parse the URL to determine the operation. + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + + // Return appropriate mock response based on path and method. + if ( self::$mock_response ) { + $response_body = self::$mock_response; + } else { + $response_body = self::get_mock_response( $path, $args ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'Success', + ), + 'headers' => array( 'Content-Type' => 'application/json' ), + 'cookies' => array(), + 'body' => wp_json_encode( $response_body ), + 'http_response' => null, + ); + } + + /** + * Get mock response based on API path. + * + * @param string $path API path. + * @param array $args Request arguments. + * + * @return array Mock response. + */ + private static function get_mock_response( $path, $args ) { + $method = $args['method'] ?? 'GET'; + + if ( strpos( $path, '/calendarList' ) !== false ) { + // Calendar list endpoint. + return array( + 'kind' => 'calendar#calendarList', + 'items' => array( + array( + 'id' => 'primary', + 'summary' => 'Primary Calendar', + ), + array( + 'id' => 'test@group.calendar.google.com', + 'summary' => 'Test Calendar', + ), + ), + ); + } + + if ( strpos( $path, '/events' ) !== false && 'POST' === $method ) { + // Create event endpoint. + $body = json_decode( $args['body'], true ); + $event = array_merge( + array( + 'kind' => 'calendar#event', + 'id' => 'test-event-id-' . time(), + 'status' => 'confirmed', + 'htmlLink' => 'https://www.google.com/calendar/event?eid=test', + 'created' => gmdate( 'c' ), + 'updated' => gmdate( 'c' ), + ), + $body + ); + return $event; + } + + if ( strpos( $path, '/events' ) !== false && 'PUT' === $method ) { + // Update event endpoint. + $body = json_decode( $args['body'], true ); + return array_merge( + array( + 'kind' => 'calendar#event', + 'id' => 'test-event-id-12345', + 'status' => 'confirmed', + 'updated' => gmdate( 'c' ), + ), + $body + ); + } + + // Default response. + return array( + 'success' => true, + ); + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$request = null; + self::$mock_response = null; + + tests_add_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + tests_add_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + tests_add_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + } + + /** + * Tear down test filters. + */ + public function tear_down() { + remove_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + remove_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + remove_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + + parent::tear_down(); + } + + /** + * Test that the addon class exists and has correct constants. + */ + public function test_addon_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\GCalendar_Addon' ) ); + $this->assertEquals( 'Google Calendar', GCalendar_Addon::TITLE ); + $this->assertEquals( 'gcalendar', GCalendar_Addon::NAME ); + $this->assertEquals( '\FORMS_BRIDGE\GCalendar_Form_Bridge', GCalendar_Addon::BRIDGE ); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\GCalendar_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-gcalendar-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'invalid-bridge', + // Missing required fields. + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test creating a calendar event with all fields. + */ + public function test_create_event() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-create-event-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Team Meeting', + 'description' => 'Weekly team sync meeting', + 'location' => 'Conference Room A', + 'start' => '2024-03-20T10:00:00', + 'end' => '2024-03-20T11:00:00', + 'attendees' => 'john@example.coop,jane@example.coop', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'id', $response['data'] ); + $this->assertEquals( 'Team Meeting', $response['data']['summary'] ); + $this->assertEquals( 'Weekly team sync meeting', $response['data']['description'] ); + $this->assertEquals( 'Conference Room A', $response['data']['location'] ); + } + + /** + * Test creating event with minimal fields. + */ + public function test_create_event_minimal() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-minimal-event-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Quick Meeting', + 'start' => '2024-03-20T14:00:00', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'Quick Meeting', $response['data']['summary'] ); + $this->assertArrayHasKey( 'end', $response['data'] ); + } + + /** + * Test creating event with numeric timestamp. + */ + public function test_create_event_with_timestamp() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-timestamp-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $start_time = strtotime( '2024-03-20 10:00:00' ); + $end_time = strtotime( '2024-03-20 11:00:00' ); + + $payload = array( + 'summary' => 'Timestamp Event', + 'start' => $start_time, + 'end' => $end_time, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'start', $response['data'] ); + $this->assertArrayHasKey( 'dateTime', $response['data']['start'] ); + } + + /** + * Test creating event with attendees as array. + */ + public function test_create_event_attendees_array() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-attendees-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Team Meeting', + 'start' => '2024-03-20T10:00:00', + 'attendees' => array( 'john@example.coop', 'jane@example.coop' ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'attendees', $response['data'] ); + $this->assertCount( 2, $response['data']['attendees'] ); + } + + /** + * Test creating event with structured attendees. + */ + public function test_create_event_attendees_structured() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-structured-attendees-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Important Meeting', + 'start' => '2024-03-20T10:00:00', + 'attendees' => array( + array( + 'email' => 'john@example.coop', + 'optional' => false, + ), + array( + 'email' => 'jane@example.coop', + 'optional' => true, + ), + ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'attendees', $response['data'] ); + } + + /** + * Test error when summary is missing. + */ + public function test_error_missing_summary() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-missing-summary-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'start' => '2024-03-20T10:00:00', + 'end' => '2024-03-20T11:00:00', + ); + + $response = $bridge->submit( $payload ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'missing_summary', $response->get_error_code() ); + } + + /** + * Test error when start date is missing. + */ + public function test_error_missing_start_date() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-missing-start-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Meeting', + ); + + $response = $bridge->submit( $payload ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'missing_event_dates', $response->get_error_code() ); + } + + /** + * Test automatic end time calculation. + */ + public function test_auto_end_time() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-auto-end-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Auto End Meeting', + 'start' => '2024-03-20T10:00:00', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'end', $response['data'] ); + $this->assertArrayHasKey( 'dateTime', $response['data']['end'] ); + } + + /** + * Test creating event with color. + */ + public function test_create_event_with_color() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-color-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Colored Event', + 'start' => '2024-03-20T10:00:00', + 'colorId' => '5', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'colorId', $response['data'] ); + $this->assertEquals( '5', $response['data']['colorId'] ); + } + + /** + * Test creating event with reminders. + */ + public function test_create_event_with_reminders() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-reminders-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Event with Reminders', + 'start' => '2024-03-20T10:00:00', + 'reminders' => array( + 'useDefault' => false, + 'overrides' => array( + array( + 'method' => 'email', + 'minutes' => 1440, + ), + array( + 'method' => 'popup', + 'minutes' => 10, + ), + ), + ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'reminders', $response['data'] ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Test Event', + 'start' => '2024-03-20T10:00:00', + ); + + $response = $bridge->submit( $payload ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'gcalendar' ); + $response = $addon->ping( 'gcalendar-test-backend' ); + + $this->assertTrue( $response ); + } + + /** + * Test addon ping with invalid backend. + */ + public function test_addon_ping_invalid_backend() { + Backend::temp_registration( + array( + 'name' => 'gcalendar-invalid-host-backend', + 'base_url' => 'https://wrong.example.coop', + 'credential' => 'gcalendar-test-credential', + 'headers' => array(), + ) + ); + + $addon = Addon::addon( 'gcalendar' ); + $response = $addon->ping( 'gcalendar-invalid-host-backend' ); + + $this->assertFalse( $response ); + } + + /** + * Test addon get_endpoint_schema method for POST. + */ + public function test_addon_get_endpoint_schema_post() { + Backend::temp_registration( + array( + 'name' => 'gcalendar-test-backend', + 'base_url' => 'https://www.googleapis.com', + 'credential' => 'gcalendar-test-credential', + 'headers' => array(), + ) + ); + + $addon = Addon::addon( 'gcalendar' ); + $schema = $addon->get_endpoint_schema( + '/calendar/v3/calendars/primary/events', + 'gcalendar-test-backend', + 'POST' + ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'summary', $field_names ); + $this->assertContains( 'description', $field_names ); + $this->assertContains( 'location', $field_names ); + $this->assertContains( 'start', $field_names ); + $this->assertContains( 'end', $field_names ); + $this->assertContains( 'attendees', $field_names ); + } + + /** + * Test addon get_endpoint_schema method for GET returns empty. + */ + public function test_addon_get_endpoint_schema_get() { + Backend::temp_registration( + array( + 'name' => 'gcalendar-test-backend', + 'base_url' => 'https://www.googleapis.com', + 'credential' => 'gcalendar-test-credential', + 'headers' => array(), + ) + ); + + $addon = Addon::addon( 'gcalendar' ); + $schema = $addon->get_endpoint_schema( + '/calendar/v3/calendars/primary/events', + 'gcalendar-test-backend', + 'GET' + ); + + $this->assertIsArray( $schema ); + $this->assertEmpty( $schema ); + } + + /** + * Test invalid email in attendees is filtered out. + */ + public function test_invalid_email_filtered() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-invalid-email-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Meeting', + 'start' => '2024-03-20T10:00:00', + 'attendees' => 'valid@example.coop,invalid-email,another@example.coop', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'attendees', $response['data'] ); + $this->assertCount( 2, $response['data']['attendees'] ); + } + + /** + * Test creating event with already structured datetime. + */ + public function test_structured_datetime() { + $bridge = new GCalendar_Form_Bridge( + array( + 'name' => 'test-structured-datetime-bridge', + 'backend' => 'gcalendar-test-backend', + 'endpoint' => '/calendar/v3/calendars/primary/events', + 'method' => 'POST', + ) + ); + + $payload = array( + 'summary' => 'Structured DateTime Event', + 'start' => array( + 'dateTime' => '2024-03-20T10:00:00', + 'timeZone' => 'America/New_York', + ), + 'end' => array( + 'dateTime' => '2024-03-20T11:00:00', + 'timeZone' => 'America/New_York', + ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'start', $response['data'] ); + $this->assertEquals( '2024-03-20T10:00:00', $response['data']['start']['dateTime'] ); + $this->assertEquals( 'America/New_York', $response['data']['start']['timeZone'] ); + } +} diff --git a/tests/addons/test-gsheets.php b/tests/addons/test-gsheets.php index 7574d3ba..40a19a69 100644 --- a/tests/addons/test-gsheets.php +++ b/tests/addons/test-gsheets.php @@ -1,14 +1,834 @@ 'gsheets-test-credential', + 'schema' => 'Bearer', + 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'access_token' => 'test-access-token-12345', + 'expires_at' => time() + 3600, + 'refresh_token' => 'test-refresh-token', + ) + ), + ); + } + + /** + * Test backend provider. + * + * @return Backend[] + */ + public static function backends_provider() { + return array( + new Backend( + array( + 'name' => 'gsheets-test-backend', + 'base_url' => 'https://sheets.googleapis.com/v4/spreadsheets', + 'credential' => 'gsheets-test-credential', + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'Accept', + 'value' => 'application/json', + ), + ), + ) + ), + ); + } + + /** + * HTTP requests interceptor. + * + * @param mixed $pre Initial pre hook value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public static function pre_http_request( $pre, $args, $url ) { + self::$request = array( + 'args' => $args, + 'url' => $url, + ); + + // Parse the URL to determine the operation. + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + $query = $parsed_url['query'] ?? ''; + + // Return appropriate mock response based on path and method. + if ( self::$mock_response ) { + $response_body = self::$mock_response; + } else { + $response_body = self::get_mock_response( $path, $query, $args ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'Success', + ), + 'headers' => array( 'Content-Type' => 'application/json' ), + 'cookies' => array(), + 'body' => wp_json_encode( $response_body ), + 'http_response' => null, + ); + } + + /** + * Get mock response based on API path. + * + * @param string $path API path. + * @param string $query Query string. + * @param array $args Request arguments. + * + * @return array Mock response. + */ + private static function get_mock_response( $path, $query, $args ) { + $method = $args['method'] ?? 'GET'; + + // Get spreadsheet metadata (list of sheets). + if ( preg_match( '/\/spreadsheets\/([^\/]+)$/', $path, $matches ) && 'GET' === $method ) { + return array( + 'spreadsheetId' => self::SPREADSHEET_ID, + 'properties' => array( + 'title' => 'Test Spreadsheet', + 'locale' => 'en_US', + 'timeZone' => 'America/New_York', + ), + 'sheets' => array( + array( + 'properties' => array( + 'sheetId' => 0, + 'title' => 'Sheet1', + 'index' => 0, + 'sheetType' => 'GRID', + ), + ), + array( + 'properties' => array( + 'sheetId' => 1234567890, + 'title' => 'Contacts', + 'index' => 1, + 'sheetType' => 'GRID', + ), + ), + ), + ); + } + + // Get sheet values (headers or data). + if ( strpos( $path, '/values/' ) !== false && 'GET' === $method ) { + // Extract sheet name from path. + if ( preg_match( '/\/values\/([^!]+)!/', $path, $matches ) ) { + $sheet = urldecode( $matches[1] ); + + // Return headers for the first row request. + if ( strpos( $path, '!1:1' ) !== false ) { + return array( + 'range' => "{$sheet}!A1:Z1", + 'majorDimension' => 'ROWS', + 'values' => array( + array( 'Name', 'Email', 'Phone', 'Company' ), + ), + ); + } + + // Return data rows. + return array( + 'range' => "{$sheet}!A1:D10", + 'majorDimension' => 'ROWS', + 'values' => array( + array( 'Name', 'Email', 'Phone', 'Company' ), + array( 'John Doe', 'john@example.coop', '555-1234', 'Acme Corp' ), + array( 'Jane Smith', 'jane@example.coop', '555-5678', 'Test Inc' ), + ), + ); + } + + return array( + 'range' => 'Sheet1!A1:Z1', + 'majorDimension' => 'ROWS', + 'values' => array(), + ); + } + + // Append values to sheet. + if ( strpos( $path, '/values/' ) !== false && strpos( $path, ':append' ) !== false && 'POST' === $method ) { + $body = json_decode( $args['body'], true ); + return array( + 'spreadsheetId' => self::SPREADSHEET_ID, + 'tableRange' => 'Contacts!A1:D1', + 'updates' => array( + 'spreadsheetId' => self::SPREADSHEET_ID, + 'updatedRange' => 'Contacts!A2:D2', + 'updatedRows' => 1, + 'updatedColumns' => 4, + 'updatedCells' => 4, + ), + ); + } + + // Create new sheet (batchUpdate). + if ( strpos( $path, ':batchUpdate' ) !== false && 'POST' === $method ) { + $body = json_decode( $args['body'], true ); + return array( + 'spreadsheetId' => self::SPREADSHEET_ID, + 'replies' => array( + array( + 'addSheet' => array( + 'properties' => array( + 'sheetId' => time(), + 'title' => $body['requests'][0]['addSheet']['properties']['title'] ?? 'New Sheet', + 'index' => $body['requests'][0]['addSheet']['properties']['index'] ?? 0, + 'sheetType' => 'GRID', + ), + ), + ), + ), + ); + } + + // List spreadsheets (Drive API). + if ( strpos( $path, '/drive/v3/files' ) !== false ) { + return array( + 'kind' => 'drive#fileList', + 'files' => array( + array( + 'id' => self::SPREADSHEET_ID, + 'name' => 'Test Spreadsheet', + 'mimeType' => 'application/vnd.google-apps.spreadsheet', + ), + array( + 'id' => '2CxjNWt1YSB6oGNeLvCeEZkmVVrqsumct85PhuF3vqnt', + 'name' => 'Another Spreadsheet', + 'mimeType' => 'application/vnd.google-apps.spreadsheet', + ), + ), + ); + } + + // Default response. + return array( + 'success' => true, + ); + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$request = null; + self::$mock_response = null; + + tests_add_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + tests_add_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + tests_add_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + } + + /** + * Tear down test filters. + */ + public function tear_down() { + remove_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + remove_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + remove_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + + parent::tear_down(); + } + + /** + * Test that the addon class exists and has correct constants. + */ + public function test_addon_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\GSheets_Addon' ) ); + $this->assertEquals( 'Google Sheets', GSheets_Addon::TITLE ); + $this->assertEquals( 'gsheets', GSheets_Addon::NAME ); + $this->assertEquals( '\FORMS_BRIDGE\GSheets_Form_Bridge', GSheets_Addon::BRIDGE ); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\GSheets_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-gsheets-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'invalid-bridge', + // Missing required fields. + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test appending data to existing sheet. + */ + public function test_append_to_existing_sheet() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-append-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'Alice Johnson', + 'Email' => 'alice@example.coop', + 'Phone' => '555-9999', + 'Company' => 'NewCo', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'updates', $response['data'] ); + $this->assertEquals( 1, $response['data']['updates']['updatedRows'] ); + } + + /** + * Test appending data to new sheet (auto-creates sheet). + */ + public function test_append_to_new_sheet() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-new-sheet-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'NewSheet', + ) + ); + + $payload = array( + 'Name' => 'Bob Smith', + 'Email' => 'bob@example.coop', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } + + /** + * Test appending data with headers auto-creation. + */ + public function test_append_creates_headers() { + // Mock empty headers response. + self::$mock_response = array( + 'range' => 'EmptySheet!A1:Z1', + 'majorDimension' => 'ROWS', + 'values' => array(), + ); + + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-headers-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'EmptySheet', + ) + ); + + // Reset mock after getting sheets list. + add_filter( + 'pre_http_request', + function ( $pre, $args, $url ) { + if ( strpos( $url, '/values/' ) !== false && strpos( $url, '!1:1' ) !== false ) { + return array( + 'response' => array( + 'code' => 200, + 'message' => 'Success', + ), + 'headers' => array( 'Content-Type' => 'application/json' ), + 'cookies' => array(), + 'body' => wp_json_encode( + array( + 'range' => 'EmptySheet!A1:Z1', + 'majorDimension' => 'ROWS', + 'values' => array(), + ) + ), + 'http_response' => null, + ); + } + return $pre; + }, + 11, + 3 + ); + + $payload = array( + 'Field1' => 'Value1', + 'Field2' => 'Value2', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + } + + /** + * Test flattening nested payload. + */ + public function test_flatten_nested_payload() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-flatten-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'Test User', + 'Email' => 'test@example.coop', + 'Address' => array( + 'Street' => '123 Main St', + 'City' => 'New York', + 'State' => 'NY', + ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } + + /** + * Test flattening array values to comma-separated string. + */ + public function test_flatten_array_values() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-array-values-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'Test User', + 'Email' => 'test@example.coop', + 'Tags' => array( 'customer', 'premium', 'active' ), + 'Products' => array( 'Product A', 'Product B' ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } + + /** + * Test getting headers from sheet. + */ + public function test_get_headers() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-headers-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $headers = $bridge->get_headers(); + + $this->assertFalse( is_wp_error( $headers ) ); + $this->assertIsArray( $headers ); + $this->assertContains( 'Name', $headers ); + $this->assertContains( 'Email', $headers ); + $this->assertContains( 'Phone', $headers ); + $this->assertContains( 'Company', $headers ); + } + + /** + * Test error when backend is invalid. + */ + public function test_error_invalid_backend() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $response = $bridge->submit( array( 'Name' => 'Test' ) ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'gsheets' ); + $response = $addon->ping( 'gsheets-test-backend' ); + + $this->assertTrue( $response ); + } + + /** + * Test addon ping with invalid backend host. + */ + public function test_addon_ping_invalid_host() { + Backend::temp_registration( + array( + 'name' => 'gsheets-invalid-host-backend', + 'base_url' => 'https://wrong.example.coop', + 'credential' => 'gsheets-test-credential', + 'headers' => array(), + ) + ); + + $addon = Addon::addon( 'gsheets' ); + $response = $addon->ping( 'gsheets-invalid-host-backend' ); + + $this->assertFalse( $response ); + } + + /** + * Test addon ping without credential. + */ + public function test_addon_ping_no_credential() { + Backend::temp_registration( + array( + 'name' => 'gsheets-no-cred-backend', + 'base_url' => 'https://sheets.googleapis.com/v4/spreadsheets', + 'credential' => 'non-existent-credential', + 'headers' => array(), + ) + ); + + $addon = Addon::addon( 'gsheets' ); + $response = $addon->ping( 'gsheets-no-cred-backend' ); + + $this->assertFalse( $response ); + } + + /** + * Test addon fetch method (list spreadsheets). + */ + public function test_addon_fetch() { + $addon = Addon::addon( 'gsheets' ); + $response = $addon->fetch( '', 'gsheets-test-backend' ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'files', $response['data'] ); + $this->assertNotEmpty( $response['data']['files'] ); + } + + /** + * Test addon get_endpoint_schema method for POST. + */ + public function test_addon_get_endpoint_schema_post() { + // First create a bridge so it can be found by get_endpoint_schema. + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-schema-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + // Register the bridge temporarily. + $bridges = array( $bridge ); + tests_add_filter( + 'forms_bridge_bridges', + function ( $existing_bridges, $addon ) use ( $bridges ) { + if ( 'gsheets' === $addon ) { + return $bridges; + } + + return $existing_bridges; + }, + 10, + 2 + ); + + $addon = Addon::addon( 'gsheets' ); + $schema = $addon->get_endpoint_schema( + '/' . self::SPREADSHEET_ID, + 'gsheets-test-backend', + 'POST' + ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'Name', $field_names ); + $this->assertContains( 'Email', $field_names ); + $this->assertContains( 'Phone', $field_names ); + $this->assertContains( 'Company', $field_names ); + } + + /** + * Test addon get_endpoint_schema method for GET returns empty. + */ + public function test_addon_get_endpoint_schema_get() { + $addon = Addon::addon( 'gsheets' ); + $schema = $addon->get_endpoint_schema( + '/' . self::SPREADSHEET_ID, + 'gsheets-test-backend', + 'GET' + ); + + $this->assertIsArray( $schema ); + $this->assertEmpty( $schema ); + } + + /** + * Test appending with partial data (missing columns). + */ + public function test_append_partial_data() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-partial-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'Partial User', + 'Email' => 'partial@example.coop', + // Missing Phone and Company. + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } + + /** + * Test appending with extra fields not in headers. + */ + public function test_append_extra_fields() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-extra-fields-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'Extra User', + 'Email' => 'extra@example.coop', + 'Phone' => '555-0000', + 'Company' => 'Extra Corp', + 'ExtraField' => 'This should be ignored', + 'Another' => 'Also ignored', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } + + /** + * Test empty payload handling. + */ + public function test_empty_payload() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-empty-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertFalse( is_wp_error( $response ) ); + } + + /** + * Test case-insensitive sheet name matching. + */ + public function test_case_insensitive_sheet_name() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-case-insensitive-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'CONTACTS', + ) + ); + + $payload = array( + 'Name' => 'Case Test', + 'Email' => 'case@example.coop', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + } + + /** + * Test PUT method (should work like POST). + */ + public function test_put_method() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-put-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'PUT', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'PUT User', + 'Email' => 'put@example.coop', + 'Phone' => '555-1111', + 'Company' => 'PUT Corp', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } + + /** + * Test complex nested structure flattening. + */ + public function test_complex_nested_flattening() { + $bridge = new GSheets_Form_Bridge( + array( + 'name' => 'test-complex-bridge', + 'backend' => 'gsheets-test-backend', + 'endpoint' => '/' . self::SPREADSHEET_ID, + 'method' => 'POST', + 'tab' => 'Contacts', + ) + ); + + $payload = array( + 'Name' => 'Complex User', + 'Email' => 'complex@example.coop', + 'Metadata' => array( + 'Source' => 'Web Form', + 'Campaign' => array( + 'Name' => 'Spring 2024', + 'Type' => 'Email', + ), + 'Tags' => array( 'lead', 'qualified' ), + ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + } } diff --git a/tests/addons/test-vtiger.php b/tests/addons/test-vtiger.php index 4ab3a6e4..2c910bdc 100644 --- a/tests/addons/test-vtiger.php +++ b/tests/addons/test-vtiger.php @@ -265,13 +265,13 @@ private static function get_mock_response( $operation, $query, $body ) { 'id' => '4x11', 'firstname' => 'John', 'lastname' => 'Doe', - 'email' => 'john.doe@example.com', + 'email' => 'john.doe@example.coop', ), array( 'id' => '4x12', 'firstname' => 'Jane', 'lastname' => 'Smith', - 'email' => 'jane.smith@example.com', + 'email' => 'jane.smith@example.coop', ), ), ); @@ -283,7 +283,7 @@ private static function get_mock_response( $operation, $query, $body ) { 'id' => '4x11', 'firstname' => 'John', 'lastname' => 'Doe', - 'email' => 'john.doe@example.com', + 'email' => 'john.doe@example.coop', 'phone' => '555-1234', ), ); @@ -546,7 +546,7 @@ public function test_create() { $payload = array( 'firstname' => 'John', 'lastname' => 'Doe', - 'email' => 'john.doe@example.com', + 'email' => 'john.doe@example.coop', ); $response = $bridge->submit( $payload ); From 9da3e4e5edeb7aa82917e0490c387ac6e4906296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 26 Nov 2025 12:28:22 +0100 Subject: [PATCH 5/5] feat: add google calendar on readmes --- README.md | 1 + forms-bridge/readme.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 152ee362..8fe74b2e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Forms Bridge has the following addons: - [Brevo](https://formsbridge.codeccoop.org/documentation/brevo/) - [Dolibarr](https://formsbridge.codeccoop.org/documentation/dolibarr/) - [FinanCoop](https://formsbridge.codeccoop.org/documentation/financoop/) +- [Google Calendar](https://formsbridge.codeccoop.org/documentation/google-calendar/) - [Google Sheets](https://formsbridge.codeccoop.org/documentation/google-sheets/) - [Holded](https://formsbridge.codeccoop.org/documentation/holded/) - [Listmonk](https://formsbridge.codeccoop.org/documentation/listmonk/) diff --git a/forms-bridge/readme.txt b/forms-bridge/readme.txt index 03bd41c7..d069c32b 100644 --- a/forms-bridge/readme.txt +++ b/forms-bridge/readme.txt @@ -36,6 +36,7 @@ Forms Bridge has the following addons: * [Brevo](https://formsbridge.codeccoop.org/documentation/brevo/) * [Dolibarr](https://formsbridge.codeccoop.org/documentation/dolibarr/) * [FinanCoop](https://formsbridge.codeccoop.org/documentation/financoop/) +* [Google Calendar](https://formsbridge.codeccoop.org/documentation/google-calendar/) * [Google Sheets](https://formsbridge.codeccoop.org/documentation/google-sheets/) * [Holded](https://formsbridge.codeccoop.org/documentation/holded/) * [Listmonk](https://formsbridge.codeccoop.org/documentation/listmonk/)