diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..44eb499 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,62 @@ +name: PHPUnit + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + php-matrix: + name: PHP matrix + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.php-matrix.outputs.versions }} + steps: + - uses: actions/checkout@v5 + + - uses: typisttech/php-matrix-action@0545a7d54db2c7be158b615b1e7f458ae6e99ad3 # v3.0.2 + id: php-matrix + + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + needs: php-matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ${{ fromJSON(needs.php-matrix.outputs.versions) }} + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + env: + WP_TESTS_DB_HOST: 127.0.0.1 + WP_TESTS_DB_NAME: wordpress_test + WP_TESTS_DB_USER: root + WP_TESTS_DB_PASSWORD: root + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + extensions: mysqli + coverage: none + tools: composer + + - uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1 + + - run: composer test diff --git a/.gitignore b/.gitignore index 0ce65c8..7479820 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ languages/*.mo /vendor/ /vendor-bin/*/vendor/ +/.phpunit.result.cache /node_modules/ diff --git a/composer.json b/composer.json index 78612b8..44c3d60 100644 --- a/composer.json +++ b/composer.json @@ -3,24 +3,55 @@ "description": "WordPress theme for the LibreSign SaaS", "type": "wordpress-theme", "license": "GPL-2.0-or-later", + "repositories": [ + { + "type": "composer", + "url": "https://wpackagist.org", + "only": [ + "wpackagist-plugin/*" + ] + } + ], "require": { "php": ">=8.3 <8.6" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8" + "bamarni/composer-bin-plugin": "^1.8", + "composer/installers": "^2.3", + "roots/wordpress": "^7.0", + "wp-phpunit/wp-phpunit": "^7.0", + "wpackagist-plugin/woocommerce": "^11.0" + }, + "autoload": { + "psr-4": { + "LibreSign\\WordPressTheme\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LibreSign\\WordPressTheme\\Tests\\": "tests/" + } }, "config": { "platform": { "php": "8.3" }, "allow-plugins": { - "bamarni/composer-bin-plugin": true + "bamarni/composer-bin-plugin": true, + "composer/installers": true, + "roots/wordpress-core-installer": true } }, "extra": { "bamarni-bin": { "bin-links": true, "forward-command": true + }, + "wordpress-install-dir": "vendor/wordpress", + "installer-paths": { + "vendor/test-plugins/{$name}/": [ + "type:wordpress-plugin" + ] } }, "scripts": { @@ -28,10 +59,17 @@ "cs": "phpcs", "cs:fix": "phpcbf", "stan": "phpstan analyse --memory-limit=2G", + "test": [ + "@test:unit", + "@test:integration" + ], + "test:unit": "phpunit", + "test:integration": "phpunit -c phpunit-integration.xml.dist", "ci": [ "@lint", "@cs", - "@stan" + "@stan", + "@test" ] } } diff --git a/composer.lock b/composer.lock index 8a4e2b4..740387a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7e5f17d904a4977efb85dde5d48edf0", + "content-hash": "100daeaa732ad02c2952242d18b7d8f1", "packages": [], "packages-dev": [ { @@ -63,6 +63,399 @@ "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1" }, "time": "2026-02-04T10:18:12+00:00" + }, + { + "name": "composer/installers", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/composer/installers.git", + "reference": "12fb2dfe5e16183de69e784a7b84046c43d97e8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/installers/zipball/12fb2dfe5e16183de69e784a7b84046c43d97e8e", + "reference": "12fb2dfe5e16183de69e784a7b84046c43d97e8e", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "composer/composer": "^1.10.27 || ^2.7", + "composer/semver": "^1.7.2 || ^3.4.0", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-phpunit": "^1", + "symfony/phpunit-bridge": "^7.1.1", + "symfony/process": "^5 || ^6 || ^7" + }, + "type": "composer-plugin", + "extra": { + "class": "Composer\\Installers\\Plugin", + "branch-alias": { + "dev-main": "2.x-dev" + }, + "plugin-modifies-install-path": true + }, + "autoload": { + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyle Robinson Young", + "email": "kyle@dontkry.com", + "homepage": "https://github.com/shama" + } + ], + "description": "A multi-framework Composer library installer", + "homepage": "https://composer.github.io/installers/", + "keywords": [ + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "MantisBT", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Starbug", + "Thelia", + "Whmcs", + "WolfCMS", + "agl", + "annotatecms", + "attogram", + "bitrix", + "cakephp", + "chef", + "cockpit", + "codeigniter", + "concrete5", + "concreteCMS", + "croogo", + "dokuwiki", + "drupal", + "eZ Platform", + "elgg", + "expressionengine", + "fuelphp", + "grav", + "installer", + "itop", + "known", + "kohana", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "matomo", + "mediawiki", + "miaoxing", + "modulework", + "modx", + "moodle", + "osclass", + "pantheon", + "phpbb", + "piwik", + "ppi", + "processwire", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "sydes", + "sylius", + "tastyigniter", + "wordpress", + "yawik", + "zend", + "zikula" + ], + "support": { + "issues": "https://github.com/composer/installers/issues", + "source": "https://github.com/composer/installers/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-06-24T20:46:46+00:00" + }, + { + "name": "roots/wordpress", + "version": "7.1.2", + "source": { + "type": "git", + "url": "https://github.com/roots/wordpress.git", + "reference": "eb3d108f216618a858dcaed691e00e68f0ccff9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/wordpress/zipball/eb3d108f216618a858dcaed691e00e68f0ccff9f", + "reference": "eb3d108f216618a858dcaed691e00e68f0ccff9f", + "shasum": "" + }, + "require": { + "roots/wordpress-core-installer": "^4.0", + "roots/wordpress-no-content": "self.version" + }, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "GPL-2.0-or-later" + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "issues": "https://github.com/roots/wordpress/issues", + "source": "https://github.com/roots/wordpress/tree/7.1.2" + }, + "funding": [ + { + "url": "https://github.com/roots", + "type": "github" + } + ], + "time": "2026-03-22T13:26:54+00:00" + }, + { + "name": "roots/wordpress-core-installer", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/roots/wordpress-core-installer.git", + "reference": "85d3589252eb93eee836facf33b54ce30c565bea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roots/wordpress-core-installer/zipball/85d3589252eb93eee836facf33b54ce30c565bea", + "reference": "85d3589252eb93eee836facf33b54ce30c565bea", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": ">=7.2.24" + }, + "conflict": { + "composer/installers": "<1.0.6" + }, + "replace": { + "johnpbloch/wordpress-core-installer": "*" + }, + "require-dev": { + "composer/composer": "^2.0", + "phpunit/phpunit": "^8.5" + }, + "type": "composer-plugin", + "extra": { + "class": "Roots\\Composer\\WordPressCorePlugin" + }, + "autoload": { + "psr-4": { + "Roots\\Composer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "John P. Bloch", + "email": "me@johnpbloch.com" + }, + { + "name": "Roots", + "email": "team@roots.io" + } + ], + "description": "A Composer custom installer to handle installing WordPress as a dependency", + "keywords": [ + "wordpress" + ], + "support": { + "issues": "https://github.com/roots/wordpress-core-installer/issues", + "source": "https://github.com/roots/wordpress-core-installer/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://github.com/roots", + "type": "github" + } + ], + "time": "2026-03-22T03:17:23+00:00" + }, + { + "name": "roots/wordpress-no-content", + "version": "7.1.2", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + "reference": "7.1.2" + }, + "dist": { + "type": "zip", + "url": "https://downloads.wordpress.org/release/wordpress-7.1.2-no-content.zip", + "reference": "7.1.2", + "shasum": "52c0250854a52621dc024651b63d1e088287ee16" + }, + "require": { + "php": ">= 7.4" + }, + "provide": { + "wordpress/core-implementation": "7.1.2" + }, + "suggest": { + "ext-curl": "Performs remote request operations.", + "ext-dom": "Used to validate Text Widget content and to automatically configuring IIS7+.", + "ext-exif": "Works with metadata stored in images.", + "ext-fileinfo": "Used to detect mimetype of file uploads.", + "ext-hash": "Used for hashing, including passwords and update packages.", + "ext-imagick": "Provides better image quality for media uploads.", + "ext-json": "Used for communications with other servers.", + "ext-libsodium": "Validates Signatures and provides securely random bytes.", + "ext-mbstring": "Used to properly handle UTF8 text.", + "ext-mysqli": "Connects to MySQL for database interactions.", + "ext-openssl": "Permits SSL-based connections to other hosts.", + "ext-pcre": "Increases performance of pattern matching in code searches.", + "ext-xml": "Used for XML parsing, such as from a third-party site.", + "ext-zip": "Used for decompressing Plugins, Themes, and WordPress update packages." + }, + "type": "wordpress-core", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "docs": "https://developer.wordpress.org/", + "forum": "https://wordpress.org/support/", + "irc": "irc://irc.freenode.net/wordpress", + "issues": "https://core.trac.wordpress.org/", + "rss": "https://wordpress.org/news/feed/", + "source": "https://core.trac.wordpress.org/browser", + "wiki": "https://codex.wordpress.org/" + }, + "funding": [ + { + "url": "https://wordpressfoundation.org/donate/", + "type": "other" + } + ], + "time": "2026-09-22T13:36:10+00:00" + }, + { + "name": "wp-phpunit/wp-phpunit", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/wp-phpunit/wp-phpunit.git", + "reference": "797edc710afc958854852d859c93d9aa1de6447d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/797edc710afc958854852d859c93d9aa1de6447d", + "reference": "797edc710afc958854852d859c93d9aa1de6447d", + "shasum": "" + }, + "type": "library", + "autoload": { + "files": [ + "__loaded.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Evan Mattson", + "email": "me@aaemnnost.tv" + }, + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress core PHPUnit library", + "homepage": "https://github.com/wp-phpunit", + "keywords": [ + "phpunit", + "test", + "wordpress" + ], + "support": { + "docs": "https://github.com/wp-phpunit/docs", + "issues": "https://github.com/wp-phpunit/issues", + "source": "https://github.com/wp-phpunit/wp-phpunit" + }, + "time": "2026-08-20T00:59:59+00:00" + }, + { + "name": "wpackagist-plugin/woocommerce", + "version": "11.1.2", + "source": { + "type": "svn", + "url": "https://plugins.svn.wordpress.org/woocommerce/", + "reference": "tags/11.1.2" + }, + "dist": { + "type": "zip", + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.1.2.zip" + }, + "require": { + "composer/installers": "^1.0 || ^2.0" + }, + "type": "wordpress-plugin", + "homepage": "https://wordpress.org/plugins/woocommerce/" } ], "aliases": [], diff --git a/inc/account.php b/inc/account.php index d4c06b9..d779e97 100644 --- a/inc/account.php +++ b/inc/account.php @@ -34,7 +34,7 @@ function libresign_theme_get_account_url() { function libresign_theme_get_purchase_redirect_target() { $redirect_to = ''; - if ( isset( $_REQUEST['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $redirect_to = wp_sanitize_redirect( wp_unslash( $_REQUEST['redirect_to'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } diff --git a/inc/cpf-cnpj.php b/inc/cpf-cnpj.php index 23353f6..31dee7e 100644 --- a/inc/cpf-cnpj.php +++ b/inc/cpf-cnpj.php @@ -10,12 +10,16 @@ * @package libresign */ +use LibreSign\WordPressTheme\TaxId; + defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/src/TaxId.php'; + /** * Register the CPF/CNPJ field in the checkout address section. */ -add_action( 'woocommerce_init', function () { +function libresign_theme_register_cpf_cnpj_field() { if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) { return; } @@ -29,94 +33,11 @@ 'type' => 'text', 'attributes' => array( 'autocomplete' => 'off', - 'placeholder' => __( 'Required for customers in Brazil', 'libresign' ), ), ) ); -} ); - -/** - * Validate CPF — 11-digit Brazilian individual taxpayer ID. - * - * Uses the standard Módulo 11 algorithm with decreasing weights 10..2 / 11..2. - * - * @param string $cpf Raw or formatted CPF (dots and dash are stripped). - * @return bool - */ -function libresign_theme_validate_cpf( string $cpf ): bool { - $cpf = preg_replace( '/[^0-9]/', '', $cpf ); - if ( strlen( $cpf ) !== 11 ) { - return false; - } - // All identical digits (e.g. 000.000.000-00) are never valid. - if ( preg_match( '/^(\d)\1{10}$/', $cpf ) ) { - return false; - } - - $mod11 = function ( string $str, int $start_weight ): int { - $sum = 0; - $w = $start_weight; - for ( $i = 0; $i < strlen( $str ); $i++ ) { - $sum += (int) $str[ $i ] * $w--; - } - $rem = $sum % 11; - return $rem < 2 ? 0 : 11 - $rem; - }; - - $dv1 = $mod11( substr( $cpf, 0, 9 ), 10 ); - if ( $dv1 !== (int) $cpf[9] ) { - return false; - } - return $mod11( substr( $cpf, 0, 10 ), 11 ) === (int) $cpf[10]; -} - -/** - * Validate CNPJ — 14-character Brazilian company taxpayer ID. - * - * Handles both the legacy numeric format and the new alphanumeric format - * introduced by Instrução Normativa RFB nº 2.119/2022 (Anexo Único): - * - Positions 1–12: alphanumeric (root 8 chars + order 4 chars) - * - Positions 13–14: numeric check digits - * - * Algorithm: Módulo 11 with weights 2–9 assigned right-to-left, cycling - * back to 2 after reaching 9. Character value = ord(char) − 48, which gives - * the digit value for '0'–'9' (ASCII 48–57) and 17–42 for 'A'–'Z' - * (ASCII 65–90), exactly as specified in the Receita Federal table. - * - * @param string $cnpj Raw or formatted CNPJ (dots, slash, dash are stripped). - * @return bool - */ -function libresign_theme_validate_cnpj( string $cnpj ): bool { - $cnpj = strtoupper( preg_replace( '/[\.\-\/]/', '', $cnpj ) ); - if ( strlen( $cnpj ) !== 14 ) { - return false; - } - // All identical digits — applies to legacy numeric CNPJs. - if ( preg_match( '/^(\d)\1{13}$/', $cnpj ) ) { - return false; - } - // Positions 1–12: uppercase letters or digits; positions 13–14: digits. - if ( ! preg_match( '/^[A-Z0-9]{12}\d{2}$/', $cnpj ) ) { - return false; - } - - $mod11 = function ( string $str ): int { - $sum = 0; - $w = 2; - for ( $i = strlen( $str ) - 1; $i >= 0; $i-- ) { - $sum += ( ord( $str[ $i ] ) - 48 ) * $w; - $w = 9 === $w ? 2 : $w + 1; - } - $rem = $sum % 11; - return $rem < 2 ? 0 : 11 - $rem; - }; - - $dv1 = $mod11( substr( $cnpj, 0, 12 ) ); - if ( $dv1 !== (int) $cnpj[12] ) { - return false; - } - return $mod11( substr( $cnpj, 0, 13 ) ) === (int) $cnpj[13]; } +add_action( 'woocommerce_init', 'libresign_theme_register_cpf_cnpj_field' ); /** * Server-side enforcement via the address-location validation hook. @@ -135,25 +56,16 @@ function libresign_theme_validate_cnpj( string $cnpj ): bool { return; } - $value = isset( $fields['libresign/cpf-cnpj'] ) ? trim( (string) $fields['libresign/cpf-cnpj'] ) : ''; - if ( '' === $value ) { - $errors->add( 'libresign_cpf_cnpj_required', __( 'Please enter your CPF or CNPJ.', 'libresign' ) ); - return; - } - - $digits_only = preg_replace( '/[^0-9]/', '', $value ); - $stripped = strtoupper( preg_replace( '/[\.\-\/]/', '', $value ) ); + $messages = array( + 'libresign_cpf_cnpj_required' => __( 'Please enter your CPF or CNPJ.', 'libresign' ), + 'invalid_cnpj' => __( 'Please enter a valid CNPJ.', 'libresign' ), + 'invalid_cpf' => __( 'Please enter a valid CPF.', 'libresign' ), + 'invalid_cpf_cnpj' => __( 'Please enter a valid CPF (11 digits) or CNPJ (14 characters).', 'libresign' ), + ); - if ( 14 === strlen( $stripped ) ) { - if ( ! libresign_theme_validate_cnpj( $value ) ) { - $errors->add( 'invalid_cnpj', __( 'Please enter a valid CNPJ.', 'libresign' ) ); - } - } elseif ( 11 === strlen( $digits_only ) ) { - if ( ! libresign_theme_validate_cpf( $value ) ) { - $errors->add( 'invalid_cpf', __( 'Please enter a valid CPF.', 'libresign' ) ); - } - } else { - $errors->add( 'invalid_cpf_cnpj', __( 'Please enter a valid CPF (11 digits) or CNPJ (14 characters).', 'libresign' ) ); + $error_code = TaxId::error_code( isset( $fields['libresign/cpf-cnpj'] ) ? (string) $fields['libresign/cpf-cnpj'] : '' ); + if ( '' !== $error_code ) { + $errors->add( $error_code, $messages[ $error_code ] ); } }, 10, 3 ); @@ -190,7 +102,7 @@ function libresign_theme_strip_irrelevant_cpf_cnpj( $wc_object ) { * 1. Visibility — hides the field for non-BR customers and reveals it when * the billing country is Brazil, surviving React re-renders via a * MutationObserver. - * 2. Validation — mirrors the PHP Módulo 11 algorithms above so the user + * 2. Validation — mirrors the Módulo 11 algorithms of src/TaxId.php so the user * gets native WooCommerce Blocks error feedback (red border + message * below the field) without a server round-trip. A re-entrancy guard * prevents the wp.data subscriber from triggering itself in an infinite @@ -249,7 +161,7 @@ function updateVisibility() { } // ----------------------------------------------------------------------- - // Módulo 11 validation — mirrors the PHP functions in this file. + // Módulo 11 validation — mirrors src/TaxId.php. // ----------------------------------------------------------------------- /** diff --git a/inc/footer-fragment.php b/inc/footer-fragment.php index 749f217..c93a3c8 100644 --- a/inc/footer-fragment.php +++ b/inc/footer-fragment.php @@ -7,9 +7,11 @@ * only the GitHub deploy webhook triggers a background refresh. */ +use LibreSign\WordPressTheme\SiteFragment; + defined( 'ABSPATH' ) || exit; -const LIBRESIGN_THEME_SITE_FRAGMENT_DEFAULT_LOCALE_KEY = 'default'; +require_once dirname( __DIR__ ) . '/src/SiteFragment.php'; /** * Return the supported fragment types. @@ -30,69 +32,6 @@ function libresign_theme_site_fragment_storage_directory_name( $fragment_type ) return 'header' === $fragment_type ? 'libresign-header' : 'libresign-footer'; } -/** - * Normalize a site origin string. - * - * @param string $site_origin Raw site origin. - * @return string - */ -function libresign_theme_site_fragment_normalize_site_origin( $site_origin ) { - return rtrim( trim( (string) $site_origin ), '/' ); -} - -/** - * Normalize a locale/tag into a path-friendly BCP47-style value. - * - * @param string $locale Raw locale/tag. - * @return string - */ -function libresign_theme_site_fragment_normalize_locale_tag( $locale ) { - $locale = trim( str_replace( '_', '-', (string) $locale ) ); - - if ( '' === $locale ) { - return ''; - } - - $parts = array_values( array_filter( explode( '-', $locale ), static fn ( $part ) => '' !== $part ) ); - if ( empty( $parts ) ) { - return ''; - } - - $parts[0] = strtolower( $parts[0] ); - - foreach ( $parts as $index => $part ) { - if ( 0 === $index ) { - continue; - } - - if ( 2 === strlen( $part ) || 3 === strlen( $part ) ) { - $parts[ $index ] = strtoupper( $part ); - continue; - } - - if ( 4 === strlen( $part ) ) { - $parts[ $index ] = ucfirst( strtolower( $part ) ); - continue; - } - - $parts[ $index ] = $part; - } - - return implode( '-', $parts ); -} - -/** - * Convert locale to storage key. - * - * @param string $locale Locale. - * @return string - */ -function libresign_theme_site_fragment_storage_key( $locale ) { - $locale = libresign_theme_site_fragment_normalize_locale_tag( $locale ); - - return '' === $locale ? LIBRESIGN_THEME_SITE_FRAGMENT_DEFAULT_LOCALE_KEY : $locale; -} - /** * Get the storage base dir for a fragment type. * @@ -117,28 +56,6 @@ function libresign_theme_site_fragment_storage_base_url( $fragment_type ) { return trailingslashit( $uploads['baseurl'] ) . libresign_theme_site_fragment_storage_directory_name( $fragment_type ); } -/** - * Build the published fragment URL. - * - * @param string $site_origin Static site origin. - * @param string $fragment_type Fragment type. - * @param string $locale Locale. - * @return string - */ -function libresign_theme_site_fragment_url( $site_origin, $fragment_type, $locale ) { - $site_origin = libresign_theme_site_fragment_normalize_site_origin( $site_origin ); - $locale = libresign_theme_site_fragment_normalize_locale_tag( $locale ); - $path = '/fragments/'; - - if ( '' !== $locale ) { - $path .= rawurlencode( $locale ) . '/'; - } - - $path .= $fragment_type; - - return $site_origin . $path; -} - /** * Fetch a remote URL and return the HTTP code/body. * @@ -198,50 +115,6 @@ function libresign_theme_site_fragment_is_optional_http_error( $error ) { return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status']; } -/** - * Extract CSS/JS fragment asset URLs from fragment HTML. - * - * @param string $html Fragment HTML. - * @return array|WP_Error - */ -function libresign_theme_site_fragment_extract_asset_urls( $html ) { - if ( - ! preg_match( '/\bdata-fragment-css=("|\')([^"\']+)\1/i', $html, $css_matches ) - || ! preg_match( '/\bdata-fragment-js=("|\')([^"\']+)\1/i', $html, $js_matches ) - ) { - return new WP_Error( - 'libresign_theme_site_fragment_missing_assets', - __( 'Fragment HTML is missing the expected CSS/JS references.', 'libresign' ) - ); - } - - return array( - 'css' => (string) $css_matches[2], - 'js' => (string) $js_matches[2], - ); -} - -/** - * Extract discovered locales from the default header fragment HTML. - * - * @param string $html Header fragment HTML. - * @return array - */ -function libresign_theme_site_fragment_extract_locales_from_header_html( $html ) { - $locales = array(); - - if ( preg_match_all( '~\/fragments(?:\/([^\/#?"\']+))?\/header(?:[\/#?"\']|$)~i', $html, $matches ) ) { - foreach ( $matches[1] as $locale ) { - $locale = libresign_theme_site_fragment_normalize_locale_tag( rawurldecode( (string) $locale ) ); - $locales[] = $locale; - } - } - - $locales[] = ''; - - return array_values( array_unique( array_filter( $locales, 'is_string' ) ) ); -} - /** * Build fallback locales from the local WordPress language context. * @@ -266,55 +139,11 @@ function libresign_theme_site_fragment_fallback_locales() { $candidates[] = get_locale(); $candidates[] = ''; - $candidates = array_map( 'libresign_theme_site_fragment_normalize_locale_tag', $candidates ); + $candidates = array_map( array( SiteFragment::class, 'language_tag' ), $candidates ); return array_values( array_unique( array_filter( $candidates, 'is_string' ) ) ); } -/** - * Remove fragment asset data attributes after syncing. - * - * @param string $html Fragment HTML. - * @return string - */ -function libresign_theme_site_fragment_strip_runtime_asset_attributes( $html ) { - $html = preg_replace( '/\s+data-fragment-css=("|\')[^"\']+\1/i', '', $html ) ?? $html; - $html = preg_replace( '/\s+data-fragment-js=("|\')[^"\']+\1/i', '', $html ) ?? $html; - - return $html; -} - -/** - * Rewrite root-relative URLs so stored artifacts continue pointing at the static site. - * - * @param string $content Content to rewrite. - * @param string $site_origin Static site origin. - * @return string - */ -function libresign_theme_site_fragment_rewrite_root_relative_urls( $content, $site_origin ) { - $site_origin = libresign_theme_site_fragment_normalize_site_origin( $site_origin ); - - $content = preg_replace_callback( - '/\b(href|src|action|poster)=("|\')(\/(?!\/)[^"\']*)\2/i', - static function ( $matches ) use ( $site_origin ) { - return $matches[1] . '=' . $matches[2] . $site_origin . $matches[3] . $matches[2]; - }, - $content - ) ?? $content; - - $content = preg_replace_callback( - '~url\(\s*(?:("|\')\s*)?(\/(?!\/)[^)"\']+)(?:\s*\1)?\s*\)~i', - static function ( $matches ) use ( $site_origin ) { - $quote = $matches[1]; - - return 'url(' . $quote . $site_origin . $matches[2] . $quote . ')'; - }, - $content - ) ?? $content; - - return $content; -} - /** * Collect all fragment artifacts from the static site. * @@ -324,7 +153,7 @@ static function ( $matches ) use ( $site_origin ) { * @return array|WP_Error */ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragment_types, $context = array() ) { - $site_origin = libresign_theme_site_fragment_normalize_site_origin( $site_origin ); + $site_origin = SiteFragment::origin( $site_origin ); if ( '' === $site_origin ) { return new WP_Error( 'libresign_theme_site_fragment_origin_missing', @@ -332,7 +161,7 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen ); } - $default_header_url = libresign_theme_site_fragment_url( $site_origin, 'header', '' ); + $default_header_url = SiteFragment::url( $site_origin, 'header', '' ); $default_header_response = libresign_theme_site_fragment_fetch_url( $default_header_url ); if ( is_wp_error( $default_header_response ) ) { return $default_header_response; @@ -340,7 +169,7 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen $locales = array_merge( array( '' ), - libresign_theme_site_fragment_extract_locales_from_header_html( (string) $default_header_response['body'] ), + SiteFragment::linked_locales( (string) $default_header_response['body'] ), libresign_theme_site_fragment_fallback_locales() ); $locales = array_values( array_unique( $locales ) ); @@ -353,7 +182,7 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen $summary[ $fragment_type ] = array(); foreach ( $locales as $locale ) { - $fragment_url = libresign_theme_site_fragment_url( $site_origin, $fragment_type, $locale ); + $fragment_url = SiteFragment::url( $site_origin, $fragment_type, $locale ); if ( 'header' === $fragment_type && '' === $locale ) { $fragment_response = $default_header_response; @@ -369,13 +198,16 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen return $fragment_response; } - $asset_urls = libresign_theme_site_fragment_extract_asset_urls( (string) $fragment_response['body'] ); - if ( is_wp_error( $asset_urls ) ) { + $asset_urls = SiteFragment::asset_urls( (string) $fragment_response['body'] ); + if ( null === $asset_urls ) { if ( '' !== $locale ) { continue; } - return $asset_urls; + return new WP_Error( + 'libresign_theme_site_fragment_missing_assets', + __( 'Fragment HTML is missing the expected CSS/JS references.', 'libresign' ) + ); } foreach ( array( 'css', 'js' ) as $asset_type ) { @@ -390,15 +222,15 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen } } - $html = libresign_theme_site_fragment_strip_runtime_asset_attributes( (string) $fragment_response['body'] ); - $html = libresign_theme_site_fragment_rewrite_root_relative_urls( $html, $site_origin ); - $css = libresign_theme_site_fragment_rewrite_root_relative_urls( (string) $asset_cache[ $asset_urls['css'] ], $site_origin ); - $js = libresign_theme_site_fragment_rewrite_root_relative_urls( (string) $asset_cache[ $asset_urls['js'] ], $site_origin ); + $html = SiteFragment::without_asset_attributes( (string) $fragment_response['body'] ); + $html = SiteFragment::with_absolute_urls( $html, $site_origin ); + $css = SiteFragment::with_absolute_urls( (string) $asset_cache[ $asset_urls['css'] ], $site_origin ); + $js = SiteFragment::with_absolute_urls( (string) $asset_cache[ $asset_urls['js'] ], $site_origin ); $artifact = array( 'fragment_type' => $fragment_type, - 'locale' => libresign_theme_site_fragment_normalize_locale_tag( $locale ), - 'locale_key' => libresign_theme_site_fragment_storage_key( $locale ), + 'locale' => SiteFragment::language_tag( $locale ), + 'locale_key' => SiteFragment::storage_key( $locale ), 'fragment_url' => $fragment_url, 'css_url' => (string) $asset_urls['css'], 'js_url' => (string) $asset_urls['js'], @@ -516,7 +348,7 @@ function libresign_theme_sync_site_fragments_from_origin( $site_origin, $fragmen } return array( - 'origin' => libresign_theme_site_fragment_normalize_site_origin( $site_origin ), + 'origin' => SiteFragment::origin( $site_origin ), 'synced' => $persisted, 'locales' => $collected['locales'], ); @@ -538,20 +370,7 @@ function libresign_theme_site_fragment_locale_lookup_keys() { $candidates[] = (string) determine_locale(); $candidates[] = (string) get_locale(); - $keys = array(); - foreach ( $candidates as $candidate ) { - $normalized = libresign_theme_site_fragment_normalize_locale_tag( $candidate ); - if ( '' === $normalized ) { - continue; - } - - $keys[] = $normalized; - $keys[] = strtolower( strtok( $normalized, '-' ) ); - } - - $keys[] = LIBRESIGN_THEME_SITE_FRAGMENT_DEFAULT_LOCALE_KEY; - - return array_values( array_unique( $keys ) ); + return SiteFragment::lookup_keys( $candidates ); } /** diff --git a/inc/github-site-webhook.php b/inc/github-site-webhook.php index d4aad5f..b51b89c 100644 --- a/inc/github-site-webhook.php +++ b/inc/github-site-webhook.php @@ -3,8 +3,14 @@ * GitHub webhook receiver for production site fragment synchronization. */ +use LibreSign\WordPressTheme\SiteDeploy; +use LibreSign\WordPressTheme\SiteFragment; + defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/src/SiteDeploy.php'; +require_once dirname( __DIR__ ) . '/src/SiteFragment.php'; + const LIBRESIGN_THEME_GITHUB_SITE_WEBHOOK_NAMESPACE = 'libresign/v1'; const LIBRESIGN_THEME_GITHUB_SITE_WEBHOOK_ROUTE = '/site-deploy-webhook'; @@ -199,7 +205,7 @@ function libresign_theme_site_origin() { ); foreach ( $values as $value ) { - $value = libresign_theme_site_fragment_normalize_site_origin( $value ); + $value = SiteFragment::origin( $value ); if ( '' !== $value ) { return $value; } @@ -226,7 +232,7 @@ function libresign_theme_site_deploy_workflow_name() { } } - return 'Deploy'; + return 'pages build and deployment'; } /** @@ -285,92 +291,6 @@ function libresign_theme_github_site_webhook_ignored_response( $data = array(), return $response; } -/** - * Verify the GitHub webhook HMAC signature. - * - * @param string $body Raw request body. - * @param string $signature Signature header value. - * @param string $secret Shared secret. - * @return bool - */ -function libresign_theme_verify_github_webhook_signature( $body, $signature, $secret ) { - $secret = trim( (string) $secret ); - $signature = trim( (string) $signature ); - - if ( '' === $body || '' === $secret || '' === $signature ) { - return false; - } - - if ( 0 === stripos( $signature, 'sha256=' ) ) { - $signature = substr( $signature, 7 ); - } - - if ( ! ctype_xdigit( $signature ) ) { - return false; - } - - $expected = hash_hmac( 'sha256', $body, $secret ); - - return hash_equals( $expected, strtolower( $signature ) ); -} - -/** - * Check whether the webhook user agent looks like GitHub Hookshot. - * - * @param string $user_agent User agent header. - * @return bool - */ -function libresign_theme_is_github_hookshot_user_agent( $user_agent ) { - return 0 === strpos( trim( (string) $user_agent ), 'GitHub-Hookshot/' ); -} - -/** - * Extract the workflow name from the payload. - * - * @param array $payload Parsed payload. - * @return string - */ -function libresign_theme_site_deploy_workflow_name_from_payload( $payload ) { - $workflow_run_name = isset( $payload['workflow_run']['name'] ) ? trim( (string) $payload['workflow_run']['name'] ) : ''; - if ( '' !== $workflow_run_name ) { - return $workflow_run_name; - } - - return isset( $payload['workflow']['name'] ) ? trim( (string) $payload['workflow']['name'] ) : ''; -} - -/** - * Determine whether the payload represents the production site deploy event. - * - * @param array $payload Parsed payload. - * @return bool - */ -function libresign_theme_is_production_site_deploy_workflow_run( $payload ) { - $repository = isset( $payload['repository']['full_name'] ) ? trim( (string) $payload['repository']['full_name'] ) : ''; - $action = isset( $payload['action'] ) ? trim( (string) $payload['action'] ) : ''; - $conclusion = isset( $payload['workflow_run']['conclusion'] ) ? trim( (string) $payload['workflow_run']['conclusion'] ) : ''; - $head_branch = isset( $payload['workflow_run']['head_branch'] ) ? trim( (string) $payload['workflow_run']['head_branch'] ) : ''; - $workflow_name = libresign_theme_site_deploy_workflow_name_from_payload( $payload ); - - if ( libresign_theme_site_deploy_repository_name() !== $repository ) { - return false; - } - - if ( 'completed' !== $action ) { - return false; - } - - if ( 'success' !== $conclusion ) { - return false; - } - - if ( libresign_theme_site_deploy_branch_name() !== $head_branch ) { - return false; - } - - return libresign_theme_site_deploy_workflow_name() === $workflow_name; -} - /** * Mark a webhook delivery as processed, returning false when duplicated. * @@ -429,7 +349,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { } $user_agent = (string) $request->get_header( 'user-agent' ); - if ( ! libresign_theme_is_github_hookshot_user_agent( $user_agent ) ) { + if ( ! SiteDeploy::is_github_delivery( $user_agent ) ) { return new WP_Error( 'libresign_theme_github_webhook_invalid_agent', __( 'The webhook request does not look like a GitHub delivery.', 'libresign' ), @@ -439,7 +359,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { $body = (string) $request->get_body(); $signature = (string) $request->get_header( 'x-hub-signature-256' ); - if ( ! libresign_theme_verify_github_webhook_signature( $body, $signature, $secret ) ) { + if ( ! SiteDeploy::signature_matches( $body, $signature, $secret ) ) { return new WP_Error( 'libresign_theme_github_webhook_invalid_signature', __( 'Invalid GitHub webhook signature.', 'libresign' ), @@ -476,7 +396,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { } // Deploy workflow started: post "starting" comment on the PR. - if ( libresign_theme_is_site_deploy_starting( $payload ) ) { + if ( SiteDeploy::is_deploy_starting( $payload, libresign_theme_site_deploy_repository_name() ) ) { $delivery_id = (string) $request->get_header( 'x-github-delivery' ); if ( ! libresign_theme_mark_github_delivery_once( $delivery_id ) ) { return libresign_theme_github_site_webhook_ignored_response( @@ -487,12 +407,18 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { return rest_ensure_response( array( 'status' => 'acknowledged', 'action' => 'deploy_starting' ) ); } - if ( ! libresign_theme_is_production_site_deploy_workflow_run( $payload ) ) { + $is_production_deploy = SiteDeploy::is_production_deploy( + $payload, + libresign_theme_site_deploy_repository_name(), + libresign_theme_site_deploy_branch_name(), + libresign_theme_site_deploy_workflow_name() + ); + if ( ! $is_production_deploy ) { return libresign_theme_github_site_webhook_ignored_response( array( 'reason' => 'not_production_deploy', 'repository' => isset( $payload['repository']['full_name'] ) ? (string) $payload['repository']['full_name'] : '', - 'workflow_name' => libresign_theme_site_deploy_workflow_name_from_payload( $payload ), + 'workflow_name' => SiteDeploy::workflow_name( $payload ), 'head_branch' => isset( $payload['workflow_run']['head_branch'] ) ? (string) $payload['workflow_run']['head_branch'] : '', 'conclusion' => isset( $payload['workflow_run']['conclusion'] ) ? (string) $payload['workflow_run']['conclusion'] : '', ) @@ -537,7 +463,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { array( 'delivery_id' => $delivery_id, 'repository' => libresign_theme_site_deploy_repository_name(), - 'workflow' => libresign_theme_site_deploy_workflow_name_from_payload( $payload ), + 'workflow' => SiteDeploy::workflow_name( $payload ), 'head_branch' => isset( $workflow_run['head_branch'] ) ? (string) $workflow_run['head_branch'] : '', 'source_sha' => isset( $workflow_run['head_sha'] ) ? (string) $workflow_run['head_sha'] : '', 'source_url' => isset( $workflow_run['html_url'] ) ? (string) $workflow_run['html_url'] : '', @@ -552,7 +478,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { 'status' => 'synced', 'delivery_id' => $delivery_id, 'repository' => libresign_theme_site_deploy_repository_name(), - 'workflow' => libresign_theme_site_deploy_workflow_name_from_payload( $payload ), + 'workflow' => SiteDeploy::workflow_name( $payload ), 'origin' => $sync_result['origin'], 'synced' => $sync_result['synced'], ) @@ -612,16 +538,7 @@ function libresign_theme_post_pr_sync_comment( string $site_origin, array $sync_ return; } - $synced = $sync_result['synced'] ?? array(); - $header_list = implode( ', ', (array) ( $synced['header'] ?? array() ) ); - $footer_list = implode( ', ', (array) ( $synced['footer'] ?? array() ) ); - - $body = "✅ **Header and footer fragments updated in production!**\n\n" . - "| Fragment | Synced locales |\n" . - "|----------|----------------|\n" . - "| Header | `{$header_list}` |\n" . - "| Footer | `{$footer_list}` |\n\n" . - "Source: {$site_origin}"; + $body = SiteDeploy::sync_comment( $sync_result['synced'] ?? array(), $site_origin ); $parts = explode( '/', $repository, 2 ); if ( count( $parts ) !== 2 || '' === $parts[0] || '' === $parts[1] ) { @@ -643,24 +560,6 @@ function libresign_theme_post_pr_sync_comment( string $site_origin, array $sync_ ); } -/** - * Determine whether the payload represents the Deploy workflow starting on main. - * - * @param array $payload Parsed payload. - * @return bool - */ -function libresign_theme_is_site_deploy_starting( array $payload ): bool { - $repository = isset( $payload['repository']['full_name'] ) ? trim( (string) $payload['repository']['full_name'] ) : ''; - $action = isset( $payload['action'] ) ? trim( (string) $payload['action'] ) : ''; - $head_branch = isset( $payload['workflow_run']['head_branch'] ) ? trim( (string) $payload['workflow_run']['head_branch'] ) : ''; - $wf_name = libresign_theme_site_deploy_workflow_name_from_payload( $payload ); - - return libresign_theme_site_deploy_repository_name() === $repository - && 'in_progress' === $action - && 'main' === $head_branch - && 'Deploy' === $wf_name; -} - /** * Handle the Deploy workflow starting: find the merged PR and post a "starting" comment. * @@ -731,16 +630,5 @@ function libresign_theme_find_pr_for_commit( string $repository, string $sha, st return 0; } - $pulls = json_decode( wp_remote_retrieve_body( $response ), true ); - if ( ! is_array( $pulls ) ) { - return 0; - } - - foreach ( $pulls as $pr ) { - if ( ! empty( $pr['merged_at'] ) ) { - return (int) $pr['number']; - } - } - - return 0; + return SiteDeploy::merged_pull_request( json_decode( wp_remote_retrieve_body( $response ), true ) ); } \ No newline at end of file diff --git a/inc/logo.php b/inc/logo.php index 91a1775..1377832 100644 --- a/inc/logo.php +++ b/inc/logo.php @@ -14,8 +14,12 @@ * @package libresign */ +use LibreSign\WordPressTheme\Logo; + defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/src/Logo.php'; + /** * Return the LibreSign logo URL for the given display context. * @@ -41,31 +45,7 @@ function libresign_theme_get_theme_logo_url( string $variant = 'light' ): string * device pixel ratio. */ function libresign_theme_custom_logo_needs_fallback( $custom_logo_html ) { - if ( '' === trim( (string) $custom_logo_html ) ) { - return true; - } - - if ( ! preg_match( '/]+>/', (string) $custom_logo_html, $tag_matches ) ) { - return true; - } - - $img_tag = $tag_matches[0]; - - // Collect every image URL from src and srcset. - $urls = array(); - - if ( preg_match( '/src=["\']([^"\']+)["\']/', $img_tag, $m ) ) { - $urls[] = html_entity_decode( $m[1] ); - } - - if ( preg_match( '/srcset=["\']([^"\']+)["\']/', $img_tag, $m ) ) { - foreach ( explode( ',', html_entity_decode( $m[1] ) ) as $part ) { - $candidate = trim( explode( ' ', trim( $part ) )[0] ); - if ( $candidate ) { - $urls[] = $candidate; - } - } - } + $urls = Logo::image_urls( (string) $custom_logo_html ); if ( empty( $urls ) ) { return true; @@ -117,29 +97,10 @@ function libresign_theme_filter_custom_logo( $custom_logo_html, $blog_id ) { return $custom_logo_html; } - $logo_light = esc_url( libresign_theme_get_theme_logo_url( 'light' ) ); - $logo_dark = esc_url( libresign_theme_get_theme_logo_url( 'dark' ) ); - - // Remove srcset/sizes and set src to the light-background logo. - $patched = preg_replace( '/\ssrcset=["\'][^"\']*["\']/', '', $custom_logo_html ); - $patched = preg_replace( '/\ssizes=["\'][^"\']*["\']/', '', $patched ); - $patched = preg_replace( - '/(]+)src=["\'][^"\']*["\']/', - '$1src="' . $logo_light . '"', - $patched - ); - - // Wrap in for automatic dark/light switching. - $patched = preg_replace( - '/(]+>)/', - '' - . '' - . '' - . '$1' - . '', - $patched + return Logo::with_fallback( + (string) $custom_logo_html, + esc_url( libresign_theme_get_theme_logo_url( 'light' ) ), + esc_url( libresign_theme_get_theme_logo_url( 'dark' ) ) ); - - return $patched ?: $custom_logo_html; } add_filter( 'get_custom_logo', 'libresign_theme_filter_custom_logo', 10, 2 ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0bfeb29..705cb92 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -36,9 +36,11 @@ + /woocommerce/* + /tests/* diff --git a/phpstan.neon.dist b/phpstan.neon.dist index cfa98c4..4480303 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,23 +1,29 @@ includes: - vendor-bin/phpstan/vendor/szepeviktor/phpstan-wordpress/extension.neon + - vendor-bin/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon + - vendor-bin/phpstan/vendor/phpstan/phpstan-phpunit/rules.neon parameters: level: 5 paths: - functions.php - inc + - src - patterns - woocommerce + - tests bootstrapFiles: - vendor-bin/phpstan/vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php + - vendor-bin/phpunit/vendor/autoload.php + scanDirectories: + - vendor/wp-phpunit/wp-phpunit/includes ignoreErrors: - - - identifier: constant.notFound - message: '#^Constant (AUTH_KEY|SECURE_AUTH_SALT|NONCE_SALT) not found\.$#' - path: inc/github-site-webhook.php - identifier: isset.property path: inc/registration.php - identifier: booleanAnd.rightAlwaysTrue path: inc/registration.php + - + identifier: assign.propertyType + path: tests/Integration/Inc/RegistrationTest.php diff --git a/phpunit-integration.xml.dist b/phpunit-integration.xml.dist new file mode 100644 index 0000000..aaa3a56 --- /dev/null +++ b/phpunit-integration.xml.dist @@ -0,0 +1,24 @@ + + + + + tests/Integration + + + + + functions.php + inc + src + woocommerce + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2f71cdd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/readme.txt b/readme.txt index 486e55a..1cf7b36 100644 --- a/readme.txt +++ b/readme.txt @@ -56,8 +56,23 @@ Every check is a Composer script: - `composer lint`: php -l on every file - `composer cs`: PHPCS - `composer stan`: PHPStan +- `composer test`: PHPUnit - `composer ci`: all of the above, in this order +`composer install` brings in WordPress, the WordPress test suite and WooCommerce, +so the tests only need a MySQL/MariaDB database they are allowed to wipe on every +run. The connection comes from `WP_TESTS_DB_NAME` (`wordpress_test`), +`WP_TESTS_DB_USER` (`root`), `WP_TESTS_DB_PASSWORD` (`root`), `WP_TESTS_DB_HOST` +(`mariadb`) and `WP_TESTS_TABLE_PREFIX` (`wptests_`). + +On the local SaaS stack: + + docker exec wordpress-docker-mariadb-1 \ + mariadb -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS wordpress_test;' + + docker exec -w /var/www/html/wp-content/themes/libresign \ + wordpress-docker-wordpress-1 composer test + == Development notes == - Site fragment sync/render bootstrap: `inc/footer-fragment.php` diff --git a/src/Logo.php b/src/Logo.php new file mode 100644 index 0000000..4ca61ec --- /dev/null +++ b/src/Logo.php @@ -0,0 +1,48 @@ +]+>/', $html, $tag_matches ) ) { + return array(); + } + + $urls = array(); + + if ( preg_match( '/src=["\']([^"\']+)["\']/', $tag_matches[0], $matches ) ) { + $urls[] = html_entity_decode( $matches[1] ); + } + + if ( preg_match( '/srcset=["\']([^"\']+)["\']/', $tag_matches[0], $matches ) ) { + foreach ( explode( ',', html_entity_decode( $matches[1] ) ) as $candidate ) { + $url = trim( explode( ' ', trim( $candidate ) )[0] ); + if ( $url ) { + $urls[] = $url; + } + } + } + + return $urls; + } + + public static function with_fallback( string $html, string $light_logo_url, string $dark_logo_url ): string { + $patched = preg_replace( '/\ssrcset=["\'][^"\']*["\']/', '', $html ) ?? $html; + $patched = preg_replace( '/\ssizes=["\'][^"\']*["\']/', '', $patched ) ?? $patched; + $patched = preg_replace( '/(]+)src=["\'][^"\']*["\']/', '$1src="' . $light_logo_url . '"', $patched ) ?? $patched; + + return preg_replace( + '/(]+>)/', + '' + . '' + . '' + . '$1' + . '', + $patched + ) ?? $patched; + } +} diff --git a/src/SiteDeploy.php b/src/SiteDeploy.php new file mode 100644 index 0000000..ba75e35 --- /dev/null +++ b/src/SiteDeploy.php @@ -0,0 +1,101 @@ + $payload + */ + public static function workflow_name( array $payload ): string { + $run_name = self::text( $payload, 'workflow_run', 'name' ); + + return '' !== $run_name ? $run_name : self::text( $payload, 'workflow', 'name' ); + } + + /** + * @param array $payload + */ + public static function is_production_deploy( array $payload, string $repository, string $branch, string $workflow ): bool { + return self::text( $payload, 'repository', 'full_name' ) === $repository + && self::text( $payload, 'action' ) === 'completed' + && self::text( $payload, 'workflow_run', 'conclusion' ) === 'success' + && self::text( $payload, 'workflow_run', 'head_branch' ) === $branch + && self::workflow_name( $payload ) === $workflow; + } + + /** + * @param array $payload + */ + public static function is_deploy_starting( array $payload, string $repository ): bool { + return self::text( $payload, 'repository', 'full_name' ) === $repository + && self::text( $payload, 'action' ) === 'in_progress' + && self::text( $payload, 'workflow_run', 'head_branch' ) === 'main' + && self::workflow_name( $payload ) === 'Deploy'; + } + + public static function merged_pull_request( mixed $pull_requests ): int { + if ( ! is_array( $pull_requests ) ) { + return 0; + } + + foreach ( $pull_requests as $pull_request ) { + if ( ! empty( $pull_request['merged_at'] ) ) { + return (int) $pull_request['number']; + } + } + + return 0; + } + + /** + * @param array $synced + */ + public static function sync_comment( array $synced, string $origin ): string { + $header_locales = implode( ', ', (array) ( $synced['header'] ?? array() ) ); + $footer_locales = implode( ', ', (array) ( $synced['footer'] ?? array() ) ); + + return "✅ **Header and footer fragments updated in production!**\n\n" . + "| Fragment | Synced locales |\n" . + "|----------|----------------|\n" . + "| Header | `{$header_locales}` |\n" . + "| Footer | `{$footer_locales}` |\n\n" . + "Source: {$origin}"; + } + + /** + * @param array $payload + */ + private static function text( array $payload, string $key, string $subkey = '' ): string { + $value = $payload[ $key ] ?? ''; + + if ( '' !== $subkey ) { + $value = is_array( $value ) ? ( $value[ $subkey ] ?? '' ) : ''; + } + + return is_scalar( $value ) ? trim( (string) $value ) : ''; + } +} diff --git a/src/SiteFragment.php b/src/SiteFragment.php new file mode 100644 index 0000000..d9a4f2a --- /dev/null +++ b/src/SiteFragment.php @@ -0,0 +1,125 @@ + '' !== $part ) ); + if ( empty( $parts ) ) { + return ''; + } + + $parts[0] = strtolower( $parts[0] ); + + foreach ( $parts as $index => $part ) { + if ( 0 === $index ) { + continue; + } + + if ( 2 === strlen( $part ) || 3 === strlen( $part ) ) { + $parts[ $index ] = strtoupper( $part ); + } elseif ( 4 === strlen( $part ) ) { + $parts[ $index ] = ucfirst( strtolower( $part ) ); + } + } + + return implode( '-', $parts ); + } + + public static function storage_key( string $locale ): string { + $tag = self::language_tag( $locale ); + + return '' === $tag ? self::DEFAULT_LOCALE_KEY : $tag; + } + + public static function url( string $origin, string $fragment_type, string $locale ): string { + $tag = self::language_tag( $locale ); + + return self::origin( $origin ) . '/fragments/' . ( '' === $tag ? '' : rawurlencode( $tag ) . '/' ) . $fragment_type; + } + + /** + * @return array{css: string, js: string}|null + */ + public static function asset_urls( string $html ): ?array { + if ( + ! preg_match( '/\bdata-fragment-css=("|\')([^"\']+)\1/i', $html, $css_matches ) + || ! preg_match( '/\bdata-fragment-js=("|\')([^"\']+)\1/i', $html, $js_matches ) + ) { + return null; + } + + return array( + 'css' => $css_matches[2], + 'js' => $js_matches[2], + ); + } + + /** + * @return string[] + */ + public static function linked_locales( string $header_html ): array { + $locales = array(); + + if ( preg_match_all( '~\/fragments(?:\/([^\/#?"\']+))?\/header(?:[\/#?"\']|$)~i', $header_html, $matches ) ) { + foreach ( $matches[1] as $locale ) { + $locales[] = self::language_tag( rawurldecode( $locale ) ); + } + } + + $locales[] = ''; + + return array_values( array_unique( $locales ) ); + } + + /** + * @param string[] $candidates + * @return string[] + */ + public static function lookup_keys( array $candidates ): array { + $keys = array(); + + foreach ( $candidates as $candidate ) { + $tag = self::language_tag( $candidate ); + if ( '' === $tag ) { + continue; + } + + $keys[] = $tag; + $keys[] = strtolower( (string) strtok( $tag, '-' ) ); + } + + $keys[] = self::DEFAULT_LOCALE_KEY; + + return array_values( array_unique( $keys ) ); + } + + public static function without_asset_attributes( string $html ): string { + $html = preg_replace( '/\s+data-fragment-css=("|\')[^"\']+\1/i', '', $html ) ?? $html; + + return preg_replace( '/\s+data-fragment-js=("|\')[^"\']+\1/i', '', $html ) ?? $html; + } + + public static function with_absolute_urls( string $content, string $origin ): string { + $origin = self::origin( $origin ); + + $content = preg_replace_callback( + '/\b(href|src|action|poster)=("|\')(\/(?!\/)[^"\']*)\2/i', + static fn ( $matches ) => $matches[1] . '=' . $matches[2] . $origin . $matches[3] . $matches[2], + $content + ) ?? $content; + + return preg_replace_callback( + '~url\(\s*(?:("|\')\s*)?(\/(?!\/)[^)"\']+)(?:\s*\1)?\s*\)~i', + static fn ( $matches ) => 'url(' . $matches[1] . $origin . $matches[2] . $matches[1] . ')', + $content + ) ?? $content; + } +} diff --git a/src/TaxId.php b/src/TaxId.php new file mode 100644 index 0000000..8bacf4f --- /dev/null +++ b/src/TaxId.php @@ -0,0 +1,74 @@ += 0; $position-- ) { + $sum += ( ord( $characters[ $position ] ) - 48 ) * $weight; + $weight = 9 === $weight ? 2 : $weight + 1; + } + $remainder = $sum % 11; + + return $remainder < 2 ? 0 : 11 - $remainder; + }; + + return $check_digit( substr( $cnpj, 0, 12 ) ) === (int) $cnpj[12] + && $check_digit( substr( $cnpj, 0, 13 ) ) === (int) $cnpj[13]; + } + + private static function digits( string $value ): string { + return (string) preg_replace( '/[^0-9]/', '', $value ); + } + + private static function without_formatting( string $value ): string { + return strtoupper( (string) preg_replace( '/[\.\-\/]/', '', $value ) ); + } +} diff --git a/tests/Integration/Inc/AccountTest.php b/tests/Integration/Inc/AccountTest.php new file mode 100644 index 0000000..2be5d3f --- /dev/null +++ b/tests/Integration/Inc/AccountTest.php @@ -0,0 +1,216 @@ +store = new StoreFactory(); + } + + public function tear_down() { + $_GET = array(); + $_REQUEST = array(); + $_SERVER['REQUEST_URI'] = ''; + unset( $GLOBALS['wp']->query_vars['lost-password'] ); + $this->store->empty_cart(); + add_filter( 'the_content', 'wpautop' ); + add_filter( 'the_content', 'shortcode_unautop' ); + set_current_screen( 'front' ); + + parent::tear_down(); + } + + public function test_the_account_is_the_woocommerce_my_account_page() { + $this->assertSame( wc_get_page_permalink( 'myaccount' ), libresign_theme_get_account_url() ); + } + + public function test_goes_back_to_the_page_asked_for() { + $_REQUEST['redirect_to'] = '/store/'; + + $this->assertSame( '/store/', libresign_theme_get_purchase_redirect_target() ); + } + + public function test_never_sends_to_another_site() { + $_REQUEST['redirect_to'] = 'https://evil.example/'; + + $this->assertSame( libresign_theme_get_account_url(), libresign_theme_get_purchase_redirect_target() ); + } + + public function test_ignores_a_redirect_that_is_not_a_single_url() { + $_REQUEST['redirect_to'] = array( '/store/' ); + + $this->assertSame( libresign_theme_get_account_url(), libresign_theme_get_purchase_redirect_target() ); + } + + public function test_goes_to_checkout_while_buying() { + $this->store->add_to_cart( $this->store->plan() ); + + $this->assertSame( wc_get_checkout_url(), libresign_theme_get_purchase_redirect_target() ); + } + + public function test_goes_to_the_account_otherwise() { + wc_load_cart(); + + $this->assertSame( libresign_theme_get_account_url(), libresign_theme_get_purchase_redirect_target() ); + } + + /** + * @dataProvider provide_lost_password_actions + */ + public function test_recognizes_the_lost_password_action( $action, $expected ) { + $_GET['action'] = $action; + + $this->assertSame( $expected, libresign_theme_is_lost_password_request() ); + } + + public static function provide_lost_password_actions() { + yield 'lost password' => array( 'lostpassword', true ); + yield 'in another letter case' => array( 'LostPassword', true ); + yield 'another action' => array( 'login', false ); + } + + public function test_recognizes_the_woocommerce_lost_password_endpoint() { + $GLOBALS['wp']->query_vars['lost-password'] = ''; + + $this->assertTrue( libresign_theme_is_lost_password_request() ); + } + + public function test_a_plain_request_is_not_a_lost_password_request() { + $this->assertFalse( libresign_theme_is_lost_password_request() ); + } + + /** + * @dataProvider provide_request_uris + */ + public function test_recognizes_the_direct_lost_password_route( $request_uri, $expected ) { + unset( $_SERVER['REQUEST_URI'] ); + if ( null !== $request_uri ) { + $_SERVER['REQUEST_URI'] = $request_uri; + } + + $this->assertSame( $expected, libresign_theme_is_direct_lost_password_route() ); + } + + public static function provide_request_uris() { + yield 'with the trailing slash' => array( '/lost-password/', true ); + yield 'without the trailing slash' => array( '/lost-password', true ); + yield 'with a query string' => array( '/lost-password/?reset-link-sent=true', true ); + yield 'under the account page' => array( '/account/lost-password/', false ); + yield 'the home page' => array( '/', false ); + yield 'no request uri' => array( null, false ); + } + + /** + * @dataProvider provide_reset_confirmations + */ + public function test_recognizes_a_password_reset_confirmation( $query, $expected ) { + $_GET = $query; + + $this->assertSame( $expected, libresign_theme_is_password_reset_confirmation() ); + } + + public static function provide_reset_confirmations() { + yield 'a reset link with the user id' => array( array( 'key' => 'abc', 'id' => '7' ), true ); + yield 'a reset link with the user login' => array( array( 'key' => 'abc', 'login' => 'ana' ), true ); + yield 'the set new password step' => array( array( 'show-reset-form' => 'true' ), true ); + yield 'the link sent step' => array( array( 'reset-link-sent' => 'true' ), true ); + yield 'a key without its user' => array( array( 'key' => 'abc' ), false ); + yield 'nothing' => array( array(), false ); + } + + public function test_the_lost_password_form_posts_to_woocommerce() { + $_SERVER['REQUEST_URI'] = '/lost-password/'; + + ob_start(); + libresign_theme_render_lost_password_form(); + $form = (string) ob_get_clean(); + + $this->assertStringContainsString( '', $form ); + $this->assertStringContainsString( 'name="woocommerce-lost-password-nonce"', $form ); + $this->assertStringContainsString( 'assertStringContainsString( 'Back to sign in', $form ); + } + + public function test_the_account_page_renders_the_woocommerce_account() { + $content = $this->account_page_content(); + + $this->assertStringContainsString( 'woocommerce-form-login', $content ); + $this->assertFalse( has_filter( 'the_content', 'wpautop' ) ); + } + + public function test_the_account_page_renders_the_lost_password_form() { + $this->assertStringContainsString( 'libresign-lost-password__card', $this->account_page_content( array( 'action' => 'lostpassword' ) ) ); + } + + public function test_the_account_page_leaves_the_reset_confirmation_to_woocommerce() { + $content = $this->account_page_content( + array( + 'action' => 'lostpassword', + 'show-reset-form' => 'true', + ) + ); + + $this->assertStringNotContainsString( 'libresign-lost-password__card', $content ); + $this->assertStringContainsString( 'woocommerce', $content ); + } + + public function test_keeps_the_content_of_other_pages() { + $this->in_the_loop_of( home_url() ); + + $this->assertSame( '

Page

', libresign_theme_prepend_saas_onboarding_to_content( '

Page

' ) ); + } + + public function test_keeps_the_content_outside_the_loop() { + add_filter( 'woocommerce_is_account_page', '__return_true' ); + $this->go_to( home_url() ); + + $this->assertSame( '

Page

', libresign_theme_prepend_saas_onboarding_to_content( '

Page

' ) ); + } + + public function test_keeps_the_content_in_the_admin() { + add_filter( 'woocommerce_is_account_page', '__return_true' ); + $this->in_the_loop_of( home_url() ); + set_current_screen( 'dashboard' ); + + $this->assertSame( '

Page

', libresign_theme_prepend_saas_onboarding_to_content( '

Page

' ) ); + } + + /** + * @dataProvider provide_requests_the_direct_route_leaves_alone + */ + public function test_the_direct_lost_password_route_leaves_other_requests_alone( $request_uri, $query, $account_page ) { + $_SERVER['REQUEST_URI'] = $request_uri; + $_GET = $query; + add_filter( 'woocommerce_is_account_page', $account_page ? '__return_true' : '__return_false' ); + + $this->expectOutputString( '' ); + + libresign_theme_render_direct_lost_password_route(); + } + + public static function provide_requests_the_direct_route_leaves_alone() { + yield 'another route' => array( '/store/', array(), false ); + yield 'a reset confirmation' => array( '/lost-password/', array( 'show-reset-form' => 'true' ), false ); + yield 'the route of the account' => array( '/lost-password/', array(), true ); + } + + private function account_page_content( $query = array() ) { + add_filter( 'woocommerce_is_account_page', '__return_true' ); + $this->in_the_loop_of( add_query_arg( $query, home_url( '/' ) ) ); + + return libresign_theme_prepend_saas_onboarding_to_content( '' ); + } + + private function in_the_loop_of( $url ) { + $this->go_to( $url ); + $GLOBALS['wp_query']->in_the_loop = true; + } +} diff --git a/tests/Integration/Inc/AssetsTest.php b/tests/Integration/Inc/AssetsTest.php new file mode 100644 index 0000000..8a7d7a6 --- /dev/null +++ b/tests/Integration/Inc/AssetsTest.php @@ -0,0 +1,67 @@ +enqueue()->registered['libresign-saas']; + + $this->assertSame( get_theme_file_uri( 'assets/css/saas.css' ), $style->src ); + $this->assertSame( array( 'woocommerce-general' ), $style->deps ); + $this->assertSame( (string) filemtime( get_theme_file_path( 'assets/css/saas.css' ) ), $style->ver ); + } + + public function test_the_saas_stylesheet_stands_alone_without_the_woocommerce_styles() { + $woocommerce_general = wp_styles()->registered['woocommerce-general'] ?? null; + remove_action( 'wp_enqueue_scripts', array( 'WC_Frontend_Scripts', 'load_scripts' ) ); + wp_deregister_style( 'woocommerce-general' ); + + $deps = $this->enqueue()->registered['libresign-saas']->deps; + + if ( null !== $woocommerce_general ) { + wp_styles()->registered['woocommerce-general'] = $woocommerce_general; + } + + $this->assertSame( array(), $deps ); + } + + public function test_loads_the_assets_the_site_header_and_footer_use() { + $styles = $this->enqueue(); + + $this->assertSame( get_theme_file_uri( 'assets/vendor/bootstrap/bootstrap-grid.min.css' ), $styles->registered['bootstrap-css']->src ); + $this->assertSame( get_theme_file_uri( 'assets/vendor/lineicons/lineicons.css' ), $styles->registered['lineicons']->src ); + $this->assertSame( get_theme_file_uri( 'assets/css/header-footer.css' ), $styles->registered['libresign-header-footer']->src ); + $this->assertContains( 'libresign-header-footer', $styles->queue ); + } + + public function test_the_header_and_footer_styles_come_after_the_other_stylesheets() { + $queue = $this->enqueue()->queue; + + $this->assertSame( 'libresign-header-footer', end( $queue ) ); + } +} diff --git a/tests/Integration/Inc/CheckoutTest.php b/tests/Integration/Inc/CheckoutTest.php new file mode 100644 index 0000000..a306fe7 --- /dev/null +++ b/tests/Integration/Inc/CheckoutTest.php @@ -0,0 +1,35 @@ +assertSame( + 'I agree to the terms and privacy policy before placing the order.', + apply_filters( 'woocommerce_get_terms_and_conditions_checkbox_text', '' ) + ); + } + + public function test_refuses_an_order_without_the_policy_consent() { + $errors = $this->checkout_errors( array( 'terms' => 0 ) ); + + $this->assertContains( 'libresign_policy_consent', $errors->get_error_codes() ); + } + + public function test_accepts_an_order_with_the_policy_consent() { + $errors = $this->checkout_errors( array( 'terms' => 1 ) ); + + $this->assertNotContains( 'libresign_policy_consent', $errors->get_error_codes() ); + } +} diff --git a/tests/Integration/Inc/CpfCnpjTest.php b/tests/Integration/Inc/CpfCnpjTest.php new file mode 100644 index 0000000..6a8db00 --- /dev/null +++ b/tests/Integration/Inc/CpfCnpjTest.php @@ -0,0 +1,119 @@ +get( CheckoutFields::class )->get_additional_fields()['libresign/cpf-cnpj']; + + $this->assertSame( 'CPF or CNPJ', $field['label'] ); + $this->assertSame( 'address', $field['location'] ); + $this->assertFalse( $field['required'] ); + $this->assertSame( array( 'autocomplete' => 'off' ), $field['attributes'] ); + } + + /** + * @dataProvider provide_addresses + */ + public function test_validates_the_field_of_a_brazilian_billing_address( $group, $fields, $errors ) { + $validation = new WP_Error(); + + do_action( 'woocommerce_blocks_validate_location_address_fields', $validation, $fields, $group ); + + $this->assertSame( $errors, $validation->get_error_codes() ); + } + + public static function provide_addresses() { + yield 'a shipping address is not checked' => array( 'shipping', array( 'country' => 'BR' ), array() ); + yield 'another country is not checked' => array( 'billing', array( 'country' => 'US' ), array() ); + yield 'an address without fields' => array( 'billing', null, array() ); + yield 'missing in Brazil' => array( 'billing', array( 'country' => 'BR' ), array( 'libresign_cpf_cnpj_required' ) ); + yield 'blank in Brazil' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => ' ' ), array( 'libresign_cpf_cnpj_required' ) ); + yield 'a valid cpf' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => '529.982.247-25' ), array() ); + yield 'an invalid cpf' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => '529.982.247-24' ), array( 'invalid_cpf' ) ); + yield 'a valid cnpj' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => '11.222.333/0001-81' ), array() ); + yield 'a valid alphanumeric cnpj' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => '12.ABC.345/01DE-35' ), array() ); + yield 'an invalid cnpj' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => '11.222.333/0001-80' ), array( 'invalid_cnpj' ) ); + yield 'neither a cpf nor a cnpj in length' => array( 'billing', array( 'country' => 'BR', 'libresign/cpf-cnpj' => '123456789' ), array( 'invalid_cpf_cnpj' ) ); + } + + public function test_drops_the_field_from_the_shipping_address_and_keeps_it_on_a_brazilian_billing() { + $order = $this->order_with_cpf_cnpj( 'BR' ); + + do_action( 'woocommerce_store_api_checkout_update_order_from_request', $order, new WP_REST_Request() ); + + $this->assertSame( '', $order->get_meta( '_wc_shipping/libresign/cpf-cnpj' ) ); + $this->assertSame( '529.982.247-25', $order->get_meta( '_wc_billing/libresign/cpf-cnpj' ) ); + } + + public function test_drops_the_field_from_an_order_billed_outside_brazil() { + $order = $this->order_with_cpf_cnpj( 'US' ); + + do_action( 'woocommerce_store_api_checkout_update_order_from_request', $order, new WP_REST_Request() ); + + $this->assertSame( '', $order->get_meta( '_wc_shipping/libresign/cpf-cnpj' ) ); + $this->assertSame( '', $order->get_meta( '_wc_billing/libresign/cpf-cnpj' ) ); + } + + public function test_drops_the_field_from_a_customer_billed_outside_brazil() { + $customer = new WC_Customer(); + $customer->set_billing_country( 'US' ); + $customer->update_meta_data( '_wc_billing/libresign/cpf-cnpj', '529.982.247-25' ); + + do_action( 'woocommerce_store_api_checkout_update_customer_from_request', $customer, new WP_REST_Request() ); + + $this->assertSame( '', $customer->get_meta( '_wc_billing/libresign/cpf-cnpj' ) ); + } + + public function test_ignores_what_is_not_a_woocommerce_object() { + $this->expectNotToPerformAssertions(); + + libresign_theme_strip_irrelevant_cpf_cnpj( 'not an order' ); + } + + public function test_prints_the_field_script_on_the_checkout() { + add_filter( 'woocommerce_is_checkout', '__return_true' ); + + $footer = $this->footer(); + + $this->assertStringContainsString( "var FIELD_KEY = 'billing_libresign/cpf-cnpj';", $footer ); + $this->assertStringContainsString( 'var ERROR_MSG = "Please enter your CPF or CNPJ.";', $footer ); + } + + public function test_prints_no_field_script_outside_the_checkout() { + $this->go_to( home_url() ); + + $this->assertStringNotContainsString( 'billing_libresign/cpf-cnpj', $this->footer() ); + } + + private function footer() { + $this->setExpectedDeprecated( 'the_block_template_skip_link' ); + + ob_start(); + do_action( 'wp_footer' ); + + return (string) ob_get_clean(); + } + + private function order_with_cpf_cnpj( $country ) { + $order = new WC_Order(); + $order->set_billing_country( $country ); + $order->update_meta_data( '_wc_billing/libresign/cpf-cnpj', '529.982.247-25' ); + $order->update_meta_data( '_wc_shipping/libresign/cpf-cnpj', '529.982.247-25' ); + + return $order; + } +} diff --git a/tests/Integration/Inc/FooterFragmentTest.php b/tests/Integration/Inc/FooterFragmentTest.php new file mode 100644 index 0000000..c2b1d41 --- /dev/null +++ b/tests/Integration/Inc/FooterFragmentTest.php @@ -0,0 +1,257 @@ +site = new StaticSite(); + } + + public function tear_down() { + foreach ( array( 'header', 'footer' ) as $fragment_type ) { + libresign_theme_site_fragment_recursive_delete( libresign_theme_site_fragment_storage_base_dir( $fragment_type ) ); + } + + parent::tear_down(); + } + + public function test_stores_each_fragment_type_in_its_own_folder() { + $this->assertSame( array( 'header', 'footer' ), libresign_theme_site_fragment_supported_types() ); + $this->assertStringEndsWith( '/libresign-header', libresign_theme_site_fragment_storage_base_dir( 'header' ) ); + $this->assertStringEndsWith( '/libresign-footer', libresign_theme_site_fragment_storage_base_url( 'footer' ) ); + } + + public function test_falls_back_to_the_site_locale() { + $this->assertSame( array( 'en-US', '' ), libresign_theme_site_fragment_fallback_locales() ); + } + + public function test_looks_the_locale_up_from_the_most_specific_to_the_default() { + $this->assertSame( array( 'en-US', 'en', 'default' ), libresign_theme_site_fragment_locale_lookup_keys() ); + } + + public function test_only_a_not_found_is_an_optional_error() { + $this->assertTrue( libresign_theme_site_fragment_is_optional_http_error( new WP_Error( 'x', 'x', array( 'status' => 404 ) ) ) ); + $this->assertFalse( libresign_theme_site_fragment_is_optional_http_error( new WP_Error( 'x', 'x', array( 'status' => 500 ) ) ) ); + $this->assertFalse( libresign_theme_site_fragment_is_optional_http_error( new WP_Error( 'x', 'x' ) ) ); + } + + public function test_fetches_a_url() { + $this->site->serve( '/fragments/header', '
' ); + + $this->assertSame( + array( + 'code' => 200, + 'body' => '
', + ), + libresign_theme_site_fragment_fetch_url( $this->site->origin() . '/fragments/header' ) + ); + } + + public function test_a_failing_status_is_an_error_with_the_status() { + $error = libresign_theme_site_fragment_fetch_url( $this->site->origin() . '/fragments/header' ); + + $this->assertWPError( $error ); + $this->assertSame( 'libresign_theme_site_fragment_http_status', $error->get_error_code() ); + $this->assertSame( 404, $error->get_error_data()['status'] ); + } + + public function test_a_transport_failure_is_an_error() { + $this->assertWPError( libresign_theme_site_fragment_fetch_url( 'https://libresign.coop/fragments/header' ) ); + } + + public function test_syncing_needs_an_origin() { + $error = libresign_theme_sync_site_fragments_from_origin( ' ' ); + + $this->assertWPError( $error ); + $this->assertSame( 'libresign_theme_site_fragment_origin_missing', $error->get_error_code() ); + } + + public function test_collects_every_fragment_in_every_locale_the_site_publishes() { + $this->publish_site(); + + $collected = libresign_theme_site_fragment_collect_artifacts( $this->site->origin(), array( 'header', 'footer' ), array( 'source_sha' => 'abc123' ) ); + + $this->assertSame( array( '', 'pt-BR', 'en-US' ), $collected['locales'] ); + $this->assertSame( + array( + 'header' => array( 'default', 'pt-BR' ), + 'footer' => array( 'default', 'pt-BR' ), + ), + $collected['summary'] + ); + $this->assertSame( + array( + '/fragments/header', + '/assets/header.css', + '/assets/header.js', + '/fragments/pt-BR/header', + '/fragments/en-US/header', + '/fragments/footer', + '/assets/footer.css', + '/assets/footer.js', + '/fragments/pt-BR/footer', + '/fragments/en-US/footer', + ), + $this->site->paths() + ); + + $header = $collected['artifacts'][0]; + $this->assertSame( '
pt
', $header['html'] ); + $this->assertSame( 'a{background:url(' . $this->site->origin() . '/bg.png)}', $header['css'] ); + $this->assertSame( 'console.log("header");', $header['js'] ); + $this->assertSame( 'abc123', $header['source_sha'] ); + $this->assertSame( hash( 'sha256', $header['html'] . "\n" . $header['css'] . "\n" . $header['js'] ), $header['version'] ); + } + + public function test_a_missing_default_header_stops_the_sync() { + $error = libresign_theme_site_fragment_collect_artifacts( $this->site->origin(), array( 'header' ) ); + + $this->assertWPError( $error ); + $this->assertSame( 404, $error->get_error_data()['status'] ); + } + + public function test_a_default_fragment_without_assets_stops_the_sync() { + $this->publish_site(); + $this->site->serve( '/fragments/footer', '
' ); + + $error = libresign_theme_site_fragment_collect_artifacts( $this->site->origin(), array( 'header', 'footer' ) ); + + $this->assertSame( 'libresign_theme_site_fragment_missing_assets', $error->get_error_code() ); + } + + public function test_skips_a_locale_whose_fragment_has_no_assets() { + $this->publish_site(); + $this->site->serve( '/fragments/pt-BR/footer', '
' ); + + $collected = libresign_theme_site_fragment_collect_artifacts( $this->site->origin(), array( 'footer' ) ); + + $this->assertSame( array( 'footer' => array( 'default' ) ), $collected['summary'] ); + } + + public function test_a_locale_that_fails_for_another_reason_stops_the_sync() { + $this->publish_site(); + $this->site->serve( '/fragments/pt-BR/footer', 'error', 500 ); + + $error = libresign_theme_site_fragment_collect_artifacts( $this->site->origin(), array( 'footer' ) ); + + $this->assertSame( 500, $error->get_error_data()['status'] ); + } + + public function test_a_missing_asset_stops_the_sync() { + $this->publish_site(); + $this->site->serve( '/assets/footer.js', 'gone', 404 ); + + $error = libresign_theme_site_fragment_collect_artifacts( $this->site->origin(), array( 'footer' ) ); + + $this->assertSame( 404, $error->get_error_data()['status'] ); + } + + public function test_stores_the_synced_fragments_with_their_manifest() { + $this->publish_site(); + + $result = libresign_theme_sync_site_fragments_from_origin( $this->site->origin() . '/', array(), array( 'source_url' => 'https://github.com/run/1' ) ); + + $this->assertSame( $this->site->origin(), $result['origin'] ); + $this->assertSame( array( 'default', 'pt-BR' ), $result['synced']['footer'] ); + + $folder = libresign_theme_site_fragment_storage_base_dir( 'footer' ) . '/pt-BR'; + $manifest = json_decode( (string) file_get_contents( $folder . '/manifest.json' ), true ); + + $this->assertSame( '
', file_get_contents( $folder . '/footer.html' ) ); + $this->assertSame( 'footer{}', file_get_contents( $folder . '/footer.css' ) ); + $this->assertSame( 'console.log("footer");', file_get_contents( $folder . '/footer.js' ) ); + $this->assertSame( 'pt-BR', $manifest['locale_key'] ); + $this->assertSame( 'https://github.com/run/1', $manifest['source_url'] ); + $this->assertSame( libresign_theme_site_fragment_storage_base_url( 'footer' ) . '/pt-BR', $manifest['base_url'] ); + } + + public function test_a_new_sync_replaces_the_locales_stored_before() { + wp_mkdir_p( libresign_theme_site_fragment_storage_base_dir( 'header' ) . '/fr' ); + $this->publish_site(); + + libresign_theme_sync_site_fragments_from_origin( $this->site->origin() ); + + $this->assertDirectoryDoesNotExist( libresign_theme_site_fragment_storage_base_dir( 'header' ) . '/fr' ); + $this->assertDirectoryExists( libresign_theme_site_fragment_storage_base_dir( 'header' ) . '/default' ); + } + + public function test_a_failed_sync_keeps_the_stored_fragments() { + wp_mkdir_p( libresign_theme_site_fragment_storage_base_dir( 'header' ) . '/fr' ); + + $this->assertWPError( libresign_theme_sync_site_fragments_from_origin( $this->site->origin() ) ); + $this->assertDirectoryExists( libresign_theme_site_fragment_storage_base_dir( 'header' ) . '/fr' ); + } + + public function test_renders_and_enqueues_the_stored_fragments_in_place_of_the_template_parts() { + $this->store( + array( + array( 'header', 'default', '
stored
' ), + array( 'footer', 'default', '
en
' ), + array( 'footer', 'en', '
en-generic
' ), + array( 'footer', 'en-US', '
en-US
' ), + ) + ); + + do_action( 'wp_enqueue_scripts' ); + + $this->assertSame( '
stored
', apply_filters( 'render_block_core/template-part', '
theme
', array( 'attrs' => array( 'slug' => 'header' ) ) ) ); + $this->assertSame( '
en-US
', apply_filters( 'render_block_core/template-part', '
theme
', array( 'attrs' => array( 'slug' => 'footer' ) ) ) ); + $this->assertSame( '', apply_filters( 'render_block_core/template-part', '', array( 'attrs' => array( 'slug' => 'sidebar' ) ) ) ); + $this->assertSame( libresign_theme_site_fragment_storage_base_url( 'header' ) . '/default/header.css', wp_styles()->registered['libresign-site-header-fragment']->src ); + $this->assertSame( libresign_theme_site_fragment_storage_base_url( 'footer' ) . '/en-US/footer.js', wp_scripts()->registered['libresign-site-footer-fragment']->src ); + $this->assertSame( 'module', wp_scripts()->get_data( 'libresign-site-header-fragment', 'type' ) ); + } + + private function store( $fragments ) { + $artifacts = array(); + + foreach ( $fragments as list( $fragment_type, $locale_key, $html ) ) { + $artifacts[] = array( + 'fragment_type' => $fragment_type, + 'locale' => 'default' === $locale_key ? '' : $locale_key, + 'locale_key' => $locale_key, + 'fragment_url' => '', + 'css_url' => '', + 'js_url' => '', + 'generated_at' => '', + 'source_sha' => '', + 'source_url' => '', + 'html' => $html, + 'css' => '', + 'js' => '', + 'version' => '1', + ); + } + + libresign_theme_site_fragment_persist_artifacts( + array( + 'artifacts' => $artifacts, + 'summary' => array(), + ) + ); + } + + private function publish_site() { + $origin = $this->site->origin(); + $header = '
pt
'; + $footer = '
'; + + $this->site->serve( '/fragments/header', $header ); + $this->site->serve( '/fragments/pt-BR/header', $header ); + $this->site->serve( '/fragments/footer', $footer ); + $this->site->serve( '/fragments/pt-BR/footer', str_replace( '