This is an example page. It\'s different from a blog post because it will stay in one place and will show up in your site navigation (in most themes). Most people start with an About page that introduces them to potential site visitors. It might say something like this:
\n\n\n\n
Hi there! I\'m a bike messenger by day, aspiring actor by night, and this is my website. I live in Los Angeles, have a great dog named Jack, and I like piña coladas. (And gettin\' caught in the rain.)
\n\n\n\n
...or something like this:
\n\n\n\n
The XYZ Doohickey Company was founded in 1971, and has been providing quality doohickeys to the public ever since. Located in Gotham City, XYZ employs over 2,000 people and does all kinds of awesome things for the Gotham community.
\n\n\n\n
As a new WordPress user, you should go to your dashboard to delete this page and create new pages for your content. Have fun!
Suggested text: Our website address is: http://localhost:8000.
Comments
Suggested text: When visitors leave comments on the site we collect the data shown in the comments form, and also the visitor’s IP address and browser user agent string to help spam detection.
An anonymized string created from your email address (also called a hash) may be provided to the Gravatar service to see if you are using it. The Gravatar service privacy policy is available here: https://automattic.com/privacy/. After approval of your comment, your profile picture is visible to the public in the context of your comment.
Media
Suggested text: If you upload images to the website, you should avoid uploading images with embedded location data (EXIF GPS) included. Visitors to the website can download and extract any location data from images on the website.
Cookies
Suggested text: If you leave a comment on our site you may opt-in to saving your name, email address and website in cookies. These are for your convenience so that you do not have to fill in your details again when you leave another comment. These cookies will last for one year.
If you visit our login page, we will set a temporary cookie to determine if your browser accepts cookies. This cookie contains no personal data and is discarded when you close your browser.
When you log in, we will also set up several cookies to save your login information and your screen display choices. Login cookies last for two days, and screen options cookies last for a year. If you select "Remember Me", your login will persist for two weeks. If you log out of your account, the login cookies will be removed.
If you edit or publish an article, an additional cookie will be saved in your browser. This cookie includes no personal data and simply indicates the post ID of the article you just edited. It expires after 1 day.
Embedded content from other websites
Suggested text: Articles on this site may include embedded content (e.g. videos, images, articles, etc.). Embedded content from other websites behaves in the exact same way as if the visitor has visited the other website.
These websites may collect data about you, use cookies, embed additional third-party tracking, and monitor your interaction with that embedded content, including tracking your interaction with the embedded content if you have an account and are logged in to that website.
Who we share your data with
Suggested text: If you request a password reset, your IP address will be included in the reset email.
How long we retain your data
Suggested text: If you leave a comment, the comment and its metadata are retained indefinitely. This is so we can recognize and approve any follow-up comments automatically instead of holding them in a moderation queue.
For users that register on our website (if any), we also store the personal information they provide in their user profile. All users can see, edit, or delete their personal information at any time (except they cannot change their username). Website administrators can also see and edit that information.
What rights you have over your data
Suggested text: If you have an account on this site, or have left comments, you can request to receive an exported file of the personal data we hold about you, including any data you have provided to us. You can also request that we erase any personal data we hold about you. This does not include any data we are obliged to keep for administrative, legal, or security purposes.
Where your data is sent
Suggested text: Visitor comments may be checked through an automated spam detection service.
','Privacy Policy','','draft','closed','open','','privacy-policy','','','2022-07-12 21:17:35','2022-07-12 21:17:35','',0,'http://localhost:8000/?page_id=3',0,'page','',0);
+/*!40000 ALTER TABLE `wp_posts` ENABLE KEYS */;
+UNLOCK TABLES;
+
+--
+-- Table structure for table `wp_term_relationships`
+--
+
+DROP TABLE IF EXISTS `wp_term_relationships`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `wp_term_relationships` (
+ `object_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `term_taxonomy_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `term_order` int(11) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`object_id`,`term_taxonomy_id`),
+ KEY `term_taxonomy_id` (`term_taxonomy_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Dumping data for table `wp_term_relationships`
+--
+
+LOCK TABLES `wp_term_relationships` WRITE;
+/*!40000 ALTER TABLE `wp_term_relationships` DISABLE KEYS */;
+INSERT INTO `wp_term_relationships` VALUES (1,1,0);
+/*!40000 ALTER TABLE `wp_term_relationships` ENABLE KEYS */;
+UNLOCK TABLES;
+
+--
+-- Table structure for table `wp_term_taxonomy`
+--
+
+DROP TABLE IF EXISTS `wp_term_taxonomy`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `wp_term_taxonomy` (
+ `term_taxonomy_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `term_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `taxonomy` varchar(32) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `description` longtext COLLATE utf8mb4_unicode_520_ci NOT NULL,
+ `parent` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `count` bigint(20) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`term_taxonomy_id`),
+ UNIQUE KEY `term_id_taxonomy` (`term_id`,`taxonomy`),
+ KEY `taxonomy` (`taxonomy`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Dumping data for table `wp_term_taxonomy`
+--
+
+LOCK TABLES `wp_term_taxonomy` WRITE;
+/*!40000 ALTER TABLE `wp_term_taxonomy` DISABLE KEYS */;
+INSERT INTO `wp_term_taxonomy` VALUES (1,1,'category','',0,1);
+/*!40000 ALTER TABLE `wp_term_taxonomy` ENABLE KEYS */;
+UNLOCK TABLES;
+
+--
+-- Table structure for table `wp_termmeta`
+--
+
+DROP TABLE IF EXISTS `wp_termmeta`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `wp_termmeta` (
+ `meta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `term_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `meta_key` varchar(255) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
+ `meta_value` longtext COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
+ PRIMARY KEY (`meta_id`),
+ KEY `term_id` (`term_id`),
+ KEY `meta_key` (`meta_key`(191))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Dumping data for table `wp_termmeta`
+--
+
+LOCK TABLES `wp_termmeta` WRITE;
+/*!40000 ALTER TABLE `wp_termmeta` DISABLE KEYS */;
+/*!40000 ALTER TABLE `wp_termmeta` ENABLE KEYS */;
+UNLOCK TABLES;
+
+--
+-- Table structure for table `wp_terms`
+--
+
+DROP TABLE IF EXISTS `wp_terms`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `wp_terms` (
+ `term_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `name` varchar(200) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `slug` varchar(200) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `term_group` bigint(10) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`term_id`),
+ KEY `slug` (`slug`(191)),
+ KEY `name` (`name`(191))
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Dumping data for table `wp_terms`
+--
+
+LOCK TABLES `wp_terms` WRITE;
+/*!40000 ALTER TABLE `wp_terms` DISABLE KEYS */;
+INSERT INTO `wp_terms` VALUES (1,'Uncategorized','uncategorized',0);
+/*!40000 ALTER TABLE `wp_terms` ENABLE KEYS */;
+UNLOCK TABLES;
+
+--
+-- Table structure for table `wp_usermeta`
+--
+
+DROP TABLE IF EXISTS `wp_usermeta`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `wp_usermeta` (
+ `umeta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) unsigned NOT NULL DEFAULT 0,
+ `meta_key` varchar(255) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
+ `meta_value` longtext COLLATE utf8mb4_unicode_520_ci DEFAULT NULL,
+ PRIMARY KEY (`umeta_id`),
+ KEY `user_id` (`user_id`),
+ KEY `meta_key` (`meta_key`(191))
+) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Dumping data for table `wp_usermeta`
+--
+
+LOCK TABLES `wp_usermeta` WRITE;
+/*!40000 ALTER TABLE `wp_usermeta` DISABLE KEYS */;
+INSERT INTO `wp_usermeta` VALUES (1,1,'nickname','admin_tester'),(2,1,'first_name',''),(3,1,'last_name',''),(4,1,'description',''),(5,1,'rich_editing','true'),(6,1,'syntax_highlighting','true'),(7,1,'comment_shortcuts','false'),(8,1,'admin_color','fresh'),(9,1,'use_ssl','0'),(10,1,'show_admin_bar_front','true'),(11,1,'locale',''),(12,1,'wp_capabilities','a:1:{s:13:\"administrator\";b:1;}'),(13,1,'wp_user_level','10'),(14,1,'dismissed_wp_pointers',''),(15,1,'show_welcome_panel','1');
+/*!40000 ALTER TABLE `wp_usermeta` ENABLE KEYS */;
+UNLOCK TABLES;
+
+--
+-- Table structure for table `wp_users`
+--
+
+DROP TABLE IF EXISTS `wp_users`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `wp_users` (
+ `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `user_login` varchar(60) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `user_pass` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `user_nicename` varchar(50) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `user_email` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `user_url` varchar(100) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `user_registered` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `user_activation_key` varchar(255) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ `user_status` int(11) NOT NULL DEFAULT 0,
+ `display_name` varchar(250) COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '',
+ PRIMARY KEY (`ID`),
+ KEY `user_login_key` (`user_login`),
+ KEY `user_nicename` (`user_nicename`),
+ KEY `user_email` (`user_email`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Dumping data for table `wp_users`
+--
+
+LOCK TABLES `wp_users` WRITE;
+/*!40000 ALTER TABLE `wp_users` DISABLE KEYS */;
+INSERT INTO `wp_users` VALUES (1,'admin_tester','$P$Bld8rDAtdMfUik5w32JeGRi4Pw47W41','admin_tester','admin_tester@dev.null','http://localhost:8000','2022-07-12 21:17:34','',0,'admin_tester');
+/*!40000 ALTER TABLE `wp_users` ENABLE KEYS */;
+UNLOCK TABLES;
+/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
+
+/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
+/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
+/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
+/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
+/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
+
+-- Dump completed on 2022-10-17 12:32:32
diff --git a/projects/plugins/crm/tests/_output/.gitignore b/projects/plugins/crm/tests/_output/.gitignore
new file mode 100644
index 000000000000..c96a04f008ee
--- /dev/null
+++ b/projects/plugins/crm/tests/_output/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
\ No newline at end of file
diff --git a/projects/plugins/crm/tests/_support/AcceptanceTester.php b/projects/plugins/crm/tests/_support/AcceptanceTester.php
new file mode 100644
index 000000000000..447896630221
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/AcceptanceTester.php
@@ -0,0 +1,43 @@
+ 'zerobscrm-dash',
+ 'contacts' => 'manage-customers',
+ 'quotes' => 'manage-quotes',
+ 'invoices' => 'manage-invoices',
+ 'transactions' => 'manage-transactions',
+ 'events' => 'manage-events',
+ 'settings' => 'zerobscrm-plugin-settings',
+ 'extensions' => 'zerobscrm-extensions',
+ 'add-edit' => 'zbs-add-edit',
+ );
+
+ public function gotoAdminPage( $page_name, $query = '' ) {
+ $this->amOnAdminPage( 'admin.php?page=' . $this->pages[ $page_name ] . $query );
+ }
+
+}
diff --git a/projects/plugins/crm/tests/_support/FunctionalTester.php b/projects/plugins/crm/tests/_support/FunctionalTester.php
new file mode 100644
index 000000000000..dd1c5b9ba94c
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/FunctionalTester.php
@@ -0,0 +1,26 @@
+setup_database();
+
+ $this->loadSlugs();
+
+ $this->run_server();
+ }
+
+ public function _afterSuite() {
+ parent::_afterSuite();
+
+ $this->check_php_errors();
+
+ $this->stop_server();
+ }
+
+ public function setup_database() {
+ // todo: Setup the database before the test suite. Here we can select which database we're going to use
+ // todo: Update the WP database with the url and hostname
+ }
+
+ public function run_server() {
+ $server_host = $this->config['server_host'];
+ $wp_path = $this->config['wp_path'];
+
+ $wp_version = '-';
+ include_once "$wp_path/wp-includes/version.php"; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
+
+ $serverCmd = "php -S $server_host -t $wp_path";
+
+ $this->serverProcess = new RunProcess( $serverCmd, $this->server_output_path );
+ echo "\e[33mPHP version:\e[96m " . PHP_VERSION;
+ echo "\n\e[33mWordPress installation:\e[96m $wp_path";
+ echo "\n\e[33mWordPress version:\e[96m $wp_version";
+ echo "\n\n\e[33mSetting up the server...";
+
+ $pid = $this->serverProcess->run();
+
+ // Sleep for 1.5 seconds to give time to warm up the server
+ usleep( 1500000 );
+
+ echo "\n\e[97m\e[42m OK \e[49m \e[33mThe server is running at \e[96m$server_host \e[33mwith PID: \e[96m$pid \e[39m\n";
+ }
+
+ public function stop_server() {
+ if ( $this->serverProcess && $this->serverProcess->isRunning() ) {
+ echo "\n\e[33mStopping the server...";
+
+ $this->serverProcess->stop();
+
+ echo " \e[97m\e[42m\e[42m OK \e[49m \e[33mserver stopped\e[39m\n";
+ }
+ }
+
+ public function __destruct() {
+ $this->stop_server();
+ }
+
+ public function check_php_errors() {
+ echo "\n\e[33mChecking PHP errors...";
+
+ $output = fopen( $this->server_output_path, 'r' );
+
+ $php_errors = array(
+ 'Error',
+ 'Notice',
+ 'Parsing Error',
+ 'Warning',
+ 'Fatal error',
+ 'Exception',
+ 'Deprecated',
+ );
+
+ $errors = array();
+
+ while ( ! feof( $output ) ) {
+ $row = fgets( $output );
+ // todo: we can do the same using a regular expression and preg_match
+ foreach ( $php_errors as $php_error ) {
+ if ( strpos( $row, $php_error ) !== false ) {
+ $errors[] = $row;
+ }
+ }
+ }
+
+ if ( count( $errors ) > 0 ) {
+ $this->fail( "\e[91mHey! Something seems wrong.\n\e[39mThere are some PHP errors or notices:\e[93m\n\n" . print_r( $errors, true ) . "\e[39m" );
+ } else {
+ echo " \e[97m\e[42m\e[42m OK \e[49m\e[33m everything went well";
+ }
+ }
+
+ // we use this to load in the default slugs (copied directly from ZeroBSCRM.Core.php)
+ protected function loadSlugs() {
+
+ // Last copied 4.0.8
+ // Begin copy from Core.php
+
+ // WLREMOVE
+ $this->slugs['home'] = 'zerobscrm-plugin';
+ // /WLREMOVE
+ $this->slugs['dash'] = 'zerobscrm-dash';
+ $this->slugs['settings'] = 'zerobscrm-plugin-settings';
+ // $this->slugs['logout'] = "zerobscrm-logout"; // <<< 403 Forbidden
+ $this->slugs['datatools'] = 'zerobscrm-datatools';
+ // $this->slugs['welcome'] = "zerobscrm-welcome"; // <<< 403 Forbidden
+ $this->slugs['crmresources'] = 'jpcrm-resources';
+ $this->slugs['extensions'] = 'zerobscrm-extensions';
+ // $this->slugs['export'] = "zerobscrm-export"; // <<< 403 Forbidden
+ $this->slugs['systemstatus'] = 'zerobscrm-systemstatus';
+ // $this->slugs['sync'] = "zerobscrm-sync"; // <<< 403 Forbidden
+ // These don't seem to be used anymore?
+ // $this->slugs['connect'] = "zerobscrm-connect";
+ // $this->slugs['app'] = "zerobscrm-app";
+ // $this->slugs['whlang'] = "zerobscrm-whlang";
+ // $this->slugs['customfields'] = "zerobscrm-customfields";
+ // $this->slugs['import'] = "zerobscrm-import";
+
+ // CSV importer Lite
+ $this->slugs['csvlite'] = 'zerobscrm-csvimporterlite-app';
+
+ // } FOR NOW wl needs these:
+ // $this->slugs['bulktagger'] = "zerobscrm-batch-tagger"; // <<< 403 Forbidden
+ // $this->slugs['salesdash'] = "sales-dash"; // <<< 403 Forbidden
+ // $this->slugs['stripesync'] = "zerobscrm-stripesync-app"; // <<< 403 Forbidden
+ // $this->slugs['woosync'] = "woo-importer"; // <<< 403 Forbidden
+ // $this->slugs['paypalsync'] = "zerobscrm-paypal-app"; // <<< 403 Forbidden
+
+ // } OTHER UI PAGES WHICH WEREN'T IN SLUG - MS CLASS ADDITION
+ // } WH: Not sure which we're using here, think first set cleaner:
+ // NOTE: DAL3 + these are referenced in DAL2.php so be aware :)
+ // (This helps for generically linking back to list obj etc.)
+ // USE zbsLink!
+ $this->slugs['managecontacts'] = 'manage-customers';
+ $this->slugs['managequotes'] = 'manage-quotes';
+ $this->slugs['manageinvoices'] = 'manage-invoices';
+ $this->slugs['managetransactions'] = 'manage-transactions';
+ $this->slugs['managecompanies'] = 'manage-companies';
+ $this->slugs['manageformscrm'] = 'manage-forms';
+ $this->slugs['segments'] = 'manage-segments';
+ $this->slugs['quote-templates'] = 'manage-quote-templates';
+ $this->slugs['manage-events'] = 'manage-events';
+ // $this->slugs['manage-events-completed'] = "manage-events-completed"; // <<< 403 Forbidden
+ // $this->slugs['managecontactsprev'] = "manage-customers-crm"; // <<< 403 Forbidden
+ // $this->slugs['managequotesprev'] = "manage-quotes-crm"; // <<< 403 Forbidden
+ // $this->slugs['managetransactionsprev'] = "manage-transactions-crm"; // <<< 403 Forbidden
+ // $this->slugs['manageinvoicesprev'] = "manage-invoices-crm"; // <<< 403 Forbidden
+ // $this->slugs['managecompaniesprev'] = "manage-companies-crm"; // <<< 403 Forbidden
+ // $this->slugs['manageformscrmprev'] = "manage-forms-crm"; // <<< 403 Forbidden
+
+ // } NEW UI - ADD or EDIT, SEND EMAIL, NOTIFICATIONS
+ $this->slugs['addedit'] = 'zbs-add-edit';
+ $this->slugs['sendmail'] = 'zerobscrm-send-email';
+
+ $this->slugs['emails'] = 'zerobscrm-emails';
+
+ $this->slugs['notifications'] = 'zerobscrm-notifications';
+
+ // } TEAM - Manage the CRM team permissions
+ $this->slugs['team'] = 'zerobscrm-team';
+
+ // } Export tools
+ $this->slugs['export-tools'] = 'zbs-export-tools';
+
+ // } Your Profile (for Calendar Sync and Personalised Stuff (like your own task history))
+ $this->slugs['your-profile'] = 'your-crm-profile';
+
+ $this->slugs['reminders'] = 'zbs-reminders';
+
+ // } Adds a USER (i.e. puts our menu on user-new.php through ?page =)
+ // $this->slugs['zbs-new-user'] = "zbs-add-user"; // <<< 403 Forbidden
+ // $this->slugs['zbs-edit-user'] = "zbs-edit-user"; // <<< 403 Forbidden
+
+ // emails
+ $this->slugs['email-templates'] = 'zbs-email-templates';
+
+ // tag manager
+ $this->slugs['tagmanager'] = 'tag-manager';
+
+ // } Deletion and no access
+ $this->slugs['zbs-deletion'] = 'zbs-deletion';
+ $this->slugs['zbs-noaccess'] = 'zbs-noaccess';
+
+ // } Modules/extensions
+ $this->slugs['modules'] = 'zerobscrm-modules';
+ $this->slugs['extensions'] = 'zerobscrm-extensions';
+
+ // } File Editor
+ $this->slugs['editfile'] = 'zerobscrm-edit-file';
+
+ // } Extensions Deactivated error
+ $this->slugs['extensions-active'] = 'zbs-extensions-active';
+
+ // End copy from Core.php
+
+ // Addition of add-edit variants to catch edit pages :)
+ // e.g. /wp-admin/admin.php?page=zbs-add-edit&action=edit&zbstype=quotetemplate
+ // ... and tag manager pages
+ // ... and export tool page
+ $types = array( 'contact', 'company', 'quote', 'quotetemplate', 'invoice', 'transaction', 'event', 'form' );
+ foreach ( $types as $type ) {
+
+ // add new
+ $this->slugs[ 'add-new-' . $type ] = 'zbs-add-edit&action=edit&zbstype=' . $type;
+
+ // tag manager
+ $this->slugs[ 'tag-mgr-' . $type ] = 'tag-manager&tagtype=' . $type;
+
+ // export tools
+ $this->slugs[ 'export-tools-' . $type ] = 'zbs-export-tools&zbstype=' . $type;
+
+ }
+ }
+
+ public function getDatabase() {
+ return $this->config['database'];
+ }
+
+ /**
+ * Get the JetpackCRM table name
+ *
+ * @param $name
+ * @return string
+ */
+ public function table( $name ) {
+ return $this->config['jpcrm_prefix'] . $name;
+ }
+
+ public function pdo() {
+ return $this->getModule( 'Db' )->dbh;
+
+ // $dbh->exec('CREATE DATABASE testestest');
+ }
+
+ /**
+ * Load a page from it's core slug
+ *
+ * @param $page_slug
+ * @param string $query
+ */
+ public function goToPageViaSlug( $page_slug, $query = '' ) {
+ $this->amOnAdminPage( 'admin.php?page=' . $this->slugs[ $page_slug ] . $query );
+ }
+
+ /**
+ * retrieve slug pile
+ */
+ public function getSlugs() {
+ // pass back
+ return $this->slugs;
+ }
+
+ /**
+ * Check if see PHP error in the page. Need debug mode on: define( 'WP_DEBUG', true );
+ */
+ public function dontSeeAnyErrors() {
+ $this->dontSee( 'Notice: ' );
+ $this->dontSee( 'Parse error: ' );
+ $this->dontSee( 'Warning: ' );
+ $this->dontSee( 'Fatal error: ' );
+ }
+
+}
diff --git a/projects/plugins/crm/tests/_support/Helper/RunProcess.php b/projects/plugins/crm/tests/_support/Helper/RunProcess.php
new file mode 100644
index 000000000000..1ca3d754a10d
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/Helper/RunProcess.php
@@ -0,0 +1,63 @@
+cmd = $cmd;
+ $this->outputFile = $outputFile;
+ $this->append = $append;
+ }
+
+ public function run() {
+ if ( $this->cmd === null ) {
+ return;
+ }
+
+ $this->pid = (int) shell_exec( sprintf( '%s %s %s 2>&1 & echo $!', $this->cmd, ( $this->append ) ? '>>' : '>', $this->outputFile ) );
+
+ return $this->pid;
+ }
+
+ public function isRunning() {
+ try {
+ $result = shell_exec( sprintf( 'ps %d 2>&1', $this->pid ) );
+ if ( count( preg_split( "/\n/", $result ) ) > 2 && ! preg_match( '/ERROR: Process ID out of range/', $result ) ) {
+ return true;
+ }
+ } catch ( Exception $e ) {
+
+ }
+
+ return false;
+ }
+
+ public function stop() {
+ try {
+ $result = shell_exec( sprintf( 'kill %d 2>&1', $this->pid ) );
+ if ( ! preg_match( '/No such process/', $result ) ) {
+ return true;
+ }
+ } catch ( Exception $e ) {
+
+ }
+
+ return false;
+ }
+}
diff --git a/projects/plugins/crm/tests/_support/Helper/Unit.php b/projects/plugins/crm/tests/_support/Helper/Unit.php
new file mode 100644
index 000000000000..12489a96e18f
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/Helper/Unit.php
@@ -0,0 +1,9 @@
+
+ */
+ protected $requiredFields = array( 'adminUsername', 'adminPassword', 'adminPath' );
+
+ /**
+ * Returns all the cookies whose name matches a regex pattern.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('customer','password');
+ * $I->amOnPage('/shop');
+ * $cartCookies = $I->grabCookiesWithPattern("#^shop_cart\\.*#");
+ * ```
+ *
+ * @param string $cookiePattern The regular expression pattern to use for the matching.
+ *
+ * @return array|null An array of cookies matching the pattern.
+ */
+ public function grabCookiesWithPattern( $cookiePattern ) {
+ /**
+ * @var array
+ */
+ $cookies = $this->client->getCookieJar()->all();
+
+ if ( ! $cookies ) {
+ return null;
+ }
+ $matchingCookies = array_filter(
+ $cookies,
+ static function ( $cookie ) use ( $cookiePattern ) {
+ return preg_match( $cookiePattern, $cookie->getName() );
+ }
+ );
+ $cookieList = array_map(
+ static function ( $cookie ) {
+ return sprintf( '{"%s": "%s"}', $cookie->getName(), $cookie->getValue() );
+ },
+ $matchingCookies
+ );
+
+ $this->debug( 'Cookies matching pattern ' . $cookiePattern . ' : ' . implode( ', ', $cookieList ) );
+
+ return count( $matchingCookies ) ? $matchingCookies : null;
+ }
+
+ /**
+ * In the plugin administration screen activates a plugin clicking the "Activate" link.
+ *
+ * The method will **not** handle authentication to the admin area.
+ *
+ * @example
+ * ```php
+ * // Activate a plugin.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->activatePlugin('hello-dolly');
+ * // Activate a list of plugins.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->activatePlugin(['hello-dolly','another-plugin']);
+ * ```
+ *
+ * @param string|array $pluginSlug The plugin slug, like "hello-dolly" or a list of plugin slugs.
+ *
+ * @return void
+ */
+ public function activatePlugin( $pluginSlug ) {
+ foreach ( (array) $pluginSlug as $plugin ) {
+ $this->checkOption( '//*[@data-slug="' . $plugin . '"]/th/input' );
+ }
+ $this->selectOption( 'action', 'activate-selected' );
+ $this->click( '#doaction' );
+ }
+
+ /**
+ * In the plugin administration screen deactivate a plugin clicking the "Deactivate" link.
+ *
+ * The method will **not** handle authentication and navigation to the plugins administration page.
+ *
+ * @example
+ * ```php
+ * // Deactivate one plugin.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->deactivatePlugin('hello-dolly');
+ * // Deactivate a list of plugins.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->deactivatePlugin(['hello-dolly', 'my-plugin']);
+ * ```
+ *
+ * @param string|array $pluginSlug The plugin slug, like "hello-dolly", or a list of plugin slugs.
+ *
+ * @return void
+ */
+ public function deactivatePlugin( $pluginSlug ) {
+ foreach ( (array) $pluginSlug as $plugin ) {
+ $this->checkOption( '//*[@data-slug="' . $plugin . '"]/th/input' );
+ }
+ $this->selectOption( 'action', 'deactivate-selected' );
+ $this->click( '#doaction' );
+ }
+
+ /**
+ * Validates the module configuration.
+ *
+ * @return void
+ *
+ * @throws \Codeception\Exception\ModuleConfigException|\Codeception\Exception\ModuleException If there's any issue.
+ */
+ protected function validateConfig() {
+ $this->configBackCompat();
+
+ parent::validateConfig();
+ }
+}
diff --git a/projects/plugins/crm/tests/_support/Module/WPBrowserMethods.php b/projects/plugins/crm/tests/_support/Module/WPBrowserMethods.php
new file mode 100644
index 000000000000..54908798634e
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/Module/WPBrowserMethods.php
@@ -0,0 +1,515 @@
+logOut(true);
+ * // Log out using the `wp-login.php` form and remain there.
+ * $I->logOut(false);
+ * // Log out using the `wp-login.php` form and move to another page.
+ * $I->logOut('/some-other-page');
+ * ```
+ *
+ * @param bool|string $redirectTo Whether to redirect to another (optionally specified) page after the logout.
+ *
+ * @return void
+ */
+ public function logOut( $redirectTo = false ) {
+ $previousUri = $this->_getCurrentUri();
+ $loginUri = $this->getLoginUrl();
+ $this->amOnPage( $loginUri . '?action=logout' );
+ // Use XPath to have a better performance and find the link in any language.
+ $this->click( "//a[contains(@href,'action=logout')]" );
+ $this->seeInCurrentUrl( 'loggedout=true' );
+ if ( $redirectTo ) {
+ $redirectUri = $redirectTo === true ? $previousUri : $redirectTo;
+ $this->amOnPage( $redirectUri );
+ }
+ }
+
+ /**
+ * Login as the administrator user using the credentials specified in the module configuration.
+ *
+ * The method will **not** follow redirection, after the login, to any page.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnAdminPage('/');
+ * $I->see('Dashboard');
+ * ```
+ *
+ * @return void
+ */
+ public function loginAsAdmin() {
+ $this->loginAs( $this->config['adminUsername'], $this->config['adminPassword'] );
+ }
+
+ /**
+ * Login as the specified user.
+ *
+ * The method will **not** follow redirection, after the login, to any page.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('user', 'password');
+ * $I->amOnAdminPage('/');
+ * $I->see('Dashboard');
+ * ```
+ *
+ * @param string $username The user login name.
+ * @param string $password The user password in plain text.
+ *
+ * @return void
+ */
+ public function loginAs( $username, $password ) {
+ $this->amOnPage( $this->loginUrl );
+
+ if ( method_exists( $this, 'waitForElementVisible' ) ) {
+ $this->waitForElementVisible( '#loginform' );
+ }
+
+ $params = array(
+ 'log' => $username,
+ 'pwd' => $password,
+ 'testcookie' => '1',
+ 'redirect_to' => '',
+ );
+ $this->submitForm( '#loginform', $params, '#wp-submit' );
+ }
+
+ /**
+ * Initializes the module setting the properties values.
+ *
+ * @return void
+ */
+ public function _initialize() {
+ parent::_initialize();
+
+ $this->configBackCompat();
+
+ $adminPath = $this->config['adminPath'];
+ $this->loginUrl = str_replace( 'wp-admin', 'wp-login.php', $adminPath );
+ $this->adminPath = rtrim( $adminPath, '/' );
+ $this->pluginsPath = $this->adminPath . '/plugins.php';
+ }
+
+ /**
+ * Returns the WordPress authentication cookie.
+ *
+ * @param string|null $pattern The pattern to filter the cookies by.
+ *
+ * @return FacebookWebdriverCookie|Cookie|null The WordPress authorization cookie or `null` if not found.
+ */
+ protected function grabWordPressAuthCookie( $pattern = null ) {
+ if ( ! method_exists( $this, 'grabCookiesWithPattern' ) ) {
+ return null;
+ }
+
+ $pattern = $pattern ? $pattern : '/^wordpress_[a-z0-9]{32}$/';
+ $cookies = $this->grabCookiesWithPattern( $pattern );
+
+ return empty( $cookies ) ? null : array_pop( $cookies );
+ }
+
+ /**
+ * Returns the WordPress login cookie.
+ *
+ * @param string|null $pattern The pattern to filter the cookies by.
+ *
+ * @return FacebookWebdriverCookie|Cookie|null The WordPress login cookie or `null` if not found.
+ */
+ protected function grabWordPressLoginCookie( $pattern = null ) {
+ if ( ! method_exists( $this, 'grabCookiesWithPattern' ) ) {
+ return null;
+ }
+
+ $pattern = $pattern ? $pattern : '/^wordpress_logged_in_[a-z0-9]{32}$/';
+ $cookies = $this->grabCookiesWithPattern( $pattern );
+
+ return empty( $cookies ) ? null : array_pop( $cookies );
+ }
+
+ /**
+ * Go to the plugins administration screen.
+ *
+ * The method will **not** handle authentication.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->activatePlugin('hello-dolly');
+ * ```
+ *
+ * @return void
+ */
+ public function amOnPluginsPage() {
+ if ( ! isset( $this->pluginsPath ) ) {
+ throw new ModuleException( $this, 'Plugins path is not set.' );
+ }
+ $this->amOnPage( $this->pluginsPath );
+ }
+
+ /**
+ * Go the "Pages" administration screen.
+ *
+ * The method will **not** handle authentication.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnPagesPage();
+ * $I->see('Add New');
+ * ```
+ *
+ * @return void
+ */
+ public function amOnPagesPage() {
+ $this->amOnPage( $this->adminPath . '/edit.php?post_type=page' );
+ }
+
+ /**
+ * Assert a plugin is not activated in the plugins administration screen.
+ *
+ * The method will **not** handle authentication and navigation to the plugin administration screen.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->seePluginDeactivated('my-plugin');
+ * ```
+ *
+ * @param string $pluginSlug The plugin slug, like "hello-dolly".
+ *
+ * @return void
+ */
+ public function seePluginDeactivated( $pluginSlug ) {
+ $this->seePluginInstalled( $pluginSlug );
+ $this->seeElement( "table.plugins tr[data-slug='$pluginSlug'].inactive" );
+ }
+
+ /**
+ * Assert a plugin is installed, no matter its activation status, in the plugin adminstration screen.
+ *
+ * The method will **not** handle authentication and navigation to the plugin administration screen.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->seePluginInstalled('my-plugin');
+ * ```
+ *
+ * @param string $pluginSlug The plugin slug, like "hello-dolly".
+ *
+ * @return void
+ */
+ public function seePluginInstalled( $pluginSlug ) {
+ $this->seeElement( "table.plugins tr[data-slug='$pluginSlug']" );
+ }
+
+ /**
+ * Assert a plugin is activated in the plugin administration screen.
+ *
+ * The method will **not** handle authentication and navigation to the plugin administration screen.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->seePluginActivated('my-plugin');
+ * ```
+ *
+ * @param string $pluginSlug The plugin slug, like "hello-dolly".
+ *
+ * @return void
+ */
+ public function seePluginActivated( $pluginSlug ) {
+ $this->seePluginInstalled( $pluginSlug );
+ $this->seeElement( "table.plugins tr[data-slug='$pluginSlug'].active" );
+ }
+
+ /**
+ * Assert a plugin is not installed in the plugins administration screen.
+ *
+ * The method will **not** handle authentication and navigation to the plugin administration screen.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->dontSeePluginInstalled('my-plugin');
+ * ```
+ *
+ * @param string $pluginSlug The plugin slug, like "hello-dolly".
+ *
+ * @return void
+ */
+ public function dontSeePluginInstalled( $pluginSlug ) {
+ $this->dontSeeElement( "table.plugins tr[data-slug='$pluginSlug']" );
+ }
+
+ /**
+ * In an administration screen look for an error admin notice.
+ *
+ * The check is class-based to decouple from internationalization.
+ * The method will **not** handle authentication and navigation the administration area.
+ *
+ * @param string|array $classes A list of classes the notice should have other than the
+ * `.notice.notice-error` ones.
+ *
+ * @return void
+ * @example
+ * ```php
+ * $I->loginAsAdmin()
+ * $I->amOnAdminPage('/');
+ * $I->seeErrorMessage('.my-plugin');
+ * ```
+ */
+ public function seeErrorMessage( $classes = '' ) {
+ $classes = (array) $classes;
+ $classes = implode( '.', $classes );
+
+ $this->seeElement( '.notice.notice-error' . ( $classes ?: '' ) );
+ }
+
+ /**
+ * Checks that the current page is one generated by the `wp_die` function.
+ *
+ * The method will try to identify the page based on the default WordPress die page HTML attributes.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('user', 'password');
+ * $I->amOnAdminPage('/forbidden');
+ * $I->seeWpDiePage();
+ * ```
+ *
+ * @return void
+ */
+ public function seeWpDiePage() {
+ $this->seeElement( 'body#error-page' );
+ }
+
+ /**
+ * In an administration screen look for an admin notice.
+ *
+ * The check is class-based to decouple from internationalization.
+ * The method will **not** handle authentication and navigation the administration area.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin()
+ * $I->amOnAdminPage('/');
+ * $I->seeMessage('.missing-api-token.my-plugin');
+ * ```
+ *
+ * @param array|string $classes A list of classes the message should have in addition to the `.notice` one.
+ *
+ * @return void
+ */
+ public function seeMessage( $classes = '' ) {
+ $classes = (array) $classes;
+ $classes = implode( '.', $classes );
+
+ $this->seeElement( '.notice' . ( $classes ?: '' ) );
+ }
+
+ /**
+ * Returns WordPress default test cookie object if present.
+ *
+ * @example
+ * ```php
+ * // Grab the default WordPress test cookie.
+ * $wpTestCookie = $I->grabWordPressTestCookie();
+ * // Grab a customized version of the test cookie.
+ * $myTestCookie = $I->grabWordPressTestCookie('my_test_cookie');
+ * ```
+ *
+ * @param string $name Optional, overrides the default cookie name.
+ *
+ * @return \Symfony\Component\BrowserKit\Cookie|null Either a cookie object or `null`.
+ */
+ public function grabWordPressTestCookie( $name = null ) {
+ $name = $name ?: 'wordpress_test_cookie';
+
+ return $this->grabCookie( $name );
+ }
+
+ /**
+ * Go to a page in the admininstration area of the site.
+ *
+ * This method will **not** handle authentication to the administration area.
+ *
+ * @example
+ *
+ * ```php
+ * $I->loginAs('user', 'password');
+ * // Go to the plugins management screen.
+ * $I->amOnAdminPage('/plugins.php');
+ * ```
+ *
+ * @param string $page The path, relative to the admin area URL, to the page.
+ *
+ * @return string The admin page path.
+ */
+ public function amOnAdminPage( $page ) {
+ return $this->amOnPage( $this->adminPath . '/' . ltrim( $page, '/' ) );
+ }
+
+ /**
+ * Go to the `admin-ajax.php` page to start a synchronous, and blocking, `GET` AJAX request.
+ *
+ * The method will **not** handle authentication, nonces or authorization.
+ *
+ * @example
+ * ```php
+ * $I->amOnAdminAjaxPage(['action' => 'my-action', 'data' => ['id' => 23], 'nonce' => $nonce]);
+ * ```
+ *
+ * @param string|array $queryVars A string or array of query variables to append to the AJAX path.
+ *
+ * @return string The admin page path.
+ */
+ public function amOnAdminAjaxPage( $queryVars = null ) {
+ $path = 'admin-ajax.php';
+ if ( $queryVars !== null ) {
+ $path .= '?' . ( is_array( $queryVars ) ? build_query( $queryVars ) : ltrim( $queryVars, '?' ) );
+ }
+
+ return $this->amOnAdminPage( $path );
+ }
+
+ /**
+ * Go to the cron page to start a synchronous, and blocking, `GET` request to the cron script.
+ *
+ * @example
+ * ```php
+ * // Triggers the cron job with an optional query argument.
+ * $I->amOnCronPage('/?some-query-var=some-value');
+ * ```
+ *
+ * @param string|array $queryVars A string or array of query variables to append to the AJAX path.
+ *
+ * @return string The page path.
+ */
+ public function amOnCronPage( $queryVars = null ) {
+ $path = 'wp-cron.php';
+ if ( $queryVars !== null ) {
+ $path .= '?' . ( is_array( $queryVars ) ? build_query( $queryVars ) : ltrim( $queryVars, '?' ) );
+ }
+
+ return $this->amOnPage( $path );
+ }
+
+ /**
+ * Go to the admin page to edit the post with the specified ID.
+ *
+ * The method will **not** handle authentication the admin area.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $postId = $I->havePostInDatabase();
+ * $I->amEditingPostWithId($postId);
+ * $I->fillField('post_title', 'Post title');
+ * ```
+ *
+ * @param int $id The post ID.
+ *
+ * @return void
+ */
+ public function amEditingPostWithId( $id ) {
+ if ( ! is_numeric( $id ) || (int) $id !== $id ) {
+ throw new \InvalidArgumentException( 'ID must be an int value' );
+ }
+
+ $this->amOnAdminPage( '/post.php?post=' . $id . '&action=edit' );
+ }
+
+ /**
+ * Configures for back-compatibility.
+ *
+ * @return void
+ */
+ protected function configBackCompat() {
+ if ( isset( $this->config['adminUrl'] ) && ! isset( $this->config['adminPath'] ) ) {
+ $this->config['adminPath'] = $this->config['adminUrl'];
+ }
+ }
+
+ /**
+ * Sets the admin path.
+ *
+ * @param string $adminPath The admin path.
+ *
+ * @return void
+ */
+ protected function setAdminPath( $adminPath ) {
+ $this->adminPath = $adminPath;
+ }
+
+ /**
+ * Returns the admin path.
+ *
+ * @return string The admin path.
+ */
+ protected function getAdminPath() {
+ return $this->adminPath;
+ }
+
+ /**
+ * Sets the login URL.
+ *
+ * @param string $loginUrl The login URL.
+ *
+ * @return void
+ */
+ protected function setLoginUrl( $loginUrl ) {
+ $this->loginUrl = $loginUrl;
+ }
+
+ /**
+ * Returns the login URL.
+ *
+ * @return string The login URL.
+ */
+ private function getLoginUrl() {
+ return $this->loginUrl;
+ }
+}
diff --git a/projects/plugins/crm/tests/_support/Module/WPWebDriver.php b/projects/plugins/crm/tests/_support/Module/WPWebDriver.php
new file mode 100644
index 000000000000..343a3591d0bb
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/Module/WPWebDriver.php
@@ -0,0 +1,273 @@
+
+ */
+ protected $requiredFields = array( 'adminUsername', 'adminPassword', 'adminPath' );
+
+ /**
+ * The login attempts counter.
+ *
+ * @var int
+ */
+ protected $loginAttempt = 0;
+ /**
+ * Login as the administrator user using the credentials specified in the module configuration.
+ *
+ * The method will **not** follow redirection, after the login, to any page.
+ *
+ * @example
+ * ```php
+ * $I->loginAsAdmin();
+ * $I->amOnAdminPage('/');
+ * $I->see('Dashboard');
+ * ```
+ *
+ * @param int $timeout The max time, in seconds, to try to login.
+ * @param int $maxAttempts The max number of attempts to try to login.
+ *
+ * @return void
+ *
+ * @throws ModuleException If all the attempts of obtaining the cookie fail.
+ */
+ public function loginAsAdmin( $timeout = 10, $maxAttempts = 5 ) {
+ $this->loginAs( $this->config['adminUsername'], $this->config['adminPassword'], $timeout, $maxAttempts );
+ }
+
+ /**
+ * Login as the specified user.
+ *
+ * The method will **not** follow redirection, after the login, to any page.
+ * Depending on the driven browser the login might be "too fast" and the server might have not
+ * replied with valid cookies yet; in that case the method will re-attempt the login to obtain
+ * the cookies.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('user', 'password');
+ * $I->amOnAdminPage('/');
+ * $I->see('Dashboard');
+ * ```
+ *
+ * @param string $username The user login name.
+ * @param string $password The user password in plain text.
+ * @param int $timeout The max time, in seconds, to try to login.
+ * @param int $maxAttempts The max number of attempts to try to login.
+ *
+ * @throws ModuleException If all the attempts of obtaining the cookie fail.
+ *
+ * @return void
+ */
+ public function loginAs( $username, $password, $timeout = 10, $maxAttempts = 5 ) {
+ if ( $this->loginAttempt === $maxAttempts ) {
+ throw new ModuleException(
+ __CLASS__,
+ "Could not login as [{$username}, {$password}] after {$maxAttempts} attempts."
+ );
+ }
+
+ $this->debug( "Trying to login, attempt {$this->loginAttempt}/{$maxAttempts}..." );
+
+ $this->amOnPage( $this->getLoginUrl() );
+
+ $this->waitForElement( '#user_login', $timeout );
+ $this->waitForElement( '#user_pass', $timeout );
+ $this->waitForElement( '#wp-submit', $timeout );
+
+ $this->fillField( '#user_login', $username );
+ $this->fillField( '#user_pass', $password );
+ $this->click( '#wp-submit' );
+
+ $authCookie = $this->grabWordPressAuthCookie();
+ $loginCookie = $this->grabWordPressLoginCookie();
+ $empty_cookies = empty( $authCookie ) && empty( $loginCookie );
+
+ if ( $empty_cookies ) {
+ ++$this->loginAttempt;
+ $this->wait( 1 );
+ $this->loginAs( $username, $password, $timeout, $maxAttempts );
+ }
+
+ $this->loginAttempt = 0;
+ }
+
+ /**
+ * Returns all the cookies whose name matches a regex pattern.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('customer','password');
+ * $I->amOnPage('/shop');
+ * $cartCookies = $I->grabCookiesWithPattern("#^shop_cart\\.*#");
+ * ```
+ *
+ * @param string $cookiePattern The regular expression pattern to use for the matching.
+ *
+ * @return array|null An array of cookies matching the pattern.
+ */
+ public function grabCookiesWithPattern( $cookiePattern ) {
+ /** @var array $cookies */
+ $cookies = $this->webDriver->manage()->getCookies();
+
+ if ( ! $cookies ) {
+ return null;
+ }
+ $matchingCookies = array_filter(
+ $cookies,
+ static function ( $cookie ) use ( $cookiePattern ) {
+ return preg_match( $cookiePattern, $cookie->getName() );
+ }
+ );
+ $cookieList = array_map(
+ static function ( $cookie ) {
+ return sprintf( '{"%s": "%s"}', $cookie->getName(), $cookie->getValue() );
+ },
+ $matchingCookies
+ );
+
+ $this->debug( 'Cookies matching pattern ' . $cookiePattern . ' : ' . implode( ', ', $cookieList ) );
+
+ return count( $matchingCookies ) ? $matchingCookies : null;
+ }
+
+ /**
+ * Waits for any jQuery triggered AJAX request to be resolved.
+ *
+ * @example
+ * ```php
+ * $I->amOnPage('/triggering-ajax-requests');
+ * $I->waitForJqueryAjax();
+ * $I->see('From AJAX');
+ * ```
+ *
+ * @param int $time The max time to wait for AJAX requests to complete.
+ *
+ * @return void
+ */
+ public function waitForJqueryAjax( $time = 10 ) {
+ $this->waitForJS( 'return jQuery.active == 0', $time );
+ }
+
+ /**
+ * Grabs the current page full URL including the query vars.
+ *
+ * @example
+ * ```php
+ * $today = date('Y-m-d');
+ * $I->amOnPage('/concerts?date=' . $today);
+ * $I->assertRegExp('#\\/concerts$#', $I->grabFullUrl());
+ * ```
+ *
+ * @return string The full page URL.
+ */
+ public function grabFullUrl() {
+ return $this->executeJS( 'return location.href' );
+ }
+
+ /**
+ * Validates the module configuration..
+ *
+ * @internal
+ *
+ * @return void
+ * @throws ModuleConfigException|ModuleException If there's an issue with the configuration.
+ */
+ protected function validateConfig() {
+ $this->configBackCompat();
+
+ parent::validateConfig();
+ }
+
+ /**
+ * In the plugin administration screen deactivate a plugin clicking the "Deactivate" link.
+ *
+ * The method will **not** handle authentication and navigation to the plugins administration page.
+ *
+ * @example
+ * ```php
+ * // Deactivate one plugin.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->deactivatePlugin('hello-dolly');
+ * // Deactivate a list of plugins.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->deactivatePlugin(['hello-dolly', 'my-plugin']);
+ * ```
+ *
+ * @param string|array $pluginSlug The plugin slug, like "hello-dolly", or a list of plugin slugs.
+ *
+ * @return void
+ */
+ public function deactivatePlugin( $pluginSlug ) {
+ foreach ( (array) $pluginSlug as $plugin ) {
+ $option = '//*[@data-slug="' . $plugin . '"]/th/input';
+ $this->scrollTo( $option, 0, -40 );
+ $this->checkOption( $option );
+ }
+ $this->scrollTo( 'select[name="action"]', 0, -40 );
+ $this->selectOption( 'action', 'deactivate-selected' );
+ $this->click( '#doaction' );
+ }
+
+ /**
+ * In the plugin administration screen activates one or more plugins clicking the "Activate" link.
+ *
+ * The method will **not** handle authentication and navigation to the plugins administration page.
+ *
+ * @example
+ * ```php
+ * // Activate a plugin.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->activatePlugin('hello-dolly');
+ * // Activate a list of plugins.
+ * $I->loginAsAdmin();
+ * $I->amOnPluginsPage();
+ * $I->activatePlugin(['hello-dolly','another-plugin']);
+ * ```
+ *
+ * @param string|array $pluginSlug The plugin slug, like "hello-dolly" or a list of plugin slugs.
+ *
+ * @return void
+ */
+ public function activatePlugin( $pluginSlug ) {
+ $plugins = (array) $pluginSlug;
+ foreach ( $plugins as $plugin ) {
+ $option = '//*[@data-slug="' . $plugin . '"]/th/input';
+ $this->scrollTo( $option, 0, -40 );
+ $this->checkOption( $option );
+ }
+ $this->scrollTo( 'select[name="action"]', 0, -40 );
+ $this->selectOption( 'action', 'activate-selected' );
+ $this->click( '#doaction' );
+ }
+}
diff --git a/projects/plugins/crm/tests/_support/Module/WordPress.php b/projects/plugins/crm/tests/_support/Module/WordPress.php
new file mode 100644
index 000000000000..ba7d654d0bfc
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/Module/WordPress.php
@@ -0,0 +1,481 @@
+
+ */
+ protected $requiredFields = array( 'wpRootFolder', 'adminUsername', 'adminPassword' );
+
+ /**
+ * The default module configuration.
+ *
+ * @var array
+ */
+ protected $config = array( 'adminPath' => '/wp-admin' );
+
+ /**
+ * @var bool
+ */
+ protected $isMockRequest = false;
+
+ /**
+ * @var bool
+ */
+ protected $lastRequestWasAdmin = false;
+
+ /**
+ * @var string
+ */
+ protected $dependencyMessage
+ = <<< EOF
+Example configuring WPDb
+--
+modules
+ enabled:
+ - WPDb:
+ dsn: 'mysql:host=localhost;dbname=wp'
+ user: 'root'
+ password: 'root'
+ dump: 'tests/_data/dump.sql'
+ populate: true
+ cleanup: true
+ reconnect: false
+ url: 'http://wp.dev'
+ tablePrefix: 'wp_'
+ - WordPress:
+ depends: WPDb
+ wpRootFolder: "/Users/Luca/Sites/codeception-acceptance"
+ adminUsername: 'admin'
+ adminPassword: 'admin'
+EOF;
+
+ /**
+ * @var WPDb
+ */
+ protected $wpdbModule;
+
+ /**
+ * @var string The site URL.
+ */
+ protected $siteUrl;
+
+ /**
+ * @var string The string that will hold the response content after each request handling.
+ */
+ public $response = '';
+
+ /**
+ * WordPress constructor.
+ *
+ * @param \Codeception\Lib\ModuleContainer $moduleContainer The module container this module is loaded from.
+ * @param array $config The module configuration
+ * @param WordPressConnector $client The client connector that will process the requests.
+ *
+ * @throws \Codeception\Exception\ModuleConfigException If the configuration is not correct.
+ */
+ public function __construct(
+ ModuleContainer $moduleContainer,
+ $config = array(),
+ WordPressConnector $client = null
+ ) {
+ parent::__construct( $moduleContainer, $config );
+
+ $this->getWpRootFolder();
+ $this->setAdminPath( $this->config['adminPath'] );
+ $this->client = $client ?: $this->buildConnector();
+ }
+
+ /**
+ * Sets up the module.
+ *
+ * @param TestInterface $test The current test.
+ *
+ * @return void
+ *
+ * @throws \Codeception\Exception\ModuleException
+ */
+ public function _before( TestInterface $test ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ /** @var WPDb $wpdb */
+ $wpdb = $this->getModule( 'WPDb' );
+ $this->siteUrl = $wpdb->grabSiteUrl();
+ $this->setLoginUrl( '/wp-login.php' );
+ $this->setupClient( $wpdb->getSiteDomain() );
+ }
+
+ /**
+ * Sets up the client/connector for the request.
+ *
+ * @param string $siteDomain The current site domain, e.g. 'wordpress.test'.
+ *
+ * @return void
+ */
+ private function setupClient( $siteDomain ) {
+ $this->client = $this->client ?: $this->buildConnector();
+ $this->client->setUrl( $this->siteUrl );
+ $this->client->setDomain( $siteDomain );
+ $this->client->setRootFolder( $this->config['wpRootFolder'] );
+ $this->client->followRedirects( true );
+ $this->client->resetCookies();
+ $this->setCookiesFromOptions();
+ }
+
+ /**
+ * Internal method to inject the client to use.
+ *
+ * @param WordPressConnector $client The client object that should be used.
+ *
+ * @return void
+ */
+ public function _setClient( $client ) {
+ $this->client = $client;
+ }
+
+ /**
+ * Returns whether the current request is a mock one or not.
+ *
+ * @param bool $isMockRequest Whether the current request is a mock one or not.
+ *
+ * @return void
+ */
+ public function _isMockRequest( $isMockRequest = false ) {
+ $this->isMockRequest = $isMockRequest;
+ }
+
+ /**
+ * Returns whether the last request was for the admin area or not.
+ *
+ * @return bool Whether the last request was for the admin area or not.
+ */
+ public function _lastRequestWasAdmin() {
+ return $this->lastRequestWasAdmin;
+ }
+
+ /**
+ * Specifies class or module which is required for current one.
+ *
+ * THis method should return array with key as class name and value as error message
+ * [className => errorMessage]
+ *
+ * @return array A list of module dependencies.
+ */
+ public function _depends() {
+ return array( 'Codeception\Module\WPDb' => $this->dependencyMessage );
+ }
+
+ /**
+ * Injects the required modules.
+ *
+ * @param WPDb $wpdbModule An instance of the `WPDb` class.
+ *
+ * @return void
+ */
+ public function _inject( WPDb $wpdbModule ) {
+ $this->wpdbModule = $wpdbModule;
+ }
+
+ /**
+ * Go to a page in the admininstration area of the site.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('user', 'password');
+ * // Go to the plugins management screen.
+ * $I->amOnAdminPage('/plugins.php');
+ * ```
+ *
+ * @param string $page The path, relative to the admin area URL, to the page.
+ *
+ * @return string The resulting page path.
+ */
+ public function amOnAdminPage( $page ) {
+ $preparedPage = $this->preparePage( ltrim( $page, '/' ) );
+ if ( $preparedPage === '/' ) {
+ $preparedPage = 'index.php';
+ }
+ $page = $this->getAdminPath() . '/' . $preparedPage;
+
+ return $this->amOnPage( $page );
+ }
+
+ /**
+ * Prepares the page path for the request.
+ *
+ * @param string $page The input page path.
+ *
+ * @return string The prepared page path.
+ */
+ private function preparePage( $page ) {
+ $page = untrailslashIt( $page );
+ $page = empty( $page ) || preg_match( '~\\/?index\\.php\\/?~', $page ) ? '/' : $page;
+
+ return $page;
+ }
+
+ /**
+ * Go to a page on the site.
+ *
+ * The module will try to reach the page, relative to the URL specified in the module configuration, without
+ * applying any permalink resolution.
+ *
+ * @example
+ * ```php
+ * // Go the the homepage.
+ * $I->amOnPage('/');
+ * // Go to the single page of post with ID 23.
+ * $I->amOnPage('/?p=23');
+ * // Go to search page for the string "foo".
+ * $I->amOnPage('/?s=foo');
+ * ```
+ *
+ * @param string $page The path to the page, relative to the the root URL.
+ *
+ * @return string The page path.
+ */
+ public function amOnPage( $page ) {
+ $this->setRequestType( $page );
+
+ $parts = parseUrl( $page );
+ $parameters = array();
+ if ( ! empty( $parts['query'] ) ) {
+ parse_str( (string) $parts['query'], $parameters );
+ }
+
+ if ( ! $this->client instanceof WordPressConnector ) {
+ throw new ModuleException( $this, 'Connector not yet initialized.' );
+ }
+
+ $this->client->setHeaders( $this->headers );
+
+ if ( $this->isMockRequest ) {
+ return $page;
+ }
+
+ $this->setCookie( 'wordpress_test_cookie', 'WP Cookie check' );
+ $this->_loadPage( 'GET', $page, $parameters );
+
+ return $page;
+ }
+
+ /**
+ * Sets the current type of request.s
+ *
+ * @param string $page The page the request is for.
+ *
+ * @return void
+ */
+ protected function setRequestType( $page ) {
+ if ( $this->isAdminPageRequest( $page ) ) {
+ $this->lastRequestWasAdmin = true;
+ } else {
+ $this->lastRequestWasAdmin = false;
+ }
+ }
+
+ /**
+ * Whether a request is for an admin page or not.
+ *
+ * @param string $page The page to check for.
+ *
+ * @return bool Whether the current request is for an admin page or not.
+ */
+ private function isAdminPageRequest( $page ) {
+ return 0 === strpos( $page, $this->getAdminPath() );
+ }
+
+ /**
+ * Returns a list of recognized domain names for the test site.
+ *
+ * @internal This method is public for inter-operability and compatibility purposes and should
+ * not be considered part of the API.
+ *
+ * @return array A list of the internal domains.
+ */
+ public function getInternalDomains() {
+ $internalDomains = array();
+ $host = parse_url( $this->siteUrl, PHP_URL_HOST ) ?: 'localhost';
+ $internalDomains[] = '#^' . preg_quote( $host, '#' ) . '$#';
+
+ return $internalDomains;
+ }
+
+ /**
+ * Returns the absolute path to the WordPress root folder.
+ *
+ * @example
+ * ```php
+ * $root = $I->getWpRootFolder();
+ * $this->assertFileExists($root . '/someFile.txt');
+ * ```
+ *
+ * @return string The absolute path to the WordPress root folder, without a trailing slash.
+ *
+ * @throws \InvalidArgumentException If the WordPress root folder is not valid.
+ */
+ public function getWpRootFolder() {
+ if ( empty( $this->wpRootFolder ) ) {
+ try {
+ $resolvedWpRoot = resolvePath( (string) $this->config['wpRootFolder'] );
+
+ if ( $resolvedWpRoot === false ) {
+ throw new ModuleConfigException(
+ $this,
+ 'Parameter "wpRootFolder" is not a directory or is not accesssible.'
+ );
+ }
+ $this->wpRootFolder = $resolvedWpRoot;
+ } catch ( \Exception $e ) {
+ throw new ModuleConfigException(
+ __CLASS__,
+ "\nThe path `{$this->config['wpRootFolder']}` is not pointing to a valid WordPress " .
+ 'installation folder: directory not found.'
+ );
+ }
+ if ( ! file_exists( untrailslashit( (string) $this->wpRootFolder ) . '/wp-settings.php' ) ) {
+ throw new ModuleConfigException(
+ __CLASS__,
+ "\nThe `{$this->config['wpRootFolder']}` is not pointing to a valid WordPress installation " .
+ 'folder: wp-settings.php file not found.'
+ );
+ }
+ }
+
+ return $this->wpRootFolder;
+ }
+
+ /**
+ * Returns content of the last response.
+ * This method exposes an underlying API for custom assertions.
+ *
+ * @example
+ * ```php
+ * // In test class.
+ * $this->assertContains($text, $this->getResponseContent(), "foo-bar");
+ * ```
+ *
+ * @return string The response content, in plain text.
+ *
+ * @throws \Codeception\Exception\ModuleException If the underlying modules is not available.
+ */
+ public function getResponseContent() {
+ return $this->_getResponseContent();
+ }
+
+ protected function getAbsoluteUrlFor( $uri ) {
+ $uri = str_replace(
+ $this->siteUrl,
+ 'http://localhost',
+ str_replace( rawurlencode( $this->siteUrl ), rawurlencode( 'http://localhost' ), $uri )
+ );
+ return parent::getAbsoluteUrlFor( $uri );
+ }
+
+ /**
+ * Grab a cookie value from the current session, sets it in the $_COOKIE array and returns its value.
+ *
+ * This method utility is to get, in the scope of test code, the value of a cookie set during the tests.
+ *
+ * @param string $cookie The cookie name.
+ * @param array $params Parameters to filter the cookie value.
+ *
+ * @return string|null The cookie value or `null` if no cookie matching the parameters is found.
+ * @example
+ * ```php
+ * $id = $I->haveUserInDatabase('user', 'subscriber', ['user_pass' => 'pass']);
+ * $I->loginAs('user', 'pass');
+ * // The cookie is now set in the `$_COOKIE` super-global.
+ * $I->extractCookie(LOGGED_IN_COOKIE);
+ * // Generate a nonce using WordPress methods (see WPLoader in loadOnly mode) with correctly set context.
+ * wp_set_current_user($id);
+ * $nonce = wp_create_nonce('wp_rest');
+ * // Use the generated nonce to make a request to the the REST API.
+ * $I->haveHttpHeader('X-WP-Nonce', $nonce);
+ * ```
+ */
+ public function extractCookie( $cookie, array $params = array() ) {
+ $cookieValue = $this->grabCookie( $cookie, $params );
+ $_COOKIE[ $cookie ] = $cookieValue;
+
+ return $cookieValue;
+ }
+
+ /**
+ * Login as the specified user.
+ *
+ * The method will **not** follow redirection, after the login, to any page.
+ *
+ * @example
+ * ```php
+ * $I->loginAs('user', 'password');
+ * $I->amOnAdminPage('/');
+ * $I->seeElement('.admin');
+ * ```
+ *
+ * @param string $username The user login name.
+ * @param string $password The user password in plain text.
+ *
+ * @return void
+ */
+ public function loginAs( $username, $password ) {
+ $this->amOnPage( $this->getLoginUrl() );
+ $this->submitForm(
+ '#loginform',
+ array(
+ 'log' => $username,
+ 'pwd' => $password,
+ 'testcookie' => '1',
+ 'redirect_to' => '',
+ ),
+ '#wp-submit'
+ );
+ }
+
+ /**
+ * Builds and returns an instance of the WordPress connector.
+ *
+ * The method will trigger the load of required Codeception library polyfills.
+ *
+ * @return WordPressConnector
+ */
+ protected function buildConnector() {
+ return new WordPressConnector();
+ }
+}
diff --git a/projects/plugins/crm/tests/_support/UnitTester.php b/projects/plugins/crm/tests/_support/UnitTester.php
new file mode 100644
index 000000000000..2096ab7cf8ef
--- /dev/null
+++ b/projects/plugins/crm/tests/_support/UnitTester.php
@@ -0,0 +1,26 @@
+seeInDatabase(
+ 'wp_options',
+ array(
+ 'option_name' => 'blogname',
+ 'option_value' => 'Jetpack CRM Testing Site',
+ )
+ );
+ }
+
+ /**
+ * PHP server is up
+ * / loads
+ */
+ public function the_server_is_running( AcceptanceTester $I ) {
+ $I->amOnPage( '/' );
+ $I->seeResponseCodeIsSuccessful();
+ $I->see( 'Jetpack CRM Testing Site' );
+ }
+
+ /**
+ * Has WP installed
+ */
+ public function has_wp_admin_access( AcceptanceTester $I ) {
+ $I->amOnPage( '/wp-admin' );
+ $I->seeResponseCodeIsSuccessful();
+ $I->seeInCurrentUrl( 'wp-login.php' );
+ }
+
+ /**
+ * Able to login as admin
+ */
+ public function wp_login_as_admin( AcceptanceTester $I ) {
+ $I->loginAsAdmin();
+ }
+
+}
diff --git a/projects/plugins/crm/tests/acceptance/11_JPCRM_Activation_Cest.php b/projects/plugins/crm/tests/acceptance/11_JPCRM_Activation_Cest.php
new file mode 100644
index 000000000000..de83f7e9cb6b
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/11_JPCRM_Activation_Cest.php
@@ -0,0 +1,31 @@
+amOnPage( '/' );
+ $I->loginAsAdmin();
+ }
+
+ public function jpcrm_activation( AcceptanceTester $I ) {
+ // If it's installed, activate the plugin
+ $I->amOnPluginsPage();
+ $I->seePluginInstalled( 'jetpack-crm' );
+ $I->activatePlugin( 'jetpack-crm' );
+
+ // Activating the plugin directly loads the welcome wizard, so no need to move pages here.
+
+ // check no activation errors
+ $I->dontSeeElement( '#message.error' );
+
+ // The plugin is activated, now we can see the JPCRM set up page
+ $I->see( 'Essential Details' );
+ $I->see( 'Essentials' );
+ $I->see( 'Your Customers' );
+ $I->see( 'Which Extensions?' );
+ $I->see( 'Finish' );
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/12_JPCRM_Admin_Views_Cest.php b/projects/plugins/crm/tests/acceptance/12_JPCRM_Admin_Views_Cest.php
new file mode 100644
index 000000000000..eb923bd2a6e3
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/12_JPCRM_Admin_Views_Cest.php
@@ -0,0 +1,57 @@
+loginAsAdmin();
+ }
+
+ public function see_jpcrm_wp_menu( AcceptanceTester $I ) {
+ $I->amOnPage( 'wp-admin' );
+ $I->see( 'Jetpack CRM', '.wp-menu-name' );
+ }
+
+ public function see_jpcrm_top_menu( AcceptanceTester $I ) {
+
+ $I->gotoAdminPage( 'dashboard' );
+
+ $expectedAdminMenus = array(
+ 'Dashboard',
+ 'Contacts',
+ 'Tools',
+ );
+
+ foreach ( $expectedAdminMenus as $menu ) {
+ $I->see( $menu, '.zbs-admin-main-menu .item' );
+ }
+ }
+ public function see_jpcrm_dashboard( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'dashboard' );
+
+ $expectedBlocks = array(
+ 'Sales Funnel',
+ 'Revenue Chart',
+ // 'Contacts added per month',
+ // 'Total Contacts',
+ // 'Total Leads',
+ // 'Total Customers',
+ // 'Total Transactions',
+ 'Latest Contacts',
+ 'Recent Activity',
+ );
+
+ foreach ( $expectedBlocks as $block_title ) {
+ $I->see( $block_title, 'h4.panel-title' );
+ }
+ }
+
+ public function see_jpcrm_settings( AcceptanceTester $I ) {
+
+ $I->goToPageViaSlug( 'settings' );
+
+ $I->see( 'General Settings' );
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/13_JPCRM_Contacts_Cest.php b/projects/plugins/crm/tests/acceptance/13_JPCRM_Contacts_Cest.php
new file mode 100644
index 000000000000..30bf6fc4e245
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/13_JPCRM_Contacts_Cest.php
@@ -0,0 +1,99 @@
+ 'Testing user',
+ 'zbsc_lname' => 'Last name',
+ 'zbsc_status' => 'Customer',
+ 'zbsc_email' => 'my_email@email.com',
+ 'zbsc_addr1' => 'Address line 1',
+ 'zbsc_addr2' => 'Address line 2',
+ 'zbsc_city' => 'The City',
+ 'zbsc_county' => 'The County',
+ 'zbsc_country' => 'The Country',
+ 'zbsc_postcode' => '1111',
+ 'zbsc_secaddr1' => 'Address2 line 1',
+ 'zbsc_secaddr2' => 'Address2 line 2',
+ 'zbsc_seccity' => 'The City2',
+ 'zbsc_seccounty' => 'The County2',
+ 'zbsc_seccountry' => 'The Country2',
+ 'zbsc_secpostcode' => '2222',
+ 'zbsc_hometel' => '12345678',
+ 'zbsc_worktel' => '87654321',
+ 'zbsc_mobtel' => '11223344',
+ );
+ protected $edit_user_data = array(
+ 'zbsc_fname' => 'Testing user2',
+ 'zbsc_lname' => 'Last name2',
+ 'zbsc_status' => 'Lead',
+ 'zbsc_email' => 'my_email2@email.com',
+ 'zbsc_addr1' => 'Address3 line 1',
+ 'zbsc_addr2' => 'Address3 line 2',
+ 'zbsc_city' => 'The City3',
+ 'zbsc_county' => 'The County3',
+ 'zbsc_country' => 'The Country3',
+ 'zbsc_postcode' => '3333',
+ 'zbsc_secaddr1' => 'Address4 line 1',
+ 'zbsc_secaddr2' => 'Address4 line 2',
+ 'zbsc_seccity' => 'The City4',
+ 'zbsc_seccounty' => 'The County4',
+ 'zbsc_seccountry' => 'The Country4',
+ 'zbsc_secpostcode' => '4444',
+ 'zbsc_hometel' => '+3412345678',
+ 'zbsc_worktel' => '+3487654321',
+ 'zbsc_mobtel' => '+3411223344',
+ );
+
+ public function _before( AcceptanceTester $I ) {
+ $I->loginAsAdmin();
+ }
+
+ public function see_contacts_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'contacts' );
+ $I->see( 'Contacts', '#zbs-admin-top-bar' );
+ }
+
+ public function see_new_contact_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=contact' );
+ $I->see( 'New Contact', '#zbs-list-top-bar' );
+ }
+
+ public function create_new_contact( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=contact' );
+ $I->submitForm( '#zbs-edit-form', $this->user_data );
+
+ $I->seeInDatabase( $I->table( 'contacts' ), $this->user_data );
+
+ // todo: add a tag
+ }
+
+ public function view_contact( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=view&zbstype=contact&zbsid=1' );
+
+ foreach ( $this->user_data as $data ) {
+ $I->see( $data );
+ }
+ }
+
+ public function check_dashboard_has_the_added_contact( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'dashboard' );
+
+ // See the contact data in the Latest Contacts block
+ $I->see( 'Latest Contacts', '#settings_dashboard_latest_contacts_display' );
+ $I->see( $this->user_data['zbsc_fname'], '#settings_dashboard_latest_contacts_display' );
+ $I->see( $this->user_data['zbsc_lname'], '#settings_dashboard_latest_contacts_display' );
+ $I->see( $this->user_data['zbsc_status'], '#settings_dashboard_latest_contacts_display' );
+ }
+
+ public function edit_contact( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=contact&zbsid=1' );
+
+ $I->submitForm( '#zbs-edit-form', $this->edit_user_data );
+
+ $I->seeInDatabase( $I->table( 'contacts' ), $this->edit_user_data );
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/14_JPCRM_Quotes_Cest.php b/projects/plugins/crm/tests/acceptance/14_JPCRM_Quotes_Cest.php
new file mode 100644
index 000000000000..f0255ec305ba
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/14_JPCRM_Quotes_Cest.php
@@ -0,0 +1,79 @@
+ 'Testing quote',
+ 'zbscq_value' => '1000.50',
+ 'zbscq_date' => '01/01/2021',
+ 'zbscq_notes' => 'This is the quote note',
+ 'zbs_quote_content' => 'This is the quote content',
+ );
+
+ protected $quote_data2 = array(
+ 'zbscq_title' => 'Testing quote2',
+ 'zbscq_value' => '1010.50',
+ 'zbscq_date' => '01/02/2021',
+ 'zbscq_notes' => 'This is the quote note2',
+ 'zbs_quote_content' => 'This is the quote content2',
+ );
+
+ protected $expected_quote = array(
+ 'zbsq_title' => 'Testing quote',
+ 'zbsq_value' => '1000.50',
+ 'zbsq_date' => 1609459200,
+ 'zbsq_notes' => 'This is the quote note',
+ 'zbsq_content' => 'This is the quote content',
+ 'zbsq_accepted' => false,
+ );
+
+ protected $tags = array( 'the-tag' );
+
+ public function _before( AcceptanceTester $I ) {
+ $I->loginAsAdmin();
+ }
+
+ public function see_quotes_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'quotes' );
+ $I->see( 'Manage Quotes', '#zbs-admin-top-bar' );
+ }
+
+ public function see_new_quote_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=quote' );
+ $I->see( 'New Quote', '#zbs-admin-top-bar' );
+ }
+
+ public function create_new_quote( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=quote' );
+ $I->submitForm( '#zbs-edit-form', $this->quote_data );
+
+ // todo: add a tag
+
+ $I->seeInDatabase( $I->table( 'quotes' ), $this->expected_quote );
+ }
+
+ public function view_quote( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=quote&zbsid=1' );
+
+ foreach ( $this->quote_data as $field => $data ) {
+ $I->seeInField( $field, $data );
+ }
+ }
+
+ public function edit_quote( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=quote&zbsid=1' );
+ $I->submitForm( '#zbs-edit-form', $this->quote_data2 );
+
+ $expected_quote = array(
+ 'zbsq_title' => 'Testing quote2',
+ 'zbsq_value' => 1010.5,
+ 'zbsq_notes' => 'This is the quote note2',
+ 'zbsq_content' => 'This is the quote content2',
+ );
+
+ $I->seeInDatabase( $I->table( 'quotes' ), $expected_quote );
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/15_JPCRM_Invoices_Cest.php b/projects/plugins/crm/tests/acceptance/15_JPCRM_Invoices_Cest.php
new file mode 100644
index 000000000000..df6fe78bbabd
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/15_JPCRM_Invoices_Cest.php
@@ -0,0 +1,106 @@
+ 'Item-1',
+ 'description' => 'Description of item 1',
+ 'quantity' => 1,
+ 'price' => 10.5,
+ );
+ protected $item2 = array(
+ 'name' => 'Item-2',
+ 'description' => 'Description of item 2',
+ 'quantity' => 2,
+ 'price' => 30,
+ );
+ protected $invoice_data = array(
+ 'zbsi_date' => '2021-01-08',
+ 'zbsi_due' => '30',
+ 'zbs_invoice_contact' => 1,
+ 'zbs_invoice_company' => -1,
+ 'invoice-customiser-type' => 'quantity',
+ 'invoice_discount_total' => 10,
+ 'invoice_discount_type' => '%',
+ 'zbs-tag-list' => '["important"]',
+ );
+
+ protected $invoice_db_data = array(
+ 'zbsi_date' => '1610064000',
+ 'zbsi_due_date' => '1612656000',
+ 'zbsi_hours_or_quantity' => 1,
+ 'zbsi_id_override' => '1',
+ 'zbsi_discount' => 10.0,
+ 'zbsi_discount_type' => '%',
+ 'zbsi_net' => 70.5,
+ 'zbsi_total' => 63.45,
+ 'zbsi_status' => 'Draft',
+ );
+
+ public function __construct() {
+ $this->invoice_data['zbsli_itemname'] = array(
+ $this->item1['name'],
+ $this->item2['name'],
+ );
+ $this->invoice_data['zbsli_itemdes'] = array(
+ $this->item1['description'],
+ $this->item2['description'],
+ );
+ $this->invoice_data['zbsli_quan'] = array(
+ $this->item1['quantity'],
+ $this->item2['quantity'],
+ );
+ $this->invoice_data['zbsli_price'] = array(
+ $this->item1['price'],
+ $this->item2['price'],
+ );
+ }
+
+ public function _before( AcceptanceTester $I ) {
+ $I->loginAsAdmin();
+ }
+
+ public function see_invoices_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'invoices' );
+ $I->see( 'Manage Invoices', '#zbs-admin-top-bar' );
+ }
+
+ public function see_new_invoice_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=invoice' );
+ $I->see( 'New Invoice', '#zbs-list-top-bar' );
+ }
+
+ public function create_new_invoice( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=invoice' );
+ $I->submitForm( '#zbs-edit-form', $this->invoice_data );
+
+ $I->seeInDatabase( $I->table( 'invoices' ), $this->invoice_db_data );
+ }
+
+ public function see_created_invoice( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=invoice&zbsid=1' );
+
+ // We can check only that we reach the page. The data is load via AJAX.
+ $I->see( 'Edit Invoice', '#zbs-list-top-bar' );
+ }
+
+ public function create_second_invoice( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=invoice' );
+
+ $this->invoice_data['invoice_status'] = 'Paid';
+
+ $I->submitForm( '#zbs-edit-form', $this->invoice_data );
+
+ $this->invoice_db_data['zbsi_status'] = 'Paid';
+ $this->invoice_db_data['ID'] = '2';
+ $this->invoice_db_data['zbsi_id_override'] = '2';
+
+ $I->seeInDatabase( $I->table( 'invoices' ), $this->invoice_db_data );
+ }
+
+ // todo: Check invoice autonumber. Configurate the autonumber
+
+}
diff --git a/projects/plugins/crm/tests/acceptance/16_JPCRM_Transactions_Cest.php b/projects/plugins/crm/tests/acceptance/16_JPCRM_Transactions_Cest.php
new file mode 100644
index 000000000000..8a97cef0c0ad
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/16_JPCRM_Transactions_Cest.php
@@ -0,0 +1,87 @@
+ 'Transaction-ref-1',
+ 'zbst_status' => 'Succeeded',
+ 'zbst_title' => 'Transaction 1',
+ 'zbst_total' => '111.30',
+ 'zbst_date_datepart' => '2021-08-01',
+ 'zbst_date_timepart' => '11:00',
+ 'zbst_type' => 'Sale',
+ 'zbst_desc' => 'This is the Transaction 1 description',
+ 'zbst_shipping' => 5.5,
+ 'zbst_shipping_taxes' => '',
+ 'customer' => 1,
+ 'invoice_id' => 1,
+ );
+
+ protected $transaction_db_data = array(
+ 'zbst_ref' => 'Transaction-ref-1',
+ 'zbst_status' => 'Succeeded',
+ 'zbst_title' => 'Transaction 1',
+ 'zbst_total' => 111.30,
+ 'zbst_date' => 1627815600,
+ 'zbst_type' => 'Sale',
+ 'zbst_desc' => 'This is the Transaction 1 description',
+ 'zbst_shipping' => 5.5,
+ 'zbst_shipping_taxes' => '',
+ );
+
+ public function _before( AcceptanceTester $I ) {
+ $I->loginAsAdmin();
+ }
+
+ public function see_transactions_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'transactions' );
+ $I->see( 'Transaction List', '#zbs-admin-top-bar' );
+ }
+
+ public function see_new_transaction_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=transaction' );
+ $I->see( 'New Transaction', '#zbs-list-top-bar' );
+ }
+
+ public function create_new_transaction( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=transaction' );
+
+ $I->seeInField( 'zbscrm_newtransaction', 1 );
+
+ // Get the generated transaction reference >>> doesn't work
+ // $this->transaction_db_data['zbst_ref'] = $I->grabTextFrom( '#ref' );
+
+ $I->submitForm( '#zbs-edit-form', $this->transaction_data );
+
+ $I->seeInDatabase( $I->table( 'transactions' ), $this->transaction_db_data );
+ }
+
+ public function see_created_transaction( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=transaction&zbsid=1' );
+
+ // todo: get zbst_ref value to check it in the view
+ // $I->grabColumnFromDatabase()
+
+ $transaction_view_data = array(
+ 'zbst_ref' => $this->transaction_data['zbst_ref'],
+ 'zbst_status' => $this->transaction_data['zbst_status'],
+ 'zbst_title' => $this->transaction_data['zbst_title'],
+ 'zbst_total' => $this->transaction_data['zbst_total'],
+ 'zbst_date_datepart' => $this->transaction_data['zbst_date_datepart'],
+ 'zbst_date_timepart' => $this->transaction_data['zbst_date_timepart'],
+ 'zbst_type' => $this->transaction_data['zbst_type'],
+ 'zbst_desc' => $this->transaction_data['zbst_desc'],
+ 'customer' => $this->transaction_data['customer'],
+ 'invoice_id' => $this->transaction_data['invoice_id'],
+ );
+
+ $I->see( 'Edit Transaction', '#zbs-list-top-bar' );
+
+ foreach ( $transaction_view_data as $field => $value ) {
+ $I->seeInField( $field, $value );
+ }
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/17_JPCRM_Events_Cest.php b/projects/plugins/crm/tests/acceptance/17_JPCRM_Events_Cest.php
new file mode 100644
index 000000000000..69e8295c8094
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/17_JPCRM_Events_Cest.php
@@ -0,0 +1,94 @@
+ 'Task 1',
+ 'zbse_owner' => '1',
+ 'zbse_start' => '26 January 2021 17:58:30',
+ 'zbse_end' => '04 February 2021 07:58:30',
+ 'zbse_desc' => 'This is the task 1 description',
+ 'zbse_show_on_cal' => 1,
+ 'zbse_customer' => 1,
+ 'zbse_company' => '',
+ 'zbs-task-complete' => -1,
+ 'zbs_remind_task_24' => '24',
+ );
+
+ protected $event_db_data = array(
+ 'zbse_title' => 'Task 1',
+ 'zbs_owner' => '1',
+ 'zbse_start' => 1611683910,
+ 'zbse_end' => 1612425510,
+ 'zbse_desc' => 'This is the task 1 description',
+ 'zbse_show_on_cal' => 1,
+ 'zbse_complete' => -1,
+ );
+
+ public function _before( AcceptanceTester $I ) {
+ $I->loginAsAdmin();
+ }
+
+ public function see_tasks_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'events' );
+ $I->see( 'Task Calendar', '#zbs-admin-top-bar' );
+ }
+
+ public function see_new_task_page( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=event' );
+ $I->see( 'New Task', '#zbs-list-top-bar' );
+ }
+
+ public function create_new_task( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=event' );
+
+ $I->seeInField( 'zbscrm_newevent', 1 );
+
+ $I->submitForm( '#zbs-edit-form', $this->event_data );
+
+ $I->seeInDatabase( $I->table( 'events' ), $this->event_db_data );
+ }
+
+ public function see_created_task( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'add-edit', '&action=edit&zbstype=event&zbsid=1' );
+
+ $event_view_data = array(
+ 'zbse_title' => $this->event_data['zbse_title'],
+ 'zbse_start' => $this->event_data['zbse_start'],
+ 'zbse_end' => $this->event_data['zbse_end'],
+ 'zbse_desc' => $this->event_data['zbse_desc'],
+ 'zbse_show_on_cal' => $this->event_data['zbse_show_on_cal'],
+ 'zbse_customer' => $this->event_data['zbse_customer'],
+ 'zbse_company' => $this->event_data['zbse_company'],
+ 'zbs-task-complete' => $this->event_data['zbs-task-complete'],
+ 'zbs_remind_task_24' => $this->event_data['zbs_remind_task_24'],
+ );
+
+ $I->see( 'Edit Task', '#zbs-list-top-bar' );
+
+ foreach ( $event_view_data as $field => $value ) {
+ $I->seeInField( $field, $value );
+ }
+ }
+
+ public function see_task_in_calendar( AcceptanceTester $I ) {
+ $I->gotoAdminPage( 'events' );
+
+ $I->seeInTitle( 'Task Scheduler' );
+ $I->see( 'Task Calendar', '#zbs-list-top-bar' );
+
+ $event_view_data = array(
+ 'zbse_title' => $this->event_data['zbse_title'],
+ 'zbse_start' => $this->event_data['zbse_start'],
+ 'zbse_end' => $this->event_data['zbse_end'],
+ );
+
+ // Check the value in the Javascript script block
+ foreach ( $event_view_data as $value ) {
+ $I->seeInSource( $value );
+ }
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/50_JPCRM_Settings_Cest.php b/projects/plugins/crm/tests/acceptance/50_JPCRM_Settings_Cest.php
new file mode 100644
index 000000000000..fd1b7998b978
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/50_JPCRM_Settings_Cest.php
@@ -0,0 +1,408 @@
+loginAsAdmin();
+ }
+
+ public function see_settings_page( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings' );
+ $I->see( 'Settings', '#zbs-admin-top-bar' );
+
+ $I->see( '', '.item' );
+ }
+
+ public function see_settings_page_menus( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings' );
+
+ $I->see( 'General', '.item' );
+ $I->see( 'Companies', '.item' );
+ $I->see( 'Quotes', '.item' );
+ $I->see( 'Invoicing', '.item' );
+ $I->see( 'Transactions', '.item' );
+ $I->see( 'Forms', '.item' );
+ $I->see( 'Client Portal', '.item' );
+ $I->see( 'Mail', '.item' );
+ $I->see( 'Extensions', '.item' );
+ }
+
+ public function see_general_settings_page( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=settings' );
+
+ $I->see( 'General Settings', 'h1.header' );
+
+ $I->see( 'Menu Layout', 'label' );
+ $I->see( 'Show Prefix', 'label' );
+ $I->see( 'Show Customer Address Fields', 'label' );
+ $I->see( 'Second Address Fields', 'label' );
+ $I->see( 'Second Address Label', 'label' );
+ $I->see( 'Use "Countries" in Address Fields', 'label' );
+ $I->see( 'Contact Assignment', 'label' );
+ $I->see( 'Assign Ownership', 'label' );
+ $I->see( 'Task Scheduler Ownership', 'label' );
+ $I->see( 'Show Click 2 Call links', 'label' );
+ $I->see( 'Click 2 Call link type', 'label' );
+ $I->see( 'Use Navigation Mode', 'label' );
+ $I->see( 'Show Social Accounts', 'label' );
+ $I->see( 'Use AKA Mode', 'label' );
+ $I->see( 'Contact Image Mode', 'label' );
+ $I->see( 'Override WordPress', 'label' );
+ $I->see( 'Login Logo Override', 'label' );
+ $I->see( 'Custom CRM Header', 'label' );
+ $I->see( 'Disable Front-End', 'label' );
+ $I->see( 'Usage Tracking', 'label' );
+ $I->see( 'Show public credits', 'label' );
+ $I->see( 'Show admin credits', 'label' );
+ $I->see( 'Accepted Upload File Types', 'label' );
+ $I->see( 'Auto-log', 'label' );
+ }
+
+ public function save_general_settings_page( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=settings' );
+
+ $I->seeInField( 'wpzbscrm_avatarmode', '1' );
+ $I->dontSeeCheckboxIsChecked( 'jpcrm_showpoweredby_public' );
+ $I->seeCheckboxIsChecked( 'jpcrm_showpoweredby_admin' );
+
+ $I->selectOption( 'select[name=wpzbscrm_avatarmode]', '2' );
+ $I->checkOption( 'jpcrm_showpoweredby_public' );
+ $I->uncheckOption( 'jpcrm_showpoweredby_admin' );
+
+ $I->click( 'Save Settings', 'button' );
+
+ $I->seeInField( 'wpzbscrm_avatarmode', '2' );
+ $I->seeCheckboxIsChecked( 'jpcrm_showpoweredby_public' );
+ $I->dontSeeCheckboxIsChecked( 'jpcrm_showpoweredby_admin' );
+ }
+
+ public function see_business_info_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=bizinfo' );
+
+ $I->see( 'Business Info', 'h1.header' );
+
+ $I->see( 'Your Business Name', 'label' );
+ $I->see( 'Your Business Logo', 'label' );
+ $I->see( 'Owner Name', 'label' );
+ $I->see( 'Business Contact Email', 'label' );
+ $I->see( 'Business Website URL', 'label' );
+ $I->see( 'Business Telephone Number', 'label' );
+ $I->see( 'Twitter Handle', 'label' );
+ $I->see( 'Facebook Page', 'label' );
+ $I->see( 'LinkedIn ID', 'label' );
+
+ $I->see( 'follow Jetpack CRM' );
+ }
+ public function save_business_info_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=bizinfo' );
+
+ $I->fillField( 'businessname', 'Test Company' );
+ $I->click( 'Save Settings', 'button' );
+
+ $I->seeInField( 'businessname', 'Test Company' );
+ }
+
+ public function see_custom_fields_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=customfields' );
+
+ $I->see( 'Custom Fields', 'h1.header' );
+ $I->see( 'Contact Custom Fields' );
+ $I->see( 'Contact Custom File Upload Boxes' );
+ $I->see( 'Company Custom Fields' );
+ $I->see( 'Quote Custom Fields' );
+ $I->see( 'Invoice Custom Fields' );
+ $I->see( 'Transaction Custom Fields' );
+ $I->see( 'Address Custom Fields' );
+ $I->see( 'Save Custom Fields' );
+ }
+
+ public function save_custom_fields_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=customfields' );
+
+ $custom_field = array(
+ 'wpzbscrm_cf[customers][name][]' => 'New Field',
+ 'wpzbscrm_cf[customers][type][]' => 'text',
+ 'wpzbscrm_cf[customers][placeholder][]' => 'This is the placeholder',
+ );
+
+ $I->submitForm( '[action="?page=zerobscrm-plugin-settings&tab=customfields"]', $custom_field );
+
+ $I->seeInSource( '{"customers":{"new-field":["text","New Field","This is the placeholder","new-field"]}' );
+ }
+
+ public function see_field_sorts_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=fieldsorts' );
+
+ $I->see( 'Field Sorts', 'h1.header' );
+ $I->see( 'Address Fields' );
+ $I->see( 'Contact Fields' );
+ $I->see( 'Company Fields' );
+ }
+
+ public function see_field_options_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=fieldoptions' );
+
+ $I->see( 'Field Options', 'h1.header' );
+ $I->see( 'General Field Options' );
+ $I->see( 'Contact Field Options' );
+ $I->see( 'Funnels' );
+ }
+
+ public function see_list_view_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=listview' );
+
+ $I->see( 'List View', 'h1.header' );
+ $I->see( 'Not Contacted in X Days' );
+ $I->see( 'Allow Inline Edits' );
+ }
+
+ public function save_list_view_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=listview' );
+
+ $I->fillField( 'wpzbscrm_notcontactedinx', '40' );
+ $I->click( 'Save Settings' );
+
+ $I->seeInField( 'wpzbscrm_notcontactedinx', '40' );
+ }
+
+ public function see_tax_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=tax' );
+
+ $I->see( 'Tax Settings', 'h1.header' );
+ $I->see( 'Tax Rates' );
+ }
+
+ public function save_tax_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=tax' );
+
+ $tax_data = array(
+ 'jpcrm-taxtable-line[ids][]' => -1,
+ 'jpcrm-taxtable-line[names][]' => 'IGIC',
+ 'jpcrm-taxtable-line[rates][]' => 7.0,
+ );
+
+ $I->submitForm( 'form', $tax_data );
+
+ $I->seeInSource( '{"id":"1","owner":"1","name":"IGIC","rate":"7.00"' );
+ }
+
+ public function see_license_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=license' );
+
+ $I->see( 'License Key', 'h1.header' );
+ $I->see( 'License Key' );
+ }
+
+ public function see_companies_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=companies' );
+
+ $I->see( 'Companies Settings', 'h1.header' );
+ $I->see( 'General B2B Settings' );
+ $I->see( 'Company Field Options' );
+ }
+
+ public function save_companies_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=companies' );
+
+ $I->selectOption( 'jpcrm_setting_coororg', 'Domain' );
+ $I->fillField( 'jpcrm-status-companies', 'Lead,Customer,Refused,Blacklisted,Priority' );
+ $I->click( 'Save Settings' );
+
+ $I->seeOptionIsSelected( 'jpcrm_setting_coororg', 'Domain' );
+ $I->seeInField( 'jpcrm-status-companies', 'Lead,Customer,Refused,Blacklisted,Priority' );
+ }
+
+ public function see_quotes_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=quotebuilder' );
+
+ $I->see( 'Quotes', 'h1.header' );
+ $I->see( 'Enable Quote Builder' );
+ }
+
+ public function save_quotes_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=quotebuilder' );
+
+ $I->uncheckOption( 'wpzbscrm_usequotebuilder' );
+ $I->click( 'Save Settings' );
+
+ $I->dontSeeCheckboxIsChecked( 'wpzbscrm_usequotebuilder' );
+
+ $I->checkOption( 'wpzbscrm_usequotebuilder' );
+ $I->click( 'Save Settings' );
+
+ $I->seeCheckboxIsChecked( 'wpzbscrm_usequotebuilder' );
+ }
+
+ public function see_invoicing_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=invbuilder' );
+
+ $I->see( 'Invoicing', 'h1.header' );
+ $I->see( 'Reference type' );
+ $I->see( 'Invoice reference label' );
+ $I->see( 'Extra Invoice Info' );
+ $I->see( 'Payment Info' );
+ $I->see( 'Thank You' );
+ $I->see( 'Extra Statement Info' );
+ $I->see( 'Hide Invoice ID' );
+ $I->see( 'Enable tax' );
+ $I->see( 'Enable discounts' );
+ $I->see( 'Enable shipping' );
+ }
+
+ public function save_invoicing_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=invbuilder' );
+
+ $invoicing_data = array(
+ 'refprefix' => 'INV-',
+ 'refnextnum' => 2,
+ 'refsuffix' => '-D',
+ 'reflabel' => 'Ref',
+ 'businessextra' => 'Company info',
+ );
+
+ foreach ( $invoicing_data as $name => $data ) {
+ $I->fillField( $name, $data );
+ }
+
+ $I->click( 'Save Settings' );
+
+ foreach ( $invoicing_data as $name => $data ) {
+ $I->seeInField( $name, $data );
+ }
+ }
+
+ public function see_transactions_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=transactions' );
+
+ $I->see( 'Transactions', 'h1.header' );
+ $I->see( 'Use Shipping' );
+ $I->see( 'Use Paid/Completed Dates' );
+ $I->see( 'Include these statuses in total value' );
+ $I->see( 'Transaction Status' );
+
+ $I->see( 'Additional settings on transactions' );
+ $I->see( 'Show fee' );
+ $I->see( 'Show tax' );
+ $I->see( 'Show discount' );
+ $I->see( 'Show net amount' );
+ }
+
+ public function save_transactions_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=transactions' );
+
+ $I->uncheckOption( 'wpzbscrm_shippingfortransactions' );
+ $I->checkOption( 'wpzbscrm_paiddatestransaction' );
+ $I->checkOption( 'wpzbscrm_transaction_fee' );
+ $I->checkOption( 'wpzbscrm_transaction_tax' );
+ $I->checkOption( 'wpzbscrm_transaction_discount' );
+ $I->checkOption( 'wpzbscrm_transaction_net' );
+
+ $I->click( 'Save Settings' );
+
+ $I->seeCheckboxIsChecked( 'wpzbscrm_paiddatestransaction' );
+ $I->dontSeeCheckboxIsChecked( 'wpzbscrm_shippingfortransactions' );
+ $I->seeCheckboxIsChecked( 'wpzbscrm_transaction_fee' );
+ $I->seeCheckboxIsChecked( 'wpzbscrm_transaction_tax' );
+ $I->seeCheckboxIsChecked( 'wpzbscrm_transaction_discount' );
+ $I->seeCheckboxIsChecked( 'wpzbscrm_transaction_net' );
+ }
+
+ public function see_forms_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=forms' );
+
+ $I->see( 'Forms', 'h1.header' );
+ $I->see( 'Enable reCaptcha' );
+ $I->see( 'reCaptcha Site Key' );
+ $I->see( 'reCaptcha Site Secret' );
+ }
+
+ public function save_forms_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=forms' );
+
+ $I->click( 'Save Settings' );
+ }
+
+ public function see_client_portal_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=clients' );
+
+ $I->see( 'Client Portal', 'h1.header' );
+ $I->see( 'Client Portal Page' );
+ $I->see( 'Allow Easy-Access Links' );
+ $I->see( 'Generate WordPress Users for new contacts' );
+ $I->see( 'Only Generate Users for Statuses' );
+ $I->see( 'Assign extra role when generating users' );
+ $I->see( 'Fields to hide on Portal' );
+ }
+
+ public function save_client_portal_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=clients' );
+
+ $I->uncheckOption( 'wpzbscrm_easyaccesslinks' );
+
+ $I->click( 'Save Settings' );
+
+ $I->dontSeeCheckboxIsChecked( 'wpzbscrm_easyaccesslinks' );
+ }
+
+ /*
+ public function see_api_settings( AcceptanceTester $I )
+ {
+ $I->goToPageViaSlug('settings', '&tab=api');
+
+ $I->see('API Settings', 'h1.header');
+ $I->see( 'API Endpoint' );
+ $I->see( 'API Key' );
+ $I->see( 'API Secret' );
+ }*/
+
+ public function see_mail_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=mail' );
+
+ $I->see( 'Mail Settings', 'h1.header' );
+ $I->see( 'Track Open Statistics' );
+ $I->see( 'Disable SSL Verification' );
+ $I->see( 'Format of Sender Name' );
+ $I->see( 'Email Unsubscribe Line' );
+ $I->see( 'Unsubscribe Page' );
+ $I->see( 'Email Unsubscribe Line' );
+ }
+
+ public function save_mail_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=mail' );
+
+ $I->uncheckOption( 'wpzbscrm_emailtracking' );
+
+ $I->click( 'Save Settings' );
+
+ $I->dontSeeCheckboxIsChecked( 'wpzbscrm_emailtracking' );
+ }
+
+ public function see_mail_delivery_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=maildelivery' );
+
+ $I->see( 'Mail Delivery', 'h1.header' );
+ }
+
+ public function see_locale_settings( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=locale' );
+
+ $I->see( 'Currency Symbol', 'label' );
+ $I->see( 'Currency Format', 'label' );
+ $I->see( 'Install a font', 'label' );
+ }
+
+ public function save_locale_settings_page( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'settings', '&tab=locale' );
+
+ $I->seeInField( 'wpzbscrm_currency', 'USD' );
+
+ $I->selectOption( 'select[name=wpzbscrm_currency]', 'EUR' );
+
+ $I->click( 'Save Settings', 'button' );
+
+ $I->seeInField( 'wpzbscrm_currency', 'EUR' );
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/51_JPCRM_Extensions_Cest.php b/projects/plugins/crm/tests/acceptance/51_JPCRM_Extensions_Cest.php
new file mode 100644
index 000000000000..f795507332fe
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/51_JPCRM_Extensions_Cest.php
@@ -0,0 +1,22 @@
+loginAsAdmin();
+ }
+
+ public function see_modules_page( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'modules' );
+ $I->see( 'Core Modules', '#core-modules' );
+ }
+
+ public function see_extensions_page( AcceptanceTester $I ) {
+ $I->goToPageViaSlug( 'extensions' );
+ $I->see( 'Premium Extensions', '.box-title' );
+ }
+
+}
diff --git a/projects/plugins/crm/tests/acceptance/60_JPCRM_Files_Cest.php b/projects/plugins/crm/tests/acceptance/60_JPCRM_Files_Cest.php
new file mode 100644
index 000000000000..a33a4b1d4b8e
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/60_JPCRM_Files_Cest.php
@@ -0,0 +1,22 @@
+loginAsAdmin();
+ }
+
+ public function see_templates_index_blocker_files( AcceptanceTester $I ) {
+ // attempt to directly load index blocker
+ $I->amOnPage( '/wp-content/plugins/crm/templates/index.html' );
+
+ // see that our html comment is present (means our file was created)
+ $I->seeInSource( '' );
+ }
+
+}
diff --git a/projects/plugins/crm/tests/acceptance/90_JPCRM_Admin_All_Pages_Cest.php b/projects/plugins/crm/tests/acceptance/90_JPCRM_Admin_All_Pages_Cest.php
new file mode 100644
index 000000000000..c5399f03af6b
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/90_JPCRM_Admin_All_Pages_Cest.php
@@ -0,0 +1,34 @@
+loginAsAdmin();
+ }
+
+ /**
+ * Page Checker
+ * Loads all the slugs (and test for php errors!)
+ * ...this is a temporary hackaround/test, it might not make sense as an acceptence test?
+ *
+ * @param AcceptanceTester $I
+ */
+ public function see_http_responses_200( AcceptanceTester $I ) {
+
+ // retrieve slugs
+ $slugs = $I->getSlugs();
+
+ // cycle through the pages
+ foreach ( $slugs as $slug => $page ) {
+
+ // load page
+ $I->goToPageViaSlug( $slug );
+
+ $I->seeResponseCodeIs( 200 );
+ }
+ }
+}
diff --git a/projects/plugins/crm/tests/acceptance/92_JPCRM_Deactivation_Activation_Cest.php b/projects/plugins/crm/tests/acceptance/92_JPCRM_Deactivation_Activation_Cest.php
new file mode 100644
index 000000000000..2d768d287724
--- /dev/null
+++ b/projects/plugins/crm/tests/acceptance/92_JPCRM_Deactivation_Activation_Cest.php
@@ -0,0 +1,51 @@
+loginAsAdmin();
+ }
+
+ public function jpcrm_deactivation( AcceptanceTester $I ) {
+ $I->amOnPluginsPage();
+
+ $I->seePluginActivated( $this->plugin_slug );
+ $I->deactivatePlugin( $this->plugin_slug );
+
+ $I->see( 'Before you go' );
+ $I->seeInSource( 'https://forms.gle/q5KjMBytni3kfFco7' );
+ $I->see( 'Not right now' );
+
+ $I->click( 'Not right now' );
+ $I->seeInCurrentUrl( '/plugins.php' );
+ $I->see( 'Plugins' );
+ }
+
+ public function jpcrm_force_wizard( AcceptanceTester $I ) {
+ $I->amOnPluginsPage();
+ // Plugin should be deactivated
+ $I->seePluginDeactivated( $this->plugin_slug );
+ $I->activatePlugin( $this->plugin_slug );
+
+ $I->gotoAdminPage( 'dashboard', '&jpcrm_force_wizard=1' );
+ $I->see( 'Essential Details' );
+ $I->see( 'Essentials' );
+ $I->see( 'Your Customers' );
+ $I->see( 'Which Extensions?' );
+ }
+
+ public function jpcrm_is_activated( AcceptanceTester $I ) {
+ $I->amOnPluginsPage();
+ $I->seePluginActivated( $this->plugin_slug );
+
+ // Check that Jetpack CRM menu is there
+ $I->amOnPage( 'wp-admin' );
+ $I->see( 'Jetpack CRM', '.wp-menu-name' );
+ }
+
+}
diff --git a/projects/plugins/crm/tests/action-skip-test-php.sh b/projects/plugins/crm/tests/action-skip-test-php.sh
new file mode 100755
index 000000000000..39d7cb46c2e4
--- /dev/null
+++ b/projects/plugins/crm/tests/action-skip-test-php.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+if php -r 'exit( version_compare( PHP_VERSION, "7.2.0", "<" ) ? 0 : 1 );'; then
+ echo "PHP version is too old to run tests. 7.2 is required, but $(php -r 'echo PHP_VERSION;') is installed. Skipping.";
+ exit 3
+fi
diff --git a/projects/plugins/crm/tests/action-test-php.sh b/projects/plugins/crm/tests/action-test-php.sh
new file mode 100755
index 000000000000..7f67c191b3fa
--- /dev/null
+++ b/projects/plugins/crm/tests/action-test-php.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+set -eo pipefail
+
+if [[ -z "$CI" ]]; then
+ echo "This script is only for use in the CI environment. Sorry."
+ exit 1
+fi
+
+: "${WORDPRESS_DIR:?WORDPRESS_DIR needs to be set and non-empty.}"
+
+# Point output dir to the CI artifacts dir.
+if [[ -n "$ARTIFACTS_DIR" ]]; then
+ rm -rf tests/_output
+ ln -s "$ARTIFACTS_DIR" tests/_output
+fi
+
+# Setup database. Even though tests/acceptance.suite.yml contains commands to create it, it chokes before it gets to run them without this.
+mysql -e "DROP DATABASE IF EXISTS jpcrm_testing; CREATE DATABASE jpcrm_testing;"
+
+# Setup config.
+cp tests/acceptance.suite.dist.yml tests/acceptance.suite.yml
+sed -i 's/some_db_user/root/g' tests/acceptance.suite.yml
+sed -i 's/some_db_pass/root/g' tests/acceptance.suite.yml
+sed -i 's/host=localhost/host=127.0.0.1/g' tests/acceptance.suite.yml
+sed -i 's!/path/to/test/file/must-overwrite-it-in-acceptance.suite.yml!'"$WORDPRESS_DIR"'!g' tests/acceptance.suite.yml
+
+# Setup WordPress runtime config.
+cp "$WORDPRESS_DIR/wp-config-sample.php" "$WORDPRESS_DIR/wp-config.php"
+sed -i 's/database_name_here/jpcrm_testing/g' "$WORDPRESS_DIR/wp-config.php"
+sed -i 's/username_here/root/g' "$WORDPRESS_DIR/wp-config.php"
+sed -i 's/password_here/root/g' "$WORDPRESS_DIR/wp-config.php"
+sed -i 's/localhost/127.0.0.1/g' "$WORDPRESS_DIR/wp-config.php"
+sed -i '/Add any custom values between this line and the "stop editing" line./a define("DISABLE_WP_CRON", true);\ndefine("WP_AUTO_UPDATE_CORE", false);' "$WORDPRESS_DIR/wp-config.php"
+
+# Build and run tests
+composer build-tests
+composer tests
diff --git a/projects/plugins/crm/tests/php/bootstrap.php b/projects/plugins/crm/tests/php/bootstrap.php
deleted file mode 100644
index 46763b04a2cd..000000000000
--- a/projects/plugins/crm/tests/php/bootstrap.php
+++ /dev/null
@@ -1,11 +0,0 @@
-