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/addons/gcalendar/assets/logo.png b/forms-bridge/addons/gcalendar/assets/logo.png new file mode 100644 index 00000000..0767b22c Binary files /dev/null and b/forms-bridge/addons/gcalendar/assets/logo.png differ 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..5985589d --- /dev/null +++ b/forms-bridge/addons/gcalendar/class-gcalendar-addon.php @@ -0,0 +1,221 @@ + '__gcalendar-' . time(), + 'backend' => $backend, + 'endpoint' => '/calendar/v3/users/me/calendarList', + '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 ( ! in_array( $method, array( 'POST', 'PUT' ), true ) ) { + return array(); + } + + return 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', + 'schema' => array( + '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', + 'schema' => array( + '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' => 'array', + 'items' => array( + 'type' => 'string', + 'description' => 'attendee email address', + ), + 'additionalItems' => true, + ), + ), + ); + } +} + +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..d09b0d19 --- /dev/null +++ b/forms-bridge/addons/gcalendar/class-gcalendar-form-bridge.php @@ -0,0 +1,190 @@ +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( $payload['sendUpdates'] ) ) { + $event['sendUpdates'] = (bool) $payload['sendUpdates']; + } + + 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', + '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..55bdf1e1 --- /dev/null +++ b/forms-bridge/addons/gcalendar/hooks.php @@ -0,0 +1,211 @@ + 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.readonly 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' => '#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', + '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' => '/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.readonly 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/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..719a0ace --- /dev/null +++ b/forms-bridge/addons/gcalendar/templates/meetings.php @@ -0,0 +1,221 @@ + __( 'Meetings', 'forms-bridge' ), + 'description' => __( + 'Meetings bridge template. The resulting bridge will convert form submission into Google Calendar events.', + 'forms-bridge', + ), + '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', + ), + ), + ), + ), + ), +); 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 ) ) { 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', 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/) 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 );