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..395cf22
--- /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.1
+ 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..33c30f5
--- /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.1']
+ 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 }} &&
+ 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 }} --optimize-autoloader --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..a30e116
--- /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-php8.1', '5-drupal-php7.4', '5.56-drupal-php7.4' ]
+ 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/de.systopia.twingle/tests/docker-prepare.sh
+ - name: Run PHPUnit
+ run: docker compose -f tests/docker-compose.yml exec civicrm sites/default/files/civicrm/ext/de.systopia.twingle/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/README.md b/ci/README.md
new file mode 100644
index 0000000..0bfc584
--- /dev/null
+++ b/ci/README.md
@@ -0,0 +1,2 @@
+The dependencies specified in composer.json of this directory are required to
+run phpstan in CI.
diff --git a/ci/composer.json b/ci/composer.json
new file mode 100644
index 0000000..ab469d6
--- /dev/null
+++ b/ci/composer.json
@@ -0,0 +1,33 @@
+{
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "allow-plugins": {
+ "civicrm/composer-compile-plugin": false,
+ "civicrm/composer-downloads-plugin": true,
+ "cweagans/composer-patches": true
+ }
+ },
+ "require": {
+ "civicrm/civicrm-core": "^5"
+ },
+ "scripts": {
+ "post-install-or-update": [
+ "# The following statements are only necessary when the extension is inside a CiviCRM installation, actually not required in CI.",
+ "# Avoid redeclaration of function \\GuzzleHttp\\http_build_query()",
+ "if [ -e vendor/civicrm/civicrm-core/guzzle_php81_shim.php ]; then echo '' >vendor/civicrm/civicrm-core/guzzle_php81_shim.php; fi",
+ "# Avoid redeclaration of function \\GuzzleHttp\\Promise\\queue()",
+ "if [ -e vendor/guzzlehttp/promises/src/functions.php ]; then echo '' >vendor/guzzlehttp/promises/src/functions.php; fi",
+ "# Avoid CiviCRM load extensions in vendor",
+ "if [ -e vendor/civicrm ]; then find vendor/civicrm -name 'info.xml' -delete; fi",
+ "# Avoid Class 'CRM_AfformAdmin_ExtensionUtil' not found",
+ "find vendor -name '*.mgd.php' -delete"
+ ],
+ "post-install-cmd": [
+ "@post-install-or-update"
+ ],
+ "post-update-cmd": [
+ "@post-install-or-update"
+ ]
+ }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..5489fbd
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,45 @@
+{
+ "name": "systopia/de.systopia.twingle",
+ "type": "civicrm-ext",
+ "license": "AGPL-3.0-or-later",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "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"
+ ],
+ "phpunit": [
+ "@php tools/phpunit/vendor/bin/simple-phpunit --coverage-text"
+ ],
+ "test": [
+ "@phpcs",
+ "@phpstan",
+ "@phpunit"
+ ]
+ }
+}
diff --git a/info.xml b/info.xml
index 05ca281..32c0ab7 100644
--- a/info.xml
+++ b/info.xml
@@ -18,7 +18,7 @@
1.5-dev
dev
- 5.19
+ 5.56
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..ce14b44
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,77 @@
+
+
+ CiviCRM coding standard with some additional changes
+
+ api
+ Civi
+ CRM
+ tests
+
+ /CRM/[^/]+/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..0691f8e
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,42 @@
+parameters:
+ paths:
+ - api
+ - Civi
+ - CRM
+ - tests
+ excludePaths:
+ analyse:
+ - CRM/*/DAO/*
+ - tests/phpunit/bootstrap.php
+ scanFiles:
+ - twingle.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
+ - vendor/autoload.php
+ - phpstanBootstrap.php
+ level: 9
+ universalObjectCratesClasses:
+ - Civi\Core\Event\GenericHookEvent
+ 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..9f2f699
--- /dev/null
+++ b/phpstan.neon.template
@@ -0,0 +1,13 @@
+# 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/Civi/
+ - {VENDOR_DIR}/civicrm/civicrm-core/CRM/
+ - {VENDOR_DIR}/civicrm/civicrm-core/api/
+ bootstrapFiles:
+ - {VENDOR_DIR}/autoload.php
diff --git a/phpstanBootstrap.php b/phpstanBootstrap.php
new file mode 100644
index 0000000..08b3403
--- /dev/null
+++ b/phpstanBootstrap.php
@@ -0,0 +1,43 @@
+.
+ */
+
+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;
+ }
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..2df3e9e
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+ ./tests/phpunit
+
+
+
+
+ api
+ CRM
+ Civi
+
+ CRM/*/DAO
+
+
+
+
+
+
+
+
+
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
new file mode 100644
index 0000000..840130d
--- /dev/null
+++ b/tests/docker-compose.yml
@@ -0,0 +1,33 @@
+version: "3"
+services:
+ civicrm:
+ image: michaelmcandrew/civicrm:${CIVICRM_IMAGE_TAG:-5-drupal-php8.1}
+ 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
+ - 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/de.systopia.twingle:${BIND_VOLUME_PERMISSIONS:-ro}
+ - /var/www/html/sites/default/files/civicrm/ext/de.systopia.twingle/vendor
+ - /var/www/html/sites/default/files/civicrm/ext/de.systopia.twingle/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..2b0fc69
--- /dev/null
+++ b/tests/docker-phpunit.sh
@@ -0,0 +1,19 @@
+#!/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
+
+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..a293c74
--- /dev/null
+++ b/tests/docker-prepare.sh
@@ -0,0 +1,45 @@
+#!/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.
+ rm -f /var/www/html/sites/all/modules/civicrm/Civi/ActionSchedule/Mapping.php
+
+ # 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 --no-dev
+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/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..218fa72
--- /dev/null
+++ b/tools/phpstan/composer.json
@@ -0,0 +1,18 @@
+{
+ "require": {
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.7",
+ "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..ab13d43
--- /dev/null
+++ b/tools/phpunit/composer.json
@@ -0,0 +1,13 @@
+{
+ "require": {
+ "symfony/phpunit-bridge": "^6.1"
+ },
+ "scripts": {
+ "post-install-cmd": [
+ "@php vendor/bin/simple-phpunit install"
+ ],
+ "post-update-cmd": [
+ "@php vendor/bin/simple-phpunit install"
+ ]
+ }
+}