diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..f5f7003
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,240 @@
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+max_line_length = 120
+tab_width = 2
+ij_continuation_indent_size = 2
+ij_visual_guides = 80,120
+
+[{*.php}]
+ij_php_align_assignments = false
+ij_php_align_class_constants = false
+ij_php_align_enum_cases = false
+ij_php_align_group_field_declarations = false
+ij_php_align_inline_comments = false
+ij_php_align_key_value_pairs = false
+ij_php_align_match_arm_bodies = false
+ij_php_align_multiline_array_initializer_expression = false
+ij_php_align_multiline_binary_operation = false
+ij_php_align_multiline_chained_methods = false
+ij_php_align_multiline_extends_list = false
+ij_php_align_multiline_for = true
+ij_php_align_multiline_parameters = false
+ij_php_align_multiline_parameters_in_calls = false
+ij_php_align_multiline_ternary_operation = false
+ij_php_align_named_arguments = false
+ij_php_align_phpdoc_comments = false
+ij_php_align_phpdoc_param_names = false
+ij_php_anonymous_brace_style = end_of_line
+ij_php_api_weight = 28
+ij_php_array_initializer_new_line_after_left_brace = true
+ij_php_array_initializer_right_brace_on_new_line = true
+ij_php_array_initializer_wrap = on_every_item
+ij_php_assignment_wrap = normal
+ij_php_attributes_wrap = normal
+ij_php_author_weight = 28
+ij_php_binary_operation_sign_on_next_line = false
+ij_php_binary_operation_wrap = normal
+ij_php_blank_lines_after_class_header = 1
+ij_php_blank_lines_after_function = 1
+ij_php_blank_lines_after_imports = 1
+ij_php_blank_lines_after_opening_tag = 0
+ij_php_blank_lines_after_package = 1
+ij_php_blank_lines_around_class = 1
+ij_php_blank_lines_around_constants = 1
+ij_php_blank_lines_around_enum_cases = 0
+ij_php_blank_lines_around_field = 1
+ij_php_blank_lines_around_method = 1
+ij_php_blank_lines_before_class_end = 1
+ij_php_blank_lines_before_imports = 1
+ij_php_blank_lines_before_method_body = 0
+ij_php_blank_lines_before_package = 1
+ij_php_blank_lines_before_return_statement = 1
+ij_php_blank_lines_between_imports = 0
+ij_php_block_brace_style = end_of_line
+ij_php_call_parameters_new_line_after_left_paren = true
+ij_php_call_parameters_right_paren_on_new_line = true
+ij_php_call_parameters_wrap = on_every_item
+ij_php_catch_on_new_line = true
+ij_php_category_weight = 28
+ij_php_class_brace_style = end_of_line
+ij_php_comma_after_last_argument = false
+ij_php_comma_after_last_array_element = true
+ij_php_comma_after_last_closure_use_var = false
+ij_php_comma_after_last_match_arm = false
+ij_php_comma_after_last_parameter = false
+ij_php_concat_spaces = true
+ij_php_copyright_weight = 28
+ij_php_deprecated_weight = 4
+ij_php_do_while_brace_force = always
+ij_php_else_if_style = as_is
+ij_php_else_on_new_line = true
+ij_php_example_weight = 28
+ij_php_extends_keyword_wrap = off
+ij_php_extends_list_wrap = off
+ij_php_fields_default_visibility = private
+ij_php_filesource_weight = 28
+ij_php_finally_on_new_line = true
+ij_php_for_brace_force = always
+ij_php_for_statement_new_line_after_left_paren = false
+ij_php_for_statement_right_paren_on_new_line = false
+ij_php_for_statement_wrap = off
+ij_php_force_empty_methods_in_one_line = false
+ij_php_force_short_declaration_array_style = true
+ij_php_getters_setters_naming_style = camel_case
+ij_php_getters_setters_order_style = getters_first
+ij_php_global_weight = 28
+ij_php_group_use_wrap = on_every_item
+ij_php_if_brace_force = always
+ij_php_if_lparen_on_next_line = false
+ij_php_if_rparen_on_next_line = false
+ij_php_ignore_weight = 28
+ij_php_import_sorting = alphabetic
+ij_php_indent_break_from_case = true
+ij_php_indent_case_from_switch = true
+ij_php_indent_code_in_php_tags = false
+ij_php_internal_weight = 28
+ij_php_keep_blank_lines_after_lbrace = 1
+ij_php_keep_blank_lines_before_right_brace = 1
+ij_php_keep_blank_lines_in_code = 1
+ij_php_keep_blank_lines_in_declarations = 1
+ij_php_keep_control_statement_in_one_line = false
+ij_php_keep_first_column_comment = false
+ij_php_keep_indents_on_empty_lines = false
+ij_php_keep_line_breaks = false
+ij_php_keep_rparen_and_lbrace_on_one_line = true
+ij_php_keep_simple_classes_in_one_line = false
+ij_php_keep_simple_methods_in_one_line = false
+ij_php_lambda_brace_style = end_of_line
+ij_php_license_weight = 28
+ij_php_line_comment_add_space = false
+ij_php_line_comment_at_first_column = true
+ij_php_link_weight = 28
+ij_php_lower_case_boolean_const = false
+ij_php_lower_case_keywords = true
+ij_php_lower_case_null_const = false
+ij_php_method_brace_style = end_of_line
+ij_php_method_call_chain_wrap = on_every_item
+ij_php_method_parameters_new_line_after_left_paren = true
+ij_php_method_parameters_right_paren_on_new_line = true
+ij_php_method_parameters_wrap = on_every_item
+ij_php_method_weight = 28
+ij_php_modifier_list_wrap = false
+ij_php_multiline_chained_calls_semicolon_on_new_line = true
+ij_php_namespace_brace_style = 1
+ij_php_new_line_after_php_opening_tag = true
+ij_php_null_type_position = in_the_end
+ij_php_package_weight = 28
+ij_php_param_weight = 1
+ij_php_parameters_attributes_wrap = normal
+ij_php_parentheses_expression_new_line_after_left_paren = false
+ij_php_parentheses_expression_right_paren_on_new_line = false
+ij_php_phpdoc_blank_line_before_tags = true
+ij_php_phpdoc_blank_lines_around_parameters = true
+ij_php_phpdoc_keep_blank_lines = true
+ij_php_phpdoc_param_spaces_between_name_and_description = 1
+ij_php_phpdoc_param_spaces_between_tag_and_type = 1
+ij_php_phpdoc_param_spaces_between_type_and_name = 1
+ij_php_phpdoc_use_fqcn = true
+ij_php_phpdoc_wrap_long_lines = true
+ij_php_place_assignment_sign_on_next_line = false
+ij_php_place_parens_for_constructor = 1
+ij_php_property_read_weight = 28
+ij_php_property_weight = 28
+ij_php_property_write_weight = 28
+ij_php_return_type_on_new_line = false
+ij_php_return_weight = 2
+ij_php_see_weight = 5
+ij_php_since_weight = 28
+ij_php_sort_phpdoc_elements = true
+ij_php_space_after_colon = true
+ij_php_space_after_colon_in_enum_backed_type = true
+ij_php_space_after_colon_in_named_argument = true
+ij_php_space_after_colon_in_return_type = true
+ij_php_space_after_comma = true
+ij_php_space_after_for_semicolon = true
+ij_php_space_after_quest = true
+ij_php_space_after_type_cast = true
+ij_php_space_after_unary_not = false
+ij_php_space_before_array_initializer_left_brace = false
+ij_php_space_before_catch_keyword = true
+ij_php_space_before_catch_left_brace = true
+ij_php_space_before_catch_parentheses = true
+ij_php_space_before_class_left_brace = true
+ij_php_space_before_closure_left_parenthesis = true
+ij_php_space_before_colon = true
+ij_php_space_before_colon_in_enum_backed_type = false
+ij_php_space_before_colon_in_named_argument = false
+ij_php_space_before_colon_in_return_type = false
+ij_php_space_before_comma = false
+ij_php_space_before_do_left_brace = true
+ij_php_space_before_else_keyword = true
+ij_php_space_before_else_left_brace = true
+ij_php_space_before_finally_keyword = true
+ij_php_space_before_finally_left_brace = true
+ij_php_space_before_for_left_brace = true
+ij_php_space_before_for_parentheses = true
+ij_php_space_before_for_semicolon = false
+ij_php_space_before_if_left_brace = true
+ij_php_space_before_if_parentheses = true
+ij_php_space_before_method_call_parentheses = false
+ij_php_space_before_method_left_brace = true
+ij_php_space_before_method_parentheses = false
+ij_php_space_before_quest = true
+ij_php_space_before_short_closure_left_parenthesis = false
+ij_php_space_before_switch_left_brace = true
+ij_php_space_before_switch_parentheses = true
+ij_php_space_before_try_left_brace = true
+ij_php_space_before_unary_not = false
+ij_php_space_before_while_keyword = true
+ij_php_space_before_while_left_brace = true
+ij_php_space_before_while_parentheses = true
+ij_php_space_between_ternary_quest_and_colon = false
+ij_php_spaces_around_additive_operators = true
+ij_php_spaces_around_arrow = false
+ij_php_spaces_around_assignment_in_declare = true
+ij_php_spaces_around_assignment_operators = true
+ij_php_spaces_around_bitwise_operators = true
+ij_php_spaces_around_equality_operators = true
+ij_php_spaces_around_logical_operators = true
+ij_php_spaces_around_multiplicative_operators = true
+ij_php_spaces_around_null_coalesce_operator = true
+ij_php_spaces_around_pipe_in_union_type = false
+ij_php_spaces_around_relational_operators = true
+ij_php_spaces_around_shift_operators = true
+ij_php_spaces_around_unary_operator = false
+ij_php_spaces_around_var_within_brackets = false
+ij_php_spaces_within_array_initializer_braces = false
+ij_php_spaces_within_brackets = false
+ij_php_spaces_within_catch_parentheses = false
+ij_php_spaces_within_for_parentheses = false
+ij_php_spaces_within_if_parentheses = false
+ij_php_spaces_within_method_call_parentheses = false
+ij_php_spaces_within_method_parentheses = false
+ij_php_spaces_within_parentheses = false
+ij_php_spaces_within_short_echo_tags = true
+ij_php_spaces_within_switch_parentheses = false
+ij_php_spaces_within_while_parentheses = false
+ij_php_special_else_if_treatment = false
+ij_php_subpackage_weight = 28
+ij_php_ternary_operation_signs_on_next_line = true
+ij_php_ternary_operation_wrap = on_every_item
+ij_php_throws_weight = 3
+ij_php_todo_weight = 6
+ij_php_treat_multiline_arrays_and_lambdas_multiline = false
+ij_php_unknown_tag_weight = 28
+ij_php_upper_case_boolean_const = true
+ij_php_upper_case_null_const = true
+ij_php_uses_weight = 28
+ij_php_var_weight = 0
+ij_php_variable_naming_style = camel_case
+ij_php_version_weight = 28
+ij_php_while_brace_force = always
+ij_php_while_on_new_line = false
+
+[{*.neon,*.neon.dist,*neon.template}]
+indent_style = tab
+tab_width = 4
diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml
new file mode 100644
index 0000000..a8c4a2d
--- /dev/null
+++ b/.github/workflows/phpcs.yml
@@ -0,0 +1,42 @@
+name: PHP_CodeSniffer
+
+on:
+ pull_request:
+ paths:
+ - '**.php'
+ - tools/phpcs/composer.json
+ - phpcs.xml.dist
+
+jobs:
+ phpcs:
+ runs-on: ubuntu-latest
+ name: PHP_CodeSniffer
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ coverage: none
+ tools: cs2pr
+ env:
+ fail-fast: true
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('tools/phpcs/composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer composer-phpcs -- update --no-progress --prefer-dist
+
+ - name: Run PHP_CodeSniffer
+ run: composer phpcs -- -q --report=checkstyle | cs2pr
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..1333c1d
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,52 @@
+name: PHPStan
+
+on:
+ pull_request:
+ paths:
+ - '**.php'
+ - composer.json
+ - tools/phpstan/composer.json
+ - ci/composer.json
+ - phpstan.ci.neon
+ - phpstan.neon.dist
+
+jobs:
+ phpstan:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: ['7.4', '8.0', '8.3']
+ prefer: ['prefer-stable', 'prefer-lowest']
+ name: PHPStan with PHP ${{ matrix.php-versions }} ${{ matrix.prefer }}
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ coverage: none
+ env:
+ fail-fast: true
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ matrix.prefer }}-${{ hashFiles('**/composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-${{ matrix.prefer }}-
+
+ - name: Install dependencies
+ run: |
+ composer update --no-progress --prefer-dist --${{ matrix.prefer }} --optimize-autoloader &&
+ composer composer-phpunit -- update --no-progress --prefer-dist &&
+ composer composer-phpstan -- update --no-progress --prefer-dist --optimize-autoloader &&
+ composer --working-dir=ci update --no-progress --prefer-dist --${{ matrix.prefer }} --ignore-platform-req=ext-gd
+
+ - name: Run PHPStan
+ run: composer phpstan -- analyse -c phpstan.ci.neon
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
new file mode 100644
index 0000000..00634de
--- /dev/null
+++ b/.github/workflows/phpunit.yml
@@ -0,0 +1,37 @@
+name: PHPUnit
+
+on:
+ pull_request:
+ paths:
+ - '**.php'
+ - composer.json
+ - tools/phpunit/composer.json
+ - phpunit.xml.dist
+ - tests/docker-prepare.sh
+
+env:
+ # On github CI machine creating the "/vendor" volume fails otherwise with: read-only file system: unknown
+ BIND_VOLUME_PERMISSIONS: rw
+
+jobs:
+ phpunit:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ civicrm-image-tags: [ '5-drupal', '5.71-drupal-php8.1' ]
+ name: PHPUnit with Docker image michaelmcandrew/civicrm:${{ matrix.civicrm-image-tags }}
+ env:
+ CIVICRM_IMAGE_TAG: ${{ matrix.civicrm-image-tags }}
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Pull images
+ run: docker compose -f tests/docker-compose.yml pull --quiet
+ - name: Start containers
+ run: docker compose -f tests/docker-compose.yml up -d
+ - name: Prepare environment
+ run: docker compose -f tests/docker-compose.yml exec civicrm sites/default/files/civicrm/ext/hiorg/tests/docker-prepare.sh
+ - name: Run PHPUnit
+ run: docker compose -f tests/docker-compose.yml exec civicrm sites/default/files/civicrm/ext/hiorg/tests/docker-phpunit.sh
+ - name: Remove containers
+ run: docker compose -f tests/docker-compose.yml down -v
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..62dffec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+/.phpcs.cache
+/.phpunit.result.cache
+/.phpstan/
+/ci/composer.lock
+/ci/vendor/
+/composer.lock
+/phpstan.neon
+/tools/*/vendor/
+/tools/*/composer.lock
+/vendor/
diff --git a/ci/composer.json b/ci/composer.json
new file mode 100644
index 0000000..afab8ac
--- /dev/null
+++ b/ci/composer.json
@@ -0,0 +1,15 @@
+{
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "allow-plugins": {
+ "civicrm/composer-compile-plugin": false,
+ "civicrm/composer-downloads-plugin": true,
+ "cweagans/composer-patches": true
+ },
+ "sort-packages": true
+ },
+ "require": {
+ "civicrm/civicrm-core": "^5.71"
+ }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..1802a4b
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,53 @@
+{
+ "name": "systopia/hiorg",
+ "type": "civicrm-ext",
+ "license": "AGPL-3.0-or-later",
+ "authors": [
+ {
+ "name": "SYSTOPIA GmbH",
+ "email": "info@systopia.de",
+ "homepage": "https://www.systopia.de"
+ }
+ ],
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "prepend-autoloader": false,
+ "sort-packages": true
+ },
+ "require": {
+ },
+ "scripts": {
+ "composer-phpcs": [
+ "@composer --working-dir=tools/phpcs"
+ ],
+ "composer-phpstan": [
+ "@composer --working-dir=tools/phpstan"
+ ],
+ "composer-phpunit": [
+ "@composer --working-dir=tools/phpunit"
+ ],
+ "composer-tools": [
+ "@composer-phpcs",
+ "@composer-phpstan",
+ "@composer-phpunit"
+ ],
+ "phpcs": [
+ "@php tools/phpcs/vendor/bin/phpcs"
+ ],
+ "phpcbf": [
+ "@php tools/phpcs/vendor/bin/phpcbf"
+ ],
+ "phpstan": [
+ "@php tools/phpstan/vendor/bin/phpstan -v"
+ ],
+ "phpunit": [
+ "@php tools/phpunit/vendor/bin/simple-phpunit --coverage-text"
+ ],
+ "test": [
+ "@phpcs",
+ "@phpstan",
+ "@phpunit"
+ ]
+ }
+}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..5167f53
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,83 @@
+
+
+ CiviCRM coding standard with some additional changes
+
+ Civi
+ CRM
+ managed
+ settings
+ tests
+ hiorg.php
+
+ /CRM/Hiorg/DAO/.*\.php$
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.ci.neon b/phpstan.ci.neon
new file mode 100644
index 0000000..883179f
--- /dev/null
+++ b/phpstan.ci.neon
@@ -0,0 +1,13 @@
+includes:
+ - phpstan.neon.dist
+
+parameters:
+ scanDirectories:
+ - ci/vendor/civicrm/civicrm-core/CRM/
+ bootstrapFiles:
+ - ci/vendor/autoload.php
+ # Because we test with different versions in CI we have unmatched errors
+ reportUnmatchedIgnoredErrors: false
+ ignoreErrors:
+ # Errors we get when using "prefer-lowest"
+ - '#::getSubscribedEvents\(\) return type has no value type specified in iterable type array.$#'
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..e80ff9b
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,45 @@
+parameters:
+ paths:
+ - Civi
+ - CRM
+ - managed
+ - settings
+ - tests
+ - hiorg.php
+ excludePaths:
+ analyse:
+ - CRM/Hiorg/DAO/*
+ - tests/phpunit/bootstrap.php
+ scanFiles:
+ - hiorg.civix.php
+ - tools/phpunit/vendor/bin/.phpunit/phpunit/src/Framework/TestCase.php
+ scanDirectories:
+ - tools/phpunit/vendor/bin/.phpunit/phpunit/src/Framework
+ bootstrapFiles:
+ - tools/phpunit/vendor/bin/.phpunit/phpunit/vendor/autoload.php
+ - phpstanBootstrap.php
+ level: 9
+ universalObjectCratesClasses:
+ - Civi\Core\Event\GenericHookEvent
+ - CRM_Core_Config
+ - CRM_Core_DAO
+ checkTooWideReturnTypesInProtectedAndPublicMethods: true
+ checkUninitializedProperties: true
+ checkMissingCallableSignature: true
+ treatPhpDocTypesAsCertain: false
+ exceptions:
+ check:
+ missingCheckedExceptionInThrows: true
+ tooWideThrowType: true
+ checkedExceptionClasses:
+ - \Webmozart\Assert\InvalidArgumentException
+ implicitThrows: false
+ ignoreErrors:
+ # Note paths are prefixed with "*/" to work with inspections in PHPStorm because of:
+ # https://youtrack.jetbrains.com/issue/WI-63891/PHPStan-ignoreErrors-configuration-isnt-working-with-inspections
+
+ # Example
+ #- # Accessing results of API requests
+ #message: "#^Offset '[^']+' does not exist on array[^\\|]+\\|null.$#"
+ #path: */tests/phpunit/**/*Test.php
+ tmpDir: .phpstan
diff --git a/phpstan.neon.template b/phpstan.neon.template
new file mode 100644
index 0000000..34f6dfd
--- /dev/null
+++ b/phpstan.neon.template
@@ -0,0 +1,11 @@
+# Copy this file to phpstan.neon and replace {VENDOR_DIR} with the appropriate
+# path.
+
+includes:
+ - phpstan.neon.dist
+
+parameters:
+ scanDirectories:
+ - {VENDOR_DIR}/civicrm/civicrm-core/CRM/
+ bootstrapFiles:
+ - {VENDOR_DIR}/autoload.php
diff --git a/phpstanBootstrap.php b/phpstanBootstrap.php
new file mode 100644
index 0000000..67ad97d
--- /dev/null
+++ b/phpstanBootstrap.php
@@ -0,0 +1,47 @@
+.
+ */
+
+declare(strict_types = 1);
+
+// phpcs:disable Drupal.Commenting.DocComment.ContentAfterOpen
+/** @var \PHPStan\DependencyInjection\Container $container */
+/** @phpstan-var array $bootstrapFiles */
+$bootstrapFiles = $container->getParameter('bootstrapFiles');
+foreach ($bootstrapFiles as $bootstrapFile) {
+ if (str_ends_with($bootstrapFile, 'vendor/autoload.php')) {
+ $vendorDir = dirname($bootstrapFile);
+ $civiCrmVendorDir = $vendorDir . '/civicrm';
+ $civiCrmCoreDir = $civiCrmVendorDir . '/civicrm-core';
+ if (file_exists($civiCrmCoreDir)) {
+ set_include_path(get_include_path()
+ . PATH_SEPARATOR . $civiCrmCoreDir
+ . PATH_SEPARATOR . $civiCrmVendorDir . '/civicrm-packages'
+ );
+ // $bootstrapFile might not be included, yet. It is required for the
+ // following require_once, though.
+ require_once $bootstrapFile;
+ // Prevent error "Class 'CRM_Core_Exception' not found in file".
+ require_once $civiCrmCoreDir . '/CRM/Core/Exception.php';
+
+ break;
+ }
+ }
+}
+
+if (file_exists(__DIR__ . '/vendor/autoload.php')) {
+ require_once __DIR__ . '/vendor/autoload.php';
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..e6f1098
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+ ./tests/phpunit
+
+
+
+
+
+ api
+ CRM
+ Civi
+
+
+ CRM/Hiorg/DAO
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
new file mode 100644
index 0000000..2ef438e
--- /dev/null
+++ b/tests/docker-compose.yml
@@ -0,0 +1,33 @@
+services:
+ civicrm:
+ image: michaelmcandrew/civicrm:${CIVICRM_IMAGE_TAG:-5-drupal}
+ environment:
+ - PROJECT_NAME=test
+ - BASE_URL=http://localhost
+ - CIVICRM_DB_NAME=test
+ - CIVICRM_DB_USER=root
+ - CIVICRM_DB_PASS=secret
+ - CIVICRM_DB_HOST=mysql
+ - CIVICRM_DB_PORT=3306
+ - CIVICRM_SITE_KEY=TEST_KEY
+ - DRUPAL_DB_NAME=test
+ - DRUPAL_DB_USER=root
+ - DRUPAL_DB_PASS=secret
+ - DRUPAL_DB_HOST=mysql
+ - DRUPAL_DB_PORT=3306
+ - PHP_DATE_TIMEZONE=UTC
+ - DEBUG=ON
+ - SMTP_HOST=localhost
+ - SMTP_MAILDOMAIN=example.org
+ volumes:
+ - ../:/var/www/html/sites/default/files/civicrm/ext/hiorg:${BIND_VOLUME_PERMISSIONS:-ro}
+ - /var/www/html/sites/default/files/civicrm/ext/hiorg/vendor
+ - /var/www/html/sites/default/files/civicrm/ext/hiorg/tools/phpunit/vendor
+ # Don't start Apache HTTP Server, but keep container running
+ command: ["tail", "-f", "/dev/null"]
+ stop_signal: SIGKILL
+ mysql:
+ image: mariadb
+ environment:
+ MARIADB_ROOT_PASSWORD: secret
+ MARIADB_DATABASE: test
diff --git a/tests/docker-phpunit.sh b/tests/docker-phpunit.sh
new file mode 100755
index 0000000..9271976
--- /dev/null
+++ b/tests/docker-phpunit.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+set -eu -o pipefail
+
+SCRIPT_DIR=$(realpath "$(dirname "$0")")
+EXT_DIR=$(dirname "$SCRIPT_DIR")
+
+cd "$EXT_DIR"
+if [ ! -e tools/phpunit/vendor/bin ]; then
+ "$SCRIPT_DIR/docker-prepare.sh"
+fi
+
+# CIVICRM_SMARTY_AUTOLOAD_PATH is not set in the container's civicrm.settings.php so we have to do it here.
+# Otherwise this results in this error:
+# Fatal error: Cannot declare class Smarty, because the name is already in use in /var/www/html/sites/all/modules/civicrm/packages/smarty5/Smarty.php on line 4
+smarty=$(printf '%s\n' /var/www/html/sites/all/modules/civicrm/packages/smarty* | sort -r | head -n1)
+if [ -e "$smarty/Smarty.php" ]; then
+ export CIVICRM_SMARTY_AUTOLOAD_PATH="$smarty/Smarty.php"
+elif [ -e "$smarty/vendor/autoload.php" ]; then
+ export CIVICRM_SMARTY_AUTOLOAD_PATH="$smarty/vendor/autoload.php"
+fi
+
+export XDEBUG_MODE=coverage
+# TODO: Remove when not needed, anymore.
+# In Docker container with CiviCRM 5.5? all deprecations are reported as direct
+# deprecations so "disabling" check of deprecation count is necessary for the
+# tests to pass (if baselineFile does not contain all deprecations).
+export SYMFONY_DEPRECATIONS_HELPER="max[total]=99999&baselineFile=./tests/ignored-deprecations.json"
+
+composer phpunit -- --cache-result-file=/tmp/.phpunit.result.cache "$@"
diff --git a/tests/docker-prepare.sh b/tests/docker-prepare.sh
new file mode 100755
index 0000000..e95c9ce
--- /dev/null
+++ b/tests/docker-prepare.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+set -eu -o pipefail
+
+EXT_DIR=$(dirname "$(dirname "$(realpath "$0")")")
+EXT_NAME=$(basename "$EXT_DIR")
+
+i=0
+while ! mysql -h "$CIVICRM_DB_HOST" -P "$CIVICRM_DB_PORT" -u "$CIVICRM_DB_USER" --password="$CIVICRM_DB_PASS" -e 'SELECT 1;' >/dev/null 2>&1; do
+ i=$((i+1))
+ if [ $i -gt 10 ]; then
+ echo "Failed to connect to database" >&2
+ exit 1
+ fi
+
+ echo -n .
+ sleep 1
+done
+
+echo
+
+export XDEBUG_MODE=off
+if mysql -h "$CIVICRM_DB_HOST" -P "$CIVICRM_DB_PORT" -u "$CIVICRM_DB_USER" --password="$CIVICRM_DB_PASS" "$CIVICRM_DB_NAME" -e 'SELECT 1 FROM civicrm_setting LIMIT 1;' >/dev/null 2>&1; then
+ cv flush
+else
+ # For headless tests it is required that CIVICRM_UF is defined using the corresponding env variable.
+ sed -E "s/define\('CIVICRM_UF', '([^']+)'\);/define('CIVICRM_UF', getenv('CIVICRM_UF') ?: '\1');/g" \
+ -i /var/www/html/sites/default/civicrm.settings.php
+ civicrm-docker-install
+
+ # Avoid this error:
+ # The autoloader expected class "Civi\ActionSchedule\Mapping" to be defined in
+ # file "[...]/Civi/ActionSchedule/Mapping.php". The file was found but the
+ # class was not in it, the class name or namespace probably has a typo.
+ #
+ # Necessary for CiviCRM 5.66.0 - 5.74.x.
+ # https://github.com/civicrm/civicrm-core/blob/5.66.0/Civi/ActionSchedule/Mapping.php
+ if [ -e /var/www/html/sites/all/modules/civicrm/Civi/ActionSchedule/Mapping.php ] \
+ && grep -q '// Empty file' /var/www/html/sites/all/modules/civicrm/Civi/ActionSchedule/Mapping.php; then
+ rm /var/www/html/sites/all/modules/civicrm/Civi/ActionSchedule/Mapping.php
+ fi
+
+ # For headless tests these files need to exist.
+ touch /var/www/html/sites/all/modules/civicrm/sql/test_data.mysql
+ touch /var/www/html/sites/all/modules/civicrm/sql/test_data_second_domain.mysql
+
+ cv ext:enable "$EXT_NAME"
+fi
+
+cd "$EXT_DIR"
+composer update --no-progress --prefer-dist --optimize-autoloader
+composer composer-phpunit -- update --no-progress --prefer-dist
diff --git a/tests/ignored-deprecations.json b/tests/ignored-deprecations.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/tests/ignored-deprecations.json
@@ -0,0 +1 @@
+[]
diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php
new file mode 100644
index 0000000..ae0da43
--- /dev/null
+++ b/tests/phpunit/bootstrap.php
@@ -0,0 +1,122 @@
+add('CRM_', [$extensionDir]);
+ $loader->addPsr4('Civi\\', [$extensionDir . '/Civi']);
+ $loader->add('api_', [$extensionDir]);
+ $loader->addPsr4('api\\', [$extensionDir . '/api']);
+ $loader->register();
+
+ if (file_exists($extensionDir . '/autoload.php')) {
+ require_once $extensionDir . '/autoload.php';
+ }
+}
+
+/**
+ * Call the "cv" command.
+ *
+ * @param string $cmd
+ * The rest of the command to send.
+ * @param string $decode
+ * Ex: 'json' or 'phpcode'.
+ * @return mixed
+ * Response output (if the command executed normally).
+ * For 'raw' or 'phpcode', this will be a string. For 'json', it could be any JSON value.
+ * @throws \RuntimeException
+ * If the command terminates abnormally.
+ */
+function cv(string $cmd, string $decode = 'json') {
+ $cmd = 'cv ' . $cmd;
+ $descriptorSpec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR];
+ $oldOutput = getenv('CV_OUTPUT');
+ putenv('CV_OUTPUT=json');
+
+ // Execute `cv` in the original folder. This is a work-around for
+ // phpunit/codeception, which seem to manipulate PWD.
+ $cmd = sprintf('cd %s; %s', escapeshellarg(getenv('PWD')), $cmd);
+
+ $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__);
+ putenv("CV_OUTPUT=$oldOutput");
+ fclose($pipes[0]);
+ $result = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ if (proc_close($process) !== 0) {
+ throw new \RuntimeException("Command failed ($cmd):\n$result");
+ }
+ switch ($decode) {
+ case 'raw':
+ return $result;
+
+ case 'phpcode':
+ // If the last output is /*PHPCODE*/, then we managed to complete execution.
+ if (substr(trim($result), 0, 12) !== '/*BEGINPHP*/' || substr(trim($result), -10) !== '/*ENDPHP*/') {
+ throw new \RuntimeException("Command failed ($cmd):\n$result");
+ }
+ return $result;
+
+ case 'json':
+ return json_decode($result, TRUE);
+
+ default:
+ throw new \RuntimeException("Bad decoder format ($decode)");
+ }
+}
diff --git a/tools/phpcs/composer.json b/tools/phpcs/composer.json
new file mode 100644
index 0000000..980e4b9
--- /dev/null
+++ b/tools/phpcs/composer.json
@@ -0,0 +1,11 @@
+{
+ "repositories": [
+ {
+ "type": "git",
+ "url": "https://github.com/civicrm/coder.git"
+ }
+ ],
+ "require": {
+ "drupal/coder": "dev-8.x-2.x-civi"
+ }
+}
diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json
new file mode 100644
index 0000000..0ee891a
--- /dev/null
+++ b/tools/phpstan/composer.json
@@ -0,0 +1,18 @@
+{
+ "require": {
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.12",
+ "phpstan/phpstan-deprecation-rules": "^1.0",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/phpstan-strict-rules": "^1.2",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "thecodingmachine/phpstan-strict-rules": "^1.0",
+ "voku/phpstan-rules": "^3.0"
+ },
+ "config": {
+ "allow-plugins": {
+ "phpstan/extension-installer": true
+ },
+ "sort-packages": true
+ }
+}
diff --git a/tools/phpunit/composer.json b/tools/phpunit/composer.json
new file mode 100644
index 0000000..ab64ce0
--- /dev/null
+++ b/tools/phpunit/composer.json
@@ -0,0 +1,13 @@
+{
+ "require": {
+ "symfony/phpunit-bridge": "^7"
+ },
+ "scripts": {
+ "post-install-cmd": [
+ "@php vendor/bin/simple-phpunit install --configuration ../../phpunit.xml.dist"
+ ],
+ "post-update-cmd": [
+ "@php vendor/bin/simple-phpunit install --configuration ../../phpunit.xml.dist"
+ ]
+ }
+}